diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index c1c7d8ed0..25c267dc1 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -611,6 +611,14 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch, baseBra return err } if _, err := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo, branch, path, baseRef)...); err != nil { + if isMissingRegisteredWorktreeError(err) { + if pruneErr := w.pruneWorktrees(ctx, repo); pruneErr != nil { + return fmt.Errorf("gitworktree: worktree add branch %q from %q: recover stale registration: %w", branch, baseRef, pruneErr) + } + if _, retryErr := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo, branch, path, baseRef)...); retryErr == nil { + return nil + } + } return fmt.Errorf("gitworktree: worktree add branch %q from %q: %w", branch, baseRef, err) } return nil @@ -681,6 +689,14 @@ func (w *Workspace) createWorkspaceProjectRepo(ctx context.Context, repo workspa return "", err } if _, err := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo.repoPath, branch, repo.outputPath, baseRef)...); err != nil { + if isMissingRegisteredWorktreeError(err) { + if pruneErr := w.pruneWorktrees(ctx, repo.repoPath); pruneErr != nil { + return "", fmt.Errorf("gitworktree: workspace repo %q worktree add branch %q from %q: recover stale registration: %w", repo.name, branch, baseRef, pruneErr) + } + if _, retryErr := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo.repoPath, branch, repo.outputPath, baseRef)...); retryErr == nil { + return baseSHA, nil + } + } return "", fmt.Errorf("gitworktree: workspace repo %q worktree add branch %q from %q: %w", repo.name, branch, baseRef, err) } return baseSHA, nil @@ -688,8 +704,8 @@ func (w *Workspace) createWorkspaceProjectRepo(ctx context.Context, repo workspa func (w *Workspace) forceDestroyPath(ctx context.Context, repo, path string) error { _, _ = w.run(ctx, w.binary, worktreeForceRemoveArgs(repo, path)...) - if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil { - return fmt.Errorf("gitworktree: worktree prune: %w", err) + if err := w.pruneWorktrees(ctx, repo); err != nil { + return err } if err := os.RemoveAll(path); err != nil { return fmt.Errorf("gitworktree: force remove path %q: %w", path, err) @@ -697,6 +713,17 @@ func (w *Workspace) forceDestroyPath(ctx context.Context, repo, path string) err return nil } +func (w *Workspace) pruneWorktrees(ctx context.Context, repo string) error { + if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil { + return fmt.Errorf("gitworktree: worktree prune: %w", err) + } + return nil +} + +func isMissingRegisteredWorktreeError(err error) bool { + return strings.Contains(err.Error(), "is a missing but already registered worktree") +} + func (w *Workspace) revParse(ctx context.Context, repo, ref string) (string, error) { out, err := w.run(ctx, w.binary, "-C", repo, "rev-parse", "--verify", ref) if err != nil { diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_test.go index cf676ee11..3c557d2b8 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_test.go @@ -211,6 +211,68 @@ func TestCreateReusesRegisteredWorktreeAtExpectedPath(t *testing.T) { } } +func TestCreateWorkspaceProjectRepoPrunesStaleRegisteredWorktree(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + output := filepath.Join(root, "proj", "orchestrator", "proj-orchestrator", "api") + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + exitErr := exec.Command("sh", "-c", "exit 1").Run() + if exitErr == nil { + t.Fatal("expected exit error") + } + var calls []string + addAttempts := 0 + ws.run = func(_ context.Context, binary string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + calls = append(calls, joined) + switch { + case strings.Contains(joined, "rev-parse --verify --quiet origin/feature/test"): + return nil, commandError{args: append([]string{binary}, args...), err: exitErr} + case strings.Contains(joined, "rev-parse --verify --quiet origin/main"): + return nil, nil + case strings.Contains(joined, "rev-parse --verify origin/main"): + return []byte("abc123\n"), nil + case strings.Contains(joined, "worktree add -b feature/test "+output+" origin/main"): + addAttempts++ + if addAttempts == 1 { + return nil, commandError{ + args: append([]string{binary}, args...), + output: "Preparing worktree (new branch 'feature/test')\nfatal: '" + output + "' is a missing but already registered worktree;\nuse 'add -f' to override, or 'prune' or 'remove' to clear", + err: errors.New("exit status 128"), + } + } + return nil, nil + case strings.Contains(joined, "worktree prune"): + return nil, nil + default: + t.Fatalf("unexpected git invocation: %v", args) + return nil, nil + } + } + + baseSHA, err := ws.createWorkspaceProjectRepo(context.Background(), workspaceProjectRepo{ + name: "api", + repoPath: repo, + outputPath: output, + }, "feature/test") + if err != nil { + t.Fatalf("createWorkspaceProjectRepo: %v", err) + } + if baseSHA != "abc123" { + t.Fatalf("baseSHA = %q, want abc123", baseSHA) + } + if addAttempts != 2 { + t.Fatalf("add attempts = %d, want 2", addAttempts) + } + got := strings.Join(calls, "\n") + if !strings.Contains(got, "worktree prune") { + t.Fatalf("calls missing worktree prune:\n%s", got) + } +} + // TestValidateConfigRejectsPathEscapingIDs covers review item RB: filepath.Join // in managedPath cleans `..` segments before validateManagedPath sees them, so a // session id of "../other" would stay inside managedRoot while jumping projects.