Files
zero/internal/worktrees/worktrees.go
KRATOSandClaude Opus 4.8 060d38ce71 feat(security): path-jail primitive, credential-store locking, worktree git hardening (#891)
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]>
2026-08-15 20:24:08 +08:00

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 != ""
}