diff --git a/AGENTS.md b/AGENTS.md index e851024..95bfc35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,10 +58,11 @@ make test Since the real reservation (owner vs. lease vs. idle) is unknowable from disk alone, every recovered entry is marked `Leased` with a `recoveredLeaseHolder` placeholder. `Acquire` and `prune` skip recovered entries, and `destroy` only removes one via a single named `--include-leased` target. A human clears a recovered entry with `treehouse status` then `treehouse return` (or `destroy --include-leased`) once verified -- All VCS operations go through the `internal/vcs` seam (`vcs.Backend`, 19 operations); backends shell out to the `git`/`jj` binaries (go-git has incomplete worktree support). Git is the default backend everywhere, including colocated (`.jj`+`.git`) and `.jj`-only repos; jj is a strict opt-in via `vcs = "jj"` in config or `TREEHOUSE_VCS=jj` (precedence: env, repo `treehouse.toml`, user config), effective only where a `.jj` directory exists; an unrecognized `vcs` value is ignored (git default kept) with one deduped stderr warning naming the value and its source. Pooled jj workspaces are `.jj`-only and cannot carry an untracked config file, so `backendFor` resolves their opt-in by reading the `.jj/repo` pointer and checking config at the main repository root — file inspection only, never backend selection from a marker. Per-worktree facts and actions, however, dispatch on the slot's own marker (`slotMarkerBackend`: a `.git` entry wins, then a `.jj` directory): `backendForWorktree` routes `IsDirty`, `IsHeadMergedIntoRef` (with its ref from `DefaultBranchMergeRefForWorktree`), `ResetWorktree`, `ResetWorktreeToRef`, `IsWorktreeSafeToReset`, `DetachWorktree`, `FindMainRepoRootFrom`, and release-time root/branch discovery (`DefaultBranchForWorktree`), and `backendForRemoval` routes `RemoveWorktree`/`RemoveCleanWorktree` the same way, falling back to `backendFor(repoRoot)` when the path is missing so error surfacing is unchanged. This is artifact-typed dispatch of operations on an existing slot, NOT marker-based opt-in — ordinary directories without a marker and worktree creation still follow the configured backend — so the configured backend never answers for a slot of the other flavor (a `.jj`-only slot read through git would resolve the repository enclosing the pool and report its facts, misclassifying dirty work as disposable). Acquire is flavor-aware too: `get` reuses only marker-matching slots and creates new ones with the selected backend; other-flavor slots stay untouched, are surfaced by `status` (`WorktreeStatus.Flavor`), count toward `max_trees`, and are migrated via `destroy` + re-`get`. A markerless (damaged) slot fails closed everywhere: acquire never reuses it, release clears its lease without branch discovery, reset, or detach, `status` reports it `damaged` without reading its facts, prune classifies it cannot-verify without reading facts through the fallback, and destroy classifies it unverified and removes it (plain-directory route; stale registrations self-heal at the next add) only with `--include-unlanded`; as defense in depth the destructive `vcs` wrappers (`ResetWorktree`, `ResetWorktreeToRef`, `DetachWorktree`) refuse markerless paths outright +- All VCS operations go through the `internal/vcs` seam (`vcs.Backend`, 20 operations); backends shell out to the `git`/`jj` binaries (go-git has incomplete worktree support). Git is the default backend everywhere, including colocated (`.jj`+`.git`) and `.jj`-only repos; jj is a strict opt-in via `vcs = "jj"` in config or `TREEHOUSE_VCS=jj` (precedence: env, repo `treehouse.toml`, user config), effective only where a `.jj` directory exists; an unrecognized `vcs` value is ignored (git default kept) with one deduped stderr warning naming the value and its source. Pooled jj workspaces are `.jj`-only and cannot carry an untracked config file, so `backendFor` resolves their opt-in by reading the `.jj/repo` pointer and checking config at the main repository root — file inspection only, never backend selection from a marker. Per-worktree facts and actions, however, dispatch on the slot's own marker (`slotMarkerBackend`: a `.git` entry wins, then a `.jj` directory): `backendForWorktree` routes `IsDirty`, `IsHeadMergedIntoRef` (with its ref from `DefaultBranchMergeRefForWorktree`), `ResetWorktree`, `ResetWorktreeToRef`, `IsWorktreeSafeToReset`, `DetachWorktree`, `FindMainRepoRootFrom`, and release-time root/branch discovery (`DefaultBranchForWorktree`), and `backendForRemoval` routes `RemoveWorktree`/`RemoveCleanWorktree` the same way, falling back to `backendFor(repoRoot)` when the path is missing so error surfacing is unchanged. This is artifact-typed dispatch of operations on an existing slot, NOT marker-based opt-in — ordinary directories without a marker and worktree creation still follow the configured backend — so the configured backend never answers for a slot of the other flavor (a `.jj`-only slot read through git would resolve the repository enclosing the pool and report its facts, misclassifying dirty work as disposable). Acquire is flavor-aware too: `get` reuses only marker-matching slots and creates new ones with the selected backend; other-flavor slots stay untouched, are surfaced by `status` (`WorktreeStatus.Flavor`), count toward `max_trees`, and are migrated via `destroy` + re-`get`. A markerless (damaged) slot fails closed everywhere: acquire never reuses it, release clears its lease without branch discovery, reset, or detach, `status` reports it `damaged` without reading its facts, prune classifies it cannot-verify without reading facts through the fallback, and destroy classifies it unverified and removes it (plain-directory route; stale registrations self-heal at the next add) only with `--include-unlanded`; as defense in depth the destructive `vcs` wrappers (`ResetWorktree`, `ResetWorktreeToRef`, `DetachWorktree`) refuse markerless paths outright - jj backend semantics: dirty means the working-copy commit `@` is non-empty or described; reset is `jj abandon -r @` then `jj new ` (recoverable via `jj op restore`); merged-detection is the ancestry revset `@- & ~::` being empty, so squash-merged work deliberately reads unmerged (fail-safe); default branch resolution prefers origin bookmarks `main`/`master`/`trunk` and never uses bare `trunk()` (it falls back to `root()` without remotes); the workspace store pointer is rewritten to a canonicalized absolute path and canonicalized again on both read routes, so a repository reached through a symlinked path resolves to one pool identity; `PruneWorktrees` is a documented no-op with self-healing at add time; as defense in depth, `RemoveWorktree` refuses to delete an existing directory that is not a jj workspace or that is the main workspace (its `.jj/repo` is the store itself, not a pointer file), so even a misrouted call cannot silently delete git-owned files or the whole repository — an already-missing path still gets its stale workspace registration forgotten. jj tests isolate `JJ_CONFIG` (with `git.colocate=false`) and skip when `jj` is not on PATH, so CI without jj is unaffected - The pool root is made self-ignoring (`.gitignore` containing `*` written inside it) because non-colocated jj repos never read `.git/info/exclude`; inside git repos the `info/exclude` entry is still added too (`config.EnsureExcluded`) -- Self-healing: stale state entries are auto-removed, and `get` prunes stale worktree registrations before adding a worktree (the git backend via `git worktree prune`; the jj backend by forgetting a stale same-path workspace registration at add time) +- Self-healing: stale state entries are auto-removed, and `get` prunes stale worktree registrations before adding a worktree (both routes go through `vcs.PruneWorktreeAt`, which prunes the repository and additionally clears the slot's own registration when git left its `initializing` lock on it; plain `git worktree prune` silently skips locked registrations, so an interrupted `worktree add` would otherwise keep the slot name registered and wedge every later `get`. Only that lock reason is cleared and only while the directory is gone, so a user's lock is never disturbed. The jj backend has no such lock and forgets a stale same-path workspace registration at add time). A failed `vcs.AddWorktree` also removes the slot directory it just created and re-prunes, because `nextName` derives names from state alone: without that cleanup a half-created slot would collide with the same name on every later `get`. The cleanup is skipped when the slot directory already existed, so pre-existing content is never deleted +- Every git subprocess runs under a deadline (`gitCommandContext` in `internal/vcs/gitvcs`), so a stalled command fails with a diagnostic naming the subcommand, the working directory, and the env var that raises its budget. Two budgets, one for metadata commands and a longer one for size/network-bound commands, each overridable by an environment variable; README's "Git command timeouts" owns the defaults, the long-command list, and the variable names. `cmd.WaitDelay` also bounds a completed git whose output pipe a background helper still holds. The jj backend is not yet bounded this way ## Contribution Gate diff --git a/README.md b/README.md index 271b1c7..f1fd076 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,9 @@ You can instead keep the pool [inside the project](#in-project-storage) with `-- `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. +- **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 leftover registration never wedges the pool with a "missing but already registered worktree" error. + This includes the registration an interrupted `git worktree add` leaves locked, which a plain `git worktree prune` skips; a worktree you locked yourself is never touched. + If creating the worktree fails, `get` also removes the partial slot directory it just created, so the same slot name is not permanently poisoned. ## CLI Reference @@ -367,6 +369,7 @@ jj-backend notes: - Merge detection uses ancestry; squash-merged work is treated as unmerged, so lifecycle commands err on the side of keeping it. - The default branch resolves to the `main`/`master`/`trunk` bookmark, preferring origin. - A pooled jj workspace whose backing repository was deleted is classified as an orphan just like a git worktree: `prune` reports it, and `prune --prune-orphans --yes` reclaims it. +- The command deadlines in [Git command timeouts](#git-command-timeouts) bound the git backend only; jj subprocesses are not bounded yet. ### Worktree root @@ -380,6 +383,17 @@ The worktree root can also be set without a config file, and the resolved value A relative value (including `.`) is resolved from the repo root, exactly like a relative `root` in config; `treehouse` is always appended, so `--root .` places the pool at `/.treehouse/`. +### Git command timeouts + +Every `git` subprocess runs under a deadline, so a stalled command (stale index lock, a blocked credential prompt, an unreachable remote) fails with an actionable error instead of hanging forever. Two budgets apply: + +| Budget | Default | Applies to | Override | +| ------ | ------- | ---------- | -------- | +| Standard | 2 minutes | metadata commands (`rev-parse`, `status`, `merge-base`, `symbolic-ref`, ...) | `TREEHOUSE_GIT_TIMEOUT` | +| Long | 30 minutes | commands that scale with repository size or the network (`fetch`, `ls-remote`, `worktree add`, `worktree remove`, `checkout`, `read-tree`, `clean`) | `TREEHOUSE_GIT_LONG_TIMEOUT` | + +Both accept a Go duration (`export TREEHOUSE_GIT_LONG_TIMEOUT=2h`). An unparseable or non-positive value is ignored with a warning and the default is used. + ### In-project storage By default the pool lives in the global `~/.treehouse` store. Set the root to `.` to keep it **inside the project** instead: diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 3822d75..e8aeb51 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -214,7 +214,10 @@ func acquire(repoRoot, poolDir string, poolSize int, postCreate []string, opts a repoName := filepath.Base(repoRoot) wtPath := filepath.Join(poolDir, name, repoName) - if err := os.MkdirAll(filepath.Dir(wtPath), 0755); err != nil { + slotDir := filepath.Dir(wtPath) + _, slotStatErr := os.Stat(slotDir) + slotDirIsNew := errors.Is(slotStatErr, os.ErrNotExist) + if err := os.MkdirAll(slotDir, 0755); err != nil { return err } @@ -228,11 +231,20 @@ func acquire(repoRoot, poolDir string, poolSize int, postCreate []string, opts a // (e.g. a temporary .git/worktrees lock or permission issue) must not // wedge a get that would otherwise succeed; let AddWorktree surface the // real error if one exists. - if err := vcs.PruneWorktrees(repoRoot); err != nil { + if err := vcs.PruneWorktreeAt(repoRoot, wtPath); err != nil { fmt.Fprintf(os.Stderr, "🌳 Warning: failed to prune stale worktrees: %v\n", err) } if err := vcs.AddWorktree(repoRoot, wtPath, branch); err != nil { + // A failed add (timeout, interrupted checkout, full disk) can leave + // a partial directory and a registration behind. No state entry is + // written, so nextName would hand out the same slot again and every + // later get would fail with "already exists". Removing what this + // attempt created is only safe when the slot did not exist before, + // which guarantees nothing under it predates this call. + if slotDirIsNew { + cleanupPartialWorktree(repoRoot, slotDir, wtPath) + } return fmt.Errorf("failed to create worktree: %w", err) } @@ -544,6 +556,20 @@ func cwdInWorktree(cwd, worktreePath string) bool { return rel == "." || !filepath.IsAbs(rel) && len(rel) >= 1 && rel[0] != '.' } +func cleanupPartialWorktree(repoRoot, slotDir, wtPath string) { + if err := os.RemoveAll(slotDir); err != nil { + fmt.Fprintf(os.Stderr, "🌳 Warning: failed to remove partially created worktree %s: %v\n", slotDir, err) + return + } + // PruneWorktreeAt, not PruneWorktrees: git locks a worktree while it + // creates it and unlocks it only on success, and a plain prune skips + // locked registrations. Leaving that lock behind would keep the slot + // registered and wedge every later get on this pool. + if err := vcs.PruneWorktreeAt(repoRoot, wtPath); err != nil { + fmt.Fprintf(os.Stderr, "🌳 Warning: failed to prune the registration for partially created worktree %s: %v\n", slotDir, err) + } +} + func nextName(state State) string { max := 0 for _, wt := range state.Worktrees { diff --git a/internal/pool/pool_test.go b/internal/pool/pool_test.go index 06ed0ec..587f2f7 100644 --- a/internal/pool/pool_test.go +++ b/internal/pool/pool_test.go @@ -2761,3 +2761,103 @@ func TestPrune_MarkerlessSlotSkippedAsCannotVerify(t *testing.T) { t.Fatalf("markerless slot must stay on disk: %v", err) } } + +func TestCleanupPartialWorktreeRemovesSlotAndRegistration(t *testing.T) { + repoDir, poolDir := setupLocalRepo(t) + + slotDir := filepath.Join(poolDir, "1") + wtPath := filepath.Join(slotDir, filepath.Base(repoDir)) + if err := os.MkdirAll(slotDir, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, repoDir, "worktree", "add", "--detach", wtPath, "HEAD") + + cleanupPartialWorktree(repoDir, slotDir, wtPath) + + if _, err := os.Stat(slotDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected the partial slot to be removed, got %v", err) + } + out, err := exec.Command("git", "-C", repoDir, "worktree", "list", "--porcelain").CombinedOutput() + if err != nil { + t.Fatalf("git worktree list failed: %v\n%s", err, out) + } + if strings.Contains(string(out), wtPath) { + t.Fatalf("expected the stale registration to be pruned, got:\n%s", out) + } +} + +func TestAcquireReusesSlotNameAfterFailedWorktreeCreation(t *testing.T) { + repoDir, poolDir := setupLocalRepo(t) + + slotDir := filepath.Join(poolDir, "1") + wtPath := filepath.Join(slotDir, filepath.Base(repoDir)) + if err := os.MkdirAll(slotDir, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, repoDir, "worktree", "add", "--detach", wtPath, "HEAD") + cleanupPartialWorktree(repoDir, slotDir, wtPath) + + got, err := Acquire(repoDir, poolDir, 4, nil) + if err != nil { + t.Fatalf("Acquire after a cleaned-up failure should succeed: %v", err) + } + if got != wtPath { + t.Fatalf("expected the freed slot %q to be reused, got %q", wtPath, got) + } +} + +func TestAcquireKeepsPreexistingSlotContentWhenCreationFails(t *testing.T) { + repoDir, poolDir := setupLocalRepo(t) + + wtPath := filepath.Join(poolDir, "1", filepath.Base(repoDir)) + if err := os.MkdirAll(wtPath, 0o755); err != nil { + t.Fatal(err) + } + keep := filepath.Join(wtPath, "unlanded.txt") + if err := os.WriteFile(keep, []byte("do not delete\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := Acquire(repoDir, poolDir, 4, nil); err == nil { + t.Fatal("expected worktree creation over an occupied slot to fail") + } + if _, err := os.Stat(keep); err != nil { + t.Fatalf("expected pre-existing slot content to survive a failed create: %v", err) + } +} + +// TestCleanupPartialWorktreeClearsInitializingLock pins recovery from an +// interrupted worktree creation. Git locks a worktree while it is being +// created ("initializing") and unlocks it only on success, while +// `git worktree prune` silently skips every locked registration. Without +// clearing that lock the slot stays registered forever and every later +// Acquire fails with "is a missing but locked worktree". +func TestCleanupPartialWorktreeClearsInitializingLock(t *testing.T) { + repoDir, poolDir := setupLocalRepo(t) + + slotDir := filepath.Join(poolDir, "1") + wtPath := filepath.Join(slotDir, filepath.Base(repoDir)) + if err := os.MkdirAll(slotDir, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, repoDir, "worktree", "add", "--detach", wtPath, "HEAD") + runGit(t, repoDir, "worktree", "lock", "--reason", "initializing", wtPath) + + cleanupPartialWorktree(repoDir, slotDir, wtPath) + + out, err := exec.Command("git", "-C", repoDir, "worktree", "list", "--porcelain").CombinedOutput() + if err != nil { + t.Fatalf("git worktree list failed: %v\n%s", err, out) + } + if strings.Contains(string(out), wtPath) { + t.Fatalf("expected the locked registration to be cleared, got:\n%s", out) + } + + got, err := Acquire(repoDir, poolDir, 4, nil) + if err != nil { + t.Fatalf("Acquire after a cleaned-up interrupted creation should succeed: %v", err) + } + if got != wtPath { + t.Fatalf("expected the freed slot %q to be reused, got %q", wtPath, got) + } +} diff --git a/internal/vcs/gitvcs/gitvcs.go b/internal/vcs/gitvcs/gitvcs.go index d52f417..b8f1fa9 100644 --- a/internal/vcs/gitvcs/gitvcs.go +++ b/internal/vcs/gitvcs/gitvcs.go @@ -2,13 +2,50 @@ package gitvcs import ( "bytes" + "context" + "errors" "fmt" "os" "os/exec" "path/filepath" + "runtime" "strings" + "sync" + "time" ) +const ( + // defaultGitCommandTimeout bounds metadata commands, whose runtime does + // not scale with repository size or network conditions. + defaultGitCommandTimeout = 2 * time.Minute + // defaultGitLongCommandTimeout bounds commands whose legitimate runtime + // does scale that way (fetching, and writing out a working tree through + // smudge/LFS filters). They stay bounded so a stalled command still + // surfaces, but generously enough that a merely slow one is not killed + // part-way through creating a worktree. + defaultGitLongCommandTimeout = 30 * time.Minute + gitCommandWaitDelay = 250 * time.Millisecond + + gitTimeoutEnv = "TREEHOUSE_GIT_TIMEOUT" + gitLongTimeoutEnv = "TREEHOUSE_GIT_LONG_TIMEOUT" + + // worktreeInitializingLock is the lock reason git writes while + // "git worktree add" is creating a worktree. + worktreeInitializingLock = "initializing" +) + +var longRunningGitCommands = map[string]bool{ + "checkout": true, + "clean": true, + "fetch": true, + "ls-remote": true, + "read-tree": true, +} + +var longRunningGitSubcommands = map[string]map[string]bool{ + "worktree": {"add": true, "remove": true}, +} + // FindMainRepoRoot returns the main repository root for the current working // directory. Inside a linked worktree it resolves back to the owning // repository, so pool resolution is stable no matter where a command runs. @@ -179,6 +216,92 @@ func PruneWorktrees(repoRoot string) error { return err } +// PruneWorktreeAt prunes stale worktree bookkeeping like PruneWorktrees and, +// in addition, clears the registration for path when an interrupted +// "git worktree add" left git's own lock behind. Git locks a worktree while +// it creates it and unlocks it only on success, and "git worktree prune" +// silently skips every locked registration, so a timed-out or crashed add +// leaves the path registered forever and later adds fail with "is a missing +// but locked worktree". Only git's own initializing lock is cleared, and only +// while the worktree directory is gone, so a lock a user took (for example on +// a worktree stored on removable media) is never disturbed. +func PruneWorktreeAt(repoRoot, path string) error { + if err := PruneWorktrees(repoRoot); err != nil { + return err + } + locked, err := initializingLockedWorktree(repoRoot, path) + if err != nil || locked == "" { + return err + } + if _, err := runGit(repoRoot, "worktree", "unlock", locked); err != nil { + return err + } + return PruneWorktrees(repoRoot) +} + +// initializingLockedWorktree returns git's own spelling of path when it is +// still registered, gone from disk, and locked with the reason git writes +// while creating a worktree. It returns an empty path in every other case, +// including a creation still in flight (its directory already exists). +func initializingLockedWorktree(repoRoot, path string) (string, error) { + out, err := runGit(repoRoot, "worktree", "list", "--porcelain") + if err != nil { + return "", err + } + want := resolveDeepestExisting(path) + current := "" + for _, line := range strings.Split(out, "\n") { + line = strings.TrimRight(line, "\r") + if registered, ok := strings.CutPrefix(line, "worktree "); ok { + current = registered + continue + } + if line != "locked "+worktreeInitializingLock || current == "" { + continue + } + if !samePath(resolveDeepestExisting(current), want) { + continue + } + if _, err := os.Stat(current); err == nil { + return "", nil + } + return current, nil + } + return "", nil +} + +// resolveDeepestExisting canonicalizes p for comparison with the paths git +// prints, which are symlink-resolved. The worktree is already gone by the +// time this runs, so it resolves the deepest ancestor that still exists and +// re-appends the rest: a component that does not exist cannot be a symlink. +func resolveDeepestExisting(p string) string { + abs, err := filepath.Abs(filepath.FromSlash(p)) + if err != nil { + return filepath.Clean(filepath.FromSlash(p)) + } + rest := "" + for cur := abs; ; { + if resolved, err := filepath.EvalSymlinks(cur); err == nil { + return filepath.Join(resolved, rest) + } + parent := filepath.Dir(cur) + if parent == cur { + return abs + } + rest = filepath.Join(filepath.Base(cur), rest) + cur = parent + } +} + +// samePath compares two already canonicalized paths, honoring the +// case-insensitive file systems Windows uses. +func samePath(a, b string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(a, b) + } + return a == b +} + func RemoveWorktree(repoRoot, path string) error { _, err := runGit(repoRoot, "worktree", "remove", "--force", path) return err @@ -433,42 +556,53 @@ func IsHeadMergedIntoDefault(repoRoot, worktreePath string) (bool, string, error // detects a squash merge without treating unrelated target-branch changes as a // mismatch. func IsHeadMergedIntoRef(worktreePath, ref string) (bool, error) { - cmd := exec.Command("git", "merge-base", "--is-ancestor", "HEAD", ref) - cmd.Dir = worktreePath - out, err := cmd.CombinedOutput() + ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeoutFor("merge-base")) + defer cancel() + + return isHeadMergedIntoRefContext(ctx, worktreePath, ref) +} + +func isHeadMergedIntoRefContext(ctx context.Context, worktreePath, ref string) (bool, error) { + args := []string{"merge-base", "--is-ancestor", "HEAD", ref} + out, err := gitCommandContext(ctx, worktreePath, args...).CombinedOutput() if err == nil { return true, nil } + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return false, gitTimeoutError(worktreePath, args) + } if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - return isHeadContentMergedIntoRef(worktreePath, ref) + return isHeadContentMergedIntoRefContext(ctx, worktreePath, ref) } - return false, fmt.Errorf("git merge-base --is-ancestor HEAD %s: %s", ref, strings.TrimSpace(string(out))) + return false, gitCombinedOutputError(worktreePath, args, out, err) } -func isHeadContentMergedIntoRef(worktreePath, ref string) (bool, error) { - cmd := exec.Command("git", "merge-base", "HEAD", ref) - cmd.Dir = worktreePath - out, err := cmd.CombinedOutput() +func isHeadContentMergedIntoRefContext(ctx context.Context, worktreePath, ref string) (bool, error) { + args := []string{"merge-base", "HEAD", ref} + out, err := gitCommandContext(ctx, worktreePath, args...).CombinedOutput() if err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return false, gitTimeoutError(worktreePath, args) + } if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { return false, fmt.Errorf("git merge-base HEAD %s returned no common ancestor", ref) } - return false, fmt.Errorf("git merge-base HEAD %s: %s", ref, strings.TrimSpace(string(out))) + return false, gitCombinedOutputError(worktreePath, args, out, err) } base := strings.TrimSpace(string(out)) if base == "" { return false, fmt.Errorf("git merge-base HEAD %s returned no common ancestor", ref) } - baseTree, err := readTree(worktreePath, base) + baseTree, err := readTreeContext(ctx, worktreePath, base) if err != nil { return false, err } - headTree, err := readTree(worktreePath, "HEAD") + headTree, err := readTreeContext(ctx, worktreePath, "HEAD") if err != nil { return false, err } - targetTree, err := readTree(worktreePath, ref) + targetTree, err := readTreeContext(ctx, worktreePath, ref) if err != nil { return false, err } @@ -498,8 +632,8 @@ func isHeadContentMergedIntoRef(worktreePath, ref string) (bool, error) { return true, nil } -func readTree(repoRoot, ref string) (map[string]string, error) { - out, err := runGitRaw(repoRoot, "ls-tree", "-r", "-z", "--full-tree", ref) +func readTreeContext(ctx context.Context, repoRoot, ref string) (map[string]string, error) { + out, err := runGitRawContext(ctx, repoRoot, "ls-tree", "-r", "-z", "--full-tree", ref) if err != nil { return nil, err } @@ -535,28 +669,161 @@ func IsDirty(worktreePath string) (bool, error) { } func runGit(dir string, args ...string) (string, error) { - out, err := runGitRaw(dir, args...) + ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeoutFor(args...)) + defer cancel() + + return runGitContext(ctx, dir, args...) +} + +func runGitContext(ctx context.Context, dir string, args ...string) (string, error) { + out, err := runGitRawContext(ctx, dir, args...) if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } -func runGitRaw(dir string, args ...string) ([]byte, error) { - cmd := exec.Command("git", args...) - if dir != "" { - cmd.Dir = dir - } - out, err := cmd.Output() +func runGitRawContext(ctx context.Context, dir string, args ...string) ([]byte, error) { + out, err := gitCommandContext(ctx, dir, args...).Output() if err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, gitTimeoutError(dir, args) + } if exitErr, ok := err.(*exec.ExitError); ok { return nil, fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(exitErr.Stderr))) } - return nil, err + if errors.Is(err, exec.ErrWaitDelay) { + return nil, fmt.Errorf( + "git %s in %q left its output pipe open past %s, most likely held by a background helper (fsmonitor, credential cache, or a smudge/clean filter): %w", + strings.Join(args, " "), + gitWorkingDir(dir), + gitCommandWaitDelay, + err, + ) + } + return nil, fmt.Errorf("git %s in %q: %w", strings.Join(args, " "), gitWorkingDir(dir), err) } return out, nil } +func gitCommandContext(ctx context.Context, dir string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.WaitDelay = gitCommandWaitDelay + if dir != "" { + cmd.Dir = dir + } + return cmd +} + +func gitTimeoutError(dir string, args []string) error { + return fmt.Errorf( + "git %s timed out in \"%s\"; check for a stale index lock (locate it with 'git rev-parse --git-path index.lock'), blocked credential prompts, or network connectivity. Raise %s if this repository legitimately needs longer", + strings.Join(args, " "), + gitWorkingDir(dir), + gitTimeoutEnvFor(args...), + ) +} + +// gitCombinedOutputError reports a CombinedOutput failure. git's own message +// is the best diagnostic when it produced one; otherwise the subcommand, the +// working directory, and the underlying exec error keep the failure +// identifiable instead of collapsing to an empty suffix. +func gitCombinedOutputError(dir string, args []string, out []byte, err error) error { + detail := strings.TrimSpace(string(out)) + if detail != "" { + if _, isExit := err.(*exec.ExitError); isExit { + return fmt.Errorf("git %s: %s", strings.Join(args, " "), detail) + } + return fmt.Errorf("git %s in %q: %w: %s", strings.Join(args, " "), gitWorkingDir(dir), err, detail) + } + return fmt.Errorf("git %s in %q: %w", strings.Join(args, " "), gitWorkingDir(dir), err) +} + +func gitWorkingDir(dir string) string { + if dir != "" { + return dir + } + if currentDir, err := os.Getwd(); err == nil { + return currentDir + } + return "." +} + +// gitCommandTimeoutFor resolves the deadline for a git invocation. Commands +// whose runtime scales with repository size or network conditions get the +// longer budget, and either budget can be overridden for repositories that +// legitimately need more time. +func gitCommandTimeoutFor(args ...string) time.Duration { + if isLongRunningGitCommand(args) { + return gitTimeoutFromEnv(gitLongTimeoutEnv, defaultGitLongCommandTimeout) + } + return gitTimeoutFromEnv(gitTimeoutEnv, defaultGitCommandTimeout) +} + +func gitTimeoutEnvFor(args ...string) string { + if isLongRunningGitCommand(args) { + return gitLongTimeoutEnv + } + return gitTimeoutEnv +} + +func isLongRunningGitCommand(args []string) bool { + command, subcommand := gitSubcommand(args) + if longRunningGitCommands[command] { + return true + } + return longRunningGitSubcommands[command][subcommand] +} + +// gitSubcommand skips leading global options ("-c foo=bar", "--exec-path=...") +// so classification sees the actual command. +func gitSubcommand(args []string) (string, string) { + command := "" + subcommand := "" + skipValue := false + for _, arg := range args { + if skipValue { + skipValue = false + continue + } + if strings.HasPrefix(arg, "-") { + if arg == "-c" || arg == "-C" { + skipValue = true + } + continue + } + if command == "" { + command = arg + continue + } + subcommand = arg + break + } + return command, subcommand +} + +func gitTimeoutFromEnv(name string, fallback time.Duration) time.Duration { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback + } + timeout, err := time.ParseDuration(raw) + if err != nil || timeout <= 0 { + warnOnce(name+"="+raw, fmt.Sprintf("🌳 Warning: ignoring invalid %s=%q; using %s\n", name, raw, fallback)) + return fallback + } + return timeout +} + +var warnedTimeoutValues sync.Map + +func warnOnce(key, message string) { + if _, seen := warnedTimeoutValues.LoadOrStore(key, struct{}{}); seen { + return + } + fmt.Fprint(os.Stderr, message) +} + // Backend adapts this package's functions to the vcs.Backend interface. All // methods delegate to the package-level implementations so behavior is // identical whether callers use the interface or the functions directly. @@ -579,6 +846,9 @@ func (*Backend) AddWorktree(repoRoot, path, branch string) error { return AddWorktree(repoRoot, path, branch) } func (*Backend) PruneWorktrees(repoRoot string) error { return PruneWorktrees(repoRoot) } +func (*Backend) PruneWorktreeAt(repoRoot, path string) error { + return PruneWorktreeAt(repoRoot, path) +} func (*Backend) RemoveWorktree(repoRoot, path string) error { return RemoveWorktree(repoRoot, path) } diff --git a/internal/vcs/gitvcs/gitvcs_test.go b/internal/vcs/gitvcs/gitvcs_test.go index 827cb0d..33c3067 100644 --- a/internal/vcs/gitvcs/gitvcs_test.go +++ b/internal/vcs/gitvcs/gitvcs_test.go @@ -10,6 +10,211 @@ import ( "time" ) +func TestRunGitContextPreservesNormalOutputAndExitDiagnostics(t *testing.T) { + repoDir := t.TempDir() + repoDir, err := filepath.EvalSymlinks(repoDir) + if err != nil { + t.Fatal(err) + } + mustGit(t, "", "init", "--initial-branch=main", repoDir) + + out, err := runGitContext(context.Background(), repoDir, "rev-parse", "--show-toplevel") + if err != nil { + t.Fatalf("runGitContext failed: %v", err) + } + // git reports --show-toplevel with forward slashes even on Windows. + if filepath.FromSlash(out) != repoDir { + t.Fatalf("expected trimmed repository path %q, got %q", repoDir, out) + } + + _, err = runGitContext(context.Background(), repoDir, "rev-parse", "--verify", "missing-ref") + if err == nil { + t.Fatal("expected missing ref to fail") + } + if !strings.Contains(err.Error(), "git rev-parse --verify missing-ref:") { + t.Fatalf("expected ordinary git exit diagnostic, got %q", err) + } +} + +func TestRunGitContextReportsActionableTimeout(t *testing.T) { + repoDir := t.TempDir() + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + + _, err := runGitContext(ctx, repoDir, "checkout", "--detach") + if err == nil { + t.Fatal("expected expired context to fail") + } + + message := err.Error() + for _, want := range []string{ + "git checkout --detach timed out", + repoDir, + "git rev-parse --git-path index.lock", + "credential", + "network", + } { + if !strings.Contains(message, want) { + t.Errorf("expected timeout diagnostic to contain %q, got %q", want, message) + } + } +} + +func TestRunGitContextDoesNotReportCancellationAsTimeout(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := runGitContext(ctx, t.TempDir(), "status") + if err == nil { + t.Fatal("expected canceled context to fail") + } + if strings.Contains(err.Error(), "timed out") { + t.Fatalf("expected cancellation to remain distinct from timeout, got %q", err) + } +} + +func TestRunGitContextBoundsDescendantHeldOutputPipe(t *testing.T) { + helperDir := t.TempDir() + helperName := "git-pipeholder" + if filepath.Ext(os.Args[0]) == ".exe" { + helperName += ".exe" + } + helperPath := filepath.Join(helperDir, helperName) + testBinary, err := os.ReadFile(os.Args[0]) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(helperPath, testBinary, 0o755); err != nil { + t.Fatal(err) + } + + readyPath := filepath.Join(t.TempDir(), "ready") + stopPath := filepath.Join(t.TempDir(), "stop") + donePath := filepath.Join(t.TempDir(), "done") + t.Setenv("TREEHOUSE_GIT_PIPE_HOLDER", "1") + t.Setenv("TREEHOUSE_GIT_PIPE_READY", readyPath) + t.Setenv("TREEHOUSE_GIT_PIPE_STOP", stopPath) + t.Setenv("TREEHOUSE_GIT_PIPE_DONE", donePath) + defer os.WriteFile(stopPath, nil, 0o644) //nolint:errcheck -- best-effort helper cleanup + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + started := time.Now() + _, err = runGitContext(ctx, "", "--exec-path="+helperDir, "pipeholder", "-test.run=^TestGitPipeHolderHelper$") + elapsed := time.Since(started) + if writeErr := os.WriteFile(stopPath, nil, 0o644); writeErr != nil { + t.Fatal(writeErr) + } + if !waitForFile(donePath, 2*time.Second) { + t.Error("git helper did not exit after its output pipe was released") + } + if err == nil { + t.Fatal("expected pipe-holding git command to time out") + } + if !strings.Contains(err.Error(), "timed out") { + t.Fatalf("expected timeout diagnostic, got %q", err) + } + if _, statErr := os.Stat(readyPath); statErr != nil { + t.Fatalf("expected git helper and its descendant to start: %v", statErr) + } + if elapsed >= 2*time.Second { + t.Fatalf("runGitContext remained blocked by an inherited output pipe for %v", elapsed) + } +} + +func TestGitPipeHolderHelper(t *testing.T) { + if os.Getenv("TREEHOUSE_GIT_PIPE_HOLDER") != "1" { + return + } + + stopPath := os.Getenv("TREEHOUSE_GIT_PIPE_STOP") + if os.Getenv("TREEHOUSE_GIT_PIPE_DESCENDANT") == "1" { + waitForFile(stopPath, 15*time.Second) + return + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestGitPipeHolderHelper$") + cmd.Env = append(os.Environ(), "TREEHOUSE_GIT_PIPE_DESCENDANT=1") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("TREEHOUSE_GIT_PIPE_READY"), nil, 0o644); err != nil { + t.Fatal(err) + } + waitForFile(stopPath, 15*time.Second) + _ = cmd.Wait() // The command's inherited output pipe is intentionally closed on timeout. + if err := os.WriteFile(os.Getenv("TREEHOUSE_GIT_PIPE_DONE"), nil, 0o644); err != nil { + t.Fatal(err) + } +} + +func waitForFile(path string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return true + } + time.Sleep(10 * time.Millisecond) + } + return false +} + +func TestIsHeadMergedIntoRefPreservesUnmergedExitCode(t *testing.T) { + repoDir := t.TempDir() + mustGit(t, "", "init", "--initial-branch=main", repoDir) + mustGit(t, repoDir, "config", "user.email", "test@test.com") + mustGit(t, repoDir, "config", "user.name", "Test") + if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("main\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGit(t, repoDir, "add", "README.md") + mustGit(t, repoDir, "commit", "-m", "main") + mustGit(t, repoDir, "checkout", "-b", "feature") + if err := os.WriteFile(filepath.Join(repoDir, "feature.txt"), []byte("feature\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGit(t, repoDir, "add", "feature.txt") + mustGit(t, repoDir, "commit", "-m", "feature") + + merged, err := IsHeadMergedIntoRef(repoDir, "refs/heads/main") + if err != nil { + t.Fatalf("IsHeadMergedIntoRef failed: %v", err) + } + if merged { + t.Fatal("expected feature HEAD not to be merged into main") + } +} + +func TestIsHeadMergedIntoRefContextReportsTimeout(t *testing.T) { + repoDir := t.TempDir() + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + + _, err := isHeadMergedIntoRefContext(ctx, repoDir, "refs/heads/main") + if err == nil { + t.Fatal("expected expired context to fail") + } + if !strings.Contains(err.Error(), "git merge-base --is-ancestor HEAD refs/heads/main timed out") { + t.Fatalf("expected merge-base timeout diagnostic, got %q", err) + } +} + +func TestIsHeadContentMergedIntoRefContextReportsTimeout(t *testing.T) { + repoDir := t.TempDir() + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + + _, err := isHeadContentMergedIntoRefContext(ctx, repoDir, "refs/heads/main") + if err == nil { + t.Fatal("expected expired context to fail") + } + if !strings.Contains(err.Error(), "git merge-base HEAD refs/heads/main timed out") { + t.Fatalf("expected fallback merge-base timeout diagnostic, got %q", err) + } +} + func TestRepoRootFromCommonGitDirHandlesForwardSlashPath(t *testing.T) { root, ok := repoRootFromCommonGitDir("C:/Users/runner/AppData/Local/Temp/repo/.git") if !ok { @@ -537,3 +742,135 @@ func mustGit(t *testing.T, dir string, args ...string) { t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) } } + +func TestGitCommandTimeoutForSeparatesLongRunningCommands(t *testing.T) { + cases := []struct { + args []string + want time.Duration + }{ + {[]string{"rev-parse", "--show-toplevel"}, defaultGitCommandTimeout}, + {[]string{"status", "--porcelain"}, defaultGitCommandTimeout}, + {[]string{"worktree", "prune"}, defaultGitCommandTimeout}, + {[]string{"fetch", "origin"}, defaultGitLongCommandTimeout}, + {[]string{"ls-remote", "--symref", "origin", "HEAD"}, defaultGitLongCommandTimeout}, + {[]string{"worktree", "add", "--detach", "path", "ref"}, defaultGitLongCommandTimeout}, + {[]string{"worktree", "remove", "--force", "path"}, defaultGitLongCommandTimeout}, + {[]string{"read-tree", "--reset", "-u", "ref"}, defaultGitLongCommandTimeout}, + {[]string{"clean", "-fd"}, defaultGitLongCommandTimeout}, + {[]string{"checkout", "--detach"}, defaultGitLongCommandTimeout}, + {[]string{"-c", "fetch.parallel=1", "fetch", "origin"}, defaultGitLongCommandTimeout}, + {[]string{"-c", "core.pager=cat", "rev-parse", "HEAD"}, defaultGitCommandTimeout}, + } + for _, tc := range cases { + if got := gitCommandTimeoutFor(tc.args...); got != tc.want { + t.Errorf("gitCommandTimeoutFor(%q) = %s, want %s", tc.args, got, tc.want) + } + } +} + +func TestGitCommandTimeoutForHonorsEnvironmentOverrides(t *testing.T) { + t.Setenv(gitTimeoutEnv, "45s") + t.Setenv(gitLongTimeoutEnv, "3h") + + if got := gitCommandTimeoutFor("status"); got != 45*time.Second { + t.Errorf("expected overridden standard budget, got %s", got) + } + if got := gitCommandTimeoutFor("fetch", "origin"); got != 3*time.Hour { + t.Errorf("expected overridden long budget, got %s", got) + } + + t.Setenv(gitTimeoutEnv, "not-a-duration") + t.Setenv(gitLongTimeoutEnv, "0") + if got := gitCommandTimeoutFor("status"); got != defaultGitCommandTimeout { + t.Errorf("expected unparseable override to fall back, got %s", got) + } + if got := gitCommandTimeoutFor("fetch", "origin"); got != defaultGitLongCommandTimeout { + t.Errorf("expected non-positive override to fall back, got %s", got) + } +} + +func TestGitTimeoutErrorNamesTheOverridableBudget(t *testing.T) { + err := gitTimeoutError("/tmp/repo", []string{"fetch", "origin"}) + if !strings.Contains(err.Error(), gitLongTimeoutEnv) { + t.Errorf("expected long-budget override hint, got %q", err) + } + err = gitTimeoutError("/tmp/repo", []string{"status"}) + if !strings.Contains(err.Error(), gitTimeoutEnv) { + t.Errorf("expected standard-budget override hint, got %q", err) + } +} + +func TestRunGitRawContextIdentifiesNonExitFailures(t *testing.T) { + repoDir := t.TempDir() + missingGitDir := filepath.Join(repoDir, "gone") + + _, err := runGitRawContext(context.Background(), missingGitDir, "rev-parse", "--show-toplevel") + if err == nil { + t.Fatal("expected a missing working directory to fail") + } + if !strings.Contains(err.Error(), "git rev-parse --show-toplevel in") { + t.Fatalf("expected the failing subcommand in the error, got %q", err) + } + if !strings.Contains(err.Error(), missingGitDir) { + t.Fatalf("expected the working directory in the error, got %q", err) + } +} + +// TestPruneWorktreeAtClearsInterruptedCreationLock covers the recovery path +// for an interrupted "git worktree add" and its two guards: a lock a user +// took and a worktree still on disk are both left registered. +func TestPruneWorktreeAtClearsInterruptedCreationLock(t *testing.T) { + tests := []struct { + name string + lockReason string + removeDir bool + wantCleared bool + }{ + {name: "interrupted creation", lockReason: worktreeInitializingLock, removeDir: true, wantCleared: true}, + {name: "unlocked stale registration", removeDir: true, wantCleared: true}, + {name: "user lock", lockReason: "on removable media", removeDir: true}, + {name: "creation still in flight", lockReason: worktreeInitializingLock}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + base := t.TempDir() + base, err := filepath.EvalSymlinks(base) + if err != nil { + t.Fatal(err) + } + repoDir := filepath.Join(base, "repo") + wtPath := filepath.Join(base, "slot", "worktree") + + mustGit(t, "", "init", "--initial-branch=main", repoDir) + mustGit(t, repoDir, "config", "user.email", "test@test.com") + mustGit(t, repoDir, "config", "user.name", "Test") + if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGit(t, repoDir, "add", ".") + mustGit(t, repoDir, "commit", "-m", "initial") + mustGit(t, repoDir, "worktree", "add", "--detach", wtPath, "main") + if tt.lockReason != "" { + mustGit(t, repoDir, "worktree", "lock", "--reason", tt.lockReason, wtPath) + } + if tt.removeDir { + if err := os.RemoveAll(filepath.Dir(wtPath)); err != nil { + t.Fatal(err) + } + } + + if err := PruneWorktreeAt(repoDir, wtPath); err != nil { + t.Fatalf("PruneWorktreeAt failed: %v", err) + } + + out, err := exec.Command("git", "-C", repoDir, "worktree", "list", "--porcelain").CombinedOutput() + if err != nil { + t.Fatalf("git worktree list failed: %v\n%s", err, out) + } + if got := strings.Contains(string(out), wtPath); got == tt.wantCleared { + t.Fatalf("registration cleared=%v, want cleared=%v; list:\n%s", !got, tt.wantCleared, out) + } + }) + } +} diff --git a/internal/vcs/jjvcs/jjvcs.go b/internal/vcs/jjvcs/jjvcs.go index 7d82cb7..b443660 100644 --- a/internal/vcs/jjvcs/jjvcs.go +++ b/internal/vcs/jjvcs/jjvcs.go @@ -243,6 +243,11 @@ func workspaceNameFor(path string) string { // AddWorktree forgets a same-path stale registration before re-adding. func (*Backend) PruneWorktrees(repoRoot string) error { return nil } +// PruneWorktreeAt is a no-op for the same reason, and jj has no equivalent of +// git's initializing lock: AddWorktree forgets a same-path stale registration +// before re-adding. +func (*Backend) PruneWorktreeAt(repoRoot, path string) error { return nil } + // RemoveWorktree forgets the workspace and deletes its directory even if it // has local changes. func (*Backend) RemoveWorktree(repoRoot, path string) error { diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go index 295dab6..f5f4c2c 100644 --- a/internal/vcs/vcs.go +++ b/internal/vcs/vcs.go @@ -62,6 +62,11 @@ type Backend interface { // PruneWorktrees clears bookkeeping for worktrees whose directories no // longer exist. It never touches live worktrees or their data. PruneWorktrees(repoRoot string) error + // PruneWorktreeAt does what PruneWorktrees does and additionally + // clears the registration for path when the VCS locked it while + // creating the worktree and the creation never finished. It never + // touches a worktree whose directory still exists. + PruneWorktreeAt(repoRoot, path string) error // RemoveWorktree removes a worktree even if it has local changes. RemoveWorktree(repoRoot, path string) error // RemoveCleanWorktree removes a worktree, refusing if it is not clean. @@ -275,6 +280,13 @@ func AddWorktree(repoRoot, path, branch string) error { // exist. func PruneWorktrees(repoRoot string) error { return backendFor(repoRoot).PruneWorktrees(repoRoot) } +// PruneWorktreeAt clears bookkeeping for worktrees whose directories no +// longer exist, including the registration for path when an interrupted +// worktree creation left the VCS's own lock on it. +func PruneWorktreeAt(repoRoot, path string) error { + return backendFor(repoRoot).PruneWorktreeAt(repoRoot, path) +} + // RemoveWorktree removes a worktree even if it has local changes. func RemoveWorktree(repoRoot, path string) error { return backendForRemoval(repoRoot, path).RemoveWorktree(repoRoot, path)