From c6f239f424990214258fed787b8f154b26689790 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 10 Jun 2026 16:06:55 +0800 Subject: [PATCH] =?UTF-8?q?git=20workspace=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/infra/git/errors.go | 8 ++++- internal/infra/git/runner_test.go | 14 ++++++++ internal/workspace/workspace_service.go | 15 +++++++++ internal/workspace/workspace_service_test.go | 35 +++++++++++++++++++- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/internal/infra/git/errors.go b/internal/infra/git/errors.go index 8511c79..d5fd6b0 100644 --- a/internal/infra/git/errors.go +++ b/internal/infra/git/errors.go @@ -23,7 +23,13 @@ func (e *GitError) Error() string { for i, a := range e.Args { 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, diff --git a/internal/infra/git/runner_test.go b/internal/infra/git/runner_test.go index 31b2e49..6c76b0c 100644 --- a/internal/infra/git/runner_test.go +++ b/internal/infra/git/runner_test.go @@ -54,6 +54,20 @@ func TestDefaultRunner_exec_Timeout(t *testing.T) { 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) { t.Parallel() cases := []struct { diff --git a/internal/workspace/workspace_service.go b/internal/workspace/workspace_service.go index b260132..fea2aa3 100644 --- a/internal/workspace/workspace_service.go +++ b/internal/workspace/workspace_service.go @@ -228,6 +228,21 @@ func (s *workspaceService) Sync(ctx context.Context, c Caller, wsID uuid.UUID) ( } // 状态回写与请求生命周期解耦:客户端断开/请求超时也要能写回状态,避免永久卡 syncing。 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) defer cancel() if fetchErr := s.gitr.Fetch(fetchCtx, ws.MainPath, cred); fetchErr != nil { diff --git a/internal/workspace/workspace_service_test.go b/internal/workspace/workspace_service_test.go index 3e826f6..6e51547 100644 --- a/internal/workspace/workspace_service_test.go +++ b/internal/workspace/workspace_service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log/slog" + "path/filepath" "sync" "testing" "time" @@ -79,7 +80,21 @@ func (f *fakeRepo) ResetStuckSyncStatuses(_ context.Context) ([]uuid.UUID, 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 { return nil } @@ -312,6 +327,24 @@ func TestWorkspaceService_Sync_BlockedWhenCloning(t *testing.T) { 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) { t.Parallel() pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}