mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
Split 1/3 of #829, carved out as the independently-reversible hardening layer with no dependency on the orchestration work that stacks on top of it. - internal/pathjail: os.Root handle-relative path confinement — ancestor traversal is confined, the final component is checked for reparse points (including a name written with a trailing separator), unprivileged Windows junctions are covered, temp-name fragments are rejected before they can escape the directory, whitespace-bearing paths are confined as spelled, and temp files stay unpredictable and exclusive. - internal/credstore: file locking around the credential read-modify-write — exclusive for writers, shared for readers, so a reader cannot race a publish. - internal/worktrees: git invocation hardening. pathjail has no importers in this PR by design; internal/memory and internal/specialist adopt it in the stacked PRs (2/3 and 3/3). Origin-Session: local-de382f | Claude Code | 4 prompts Origin-Snapshot: 52db9b74dad5 Co-authored-by: Claude Opus 4.8 <[email protected]>
1172 lines
46 KiB
Go
1172 lines
46 KiB
Go
package worktrees
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type GitRunner func(context.Context, string, ...string) (CommandResult, error)
|
|
|
|
type CommandResult struct {
|
|
Stdout string
|
|
Stderr string
|
|
ExitCode int
|
|
}
|
|
|
|
type Options struct {
|
|
Cwd string
|
|
Name string
|
|
BaseDir string
|
|
Env map[string]string
|
|
Now func() time.Time
|
|
RunGit GitRunner
|
|
// LeasePID, when positive, records the owning process in the worktree
|
|
// lock reason. A lease carrying a PID is recoverable: if that process
|
|
// dies without releasing (SIGKILL, crash, power loss), Clean can expire
|
|
// the lease instead of skipping the locked worktree forever. Callers
|
|
// whose worktree outlives their process (zero worktrees prepare hands
|
|
// the path to an external owner) leave it zero for a persistent lock
|
|
// that only an explicit release clears.
|
|
LeasePID int
|
|
}
|
|
|
|
type Result struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
RepoRoot string `json:"repoRoot"`
|
|
SourceBranch string `json:"sourceBranch,omitempty"`
|
|
SourceCommit string `json:"sourceCommit,omitempty"`
|
|
Reused bool `json:"reused"`
|
|
// LockAcquired reports whether this Prepare call took the worktree lock.
|
|
// It is false when a reused worktree was already locked by another live
|
|
// caller; releasing that lease is that caller's responsibility, not ours.
|
|
LockAcquired bool `json:"lockAcquired"`
|
|
}
|
|
|
|
var worktreeNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$`)
|
|
|
|
func Prepare(ctx context.Context, options Options) (Result, error) {
|
|
cwd, err := resolveCwd(options.Cwd)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
runGit := options.RunGit
|
|
if runGit == nil {
|
|
runGit = defaultRunGit
|
|
}
|
|
now := options.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
name := strings.TrimSpace(options.Name)
|
|
if name == "" {
|
|
name = defaultWorktreeName(now())
|
|
}
|
|
if err := validateName(name); err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
// Clean up stale worktrees (older than 24 hours) automatically to prevent
|
|
// disk space leaks. Only after the request itself validated: a rejected
|
|
// command (for example an invalid --name) must not have destructive
|
|
// cleanup side effects before reporting its error.
|
|
if options.RunGit == nil {
|
|
_ = Clean(ctx, options, 24*time.Hour)
|
|
}
|
|
|
|
repoRoot, err := gitOutput(ctx, runGit, cwd, "rev-parse", "--show-toplevel")
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("not a git repository: %w", err)
|
|
}
|
|
repoRoot = filepath.Clean(repoRoot)
|
|
// Key the per-repository worktree bucket off the MAIN worktree's root, not
|
|
// repoRoot (which is whichever worktree - main or a linked one - this call
|
|
// happened to run from): git worktree list --porcelain always reports the
|
|
// main worktree first, regardless of invocation location, so this keeps
|
|
// Prepare and Release's ownership key derivation in agreement (see
|
|
// verifyZeroOwnedWorktree) even when Prepare runs from a linked worktree.
|
|
// Without it, a worktree prepared from a linked checkout hashes a
|
|
// different repoKey than Release computes, and its lease can never be
|
|
// cleared.
|
|
primaryRoot, err := primaryWorktreeRoot(ctx, runGit, repoRoot)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
branch, _ := gitOutput(ctx, runGit, repoRoot, "rev-parse", "--abbrev-ref", "HEAD")
|
|
commit, _ := gitOutput(ctx, runGit, repoRoot, "rev-parse", "--short", "HEAD")
|
|
|
|
baseDir := strings.TrimSpace(options.BaseDir)
|
|
if baseDir == "" {
|
|
baseDir, err = DefaultBaseDir(options.Env)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
}
|
|
baseDir, err = filepath.Abs(baseDir)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("resolve worktree dir: %w", err)
|
|
}
|
|
|
|
repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(primaryRoot))
|
|
target := filepath.Join(repoDir, name)
|
|
result := Result{
|
|
Name: name,
|
|
Path: target,
|
|
RepoRoot: primaryRoot,
|
|
SourceBranch: branch,
|
|
SourceCommit: commit,
|
|
}
|
|
reused, err := inspectTarget(target)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if reused {
|
|
sameRepo, err := sameGitCommonDir(ctx, runGit, repoRoot, target)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("inspect existing worktree repository: %w", err)
|
|
}
|
|
if !sameRepo {
|
|
return Result{}, fmt.Errorf("worktree path already exists for a different git repository: %s", target)
|
|
}
|
|
result.Reused = true
|
|
// A reused worktree may have been released by a prior run's exit, and
|
|
// an unlocked target is exposed to Clean's staleness heuristic while
|
|
// this caller is still using it, so re-establish the lease here. A
|
|
// lock already held by a live owner means another run is still using
|
|
// the path: two live runs must not share one supposedly isolated
|
|
// checkout, so reject it. A Zero lease whose recorded PID is dead
|
|
// (SIGKILL, crash) is reclaimable the same way Clean recovers it —
|
|
// otherwise a crashed `zero exec --worktree --worktree-name X` bricks
|
|
// that name until someone runs release by hand.
|
|
acquired, err := lockWorktree(ctx, runGit, repoRoot, target, options.LeasePID)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if !acquired {
|
|
reclaimed, reclaimErr := reclaimDeadOwnerLease(ctx, runGit, repoRoot, target)
|
|
if reclaimErr != nil {
|
|
return Result{}, reclaimErr
|
|
}
|
|
if reclaimed {
|
|
acquired, err = lockWorktree(ctx, runGit, repoRoot, target, options.LeasePID)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
}
|
|
}
|
|
if !acquired {
|
|
return Result{}, fmt.Errorf("worktree %s is locked by another active run; release it with `zero worktrees release %s` if that run is finished, or use a different --name", target, target)
|
|
}
|
|
result.LockAcquired = true
|
|
// Touch the worktree directory so Clean's mtime-based staleness check
|
|
// doesn't prune a worktree that's actively in use by the current process.
|
|
// Long-running tasks that don't write new files (e.g. waiting on a model)
|
|
// would otherwise accumulate stale mtimes and be wrongly force-removed.
|
|
if err := os.Chtimes(target, time.Now(), time.Now()); err != nil {
|
|
_, unlockErr := gitOutput(ctx, runGit, repoRoot, "worktree", "unlock", target)
|
|
return Result{}, errors.Join(err, unlockErr)
|
|
}
|
|
if err := writeOwnershipMarker(ctx, runGit, target); err != nil {
|
|
_, unlockErr := gitOutput(ctx, runGit, repoRoot, "worktree", "unlock", target)
|
|
return Result{}, errors.Join(err, unlockErr)
|
|
}
|
|
return result, nil
|
|
}
|
|
if err := os.MkdirAll(repoDir, 0o700); err != nil {
|
|
return Result{}, fmt.Errorf("create worktree directory: %w", err)
|
|
}
|
|
commandResult, err := runGit(ctx, repoRoot, "worktree", "add", "--detach", target, "HEAD")
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("create git worktree: %w", err)
|
|
}
|
|
if commandResult.ExitCode != 0 {
|
|
message := strings.TrimSpace(firstNonEmpty(commandResult.Stderr, commandResult.Stdout))
|
|
if message == "" {
|
|
message = fmt.Sprintf("git worktree add exited with code %d", commandResult.ExitCode)
|
|
}
|
|
return Result{}, fmt.Errorf("create git worktree: %s", message)
|
|
}
|
|
// Lock the worktree so Clean's mtime+dirty staleness heuristic (which
|
|
// cannot tell "abandoned" apart from "clean but waiting on a model,
|
|
// network, or user for a long time") never force-removes a worktree zero
|
|
// itself is still using. entry.locked already makes Clean skip a worktree
|
|
// a human locked by hand; locking here extends that same protection to
|
|
// the worktrees zero creates. An "already locked" answer on a worktree
|
|
// this call just created means another process raced us to it: treat that
|
|
// exactly like the reuse collision above rather than claiming a lease
|
|
// this call never acquired.
|
|
acquired, err := lockWorktree(ctx, runGit, repoRoot, target, options.LeasePID)
|
|
if err != nil {
|
|
// This call created target and nothing else can own it yet (a
|
|
// concurrent racer would have made lockWorktree return acquired=false,
|
|
// not an error), so a failure here (not "already locked", an actual
|
|
// git failure) must not leave an unleased, newly created worktree
|
|
// behind: Clean cannot distinguish it from live-but-not-yet-touched
|
|
// work, so it would sit as a disk leak until the staleness window
|
|
// passes. Best-effort: report a removal failure alongside the
|
|
// original lock error rather than letting it mask the leak.
|
|
// gitOutput (not a raw runGit call) so a nonzero exit code is treated
|
|
// as a failure: defaultRunGit returns a nil error alongside a nonzero
|
|
// CommandResult.ExitCode for a failed git invocation.
|
|
if _, removeErr := gitOutput(ctx, runGit, repoRoot, "worktree", "remove", "--force", target); removeErr != nil {
|
|
return Result{}, errors.Join(err, fmt.Errorf("clean up worktree after failed lock: %w", removeErr))
|
|
}
|
|
return Result{}, err
|
|
}
|
|
if !acquired {
|
|
return Result{}, fmt.Errorf("worktree %s is locked by another active run; release it with `zero worktrees release %s` if that run is finished, or use a different --name", target, target)
|
|
}
|
|
result.LockAcquired = true
|
|
if err := writeOwnershipMarker(ctx, runGit, target); err != nil {
|
|
_, unlockErr := gitOutput(ctx, runGit, repoRoot, "worktree", "unlock", target)
|
|
_, removeErr := gitOutput(ctx, runGit, repoRoot, "worktree", "remove", "--force", target)
|
|
return Result{}, errors.Join(err, unlockErr, removeErr)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// leaseReasonPrefix marks a lock Zero itself created (vs a human `git
|
|
// worktree lock`); a "(pid N)" suffix makes the lease recoverable by Clean
|
|
// once the owning process is gone.
|
|
const leaseReasonPrefix = "zero: active task worktree"
|
|
|
|
// leaseReason renders the lock reason for a Zero lease, embedding the owning
|
|
// PID when the caller's worktree lifetime is bound to its process.
|
|
func leaseReason(pid int) string {
|
|
if pid > 0 {
|
|
return fmt.Sprintf("%s (pid %d)", leaseReasonPrefix, pid)
|
|
}
|
|
return leaseReasonPrefix
|
|
}
|
|
|
|
// leasePID extracts the owning PID from a Zero lease reason. ok=false for
|
|
// human locks and PID-less Zero leases (external prepare callers), which
|
|
// only an explicit release may clear.
|
|
func leasePID(reason string) (int, bool) {
|
|
rest, found := strings.CutPrefix(strings.TrimSpace(reason), leaseReasonPrefix+" (pid ")
|
|
if !found {
|
|
return 0, false
|
|
}
|
|
digits, found := strings.CutSuffix(rest, ")")
|
|
if !found {
|
|
return 0, false
|
|
}
|
|
pid, err := strconv.Atoi(digits)
|
|
if err != nil || pid <= 0 {
|
|
return 0, false
|
|
}
|
|
return pid, true
|
|
}
|
|
|
|
// processAlive reports whether pid is a live process. It fails closed: any
|
|
// ambiguity (permission denied, platform limits) counts as alive, so an
|
|
// uncertain answer can only keep a lease, never expire one. Detection is
|
|
// platform-specific (see osProcessAlive in worktrees_posix.go /
|
|
// worktrees_windows.go): os.FindProcess alone cannot tell a dead PID from a
|
|
// live one on Windows, since OpenProcess can still succeed for a process that
|
|
// has already exited but whose handle has not been fully released.
|
|
func processAlive(pid int) bool {
|
|
if pid <= 0 {
|
|
return true
|
|
}
|
|
return osProcessAlive(pid)
|
|
}
|
|
|
|
// lockWorktree takes the Clean-protection lease on target via `git worktree
|
|
// lock`. It reports whether this call acquired the lease: a lock already held
|
|
// by someone else is left in place and reported as not acquired, so the
|
|
// caller knows the matching Release belongs to the lease's original owner.
|
|
func lockWorktree(ctx context.Context, runGit GitRunner, repoRoot string, target string, pid int) (bool, error) {
|
|
lockResult, err := runGit(ctx, repoRoot, "worktree", "lock", "--reason", leaseReason(pid), target)
|
|
if err != nil {
|
|
return false, fmt.Errorf("lock git worktree: %w", err)
|
|
}
|
|
if lockResult.ExitCode == 0 {
|
|
return true, nil
|
|
}
|
|
message := strings.TrimSpace(firstNonEmpty(lockResult.Stderr, lockResult.Stdout))
|
|
if strings.Contains(message, "already locked") {
|
|
return false, nil
|
|
}
|
|
if message == "" {
|
|
message = fmt.Sprintf("git worktree lock exited with code %d", lockResult.ExitCode)
|
|
}
|
|
return false, fmt.Errorf("lock git worktree: %s", message)
|
|
}
|
|
|
|
// reclaimDeadOwnerLease unlocks target when its porcelain entry is a Zero
|
|
// lease whose recorded PID is provably dead. Live owners, human locks, and
|
|
// PID-less Zero leases are left alone. Returns true only when an unlock ran
|
|
// successfully so the caller can retry lockWorktree.
|
|
func reclaimDeadOwnerLease(ctx context.Context, runGit GitRunner, repoRoot string, target string) (bool, error) {
|
|
output, err := gitOutput(ctx, runGit, repoRoot, "worktree", "list", "--porcelain")
|
|
if err != nil {
|
|
return false, fmt.Errorf("list git worktrees for lease reclaim: %w", err)
|
|
}
|
|
want := canonicalizePath(target)
|
|
for _, entry := range parseWorktreeList(output) {
|
|
if canonicalizePath(entry.path) != want {
|
|
continue
|
|
}
|
|
if !entry.locked {
|
|
return false, nil
|
|
}
|
|
pid, ok := leasePID(entry.lockReason)
|
|
if !ok || processAlive(pid) {
|
|
return false, nil
|
|
}
|
|
if _, err := gitOutput(ctx, runGit, repoRoot, "worktree", "unlock", entry.path); err != nil {
|
|
return false, fmt.Errorf("reclaim dead-owner lease on %s: %w", entry.path, err)
|
|
}
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// Release unlocks a worktree that Prepare locked, via `git worktree unlock`,
|
|
// making it eligible for Clean's staleness check again. Zero itself only
|
|
// knows a worktree's use is over when its own process created it and is now
|
|
// exiting (zero exec --worktree calls this via defer); zero worktrees
|
|
// prepare hands the path to an external caller with no defined end-of-life,
|
|
// so that caller must run `zero worktrees release <path>` itself once done.
|
|
// Until it does, the worktree stays locked and Clean will never touch it,
|
|
// which is the safe default over silently guessing at liveness from mtimes.
|
|
func Release(ctx context.Context, options Options, path string) error {
|
|
runGit := options.RunGit
|
|
if runGit == nil {
|
|
runGit = defaultRunGit
|
|
}
|
|
// git needs a working directory that still exists to run in. path itself
|
|
// normally works (it's the worktree being unlocked), but if a caller
|
|
// manually deleted a locked worktree directory instead of releasing it
|
|
// first, path is gone; fall back to options.Cwd (the main repo) so the
|
|
// leaked, now-orphaned lock can still be unlocked and later pruned.
|
|
dir := path
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
if cwd, cwdErr := resolveCwd(options.Cwd); cwdErr == nil {
|
|
dir = cwd
|
|
}
|
|
}
|
|
matchedPath, err := verifyZeroOwnedWorktree(ctx, runGit, dir, path)
|
|
if err != nil {
|
|
if errors.Is(err, errAlreadyUnlocked) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if _, err := gitOutput(ctx, runGit, dir, "worktree", "unlock", matchedPath); err != nil {
|
|
return fmt.Errorf("unlock git worktree: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// zeroOwnerMarkerFile is the name of the ownership marker Prepare writes into
|
|
// a worktree's own private git admin directory (`git rev-parse
|
|
// --absolute-git-dir`), never into the working tree itself: the working tree
|
|
// is what `git status` inspects for staleness/dirtiness, and a marker living
|
|
// there would either show up as untracked noise (defeating Clean's dirty
|
|
// check) or need a .gitignore entry this package has no business adding to a
|
|
// caller's repository. The admin directory survives even after the worktree
|
|
// directory itself is deleted by hand (git only forgets it on `worktree
|
|
// prune`), and is not something `git worktree add` populates on its own, so
|
|
// its presence is what actually proves Zero's own Prepare created a given
|
|
// worktree - unlike the public zero-worktree-<repoKey> path convention and
|
|
// the leaseReasonPrefix lock-reason string, both of which a user can
|
|
// reproduce by hand for a worktree of the same repository.
|
|
const zeroOwnerMarkerFile = "zero-owner"
|
|
|
|
// zeroOwnerMarkerContent is the marker's fixed body. Its value carries no
|
|
// meaning beyond "Prepare wrote this"; the file's mere presence at the
|
|
// expected location is the signal Release and Clean check.
|
|
const zeroOwnerMarkerContent = "zero: this worktree was created by `zero worktrees prepare`\n"
|
|
|
|
// writeOwnershipMarker persists the ownership marker for target, a worktree
|
|
// path that must still exist on disk (Prepare calls this immediately after
|
|
// creating or re-locking it). Overwriting an existing marker is harmless and
|
|
// lets a worktree Prepare created before this marker existed self-heal the
|
|
// next time Prepare reuses it.
|
|
func writeOwnershipMarker(ctx context.Context, runGit GitRunner, target string) error {
|
|
gitDir, err := gitOutput(ctx, runGit, target, "rev-parse", "--absolute-git-dir")
|
|
if err != nil {
|
|
return fmt.Errorf("resolve worktree git dir: %w", err)
|
|
}
|
|
// Atomic replace: Clean may read the same path from another process while
|
|
// Prepare re-marks a reused worktree. Write-then-rename so a concurrent
|
|
// reader never observes a truncated file.
|
|
destination := filepath.Join(gitDir, zeroOwnerMarkerFile)
|
|
temporary, err := os.CreateTemp(gitDir, zeroOwnerMarkerFile+".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("write worktree ownership marker: %w", err)
|
|
}
|
|
temporaryName := temporary.Name()
|
|
defer os.Remove(temporaryName)
|
|
if err := temporary.Chmod(0o600); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("write worktree ownership marker: %w", err)
|
|
}
|
|
if _, err := temporary.WriteString(zeroOwnerMarkerContent); err != nil {
|
|
_ = temporary.Close()
|
|
return fmt.Errorf("write worktree ownership marker: %w", err)
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return fmt.Errorf("write worktree ownership marker: %w", err)
|
|
}
|
|
if err := os.Rename(temporaryName, destination); err != nil {
|
|
return fmt.Errorf("write worktree ownership marker: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// hasOwnershipMarker reports whether target's own git admin directory carries
|
|
// the marker writeOwnershipMarker persists. It fails closed: any error other
|
|
// than the marker simply not existing is returned rather than treated as
|
|
// "not owned so it's fine to skip," since callers otherwise use false to mean
|
|
// "safe to leave alone."
|
|
func hasOwnershipMarker(ctx context.Context, runGit GitRunner, target string) (bool, error) {
|
|
gitDir, err := gitOutput(ctx, runGit, target, "rev-parse", "--absolute-git-dir")
|
|
if err != nil {
|
|
return false, fmt.Errorf("resolve worktree git dir: %w", err)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(gitDir, zeroOwnerMarkerFile))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("read worktree ownership marker: %w", err)
|
|
}
|
|
return string(content) == zeroOwnerMarkerContent, nil
|
|
}
|
|
|
|
// isLegacyZeroWorktree verifies whether a worktree lacking zero-owner was
|
|
// created by a pre-upgrade version of Zero for this repository.
|
|
func isLegacyZeroWorktree(ctx context.Context, runGit GitRunner, target string, repoDir string, entry worktreeEntry) bool {
|
|
if !isUnderDir(canonicalizePath(target), repoDir) {
|
|
return false
|
|
}
|
|
if entry.locked {
|
|
if !strings.HasPrefix(strings.TrimSpace(entry.lockReason), leaseReasonPrefix) {
|
|
return false
|
|
}
|
|
}
|
|
gitDir, err := gitOutput(ctx, runGit, target, "rev-parse", "--absolute-git-dir")
|
|
if err != nil || strings.TrimSpace(gitDir) == "" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// verifyZeroOwnedWorktree confirms path has a zero-worktree-<repoKey> ancestor
|
|
// directory component, is a registered worktree of the repository, and (when
|
|
// locked) carries a lock reason Zero itself set, so Release cannot be used to
|
|
// clear the lock on a worktree a user (or another tool) manages by hand: the
|
|
// command is documented as releasing a worktree `prepare` created, not an
|
|
// arbitrary git worktree lock. The ancestor-component check stands in for
|
|
// reconstructing and comparing a full repoDir, because Release has no
|
|
// reliable way to know which --dir a long-gone Prepare call used (the CLI
|
|
// never threads BaseDir through to Release, and a custom --dir is not
|
|
// recorded anywhere the lock/unlock path can read back); the repoKey
|
|
// component is Prepare's actual ownership signature regardless of which
|
|
// directory it was created under. The repository root for that key, and the
|
|
// lock reason for the ownership check, both come from the same `git worktree
|
|
// list --porcelain` call, which works whether dir is the worktree itself or
|
|
// the main repository (its first entry is always the main working tree, from
|
|
// any worktree, regardless of git-dir layout), so this needs no branching on
|
|
// which of Release's two cwd cases is in play.
|
|
func verifyZeroOwnedWorktree(ctx context.Context, runGit GitRunner, dir string, path string) (string, error) {
|
|
output, err := gitOutput(ctx, runGit, dir, "worktree", "list", "--porcelain")
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve repository for %s: %w", path, err)
|
|
}
|
|
entries := parseWorktreeList(output)
|
|
if len(entries) == 0 {
|
|
return "", fmt.Errorf("resolve repository for %s: git worktree list returned no entries", path)
|
|
}
|
|
// entries[0].path is always the main worktree (see primaryWorktreeRoot),
|
|
// which is what Prepare now also keys its repoKey off, so this and Prepare
|
|
// agree regardless of which worktree either call ran from.
|
|
want := "zero-worktree-" + repoKey(entries[0].path)
|
|
|
|
target := canonicalizePath(path)
|
|
|
|
hasZeroComponent := false
|
|
for _, component := range strings.Split(target, string(filepath.Separator)) {
|
|
if component == want {
|
|
hasZeroComponent = true
|
|
break
|
|
}
|
|
}
|
|
if !hasZeroComponent {
|
|
return "", fmt.Errorf("refusing to release %s: not a zero-managed worktree (expected an ancestor directory named %q)", path, want)
|
|
}
|
|
|
|
// Require a registered porcelain entry before unlocking. Matching only
|
|
// the public zero-worktree-<repoKey> path component would let Release
|
|
// clear a manual lock on any same-named path under that directory.
|
|
// Path comparison uses canonicalizePath so a lexical user argument
|
|
// (macOS /var vs /private/var, symlink --worktree-dir) still matches
|
|
// the physical spelling git worktree list reports.
|
|
var matchedPath string
|
|
for _, entry := range entries {
|
|
if canonicalizePath(entry.path) != target {
|
|
continue
|
|
}
|
|
matchedPath = entry.path
|
|
if !entry.locked {
|
|
// Already unlocked: nothing to release. Treat as success so a
|
|
// double release is a no-op rather than a git "not locked" error.
|
|
return "", errAlreadyUnlocked
|
|
}
|
|
if !strings.HasPrefix(entry.lockReason, leaseReasonPrefix) {
|
|
return "", fmt.Errorf("refusing to release %s: locked with reason %q, not a zero lease", path, entry.lockReason)
|
|
}
|
|
if info, statErr := os.Stat(entry.path); statErr == nil && info.IsDir() {
|
|
owned, err := hasOwnershipMarker(ctx, runGit, entry.path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("verify worktree ownership for %s: %w", path, err)
|
|
}
|
|
if !owned {
|
|
return "", fmt.Errorf("refusing to release %s: missing zero ownership marker (not created by `zero worktrees prepare`)", path)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
if matchedPath == "" {
|
|
return "", fmt.Errorf("refusing to release %s: not a registered worktree of this repository", path)
|
|
}
|
|
return matchedPath, nil
|
|
}
|
|
|
|
// errAlreadyUnlocked is a sentinel for a Zero-managed path whose git lock is
|
|
// already clear; Release returns nil without calling unlock.
|
|
var errAlreadyUnlocked = errors.New("worktree already unlocked")
|
|
|
|
// canonicalizePath returns the physical, cleaned form of path when it can be
|
|
// resolved (EvalSymlinks), otherwise Abs+Clean. Used so comparisons against
|
|
// `git worktree list --porcelain` (which reports physical paths) succeed for
|
|
// lexical or symlinked user spellings of the same location.
|
|
func canonicalizePath(path string) string {
|
|
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
|
return filepath.Clean(resolved)
|
|
}
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return filepath.Clean(path)
|
|
}
|
|
abs = filepath.Clean(abs)
|
|
// path itself does not exist (the documented release -C recovery: the
|
|
// worktree directory was deleted by hand before releasing it), so
|
|
// EvalSymlinks above had nothing to resolve. If an ANCESTOR of path is
|
|
// itself a symlink (a symlinked --worktree-dir), the plain Abs+Clean
|
|
// fallback keeps that ancestor's logical spelling, which never matches
|
|
// git's physical-path porcelain entry, and the lock can never be
|
|
// released. Resolve symlinks through the nearest existing ancestor
|
|
// instead and reattach the missing tail.
|
|
return resolveThroughNearestExistingAncestor(abs)
|
|
}
|
|
|
|
// resolveThroughNearestExistingAncestor walks up from path until it finds an
|
|
// existing ancestor directory, resolves that ancestor's symlinks, and
|
|
// reattaches path's missing remainder onto the resolved ancestor.
|
|
func resolveThroughNearestExistingAncestor(path string) string {
|
|
var missing []string
|
|
current := path
|
|
for {
|
|
if resolved, err := filepath.EvalSymlinks(current); err == nil {
|
|
result := filepath.Clean(resolved)
|
|
for i := len(missing) - 1; i >= 0; i-- {
|
|
result = filepath.Join(result, missing[i])
|
|
}
|
|
return result
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
// Reached the filesystem root without finding an existing
|
|
// ancestor; nothing left to resolve symlinks through.
|
|
return path
|
|
}
|
|
missing = append(missing, filepath.Base(current))
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
func DefaultBaseDir(env map[string]string) (string, error) {
|
|
if runtime.GOOS == "windows" {
|
|
if localAppData := strings.TrimSpace(envValue(env, "LOCALAPPDATA")); localAppData != "" {
|
|
return filepath.Join(localAppData, "zero", "worktrees"), nil
|
|
}
|
|
if profile := strings.TrimSpace(envValue(env, "USERPROFILE")); profile != "" {
|
|
return filepath.Join(profile, "AppData", "Local", "zero", "worktrees"), nil
|
|
}
|
|
}
|
|
|
|
if stateHome := strings.TrimSpace(envValue(env, "XDG_STATE_HOME")); stateHome != "" {
|
|
return filepath.Join(stateHome, "zero", "worktrees"), nil
|
|
}
|
|
home := strings.TrimSpace(firstNonEmpty(envValue(env, "HOME"), envValue(env, "USERPROFILE")))
|
|
if home == "" {
|
|
var err error
|
|
home, err = os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve user home: %w", err)
|
|
}
|
|
}
|
|
return filepath.Join(home, ".local", "state", "zero", "worktrees"), nil
|
|
}
|
|
|
|
func resolveCwd(cwd string) (string, error) {
|
|
if strings.TrimSpace(cwd) == "" {
|
|
var err error
|
|
cwd, err = os.Getwd()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve cwd: %w", err)
|
|
}
|
|
}
|
|
absolute, err := filepath.Abs(cwd)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve cwd: %w", err)
|
|
}
|
|
info, err := os.Stat(absolute)
|
|
if err != nil || !info.IsDir() {
|
|
return "", fmt.Errorf("cwd must be an existing directory: %s", absolute)
|
|
}
|
|
return filepath.Clean(absolute), nil
|
|
}
|
|
|
|
func validateName(name string) error {
|
|
if !worktreeNamePattern.MatchString(name) || strings.ContainsAny(name, `/\`) {
|
|
return fmt.Errorf("invalid worktree name %q: use letters, numbers, dots, dashes, or underscores", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inspectTarget(target string) (bool, error) {
|
|
info, err := os.Stat(target)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("inspect worktree path: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return false, fmt.Errorf("worktree path already exists and is not a directory: %s", target)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(target, ".git")); err == nil {
|
|
return true, nil
|
|
}
|
|
entries, err := os.ReadDir(target)
|
|
if err != nil {
|
|
return false, fmt.Errorf("inspect worktree directory: %w", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
return false, fmt.Errorf("worktree path already exists and is not empty: %s", target)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func gitOutput(ctx context.Context, runGit GitRunner, dir string, args ...string) (string, error) {
|
|
result, err := runGit(ctx, dir, args...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if result.ExitCode != 0 {
|
|
message := strings.TrimSpace(firstNonEmpty(result.Stderr, result.Stdout))
|
|
if message == "" {
|
|
message = fmt.Sprintf("git exited with code %d", result.ExitCode)
|
|
}
|
|
return "", fmt.Errorf("%s", message)
|
|
}
|
|
return strings.TrimSpace(result.Stdout), nil
|
|
}
|
|
|
|
// primaryWorktreeRoot returns the repository's main worktree path. `git
|
|
// worktree list --porcelain` always reports the main worktree first (its
|
|
// first entry), regardless of which linked worktree the command runs from -
|
|
// which is what lets Prepare and verifyZeroOwnedWorktree agree on one
|
|
// repoKey for the same repository even when invoked from different
|
|
// worktrees of it.
|
|
func primaryWorktreeRoot(ctx context.Context, runGit GitRunner, dir string) (string, error) {
|
|
output, err := gitOutput(ctx, runGit, dir, "worktree", "list", "--porcelain")
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve repository root: %w", err)
|
|
}
|
|
entries := parseWorktreeList(output)
|
|
if len(entries) == 0 {
|
|
return "", fmt.Errorf("resolve repository root: git worktree list returned no entries")
|
|
}
|
|
return entries[0].path, nil
|
|
}
|
|
|
|
func sameGitCommonDir(ctx context.Context, runGit GitRunner, sourceDir string, targetDir string) (bool, error) {
|
|
sourceCommonDir, err := gitCommonDir(ctx, runGit, sourceDir)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
targetCommonDir, err := gitCommonDir(ctx, runGit, targetDir)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return sourceCommonDir == targetCommonDir, nil
|
|
}
|
|
|
|
func gitCommonDir(ctx context.Context, runGit GitRunner, dir string) (string, error) {
|
|
value, err := gitOutput(ctx, runGit, dir, "rev-parse", "--git-common-dir")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !filepath.IsAbs(value) {
|
|
value = filepath.Join(dir, value)
|
|
}
|
|
absolute, err := filepath.Abs(value)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve git common dir: %w", err)
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(absolute)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve git common dir: %w", err)
|
|
}
|
|
return filepath.Clean(resolved), nil
|
|
}
|
|
|
|
// newHardenedCommand builds a subprocess that dies as one unit on cancel.
|
|
//
|
|
// Extracted so a test can exercise the SAME constructor defaultRunGit uses. A
|
|
// test that only called hardenWorktreeGit would prove the helper sets three
|
|
// fields and prove nothing about whether anything calls it — which is the
|
|
// wiring gap this feature has produced four times.
|
|
func newHardenedCommand(ctx context.Context, dir string, name string, args ...string) *exec.Cmd {
|
|
command := exec.CommandContext(ctx, name, args...)
|
|
command.Dir = dir
|
|
// Pin C locale so English phrases callers match on (e.g. "already locked"
|
|
// in lockWorktree) stay parseable under localized git installs.
|
|
//
|
|
// Set HERE rather than in defaultRunGit, so every command built through this
|
|
// constructor is parseable, not just the one path that happened to need it
|
|
// first.
|
|
command.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
|
// Without this a cancelled git is signalled but its Wait can still block on
|
|
// pipes a grandchild holds open. git is short-lived and rarely forks, so
|
|
// this is a small risk — but the worktree path is now reached by a plan,
|
|
// and a plan can be cancelled at any moment by /plans stop or by the run
|
|
// ending.
|
|
hardenWorktreeGit(command)
|
|
return command
|
|
}
|
|
|
|
func defaultRunGit(ctx context.Context, dir string, args ...string) (CommandResult, error) {
|
|
command := newHardenedCommand(ctx, dir, "git", args...)
|
|
// Capture stdout and stderr separately: callers parse Stdout for values
|
|
// (rev-parse output) and prefer Stderr for error messages. CombinedOutput
|
|
// merged the two, letting git's stderr warnings pollute parsed output and
|
|
// leaving CommandResult.Stderr always empty.
|
|
var stdout, stderr bytes.Buffer
|
|
command.Stdout = &stdout
|
|
command.Stderr = &stderr
|
|
err := command.Run()
|
|
exitCode := 0
|
|
if err != nil {
|
|
exitCode = -1
|
|
if exitError, ok := err.(*exec.ExitError); ok {
|
|
exitCode = exitError.ExitCode()
|
|
err = nil
|
|
}
|
|
}
|
|
return CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitCode}, err
|
|
}
|
|
|
|
func defaultWorktreeName(now time.Time) string {
|
|
return "task-" + now.UTC().Format("20060102-150405")
|
|
}
|
|
|
|
func repoKey(repoRoot string) string {
|
|
sum := sha1.Sum([]byte(filepath.Clean(repoRoot)))
|
|
hash := hex.EncodeToString(sum[:])[:10]
|
|
base := filepath.Base(repoRoot)
|
|
base = strings.ToLower(base)
|
|
base = strings.Trim(regexp.MustCompile(`[^a-z0-9._-]+`).ReplaceAllString(base, "-"), "-._")
|
|
if base == "" {
|
|
base = "repo"
|
|
}
|
|
return base + "-" + hash
|
|
}
|
|
|
|
func envValue(env map[string]string, key string) string {
|
|
if env != nil {
|
|
return env[key]
|
|
}
|
|
return os.Getenv(key)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Clean prunes any zero-owned git worktrees older than maxAge to prevent disk space leaks.
|
|
func Clean(ctx context.Context, options Options, maxAge time.Duration) error {
|
|
cwd, err := resolveCwd(options.Cwd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
runGit := options.RunGit
|
|
if runGit == nil {
|
|
runGit = defaultRunGit
|
|
}
|
|
repoRoot, err := gitOutput(ctx, runGit, cwd, "rev-parse", "--show-toplevel")
|
|
if err != nil {
|
|
return fmt.Errorf("not a git repository: %w", err)
|
|
}
|
|
repoRoot = filepath.Clean(repoRoot)
|
|
|
|
baseDir := strings.TrimSpace(options.BaseDir)
|
|
if baseDir == "" {
|
|
baseDir, err = DefaultBaseDir(options.Env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
baseDir, err = filepath.Abs(baseDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// git worktree list --porcelain reports each worktree's PHYSICAL location,
|
|
// resolving any symlink component (for example a --worktree-dir that is
|
|
// itself a symlink, or a symlinked ancestor). Comparing entry paths against
|
|
// a merely-absolute (but not symlink-resolved) baseDir would then reject
|
|
// every worktree Prepare actually created under a symlinked base, leaving
|
|
// them permanently unprunable. EvalSymlinks failing (most commonly because
|
|
// the directory does not exist yet) just means there is nothing under it
|
|
// to prune, so falling back to the plain absolute path is safe.
|
|
if resolved, err := filepath.EvalSymlinks(baseDir); err == nil {
|
|
baseDir = resolved
|
|
}
|
|
|
|
output, err := gitOutput(ctx, runGit, repoRoot, "worktree", "list", "--porcelain")
|
|
if err != nil {
|
|
return fmt.Errorf("list git worktrees: %w", err)
|
|
}
|
|
entries := parseWorktreeList(output)
|
|
if len(entries) == 0 {
|
|
return fmt.Errorf("list git worktrees: git worktree list returned no entries")
|
|
}
|
|
// Prepare only ever creates worktrees under this per-repository subtree
|
|
// (mirroring the repoDir it computes). Scoping pruning to baseDir itself
|
|
// would authorize deleting a worktree a user manages by hand in the same
|
|
// directory, which Zero never created and has no business force-removing.
|
|
//
|
|
// The bucket must be keyed off the MAIN worktree's root
|
|
// (entries[0].path, per primaryWorktreeRoot), not repoRoot: repoRoot is
|
|
// --show-toplevel for whichever worktree Clean itself runs from, which is
|
|
// a linked checkout's own root when Clean (or the Prepare call that
|
|
// auto-invokes it) runs there. Prepare keys repoDir off the same
|
|
// entries[0].path, so using repoRoot here instead would compute a
|
|
// different repoDir than Prepare's and filter out every actual
|
|
// zero-owned worktree for this repository whenever either call runs from
|
|
// a linked worktree.
|
|
repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(entries[0].path))
|
|
|
|
cutoff := time.Now().Add(-maxAge)
|
|
var lastErr error
|
|
for _, entry := range entries {
|
|
// Compare against the physical spelling of the entry path. Git's
|
|
// porcelain listing is normally already physical, but test fixtures
|
|
// and some symlink layouts can leave a logical spelling that would
|
|
// otherwise fail the containment check against the resolved repoDir.
|
|
// Git commands still receive entry.path (the registered spelling).
|
|
entryPath := canonicalizePath(entry.path)
|
|
// Only prune worktrees zero created for this repository (i.e. inside
|
|
// repoDir), using a path-boundary-safe comparison so a sibling
|
|
// directory that merely shares repoDir as a string prefix (e.g.
|
|
// "<repoDir>-other") can't match.
|
|
if !isUnderDir(entryPath, repoDir) {
|
|
continue
|
|
}
|
|
|
|
// A locked worktree is never a prune candidate - with one recovery
|
|
// carve-out: a Zero lease whose recorded owner process is provably
|
|
// dead (SIGKILL, crash, power loss skipped the deferred release).
|
|
// Without it, an abnormal exit leaves the lock in place forever and
|
|
// Clean can never reclaim the disk. Human locks and PID-less Zero
|
|
// leases (external prepare callers) are always honored.
|
|
expiredLease := false
|
|
if entry.locked {
|
|
pid, ok := leasePID(entry.lockReason)
|
|
if !ok || processAlive(pid) {
|
|
continue
|
|
}
|
|
expiredLease = true
|
|
}
|
|
|
|
// Prefer the physical path for filesystem probes when the entry
|
|
// resolves; fall back to the registered spelling if it does not
|
|
// (for example a prunable entry whose directory is already gone).
|
|
statPath := entryPath
|
|
info, err := os.Stat(statPath)
|
|
if err != nil && statPath != entry.path {
|
|
statPath = entry.path
|
|
info, err = os.Stat(statPath)
|
|
}
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
if expiredLease {
|
|
// Match the on-disk recovery path: a failed unlock leaves
|
|
// the lock in place and must not be reported as success, or
|
|
// the orphan is skipped silently on every later Clean pass.
|
|
if _, unlockErr := gitOutput(ctx, runGit, repoRoot, "worktree", "unlock", entry.path); unlockErr != nil {
|
|
lastErr = errors.Join(lastErr, fmt.Errorf("recover expired lease %s: %w", entry.path, unlockErr))
|
|
continue
|
|
}
|
|
}
|
|
_, _ = runGit(ctx, repoRoot, "worktree", "prune")
|
|
}
|
|
continue
|
|
}
|
|
if !info.IsDir() {
|
|
continue
|
|
}
|
|
|
|
if worktreeIsStale(statPath, cutoff) {
|
|
// Ownership / legacy status first: the dirty probe's includeIgnored
|
|
// flag depends on it. An explicit release (unlocked, marker present)
|
|
// is a completion signal, so ignored residue is reclaimable. An
|
|
// expired lease is not (task may have died mid-work). A legacy
|
|
// pre-upgrade worktree never had Release at all — unlocked was its
|
|
// only state — so it gets the same benefit of the doubt as a
|
|
// crashed lease: ignored files block removal. Probing dirty before
|
|
// this would treat every legacy unlocked worktree as released and
|
|
// delete gitignored contents (e.g. .env) on first post-upgrade Clean.
|
|
owned, err := hasOwnershipMarker(ctx, runGit, statPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
legacy := false
|
|
if !owned {
|
|
legacy = isLegacyZeroWorktree(ctx, runGit, statPath, repoDir, entry)
|
|
}
|
|
includeIgnored := expiredLease || legacy
|
|
if worktreeIsDirty(ctx, runGit, statPath, includeIgnored) {
|
|
// A stale mtime only means nothing changed at the worktree's
|
|
// top level or below recently; it does not mean the task
|
|
// holding it is done. Uncommitted or untracked changes are
|
|
// still live work waiting on a model, network, or user, so
|
|
// force-removing here would discard it. Skip until it either
|
|
// gets committed/cleaned (no longer dirty) or unlocked.
|
|
continue
|
|
}
|
|
if !owned {
|
|
if legacy {
|
|
if writeErr := writeOwnershipMarker(ctx, runGit, statPath); writeErr == nil {
|
|
owned = true
|
|
}
|
|
}
|
|
}
|
|
if !owned {
|
|
continue
|
|
}
|
|
if err := preserveUnreachableWorktreeHead(ctx, runGit, repoRoot, statPath); err != nil {
|
|
lastErr = errors.Join(lastErr, fmt.Errorf("preserve worktree HEAD %s: %w", entry.path, err))
|
|
continue
|
|
}
|
|
if expiredLease {
|
|
// Recover the dead owner's lease only once every removal
|
|
// guard has passed, so a failed unlock (or a later guard)
|
|
// leaves the lock in place rather than half-recovered.
|
|
if _, err := gitOutput(ctx, runGit, repoRoot, "worktree", "unlock", entry.path); err != nil {
|
|
lastErr = errors.Join(lastErr, fmt.Errorf("recover expired lease %s: %w", entry.path, err))
|
|
continue
|
|
}
|
|
}
|
|
// gitOutput (not a raw runGit call) so a nonzero exit code is
|
|
// reported as a failure: defaultRunGit deliberately returns a nil
|
|
// error alongside a nonzero CommandResult.ExitCode for a failed git
|
|
// invocation, so checking only the returned error would silently
|
|
// treat a failed removal (busy, permission denied) as success.
|
|
if _, err := gitOutput(ctx, runGit, repoRoot, "worktree", "remove", "--force", entry.path); err != nil {
|
|
// errors.Join, not a plain overwrite: multiple stale worktrees can
|
|
// fail removal in the same Clean pass (locking, permissions), and
|
|
// only reporting the last one would hide the others from the caller.
|
|
lastErr = errors.Join(lastErr, fmt.Errorf("remove worktree %s: %w", entry.path, err))
|
|
}
|
|
}
|
|
}
|
|
|
|
_, _ = runGit(ctx, repoRoot, "worktree", "prune")
|
|
return lastErr
|
|
}
|
|
|
|
type worktreeEntry struct {
|
|
path string
|
|
locked bool
|
|
lockReason string
|
|
}
|
|
|
|
// parseWorktreeList reads `git worktree list --porcelain` output into one
|
|
// entry per worktree. Entries are delimited by their own "worktree <path>"
|
|
// line rather than by blank-line blocks, so this tolerates both real git
|
|
// output (attribute lines plus a blank-line separator) and a minimal listing
|
|
// with no separators.
|
|
func parseWorktreeList(output string) []worktreeEntry {
|
|
var entries []worktreeEntry
|
|
var current *worktreeEntry
|
|
for _, line := range strings.Split(output, "\n") {
|
|
switch {
|
|
case strings.HasPrefix(line, "worktree "):
|
|
if current != nil {
|
|
entries = append(entries, *current)
|
|
}
|
|
current = &worktreeEntry{path: canonicalizePath(strings.TrimPrefix(line, "worktree "))}
|
|
case current != nil && (line == "locked" || strings.HasPrefix(line, "locked ")):
|
|
current.locked = true
|
|
current.lockReason = strings.TrimSpace(strings.TrimPrefix(line, "locked"))
|
|
}
|
|
}
|
|
if current != nil {
|
|
entries = append(entries, *current)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// isUnderDir reports whether path is dir itself or a descendant of it. Unlike
|
|
// a bare strings.HasPrefix(path, dir), this rejects a sibling that merely
|
|
// shares dir as a string prefix (e.g. dir "/a/base" must not match
|
|
// "/a/base-other"), and filepath.Rel makes the comparison Windows-correct.
|
|
func isUnderDir(path, dir string) bool {
|
|
rel, err := filepath.Rel(dir, path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if rel == "." {
|
|
return true
|
|
}
|
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
// preserveUnreachableWorktreeHead guards against `git worktree remove --force`
|
|
// silently discarding a commit: Prepare creates every worktree with `worktree
|
|
// add --detach`, so its HEAD is a plain commit, not a branch, and nothing
|
|
// outside that worktree's own administrative files points at it. If a task
|
|
// committed its result there and the worktree goes stale before that commit
|
|
// is otherwise referenced (merged, pushed, cherry-picked), force-removing it
|
|
// deletes the only ref keeping the commit reachable - it becomes immediately
|
|
// eligible for git gc, exactly as if it had never been committed. This checks
|
|
// whether some OTHER ref in the repository already contains worktreePath's
|
|
// HEAD; if none does, it creates a durable ref for it in the main
|
|
// repository's refs namespace before the caller proceeds to remove the
|
|
// worktree, so the commit survives (visible under refs/zero/orphaned-worktree)
|
|
// even after the worktree itself is gone.
|
|
func preserveUnreachableWorktreeHead(ctx context.Context, runGit GitRunner, repoRoot, worktreePath string) error {
|
|
// --verify --quiet distinguishes unborn/missing HEAD (exit 1, no output)
|
|
// from real probe failures (other nonzero codes). Treating every failure
|
|
// as "nothing to preserve" used to authorize force-removal when HEAD was
|
|
// simply unreadable.
|
|
result, err := runGit(ctx, worktreePath, "rev-parse", "--verify", "--quiet", "HEAD")
|
|
if err != nil {
|
|
return fmt.Errorf("resolve worktree HEAD: %w", err)
|
|
}
|
|
if result.ExitCode != 0 {
|
|
if result.ExitCode == 1 {
|
|
// Unborn or missing HEAD: no commit to keep alive.
|
|
return nil
|
|
}
|
|
message := strings.TrimSpace(firstNonEmpty(result.Stderr, result.Stdout))
|
|
if message == "" {
|
|
message = fmt.Sprintf("git exited with code %d", result.ExitCode)
|
|
}
|
|
return fmt.Errorf("resolve worktree HEAD: %s", message)
|
|
}
|
|
head := strings.TrimSpace(result.Stdout)
|
|
if head == "" {
|
|
return nil
|
|
}
|
|
contained, err := gitOutput(ctx, runGit, repoRoot, "for-each-ref", "--contains="+head, "--count=1", "--format=%(refname)")
|
|
if err != nil {
|
|
return fmt.Errorf("check ref reachability for %s: %w", head, err)
|
|
}
|
|
if strings.TrimSpace(contained) != "" {
|
|
// Already reachable from some branch/tag; the worktree's own HEAD is
|
|
// redundant and removal cannot orphan the commit.
|
|
return nil
|
|
}
|
|
refName := "refs/zero/orphaned-worktree/" + head
|
|
if _, err := gitOutput(ctx, runGit, repoRoot, "update-ref", refName, head); err != nil {
|
|
return fmt.Errorf("preserve unreachable commit %s: %w", head, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// worktreeIsStale reports whether every file under root was last modified
|
|
// before cutoff. A directory's own mtime only changes when an entry is
|
|
// added, removed, or renamed directly inside it, not when a long-running
|
|
// task edits files deeper in the tree, so checking root's mtime alone can
|
|
// mistake an actively-used worktree for stale. Walking the tree and bailing
|
|
// out on the first recent entry avoids that false positive.
|
|
//
|
|
// Any inspection failure (a WalkDir error, or a DirEntry that can't report its
|
|
// own info) fails closed: it's treated the same as "not stale," never as
|
|
// "stale," so an incomplete inspection can't authorize a forced removal.
|
|
func worktreeIsStale(root string, cutoff time.Time) bool {
|
|
stale := true
|
|
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
stale = false
|
|
return filepath.SkipAll
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
stale = false
|
|
return filepath.SkipAll
|
|
}
|
|
if info.ModTime().After(cutoff) {
|
|
stale = false
|
|
return filepath.SkipAll
|
|
}
|
|
return nil
|
|
})
|
|
return stale && walkErr == nil
|
|
}
|
|
|
|
// worktreeIsDirty reports whether a worktree has changes that block a forced
|
|
// removal, via `git status --porcelain` run inside it. A task can hold a
|
|
// worktree with live, unpushed work while it waits on a model, network, or
|
|
// user for far longer than the staleness window, without writing to the tree
|
|
// again in that time; mtime alone can't distinguish that from an abandoned
|
|
// one, but a dirty working tree still can.
|
|
//
|
|
// includeIgnored additionally counts files matched by .gitignore
|
|
// (credentials, generated drafts, task artifacts) as live data. It is set
|
|
// when the worktree's owner never signaled completion (an expired crashed
|
|
// lease): such files may be all a dead task left behind. It is clear for an
|
|
// explicitly released worktree, where ignored residue like node_modules or
|
|
// build output would otherwise make the released checkout unreclaimable at
|
|
// every age - release is the owner's statement that the task is done.
|
|
//
|
|
// An inspection failure fails closed, treating it as dirty rather than clean:
|
|
// an incomplete check must not authorize a forced removal.
|
|
func worktreeIsDirty(ctx context.Context, runGit GitRunner, path string, includeIgnored bool) bool {
|
|
args := []string{"status", "--porcelain"}
|
|
if includeIgnored {
|
|
args = append(args, "--ignored")
|
|
}
|
|
output, err := gitOutput(ctx, runGit, path, args...)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return output != ""
|
|
}
|