fix: prune stale workspace worktree registrations

This commit is contained in:
Aditi Chauhan 2026-07-01 17:05:33 +05:30
parent d42bab79ad
commit bf6a470903
2 changed files with 91 additions and 2 deletions

View File

@ -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 {

View File

@ -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.