Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ make test
- Global prune loads user-level config and hooks only because it can run without a repository context
- State file tracks pool membership and temporary owner/destroy reservations, not long-term usage status
- Git operations shell out to `git` (go-git has incomplete worktree support)
- Self-healing: stale state entries are auto-removed
- Self-healing: stale state entries are auto-removed, and `get` prunes stale git worktree registrations before adding a worktree

## Windows Compatibility

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ The default treehouse root is `~/.treehouse/`.
`treehouse prune --all` applies the same safety checks across every managed pool under the user-level treehouse root.
Backing-repository-missing orphans are reported by default; `--prune-orphans` includes them as unverified prune candidates, and `--yes` is required before deletion.
It is a dry run unless you pass `--yes`.
- **Self-healing get** - `treehouse get` prunes stale git worktree bookkeeping (e.g. left behind by a crashed or forcibly removed worktree) before adding a new worktree, so a prunable registration never wedges the pool with a "missing but already registered worktree" error.

## CLI Reference

Expand Down
55 changes: 55 additions & 0 deletions cmd/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,61 @@ func TestGetReusesWorktree(t *testing.T) {
}
}

// TestGetRecoversFromStaleWorktreeRegistration verifies that "treehouse get"
// self-heals when git still has bookkeeping for a worktree whose directory was
// removed out-of-band (e.g. a crashed agent). Without prune-before-add, git
// rejects the add with "missing but already registered worktree" and every
// subsequent get is wedged. See issue #30.
func TestGetRecoversFromStaleWorktreeRegistration(t *testing.T) {
repoDir, homeDir := setupTestRepo(t)
env := []string{"SHELL=" + exitShellBin}

// Materialize the pool directory so we can plant the stale entry at the
// exact slot path Acquire will target (slot 1 / <repo-basename>).
if _, _, code := runTreehouse(t, repoDir, homeDir, nil, "status"); code != 0 {
t.Fatalf("initial status failed (code %d)", code)
}
matches, err := filepath.Glob(filepath.Join(homeDir, ".treehouse", filepath.Base(repoDir)+"-*"))
if err != nil || len(matches) != 1 {
t.Fatalf("expected exactly one pool dir under %s/.treehouse, got %v: %v", homeDir, matches, err)
}
poolDir := matches[0]
stalePath := filepath.Join(poolDir, "1", filepath.Base(repoDir))

// Simulate a crashed scout: register a worktree at the slot path, then
// delete its directory without telling git. This leaves a prunable entry.
if err := os.MkdirAll(filepath.Dir(stalePath), 0755); err != nil {
t.Fatalf("mkdir slot parent: %v", err)
}
gitCmd(t, repoDir, "worktree", "add", "--detach", stalePath, "main")
if err := os.RemoveAll(stalePath); err != nil {
t.Fatalf("remove stale worktree dir: %v", err)
}

// Sanity: git should now consider the entry prunable.
listOut := gitCmd(t, repoDir, "worktree", "list", "--porcelain")
if !strings.Contains(listOut, "prunable") {
t.Fatalf("expected prunable entry in worktree list, got:\n%s", listOut)
}

// get must recover and create the worktree despite the stale registration.
_, getErr, code := runTreehouse(t, repoDir, homeDir, env, "get")
if code != 0 {
t.Fatalf("treehouse get failed on stale registration (code %d): %s", code, getErr)
}
if strings.Contains(getErr, "already registered") || strings.Contains(getErr, "failed to create worktree") {
t.Fatalf("get did not recover from stale registration: %s", getErr)
}
if !strings.Contains(getErr, "Entered worktree at") {
t.Fatalf("expected 'Entered worktree at' in stderr, got: %s", getErr)
}

// The worktree directory must actually exist on disk after get.
if _, err := os.Stat(filepath.Join(stalePath, "README.md")); err != nil {
t.Errorf("worktree was not recreated at %s: %v", stalePath, err)
}
}

func TestReturnFromInsideWorktreeDoesNotTerminateCaller(t *testing.T) {
repoDir, homeDir := setupTestRepo(t)
env := []string{"SHELL=" + exitShellBin}
Expand Down
9 changes: 9 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ func AddWorktree(repoRoot, path, branch string) error {
return err
}

// PruneWorktrees removes git worktree bookkeeping for worktrees whose
// directories no longer exist. It is safe by design: git only deletes
// registrations for already-missing directories and never touches live
// worktrees or their data.
func PruneWorktrees(repoRoot string) error {
_, err := runGit(repoRoot, "worktree", "prune")
return err
}

func RemoveWorktree(repoRoot, path string) error {
_, err := runGit(repoRoot, "worktree", "remove", "--force", path)
return err
Expand Down
8 changes: 8 additions & 0 deletions internal/pool/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ func Acquire(repoRoot, poolDir string, poolSize int, postCreate []string) (strin
return err
}

// Clear any stale worktree bookkeeping left behind by a crashed or
// forcibly removed worktree. Without this, git rejects the add with
// "missing but already registered worktree". Prune is safe: it only
// removes registrations whose target directories are already gone.
if err := git.PruneWorktrees(repoRoot); err != nil {
return fmt.Errorf("failed to prune stale worktrees: %w", err)
}

if err := git.AddWorktree(repoRoot, wtPath, branch); err != nil {
return fmt.Errorf("failed to create worktree: %w", err)
}
Expand Down