You've already forked agentic-coding-workflow
git workspace逻辑
This commit is contained in:
@@ -23,7 +23,13 @@ func (e *GitError) Error() string {
|
|||||||
for i, a := range e.Args {
|
for i, a := range e.Args {
|
||||||
redacted[i] = redactURLUserinfo(a)
|
redacted[i] = redactURLUserinfo(a)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("git %v: exit %d: %s", redacted, e.ExitCode, e.Stderr)
|
// stderr 为空说明进程多半没跑起来(chdir 失败、二进制缺失、超时被 kill),
|
||||||
|
// 此时 Cause 才是唯一有效信息,不能丢。
|
||||||
|
detail := e.Stderr
|
||||||
|
if detail == "" && e.Cause != nil {
|
||||||
|
detail = e.Cause.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("git %v: exit %d: %s", redacted, e.ExitCode, detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// redactURLUserinfo 剥离形如 scheme://user[:pass]@host 中的 userinfo,
|
// redactURLUserinfo 剥离形如 scheme://user[:pass]@host 中的 userinfo,
|
||||||
|
|||||||
@@ -54,6 +54,20 @@ func TestDefaultRunner_exec_Timeout(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitError_Error_EmptyStderrFallsBackToCause(t *testing.T) {
|
||||||
|
// 进程没跑起来时(chdir 失败 / 二进制缺失)stderr 为空,Error() 必须带上
|
||||||
|
// Cause,否则落库的 last_sync_error 只剩 "exit -1: " 无法定位。
|
||||||
|
t.Parallel()
|
||||||
|
ge := &GitError{Args: []string{"fetch"}, ExitCode: -1,
|
||||||
|
Cause: errors.New("chdir /data/x: no such file or directory")}
|
||||||
|
require.Contains(t, ge.Error(), "no such file or directory")
|
||||||
|
// stderr 非空时仍以 stderr 为准,不重复拼 Cause。
|
||||||
|
ge2 := &GitError{Args: []string{"fetch"}, ExitCode: 128,
|
||||||
|
Stderr: "fatal: boom", Cause: errors.New("exit status 128")}
|
||||||
|
require.Contains(t, ge2.Error(), "fatal: boom")
|
||||||
|
require.NotContains(t, ge2.Error(), "exit status 128")
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsAuthError(t *testing.T) {
|
func TestIsAuthError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
@@ -228,6 +228,21 @@ func (s *workspaceService) Sync(ctx context.Context, c Caller, wsID uuid.UUID) (
|
|||||||
}
|
}
|
||||||
// 状态回写与请求生命周期解耦:客户端断开/请求超时也要能写回状态,避免永久卡 syncing。
|
// 状态回写与请求生命周期解耦:客户端断开/请求超时也要能写回状态,避免永久卡 syncing。
|
||||||
writeCtx := context.WithoutCancel(ctx)
|
writeCtx := context.WithoutCancel(ctx)
|
||||||
|
// main 仓库目录缺失(历史 clone 从未成功 / 数据卷丢失)时 fetch 注定失败,
|
||||||
|
// 转为重新 clone 自愈:状态切回 cloning 并调度 CloneRunner,由其落 idle/error。
|
||||||
|
if _, statErr := os.Stat(ws.MainPath); errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
if s.clones == nil {
|
||||||
|
_, _ = s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusError, ws.LastSyncedAt, "main repo missing on disk; clone scheduler unavailable")
|
||||||
|
return nil, errs.New(errs.CodeGitCmdFailed, "main repo missing on disk")
|
||||||
|
}
|
||||||
|
updated, err := s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusCloning, ws.LastSyncedAt, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.audit(writeCtx, &c.UserID, "workspace.reclone", "workspace", wsID.String(), map[string]any{"reason": "main path missing"})
|
||||||
|
s.clones.Schedule(wsID)
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
fetchCtx, cancel := context.WithTimeout(ctx, s.fetchTimout)
|
fetchCtx, cancel := context.WithTimeout(ctx, s.fetchTimout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if fetchErr := s.gitr.Fetch(fetchCtx, ws.MainPath, cred); fetchErr != nil {
|
if fetchErr := s.gitr.Fetch(fetchCtx, ws.MainPath, cred); fetchErr != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -79,7 +80,21 @@ func (f *fakeRepo) ResetStuckSyncStatuses(_ context.Context) ([]uuid.UUID, error
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *fakeRepo) ListFetchTargets(_ context.Context) ([]FetchTarget, error) { return nil, nil }
|
func (f *fakeRepo) ListFetchTargets(_ context.Context) ([]FetchTarget, error) { return nil, nil }
|
||||||
func (f *fakeRepo) MarkSyncing(_ context.Context, _ uuid.UUID) (bool, error) { return false, nil }
|
|
||||||
|
// MarkSyncing 模拟真实实现的 CAS 语义:仅 idle/error 可抢到锁并置为 syncing。
|
||||||
|
func (f *fakeRepo) MarkSyncing(_ context.Context, id uuid.UUID) (bool, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
w, ok := f.wss[id]
|
||||||
|
if !ok {
|
||||||
|
return false, errors.New("not found")
|
||||||
|
}
|
||||||
|
if w.SyncStatus != SyncStatusIdle && w.SyncStatus != SyncStatusError {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
w.SyncStatus = SyncStatusSyncing
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
func (f *fakeRepo) MarkFetchResult(_ context.Context, _ uuid.UUID, _ bool, _ string) error {
|
func (f *fakeRepo) MarkFetchResult(_ context.Context, _ uuid.UUID, _ bool, _ string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -312,6 +327,24 @@ func TestWorkspaceService_Sync_BlockedWhenCloning(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceService_Sync_MissingMainPath_SchedulesReclone(t *testing.T) {
|
||||||
|
// main 仓库目录不存在(历史 clone 失败 / 数据卷丢失)时,Sync 不做 fetch,
|
||||||
|
// 而是状态切回 cloning 并调度 CloneRunner 重新 clone 自愈。
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}
|
||||||
|
sched := &fakeSched{}
|
||||||
|
svc, repo := newSvc(t, pa, sched, &fakeGit{})
|
||||||
|
id := uuid.New()
|
||||||
|
repo.wss[id] = &Workspace{
|
||||||
|
ID: id, ProjectID: pa.pid, SyncStatus: SyncStatusError,
|
||||||
|
MainPath: filepath.Join(t.TempDir(), "gone", "main"),
|
||||||
|
}
|
||||||
|
ws, err := svc.Sync(context.Background(), Caller{UserID: uuid.New()}, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, SyncStatusCloning, ws.SyncStatus)
|
||||||
|
require.Equal(t, []uuid.UUID{id}, sched.called)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCloneRunner_HappyPath(t *testing.T) {
|
func TestCloneRunner_HappyPath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}
|
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}
|
||||||
|
|||||||
Reference in New Issue
Block a user