From fa6dfacf922d0fd65d3c93ff23a589cee88db609 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:44:38 -0400 Subject: [PATCH 01/32] fix(secrets,worktrees): fix secret redaction leakage and prune stale worktrees 1. Prevent trailing redaction leaks in github_token, aws_access_key_id, and google_api_key by adding trailing word boundaries and allowing variable lengths. Refine the openai_key pattern to cleanly distinguish legacy keys and modern prefixed keys (sk-proj-, sk-svcacct-) from ordinary kebab-case phrases. 2. Implement auto-pruning of zero-owned git worktrees older than 24 hours at the start of worktrees.Prepare to prevent indefinite disk space leaks. --- internal/secrets/scanner.go | 18 +++---- internal/secrets/scanner_test.go | 34 ++++++++++++- internal/worktrees/worktrees.go | 72 ++++++++++++++++++++++++++++ internal/worktrees/worktrees_test.go | 63 ++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 11 deletions(-) diff --git a/internal/secrets/scanner.go b/internal/secrets/scanner.go index e3c612e9b..8d0fd99b3 100644 --- a/internal/secrets/scanner.go +++ b/internal/secrets/scanner.go @@ -31,18 +31,18 @@ type pattern struct { // as an openai_key. Real secrets are preceded by a delimiter (space, quote, =, :, // start-of-string), all of which satisfy \b. var patterns = []pattern{ - {"aws_access_key_id", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}`)}, - {"github_token", regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36}`)}, - {"github_pat", regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}`)}, - {"slack_token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`)}, - {"google_api_key", regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35}`)}, - // Body allows - and _ so modern prefixed keys (sk-proj-…, sk-svcacct-…) match, - // not just the legacy sk- shape. - {"openai_key", regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`)}, + {"aws_access_key_id", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)}, + {"github_token", regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36,}\b`)}, + {"github_pat", regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}\b`)}, + {"slack_token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}\b`)}, + {"google_api_key", regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}\b`)}, + // Distinguish modern prefixed keys (sk-proj- / sk-svcacct-) from normal kebab-case phrases, + // and match legacy sk- keys by length (>= 20). + {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-)[A-Za-z0-9_-]{20,}\b|\bsk-[A-Za-z0-9]{20,}\b`)}, // Match the ENTIRE PEM/OpenSSH block (header THROUGH the END marker, body // included) so redaction removes the key material, not just the header. {"private_key_block", regexp.MustCompile(`(?s)-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----.*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----`)}, - {"jwt", regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`)}, + {"jwt", regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)}, } // Scan returns the distinct secrets found in text (deduplicated by match, diff --git a/internal/secrets/scanner_test.go b/internal/secrets/scanner_test.go index 6ad4521e9..0ebfbed46 100644 --- a/internal/secrets/scanner_test.go +++ b/internal/secrets/scanner_test.go @@ -50,11 +50,11 @@ func TestRedactNestedSecretStillRemovesWholeBlock(t *testing.T) { // AWS key shape, which sorts before private_key_block). Redaction must remove // the WHOLE block: if the inner match were replaced first it would corrupt the // block's exact string and leave the BEGIN/END header in the output. - key := "-----BEGIN PRIVATE KEY-----\nAKIAABCDEFGHIJKLMNOP\nMIIEowIBAAKCAQEAbody\n-----END PRIVATE KEY-----" + key := "-----BEGIN PRIVATE KEY-----\nAKIAIOSFODNN7EXAMPLE\nMIIEowIBAAKCAQEAbody\n-----END PRIVATE KEY-----" text := "leaked:\n" + key + "\ndone" redacted, _ := Redact(text) - for _, leaked := range []string{"PRIVATE KEY", "AKIAABCDEFGHIJKLMNOP", "MIIEowIBAAKCAQEAbody"} { + for _, leaked := range []string{"PRIVATE KEY", "AKIAIOSFODNN7EXAMPLE", "MIIEowIBAAKCAQEAbody"} { if strings.Contains(redacted, leaked) { t.Fatalf("redaction leaked %q from a nested-secret block: %q", leaked, redacted) } @@ -120,3 +120,33 @@ func TestScanDetectsModernPrefixedOpenAIKeys(t *testing.T) { } } } + +func TestScanRedactsLongerKeysWithoutTailLeak(t *testing.T) { + cases := []struct { + wantType string + secret string + }{ + {"github_token", "ghp_1234567890abcdefghijklmnopqrstuvwxyz1234567890abcdef"}, // 50 chars instead of 40 + {"google_api_key", "AIzaSyA1234567890abcdefghijklmnopqrstuv12345678"}, // 50 chars instead of 39 + } + for _, tc := range cases { + text := "longer key is " + tc.secret + " in text" + redacted, findings := Redact(text) + if len(findings) != 1 || findings[0].Type != tc.wantType { + t.Errorf("expected one %s finding, got %#v", tc.wantType, findings) + continue + } + wantRedacted := "longer key is [REDACTED:" + tc.wantType + "] in text" + if redacted != wantRedacted { + t.Errorf("redacted = %q, want %q", redacted, wantRedacted) + } + } +} + +func TestScanIgnoresKebabCaseStartingWithSk(t *testing.T) { + phrase := "sk-learn-machine-learning-model" + findings := Scan("testing " + phrase + " in text") + if len(findings) != 0 { + t.Errorf("expected no match for non-secret kebab-case phrase %q, got: %#v", phrase, findings) + } +} diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 1ab9eca81..6b4260c87 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -44,6 +44,11 @@ type Result struct { var worktreeNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$`) func Prepare(ctx context.Context, options Options) (Result, error) { + // Clean up stale worktrees (older than 24 hours) automatically to prevent disk space leaks. + if options.RunGit == nil { + _ = Clean(ctx, options, 24*time.Hour) + } + cwd, err := resolveCwd(options.Cwd) if err != nil { return Result{}, err @@ -298,3 +303,70 @@ func firstNonEmpty(values ...string) string { } 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 + } + + output, err := gitOutput(ctx, runGit, repoRoot, "worktree", "list", "--porcelain") + if err != nil { + return fmt.Errorf("list git worktrees: %w", err) + } + + lines := strings.Split(output, "\n") + var lastErr error + for _, line := range lines { + if !strings.HasPrefix(line, "worktree ") { + continue + } + path := strings.TrimPrefix(line, "worktree ") + path = filepath.Clean(path) + + // Only prune worktrees that belong to zero (i.e. inside baseDir) + if !strings.HasPrefix(path, baseDir) { + continue + } + + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + _, _ = runGit(ctx, repoRoot, "worktree", "prune") + } + continue + } + + if time.Since(info.ModTime()) > maxAge { + _, err = runGit(ctx, repoRoot, "worktree", "remove", "--force", path) + if err != nil { + lastErr = fmt.Errorf("remove worktree %s: %w", path, err) + } + } + } + + _, _ = runGit(ctx, repoRoot, "worktree", "prune") + return lastErr +} diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 90df4f76f..1e58199c1 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -248,3 +248,66 @@ func fixedTime(value string) func() time.Time { } return func() time.Time { return parsed } } + +func TestCleanPrunesStaleWorktrees(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + + // Create directories representing two worktrees: one young, one stale. + youngPath := filepath.Join(baseDir, "young-task") + stalePath := filepath.Join(baseDir, "stale-task") + if err := os.MkdirAll(youngPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(stalePath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + + // Change mtime of stale-task to be in the past (e.g. 2 days ago). + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(stalePath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, // rev-parse --show-toplevel + {Stdout: "worktree " + youngPath + "\nworktree " + stalePath + "\n"}, // worktree list --porcelain + {ExitCode: 0}, // worktree remove --force + {ExitCode: 0}, // worktree prune + }, + } + + options := Options{ + Cwd: repoRoot, + BaseDir: baseDir, + RunGit: runner.Run, + } + + err := Clean(context.Background(), options, 24*time.Hour) + if err != nil { + t.Fatalf("Clean failed: %v", err) + } + + // Verify the calls made by Clean + if len(runner.calls) != 4 { + t.Fatalf("expected 4 git calls, got %d", len(runner.calls)) + } + if runner.commandLine(0) != "git rev-parse --show-toplevel" { + t.Errorf("call 0 = %q", runner.commandLine(0)) + } + if runner.commandLine(1) != "git worktree list --porcelain" { + t.Errorf("call 1 = %q", runner.commandLine(1)) + } + expectedRemoveCall := "git worktree remove --force " + filepath.Clean(stalePath) + if runner.commandLine(2) != expectedRemoveCall { + t.Errorf("call 2 = %q, want %q", runner.commandLine(2), expectedRemoveCall) + } + if runner.commandLine(3) != "git worktree prune" { + t.Errorf("call 3 = %q", runner.commandLine(3)) + } +} From cfba521a9653c60dc69f42981f804cf742c6fd92 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:02:18 -0400 Subject: [PATCH 02/32] fix(secrets,worktrees): close tail-leak edge case and worktree data-loss risk Drop the trailing \b anchor on the four secret patterns whose body class allows "-" (slack_token, google_api_key, the modern openai_key branch, jwt). \b requires a word/non-word transition, so a secret ending in "-" right before a delimiter has none, and the engine backtracked the greedy quantifier to drop that last character instead of failing the match, leaking it. The body character class already provides the real stopping boundary, so the anchor was unnecessary. Fix two issues in worktree Clean flagged in review: - Staleness was decided by the worktree directory's own mtime, which only changes when an entry is added/removed/renamed directly inside it, not when a long-running task edits existing files deeper in the tree. Clean now walks the tree and treats any recently modified entry as live, and also skips any worktree a caller has explicitly locked via git worktree lock. - baseDir ownership used a raw strings.HasPrefix, so a sibling like "-other" would false-match. Replaced with a filepath.Rel path-boundary check. --- internal/secrets/scanner.go | 16 ++- internal/secrets/scanner_test.go | 32 +++++ internal/worktrees/worktrees.go | 98 ++++++++++++++-- internal/worktrees/worktrees_test.go | 169 +++++++++++++++++++++++++++ 4 files changed, 300 insertions(+), 15 deletions(-) diff --git a/internal/secrets/scanner.go b/internal/secrets/scanner.go index 8d0fd99b3..9deba3877 100644 --- a/internal/secrets/scanner.go +++ b/internal/secrets/scanner.go @@ -30,19 +30,27 @@ type pattern struct { // mid-word — e.g. "sk-" inside "task-management-and-coordination" must NOT match // as an openai_key. Real secrets are preceded by a delimiter (space, quote, =, :, // start-of-string), all of which satisfy \b. +// +// A trailing \b is omitted on patterns whose body class allows "-": \b only +// matches at a word/non-word transition, so a secret that ends in "-" right +// before a non-word delimiter (space, end of string) has no such transition, +// and the engine backtracks the greedy quantifier to drop that last +// character rather than fail the match, leaving the trailing "-" un-redacted. +// The body character class is itself the real stopping boundary once the +// input runs out of allowed characters, so the anchor is unnecessary there. var patterns = []pattern{ {"aws_access_key_id", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)}, {"github_token", regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36,}\b`)}, {"github_pat", regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}\b`)}, - {"slack_token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}\b`)}, - {"google_api_key", regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}\b`)}, + {"slack_token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`)}, + {"google_api_key", regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}`)}, // Distinguish modern prefixed keys (sk-proj- / sk-svcacct-) from normal kebab-case phrases, // and match legacy sk- keys by length (>= 20). - {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-)[A-Za-z0-9_-]{20,}\b|\bsk-[A-Za-z0-9]{20,}\b`)}, + {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}\b`)}, // Match the ENTIRE PEM/OpenSSH block (header THROUGH the END marker, body // included) so redaction removes the key material, not just the header. {"private_key_block", regexp.MustCompile(`(?s)-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----.*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----`)}, - {"jwt", regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)}, + {"jwt", regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`)}, } // Scan returns the distinct secrets found in text (deduplicated by match, diff --git a/internal/secrets/scanner_test.go b/internal/secrets/scanner_test.go index 0ebfbed46..dacc9c659 100644 --- a/internal/secrets/scanner_test.go +++ b/internal/secrets/scanner_test.go @@ -143,6 +143,38 @@ func TestScanRedactsLongerKeysWithoutTailLeak(t *testing.T) { } } +// Patterns whose body class allows "-" (slack_token, google_api_key, the +// modern openai_key branch, jwt) must redact a secret that ends in "-" right +// before a delimiter: a trailing \b anchor would have no word/non-word +// transition to match there, forcing the engine to drop that last character +// from the match and leaking it. +func TestScanRedactsTrailingHyphenWithoutTailLeak(t *testing.T) { + cases := []struct { + wantType string + secret string + }{ + {"slack_token", "xoxb-1234567890-abcdefghi-"}, + {"google_api_key", "AIzaSyA1234567890abcdefghijklmnopqrstu-"}, + {"openai_key", "sk-proj-abcDEF123_ghiJKL456-mnoPQR789st-"}, + {"jwt", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fw-"}, + } + for _, tc := range cases { + text := "secret is " + tc.secret + " end" + redacted, findings := Redact(text) + if len(findings) != 1 || findings[0].Type != tc.wantType { + t.Errorf("%s: expected one finding for %q, got %#v", tc.wantType, tc.secret, findings) + continue + } + if strings.Contains(redacted, "- end") || strings.Contains(redacted, "-] end") { + t.Errorf("%s: trailing hyphen leaked: %q", tc.wantType, redacted) + } + wantRedacted := "secret is [REDACTED:" + tc.wantType + "] end" + if redacted != wantRedacted { + t.Errorf("%s: redacted = %q, want %q", tc.wantType, redacted, wantRedacted) + } + } +} + func TestScanIgnoresKebabCaseStartingWithSk(t *testing.T) { phrase := "sk-learn-machine-learning-model" findings := Scan("testing " + phrase + " in text") diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 6b4260c87..2a1fb282d 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -6,6 +6,7 @@ import ( "crypto/sha1" "encoding/hex" "fmt" + "io/fs" "os" "os/exec" "path/filepath" @@ -337,32 +338,37 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { return fmt.Errorf("list git worktrees: %w", err) } - lines := strings.Split(output, "\n") + cutoff := time.Now().Add(-maxAge) var lastErr error - for _, line := range lines { - if !strings.HasPrefix(line, "worktree ") { + for _, entry := range parseWorktreeList(output) { + // A worktree a caller has explicitly locked (git worktree lock) is + // never a prune candidate, regardless of its mtime. + if entry.locked { continue } - path := strings.TrimPrefix(line, "worktree ") - path = filepath.Clean(path) - // Only prune worktrees that belong to zero (i.e. inside baseDir) - if !strings.HasPrefix(path, baseDir) { + // Only prune worktrees that belong to zero (i.e. inside baseDir), using + // a path-boundary-safe comparison so a sibling directory that merely + // shares baseDir as a string prefix (e.g. "-other") can't match. + if !isUnderDir(entry.path, baseDir) { continue } - info, err := os.Stat(path) + info, err := os.Stat(entry.path) if err != nil { if os.IsNotExist(err) { _, _ = runGit(ctx, repoRoot, "worktree", "prune") } continue } + if !info.IsDir() { + continue + } - if time.Since(info.ModTime()) > maxAge { - _, err = runGit(ctx, repoRoot, "worktree", "remove", "--force", path) + if worktreeIsStale(entry.path, cutoff) { + _, err = runGit(ctx, repoRoot, "worktree", "remove", "--force", entry.path) if err != nil { - lastErr = fmt.Errorf("remove worktree %s: %w", path, err) + lastErr = fmt.Errorf("remove worktree %s: %w", entry.path, err) } } } @@ -370,3 +376,73 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { _, _ = runGit(ctx, repoRoot, "worktree", "prune") return lastErr } + +type worktreeEntry struct { + path string + locked bool +} + +// parseWorktreeList reads `git worktree list --porcelain` output into one +// entry per worktree. Entries are delimited by their own "worktree " +// 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: filepath.Clean(strings.TrimPrefix(line, "worktree "))} + case current != nil && (line == "locked" || strings.HasPrefix(line, "locked ")): + current.locked = true + } + } + 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)) +} + +// 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. +func worktreeIsStale(root string, cutoff time.Time) bool { + stale := true + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + if info.ModTime().After(cutoff) { + stale = false + return filepath.SkipAll + } + return nil + }) + return stale +} diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 1e58199c1..13aa4d72e 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -311,3 +311,172 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { t.Errorf("call 3 = %q", runner.commandLine(3)) } } + +// A worktree with a stale top-level mtime but a file that was written deep +// inside the tree more recently must not be pruned: the directory's own mtime +// only changes when an entry is added/removed/renamed directly inside it, not +// when a long-running task edits an existing nested file. +func TestCleanSkipsWorktreeWithRecentNestedActivity(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + + activePath := filepath.Join(baseDir, "active-task") + nestedDir := filepath.Join(activePath, "internal", "pkg") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(activePath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(nestedDir, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + // The file itself is freshly written (default mtime is now), simulating a + // task actively editing code deep in the worktree. + nestedFile := filepath.Join(nestedDir, "handler.go") + if err := os.WriteFile(nestedFile, []byte("package pkg"), 0o644); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + activePath + "\n"}, + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + + for _, call := range runner.calls { + if len(call.args) > 0 && call.args[0] == "remove" { + t.Fatalf("Clean removed an actively-edited worktree: %v", call.args) + } + } +} + +// A worktree explicitly locked via `git worktree lock` must never be pruned, +// regardless of how stale its mtime looks. +func TestCleanSkipsLockedWorktree(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + + lockedPath := filepath.Join(baseDir, "locked-task") + if err := os.MkdirAll(lockedPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(lockedPath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + lockedPath + "\nHEAD abc1234\nlocked in use by zero\n"}, + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + + for _, call := range runner.calls { + if len(call.args) > 0 && call.args[0] == "remove" { + t.Fatalf("Clean removed a locked worktree: %v", call.args) + } + } +} + +// A sibling directory that merely shares baseDir as a string prefix (e.g. +// "-other") must not be treated as zero-owned. +func TestCleanRejectsSiblingDirWithSharedPrefix(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + siblingDir := baseDir + "-other" + repoRoot := filepath.Join(tempDir, "repo") + + siblingPath := filepath.Join(siblingDir, "not-ours") + if err := os.MkdirAll(siblingPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(siblingPath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + siblingPath + "\n"}, + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + + for _, call := range runner.calls { + if len(call.args) > 0 && call.args[0] == "remove" { + t.Fatalf("Clean removed a sibling directory outside baseDir: %v", call.args) + } + } +} + +func TestIsUnderDir(t *testing.T) { + base := filepath.Join(string(filepath.Separator), "a", "base") + cases := []struct { + path string + want bool + }{ + {filepath.Join(base, "child"), true}, + {filepath.Join(base, "nested", "deeper"), true}, + {base, true}, + {base + "-other", false}, + {base + "-other" + string(filepath.Separator) + "child", false}, + {filepath.Join(string(filepath.Separator), "a", "elsewhere"), false}, + } + for _, c := range cases { + if got := isUnderDir(c.path, base); got != c.want { + t.Errorf("isUnderDir(%q, %q) = %v, want %v", c.path, base, got, c.want) + } + } +} + +func TestParseWorktreeListTracksLockedState(t *testing.T) { + output := "worktree /a/one\nHEAD abc\nbranch refs/heads/main\n\n" + + "worktree /a/two\nHEAD def\nlocked\n\n" + + "worktree /a/three\nHEAD ghi\nlocked some reason\ndetached\n" + + entries := parseWorktreeList(output) + if len(entries) != 3 { + t.Fatalf("expected 3 entries, got %d: %#v", len(entries), entries) + } + if entries[0].path != filepath.Clean("/a/one") || entries[0].locked { + t.Errorf("entries[0] = %#v", entries[0]) + } + if entries[1].path != filepath.Clean("/a/two") || !entries[1].locked { + t.Errorf("entries[1] = %#v", entries[1]) + } + if entries[2].path != filepath.Clean("/a/three") || !entries[2].locked { + t.Errorf("entries[2] = %#v", entries[2]) + } +} From 548cdfe432baeedce38b562a50499ba9cf0a0e5b Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:12:19 -0400 Subject: [PATCH 03/32] fix(worktrees): check exit codes on removal, fail closed on inspection errors defaultRunGit deliberately returns a nil error alongside a nonzero CommandResult.ExitCode for a failed git invocation, so the worktree remove call must check ExitCode itself instead of trusting a nil error to mean success. Route it through gitOutput, which already does that. worktreeIsStale treated an inspection failure (an unreadable file, a WalkDir error) the same as "keep walking," which can let an incompletely-inspected worktree be judged stale. Any inspection error now makes it ineligible for removal instead. --- internal/worktrees/worktrees.go | 22 +++++++--- internal/worktrees/worktrees_test.go | 60 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 2a1fb282d..d895c045c 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -366,8 +366,12 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { } if worktreeIsStale(entry.path, cutoff) { - _, err = runGit(ctx, repoRoot, "worktree", "remove", "--force", entry.path) - if err != nil { + // 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 { lastErr = fmt.Errorf("remove worktree %s: %w", entry.path, err) } } @@ -428,15 +432,21 @@ func isUnderDir(path, dir string) bool { // 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 - _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { - return nil + stale = false + return filepath.SkipAll } info, err := d.Info() if err != nil { - return nil + stale = false + return filepath.SkipAll } if info.ModTime().After(cutoff) { stale = false @@ -444,5 +454,5 @@ func worktreeIsStale(root string, cutoff time.Time) bool { } return nil }) - return stale + return stale && walkErr == nil } diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 13aa4d72e..80e8c6d8a 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -312,6 +312,66 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { } } +// defaultRunGit deliberately returns a nil error alongside a nonzero +// CommandResult.ExitCode for a failed git invocation (see its comment), so +// Clean must check ExitCode itself rather than trusting a nil error to mean +// the removal succeeded. +func TestCleanReportsErrorOnFailedRemoval(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + + stalePath := filepath.Join(baseDir, "stale-task") + if err := os.MkdirAll(stalePath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(stalePath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + stalePath + "\n"}, + {ExitCode: 1, Stderr: "fatal: unable to remove worktree: in use"}, + {ExitCode: 0}, + }, + } + + err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour) + if err == nil { + t.Fatal("expected Clean to report the failed removal") + } + if !strings.Contains(err.Error(), "in use") { + t.Errorf("error = %q, want it to include the git failure message", err.Error()) + } +} + +// An inspection failure (the root can't be stat'd or walked) must fail +// closed: worktreeIsStale reports false rather than true, so an incomplete +// inspection can never authorize a forced removal. +func TestWorktreeIsStaleFailsClosedOnInspectionError(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist") + if worktreeIsStale(missing, time.Now()) { + t.Fatal("expected worktreeIsStale to fail closed for an uninspectable root") + } +} + +func TestWorktreeIsStaleTrueForOldUntouchedTree(t *testing.T) { + dir := t.TempDir() + old := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(dir, old, old); err != nil { + t.Fatal(err) + } + if !worktreeIsStale(dir, time.Now().Add(-24*time.Hour)) { + t.Fatal("expected an old, untouched directory to be reported stale") + } +} + // A worktree with a stale top-level mtime but a file that was written deep // inside the tree more recently must not be pruned: the directory's own mtime // only changes when an entry is added/removed/renamed directly inside it, not From 008220bd6a7ba735a2170c0016494b7195ef076c Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:36:41 -0400 Subject: [PATCH 04/32] fix(secrets,worktrees): catch appended-suffix keys and scope pruning to owned worktrees The scanner's trailing \b anchors made a credential vanish entirely when followed by a word character outside its body class (an appended suffix like AKIA...EXTRA, or ghp_..._suffix): the fixed or unbounded-greedy quantifier had no valid word boundary to land on and the whole match failed, so the real secret reached the redaction output unredacted. Dropping the trailing anchors lets the body class itself stop the match, so the credential prefix still gets redacted even when noise follows it. Also recognize sk-admin- alongside sk-proj-/sk-svcacct- so OpenAI admin keys aren't skipped by the narrowed modern-key branch. Clean pruned any worktree under the caller-supplied BaseDir, but Prepare only ever creates worktrees under a per-repository zero-worktree- subtree of it. Scope pruning to that subtree so a worktree a user manages by hand elsewhere under a shared BaseDir is never force-removed. Also refuse to force-remove a worktree whose mtime looks stale but that still has uncommitted or untracked changes: a task can hold live work while waiting on a model, network, or user for longer than the staleness window without writing to the tree again. --- internal/secrets/scanner.go | 27 +++--- internal/secrets/scanner_test.go | 40 ++++++++- internal/worktrees/worktrees.go | 40 ++++++++- internal/worktrees/worktrees_test.go | 130 ++++++++++++++++++++++++--- 4 files changed, 205 insertions(+), 32 deletions(-) diff --git a/internal/secrets/scanner.go b/internal/secrets/scanner.go index 9deba3877..87efa90a3 100644 --- a/internal/secrets/scanner.go +++ b/internal/secrets/scanner.go @@ -31,22 +31,23 @@ type pattern struct { // as an openai_key. Real secrets are preceded by a delimiter (space, quote, =, :, // start-of-string), all of which satisfy \b. // -// A trailing \b is omitted on patterns whose body class allows "-": \b only -// matches at a word/non-word transition, so a secret that ends in "-" right -// before a non-word delimiter (space, end of string) has no such transition, -// and the engine backtracks the greedy quantifier to drop that last -// character rather than fail the match, leaving the trailing "-" un-redacted. -// The body character class is itself the real stopping boundary once the -// input runs out of allowed characters, so the anchor is unnecessary there. +// A trailing \b is omitted on every pattern: \b only matches at a word/non-word +// transition, so a secret immediately followed by more word characters that +// fall outside its body class (an appended suffix like "...EXAMPLEEXTRA" or +// "..._suffix") has no such transition there, and a fixed or greedy quantifier +// cannot backtrack its way to one — the whole match fails and the credential +// leaks un-redacted. The body character class is itself the real stopping +// boundary once the input runs out of allowed characters, so the anchor is +// unnecessary; the leading \b already keeps these from firing mid-word. var patterns = []pattern{ - {"aws_access_key_id", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)}, - {"github_token", regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36,}\b`)}, - {"github_pat", regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}\b`)}, + {"aws_access_key_id", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}`)}, + {"github_token", regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36,}`)}, + {"github_pat", regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}`)}, {"slack_token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`)}, {"google_api_key", regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}`)}, - // Distinguish modern prefixed keys (sk-proj- / sk-svcacct-) from normal kebab-case phrases, - // and match legacy sk- keys by length (>= 20). - {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}\b`)}, + // Distinguish modern prefixed keys (sk-proj- / sk-svcacct- / sk-admin-) from + // normal kebab-case phrases, and match legacy sk- keys by length (>= 20). + {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-|admin-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}`)}, // Match the ENTIRE PEM/OpenSSH block (header THROUGH the END marker, body // included) so redaction removes the key material, not just the header. {"private_key_block", regexp.MustCompile(`(?s)-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----.*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----`)}, diff --git a/internal/secrets/scanner_test.go b/internal/secrets/scanner_test.go index dacc9c659..94f863f13 100644 --- a/internal/secrets/scanner_test.go +++ b/internal/secrets/scanner_test.go @@ -102,11 +102,12 @@ func TestRedactNoMatchReturnsInputUnchanged(t *testing.T) { } func TestScanDetectsModernPrefixedOpenAIKeys(t *testing.T) { - // Modern keys carry sk-proj-/sk-svcacct- prefixes and use - and _ in the body; - // the legacy sk- pattern would have missed them. + // Modern keys carry sk-proj-/sk-svcacct-/sk-admin- prefixes and use - and _ in + // the body; the legacy sk- pattern would have missed them. for _, key := range []string{ "sk-proj-abcDEF123_ghiJKL456-mnoPQR789stu", "sk-svcacct-abcDEF123_ghiJKL456-mnoPQR789", + "sk-admin-abcDEF123_ghiJKL456-mnoPQR789stu", } { redacted, findings := Redact("token=" + key) if len(findings) != 1 || findings[0].Type != "openai_key" { @@ -175,6 +176,41 @@ func TestScanRedactsTrailingHyphenWithoutTailLeak(t *testing.T) { } } +// A credential with more word characters appended right after its body (a +// copy-paste artifact, an env-var-style suffix) must still have its real +// secret material redacted, even though the appended run itself is outside +// the body class and so is left un-redacted. A trailing \b anchor would find +// no word/non-word transition there and, for a fixed or unbounded-greedy +// quantifier, fail the whole match instead of backtracking, leaking the +// credential entirely. +func TestScanRedactsCredentialWithAppendedSuffix(t *testing.T) { + cases := []struct { + wantType string + secret string + suffix string + }{ + {"aws_access_key_id", "AKIAIOSFODNN7EXAMPLE", "EXTRA"}, + {"github_token", "ghp_1234567890abcdefghijklmnopqrstuvwxyz", "_suffix"}, + } + for _, tc := range cases { + text := "token=" + tc.secret + tc.suffix + " end" + redacted, findings := Redact(text) + if len(findings) != 1 || findings[0].Type != tc.wantType { + t.Fatalf("%s: expected one finding for %q, got %#v", tc.wantType, tc.secret+tc.suffix, findings) + } + if findings[0].Match != tc.secret { + t.Fatalf("%s: matched %q, want the credential prefix %q", tc.wantType, findings[0].Match, tc.secret) + } + if strings.Contains(redacted, tc.secret) { + t.Fatalf("%s: credential leaked after redaction: %q", tc.wantType, redacted) + } + wantRedacted := "token=[REDACTED:" + tc.wantType + "]" + tc.suffix + " end" + if redacted != wantRedacted { + t.Fatalf("%s: redacted = %q, want %q", tc.wantType, redacted, wantRedacted) + } + } +} + func TestScanIgnoresKebabCaseStartingWithSk(t *testing.T) { phrase := "sk-learn-machine-learning-model" findings := Scan("testing " + phrase + " in text") diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index d895c045c..17d6c446a 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -332,6 +332,11 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { if err != nil { return err } + // 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. + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) output, err := gitOutput(ctx, runGit, repoRoot, "worktree", "list", "--porcelain") if err != nil { @@ -347,10 +352,11 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { continue } - // Only prune worktrees that belong to zero (i.e. inside baseDir), using - // a path-boundary-safe comparison so a sibling directory that merely - // shares baseDir as a string prefix (e.g. "-other") can't match. - if !isUnderDir(entry.path, baseDir) { + // 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. + // "-other") can't match. + if !isUnderDir(entry.path, repoDir) { continue } @@ -366,6 +372,15 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { } if worktreeIsStale(entry.path, cutoff) { + if worktreeIsDirty(ctx, runGit, entry.path) { + // 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 + } // 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 @@ -456,3 +471,20 @@ func worktreeIsStale(root string, cutoff time.Time) bool { }) return stale && walkErr == nil } + +// worktreeIsDirty reports whether a worktree has uncommitted or untracked +// changes, 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. +// +// 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) bool { + output, err := gitOutput(ctx, runGit, path, "status", "--porcelain") + if err != nil { + return true + } + return output != "" +} diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 80e8c6d8a..55ab7652e 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -253,10 +253,11 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { tempDir := t.TempDir() baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) // Create directories representing two worktrees: one young, one stale. - youngPath := filepath.Join(baseDir, "young-task") - stalePath := filepath.Join(baseDir, "stale-task") + youngPath := filepath.Join(repoDir, "young-task") + stalePath := filepath.Join(repoDir, "stale-task") if err := os.MkdirAll(youngPath, 0o755); err != nil { t.Fatal(err) } @@ -277,6 +278,7 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { results: []CommandResult{ {Stdout: repoRoot}, // rev-parse --show-toplevel {Stdout: "worktree " + youngPath + "\nworktree " + stalePath + "\n"}, // worktree list --porcelain + {ExitCode: 0}, // status --porcelain (clean) {ExitCode: 0}, // worktree remove --force {ExitCode: 0}, // worktree prune }, @@ -294,8 +296,8 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { } // Verify the calls made by Clean - if len(runner.calls) != 4 { - t.Fatalf("expected 4 git calls, got %d", len(runner.calls)) + if len(runner.calls) != 5 { + t.Fatalf("expected 5 git calls, got %d", len(runner.calls)) } if runner.commandLine(0) != "git rev-parse --show-toplevel" { t.Errorf("call 0 = %q", runner.commandLine(0)) @@ -303,12 +305,16 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { if runner.commandLine(1) != "git worktree list --porcelain" { t.Errorf("call 1 = %q", runner.commandLine(1)) } + expectedStatusCall := "git status --porcelain" + if runner.commandLine(2) != expectedStatusCall { + t.Errorf("call 2 = %q, want %q", runner.commandLine(2), expectedStatusCall) + } expectedRemoveCall := "git worktree remove --force " + filepath.Clean(stalePath) - if runner.commandLine(2) != expectedRemoveCall { - t.Errorf("call 2 = %q, want %q", runner.commandLine(2), expectedRemoveCall) + if runner.commandLine(3) != expectedRemoveCall { + t.Errorf("call 3 = %q, want %q", runner.commandLine(3), expectedRemoveCall) } - if runner.commandLine(3) != "git worktree prune" { - t.Errorf("call 3 = %q", runner.commandLine(3)) + if runner.commandLine(4) != "git worktree prune" { + t.Errorf("call 4 = %q", runner.commandLine(4)) } } @@ -320,8 +326,9 @@ func TestCleanReportsErrorOnFailedRemoval(t *testing.T) { tempDir := t.TempDir() baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) - stalePath := filepath.Join(baseDir, "stale-task") + stalePath := filepath.Join(repoDir, "stale-task") if err := os.MkdirAll(stalePath, 0o755); err != nil { t.Fatal(err) } @@ -337,6 +344,7 @@ func TestCleanReportsErrorOnFailedRemoval(t *testing.T) { results: []CommandResult{ {Stdout: repoRoot}, {Stdout: "worktree " + stalePath + "\n"}, + {ExitCode: 0}, // status --porcelain (clean) {ExitCode: 1, Stderr: "fatal: unable to remove worktree: in use"}, {ExitCode: 0}, }, @@ -380,8 +388,9 @@ func TestCleanSkipsWorktreeWithRecentNestedActivity(t *testing.T) { tempDir := t.TempDir() baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) - activePath := filepath.Join(baseDir, "active-task") + activePath := filepath.Join(repoDir, "active-task") nestedDir := filepath.Join(activePath, "internal", "pkg") if err := os.MkdirAll(nestedDir, 0o755); err != nil { t.Fatal(err) @@ -462,13 +471,17 @@ func TestCleanSkipsLockedWorktree(t *testing.T) { } } -// A sibling directory that merely shares baseDir as a string prefix (e.g. -// "-other") must not be treated as zero-owned. +// A sibling directory that merely shares the per-repository repoDir as a +// string prefix (e.g. "-other") must not be treated as zero-owned. +// This also covers a manually managed worktree for the SAME repository that a +// user placed directly under a shared baseDir rather than inside zero's +// repoDir subtree: it must not be treated as zero-owned either. func TestCleanRejectsSiblingDirWithSharedPrefix(t *testing.T) { tempDir := t.TempDir() baseDir := filepath.Join(tempDir, "zero-worktrees") - siblingDir := baseDir + "-other" repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + siblingDir := repoDir + "-other" siblingPath := filepath.Join(siblingDir, "not-ours") if err := os.MkdirAll(siblingPath, 0o755); err != nil { @@ -501,6 +514,97 @@ func TestCleanRejectsSiblingDirWithSharedPrefix(t *testing.T) { } } +// A manually managed worktree that a user placed directly under a shared +// baseDir, outside zero's own "zero-worktree-" subtree, must never +// be pruned even though it is technically inside baseDir. +func TestCleanIgnoresWorktreeOutsideOwnedSubtree(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + + manualPath := filepath.Join(baseDir, "hand-managed-checkout") + if err := os.MkdirAll(manualPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(manualPath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + manualPath + "\n"}, + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + + for _, call := range runner.calls { + if len(call.args) > 0 && (call.args[0] == "remove" || call.args[0] == "status") { + t.Fatalf("Clean touched a worktree outside its owned subtree: %v", call.args) + } + } +} + +// A worktree with a stale top-level mtime but uncommitted or untracked +// changes must not be force-removed: a task can hold live work in a worktree +// while waiting on a model, network, or user for far longer than the +// staleness window, without ever writing to the tree again in that time. +func TestCleanSkipsDirtyStaleWorktree(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + + dirtyPath := filepath.Join(repoDir, "dirty-task") + if err := os.MkdirAll(dirtyPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(dirtyPath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + dirtyPath + "\n"}, + {Stdout: " M internal/pkg/handler.go\n"}, // status --porcelain: dirty + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + + for _, call := range runner.calls { + if len(call.args) > 0 && call.args[0] == "remove" { + t.Fatalf("Clean removed a dirty worktree: %v", call.args) + } + } +} + +// An inspection failure (git status errors out) must fail closed: treat the +// worktree as dirty rather than clean, so a broken status check can never +// authorize a forced removal. +func TestWorktreeIsDirtyFailsClosedOnInspectionError(t *testing.T) { + runner := &fakeRunner{results: []CommandResult{{ExitCode: 1, Stderr: "fatal: not a git repository"}}} + if !worktreeIsDirty(context.Background(), runner.Run, t.TempDir()) { + t.Fatal("expected worktreeIsDirty to fail closed on a status error") + } +} + func TestIsUnderDir(t *testing.T) { base := filepath.Join(string(filepath.Separator), "a", "base") cases := []struct { From e70513ffbb72c8a371327ba0bbbbaed7d4920bbb Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:09:45 -0400 Subject: [PATCH 05/32] fix(worktrees): make nested-activity test exercise the deep walk it claims to activePath/internal was created by the same MkdirAll as the nested pkg dir but never backdated, so it kept a fresh mtime and worktreeIsStale's walk reported "not stale" as soon as it hit that directory, before ever reaching the freshly-written file two levels deeper. The test passed without actually exercising recursion past the first directory. --- internal/worktrees/worktrees_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 55ab7652e..9a696f0e7 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -414,6 +414,15 @@ func TestCleanSkipsWorktreeWithRecentNestedActivity(t *testing.T) { t.Fatal(err) } + // activePath/internal was created alongside nestedDir by the MkdirAll + // above and never backdated, so it would otherwise still carry a fresh + // mtime; WalkDir would hit it and report "not stale" before ever reaching + // nestedFile, so the test would pass even if the recursive walk stopped + // checking after the first directory level. + if err := os.Chtimes(filepath.Join(activePath, "internal"), twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{ results: []CommandResult{ {Stdout: repoRoot}, From 59939e8e1ec0fa8b789460108bff3a4414a88913 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:08:02 -0400 Subject: [PATCH 06/32] fix(worktrees): lock zero-created worktrees and treat ignored files as dirty Prepare never called git worktree lock, so the entry.locked skip in Clean only ever protected worktrees a human locked by hand, never zero's own; a worktree that finished committing and sat idle (e.g. waiting on a slow model or network retry) for more than 24h looked clean-and-stale and got force-removed by the mtime+dirty heuristic alone. Lock every worktree Prepare creates so it gets the same protection. worktreeIsDirty also used git status --porcelain with no --ignored, so a worktree holding only .gitignore-matched task data (credentials, generated drafts, artifacts) reported as clean and got force-removed with --force, silently discarding it. Add --ignored so those files count as dirty too. --- internal/worktrees/worktrees.go | 35 +++++-- internal/worktrees/worktrees_test.go | 141 ++++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 8 deletions(-) diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 17d6c446a..8d821dadd 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -128,6 +128,23 @@ func Prepare(ctx context.Context, options Options) (Result, error) { } 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. + lockResult, err := runGit(ctx, repoRoot, "worktree", "lock", "--reason", "zero: active task worktree", target) + if err != nil { + return Result{}, fmt.Errorf("lock git worktree: %w", err) + } + if lockResult.ExitCode != 0 { + message := strings.TrimSpace(firstNonEmpty(lockResult.Stderr, lockResult.Stdout)) + if message == "" { + message = fmt.Sprintf("git worktree lock exited with code %d", lockResult.ExitCode) + } + return Result{}, fmt.Errorf("lock git worktree: %s", message) + } return result, nil } @@ -472,17 +489,21 @@ func worktreeIsStale(root string, cutoff time.Time) bool { return stale && walkErr == nil } -// worktreeIsDirty reports whether a worktree has uncommitted or untracked -// changes, 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. +// worktreeIsDirty reports whether a worktree has uncommitted, untracked, or +// ignored changes, via `git status --porcelain --ignored` 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. --ignored is +// included because files matched by .gitignore (credentials, generated +// drafts, task artifacts) are real data a task can leave behind; without it, +// plain `git status --porcelain` reports a worktree holding only such files +// as clean, and Clean would force-remove it and silently discard them. // // 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) bool { - output, err := gitOutput(ctx, runGit, path, "status", "--porcelain") + output, err := gitOutput(ctx, runGit, path, "status", "--porcelain", "--ignored") if err != nil { return true } diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 9a696f0e7..b9222d94a 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -52,6 +52,7 @@ func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { {Stdout: "main\n"}, {Stdout: "abc1234\n"}, {}, + {}, }, } @@ -78,6 +79,12 @@ func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { if got := runner.commandLine(3); got != "git worktree add --detach "+result.Path+" HEAD" { t.Fatalf("git worktree command = %q", got) } + // Prepare must lock every worktree it creates: this is what makes + // entry.locked inside Clean protect zero's own worktrees, not just ones a + // human locked by hand (see TestCleanSkipsLockedZeroOwnedWorktree). + if got := runner.commandLine(4); got != "git worktree lock --reason zero: active task worktree "+result.Path { + t.Fatalf("git worktree lock command = %q", got) + } } func TestPrepareReusesExistingGitWorktree(t *testing.T) { @@ -305,7 +312,7 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { if runner.commandLine(1) != "git worktree list --porcelain" { t.Errorf("call 1 = %q", runner.commandLine(1)) } - expectedStatusCall := "git status --porcelain" + expectedStatusCall := "git status --porcelain --ignored" if runner.commandLine(2) != expectedStatusCall { t.Errorf("call 2 = %q, want %q", runner.commandLine(2), expectedStatusCall) } @@ -480,6 +487,138 @@ func TestCleanSkipsLockedWorktree(t *testing.T) { } } +// A worktree zero created and locked at Prepare time (see the "worktree lock" +// call added there) must survive Clean even though it looks idle-but-clean: a +// task can finish committing and then sit waiting on a model, network, or +// user for far longer than the staleness window without touching the tree +// again, and mtime alone can't distinguish that from an abandoned worktree. +// Before this fix, Prepare never locked its own worktrees, so entry.locked +// only ever protected worktrees a human locked by hand. +func TestCleanSkipsLockedZeroOwnedWorktree(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + + idlePath := filepath.Join(repoDir, "idle-task") + if err := os.MkdirAll(idlePath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(idlePath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + idlePath + "\nlocked zero: active task worktree\n"}, + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + + for _, call := range runner.calls { + if len(call.args) > 0 && (call.args[0] == "remove" || call.args[0] == "status") { + t.Fatalf("Clean touched a locked zero-owned worktree: %v", call.args) + } + } +} + +// A worktree whose only content is matched by .gitignore (credentials, +// generated drafts, task artifacts) must still block force-removal: plain +// `git status --porcelain` reports such a worktree as clean, but +// worktreeIsDirty now also passes --ignored, so Clean must treat it as dirty. +func TestCleanSkipsWorktreeWithOnlyIgnoredFiles(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + + ignoredOnlyPath := filepath.Join(repoDir, "ignored-only-task") + if err := os.MkdirAll(ignoredOnlyPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(ignoredOnlyPath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + ignoredOnlyPath + "\n"}, + {Stdout: "!! ignored-data\n"}, // status --porcelain --ignored: ignored file present + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + + for i, call := range runner.calls { + if i == 2 { + want := "status --porcelain --ignored" + if got := strings.Join(call.args, " "); got != want { + t.Fatalf("status call args = %q, want %q", got, want) + } + } + if len(call.args) > 0 && call.args[0] == "remove" { + t.Fatalf("Clean removed a worktree with only ignored files: %v", call.args) + } + } +} + +// worktreeIsDirty must count files matched by .gitignore as dirty content: a +// worktree holding only ignored task data (credentials, generated drafts) has +// nothing to show in plain `git status --porcelain` and would otherwise pass +// as clean and be force-removed by Clean's staleness heuristic. This exercises +// the real git binary rather than the fake runner, so it also verifies +// --ignored actually changes git's answer, not just the command we send. +func TestWorktreeIsDirtyCountsIgnoredFilesAsDirty(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + run("init", "--quiet") + run("config", "user.email", "zero@example.com") + run("config", "user.name", "zero") + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored-data\n"), 0o644); err != nil { + t.Fatal(err) + } + run("add", ".gitignore") + run("commit", "--quiet", "-m", "initial") + + if worktreeIsDirty(context.Background(), defaultRunGit, dir) { + t.Fatal("expected a clean worktree with no ignored files present to report clean") + } + + if err := os.WriteFile(filepath.Join(dir, "ignored-data"), []byte("secret task artifact"), 0o644); err != nil { + t.Fatal(err) + } + + if !worktreeIsDirty(context.Background(), defaultRunGit, dir) { + t.Fatal("expected an ignored-but-present file to count as dirty") + } +} + // A sibling directory that merely shares the per-repository repoDir as a // string prefix (e.g. "-other") must not be treated as zero-owned. // This also covers a manually managed worktree for the SAME repository that a From fc516b57e6b3ca0350d987b9b991cdadd2f13190 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:50:43 -0400 Subject: [PATCH 07/32] fix(worktrees): release Zero's Prepare lock so Clean can reclaim finished worktrees Prepare locks every worktree it creates so Clean's mtime+dirty staleness heuristic never force-removes one Zero is still using, but nothing ever unlocked it, making the automatic disk-space cleanup permanently inert. Add Release (git worktree unlock) and wire it in two ways: zero exec --worktree defers a release once its own run finishes, since that flow's use of the worktree is bound to its own process. zero worktrees prepare hands the path to a longer-lived external caller with no defined end-of-life, so a new zero worktrees release subcommand lets that caller release it explicitly when done. --- internal/cli/app.go | 5 ++ internal/cli/exec.go | 9 +++ internal/cli/workflow_test.go | 101 +++++++++++++++++++++++++++ internal/cli/workflows.go | 45 ++++++++++++ internal/worktrees/worktrees.go | 19 +++++ internal/worktrees/worktrees_test.go | 28 ++++++++ 6 files changed, 207 insertions(+) diff --git a/internal/cli/app.go b/internal/cli/app.go index 80854beb4..61ed32b1c 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -81,6 +81,7 @@ type appDeps struct { runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error) + releaseWorktree func(context.Context, worktrees.Options, string) error detectVerifyPlan func(string) (verify.Plan, error) runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report @@ -187,6 +188,7 @@ func defaultAppDeps() appDeps { return mcp.RegisterTools(ctx, registry, cfg, options) }, prepareWorktree: worktrees.Prepare, + releaseWorktree: worktrees.Release, detectVerifyPlan: verify.DetectPlan, runVerify: verify.Run, runSelfVerify: selfverify.Run, @@ -532,6 +534,9 @@ func fillAppDeps(deps appDeps) appDeps { if deps.prepareWorktree == nil { deps.prepareWorktree = defaults.prepareWorktree } + if deps.releaseWorktree == nil { + deps.releaseWorktree = defaults.releaseWorktree + } if deps.detectVerifyPlan == nil { deps.detectVerifyPlan = defaults.detectVerifyPlan } diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 2d1fe542a..e57a05332 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -204,6 +204,15 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error()) } workspaceRoot = preparedWorktree.Path + // This run's own process is the only user of the worktree it just + // created or reused, so its lifetime is bound to this function: release + // the Prepare lock once it returns so Clean can reclaim the worktree + // later if it goes stale. (zero worktrees prepare hands the path to an + // external, longer-lived caller instead, so it can't release this way — + // that caller must run `zero worktrees release` itself when done.) + defer func() { + _ = deps.releaseWorktree(context.Background(), worktrees.Options{}, preparedWorktree.Path) + }() } registry := newCoreRegistry(workspaceRoot) diff --git a/internal/cli/workflow_test.go b/internal/cli/workflow_test.go index 75b844f47..e67336794 100644 --- a/internal/cli/workflow_test.go +++ b/internal/cli/workflow_test.go @@ -153,6 +153,65 @@ func TestRunWorktreesPrepareRejectsDuplicateNames(t *testing.T) { } } +func TestRunWorktreesRelease(t *testing.T) { + worktreeDir := t.TempDir() + var releasedPath string + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"worktrees", "release", worktreeDir}, &stdout, &stderr, appDeps{ + releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error { + releasedPath = path + return nil + }, + }) + + if exitCode != exitSuccess { + t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) + } + if releasedPath != worktreeDir { + t.Fatalf("released path = %q, want %q", releasedPath, worktreeDir) + } + if !strings.Contains(stdout.String(), worktreeDir) { + t.Fatalf("expected confirmation output to mention path, got %q", stdout.String()) + } +} + +func TestRunWorktreesReleaseRequiresPath(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"worktrees", "release"}, &stdout, &stderr, appDeps{ + releaseWorktree: func(context.Context, worktrees.Options, string) error { + t.Fatal("releaseWorktree should not be called without a path") + return nil + }, + }) + + if exitCode != exitUsage { + t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode) + } + if !strings.Contains(stderr.String(), "requires a worktree path") { + t.Fatalf("expected missing-path error, got %q", stderr.String()) + } +} + +func TestRunWorktreesReleaseReportsErrors(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"worktrees", "release", "/no/such/worktree"}, &stdout, &stderr, appDeps{ + releaseWorktree: func(context.Context, worktrees.Options, string) error { + return errors.New("unlock git worktree: not a valid worktree") + }, + }) + + if exitCode != exitUsage { + t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode) + } + if !strings.Contains(stderr.String(), "not a valid worktree") { + t.Fatalf("expected underlying error, got %q", stderr.String()) + } +} + func TestRunVerifyTextAndJSON(t *testing.T) { cwd := t.TempDir() plan := verify.Plan{Root: cwd, Checks: []verify.Check{{ID: "go.test", Name: "Go tests", Command: []string{"go", "test", "./..."}}}} @@ -621,6 +680,48 @@ func TestRunExecWorktreeUsesPreparedWorkspace(t *testing.T) { } } +func TestRunExecWorktreeReleasesLockAfterRun(t *testing.T) { + root := t.TempDir() + worktreeDir := t.TempDir() + var releasedPath string + releaseCalls := 0 + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"exec", "--worktree", "task-a", "hello"}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return root, nil }, + prepareWorktree: func(ctx context.Context, options worktrees.Options) (worktrees.Result, error) { + return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234"}, nil + }, + releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error { + releaseCalls++ + releasedPath = path + return nil + }, + resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) { + return execResolvedConfig(), nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return echoExecProvider{}, nil + }, + }) + + if exitCode != exitSuccess { + t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) + } + // A `zero exec --worktree` run is the only user of the worktree it prepares: + // its own process created it and is now exiting, so it must release the + // Prepare lock itself rather than leaving it locked forever (unlike `zero + // worktrees prepare`, which hands the path to a longer-lived external + // caller and has no such end-of-life signal to act on). + if releaseCalls != 1 { + t.Fatalf("releaseWorktree call count = %d, want 1", releaseCalls) + } + if releasedPath != worktreeDir { + t.Fatalf("released path = %q, want %q", releasedPath, worktreeDir) + } +} + func TestRunExecRejectsForkWithWorktree(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/cli/workflows.go b/internal/cli/workflows.go index bc13bc7f8..013740aad 100644 --- a/internal/cli/workflows.go +++ b/internal/cli/workflows.go @@ -63,6 +63,9 @@ func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDep } return exitSuccess } + if command == "release" { + return runWorktreesRelease(args, stdout, stderr, deps) + } if command != "prepare" { return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees command %q", command)) } @@ -102,6 +105,43 @@ func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDep return exitSuccess } +// runWorktreesRelease unlocks a worktree `zero worktrees prepare` created, so +// Zero's automatic Clean pass can reclaim it once it goes stale. Prepare locks +// every worktree it creates and nothing else in that command's own lifetime +// unlocks it (its process exits right after printing the path, long before +// whatever external caller actually finishes using the worktree), so that +// caller is responsible for running this once it is done. +func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + var path string + for _, arg := range args { + switch { + case arg == "-h" || arg == "--help" || arg == "help": + if err := writeWorktreesHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + case strings.HasPrefix(arg, "-"): + return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees release flag %q", arg)) + default: + if path != "" { + return writeExecUsageError(stderr, "worktree path was provided more than once") + } + path = arg + } + } + path = strings.TrimSpace(path) + if path == "" { + return writeExecUsageError(stderr, "worktrees release requires a worktree path") + } + if err := deps.releaseWorktree(context.Background(), worktrees.Options{}, path); err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if _, err := fmt.Fprintf(stdout, "released %s\n", path); err != nil { + return exitCrash + } + return exitSuccess +} + func runVerifyCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { options, help, err := parseVerifyCommandArgs(args) if err != nil { @@ -786,9 +826,14 @@ func formatCommitResult(result zerogit.CommitResult) string { func writeWorktreesHelp(w io.Writer) error { _, err := fmt.Fprint(w, `Usage: zero worktrees prepare [flags] [name] + zero worktrees release Prepares an isolated git worktree for a Zero task. +prepare locks the worktree it creates so Zero's automatic cleanup never +removes it out from under you. release unlocks a worktree prepare created, +once you are done with it, so cleanup can reclaim it later if it goes stale. + Flags: --name Worktree name; defaults to a timestamped task name --dir Base directory for Zero worktrees diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 8d821dadd..93809c737 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -148,6 +148,25 @@ func Prepare(ctx context.Context, options Options) (Result, error) { return result, 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 ` 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 + } + if _, err := gitOutput(ctx, runGit, path, "worktree", "unlock", path); err != nil { + return fmt.Errorf("unlock git worktree: %w", err) + } + return nil +} + func DefaultBaseDir(env map[string]string) (string, error) { if runtime.GOOS == "windows" { if localAppData := strings.TrimSpace(envValue(env, "LOCALAPPDATA")); localAppData != "" { diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index b9222d94a..52e317839 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -87,6 +87,34 @@ func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { } } +func TestReleaseUnlocksWorktree(t *testing.T) { + path := filepath.Join(t.TempDir(), "task-a") + runner := &fakeRunner{results: []CommandResult{{}}} + + if err := Release(context.Background(), Options{RunGit: runner.Run}, path); err != nil { + t.Fatalf("Release returned error: %v", err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected exactly one git call, got %#v", runner.calls) + } + if runner.calls[0].dir != path { + t.Fatalf("git worktree unlock dir = %q, want %q", runner.calls[0].dir, path) + } + if got := runner.commandLine(0); got != "git worktree unlock "+path { + t.Fatalf("git worktree unlock command = %q", got) + } +} + +func TestReleasePropagatesGitFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "task-a") + runner := &fakeRunner{results: []CommandResult{{ExitCode: 1, Stderr: "fatal: not a working tree"}}} + + err := Release(context.Background(), Options{RunGit: runner.Run}, path) + if err == nil || !strings.Contains(err.Error(), "not a working tree") { + t.Fatalf("Release error = %v, want it to surface the git failure", err) + } +} + func TestPrepareReusesExistingGitWorktree(t *testing.T) { root := t.TempDir() base := t.TempDir() From 477dc2855564b3dd0e917e50025e29c558105c2a Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:28:23 -0400 Subject: [PATCH 08/32] fix(worktrees): normalize release path, aggregate Clean errors, unlock deleted worktrees Address CodeRabbit's review on the lock-release fix: - zero worktrees release now resolves its path argument to absolute before calling Release, since git worktree unlock matches against the path git recorded at creation, not whatever directory the caller happens to be running from. - Clean now aggregates removal failures with errors.Join instead of overwriting lastErr, so multiple stale worktrees failing removal in the same pass are all reported, not just the last one. - Release falls back to options.Cwd as the git working directory when the worktree path itself no longer exists (e.g. a caller deleted a locked worktree by hand instead of releasing it first), so the orphaned lock can still be cleared. Added regression coverage for all three. --- internal/cli/workflow_test.go | 41 +++++++++++++++ internal/cli/workflows.go | 12 ++++- internal/worktrees/worktrees.go | 19 ++++++- internal/worktrees/worktrees_test.go | 79 ++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 3 deletions(-) diff --git a/internal/cli/workflow_test.go b/internal/cli/workflow_test.go index e67336794..b60da68c3 100644 --- a/internal/cli/workflow_test.go +++ b/internal/cli/workflow_test.go @@ -177,6 +177,47 @@ func TestRunWorktreesRelease(t *testing.T) { } } +func TestRunWorktreesReleaseNormalizesRelativePath(t *testing.T) { + // git worktree unlock matches against the path git recorded when the + // worktree was created, so a relative argument (resolved against + // whatever directory the caller happens to be running `zero` from) can + // fail to match. Chdir into a known directory and pass a relative + // argument to confirm it reaches releaseWorktree as an absolute path. + origWd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + parent := t.TempDir() + worktreeDir := filepath.Join(parent, "task-a") + if err := os.Mkdir(worktreeDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chdir(parent); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(origWd); err != nil { + t.Fatal(err) + } + }() + + var releasedPath string + var stdout, stderr bytes.Buffer + exitCode := runWithDeps([]string{"worktrees", "release", "task-a"}, &stdout, &stderr, appDeps{ + releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error { + releasedPath = path + return nil + }, + }) + + if exitCode != exitSuccess { + t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) + } + if releasedPath != worktreeDir { + t.Fatalf("released path = %q, want absolute %q", releasedPath, worktreeDir) + } +} + func TestRunWorktreesReleaseRequiresPath(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/cli/workflows.go b/internal/cli/workflows.go index 013740aad..2ccf649ae 100644 --- a/internal/cli/workflows.go +++ b/internal/cli/workflows.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "path/filepath" "strconv" "strings" "time" @@ -133,7 +134,16 @@ func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps if path == "" { return writeExecUsageError(stderr, "worktrees release requires a worktree path") } - if err := deps.releaseWorktree(context.Background(), worktrees.Options{}, path); err != nil { + // git worktree unlock matches against the path git recorded when the + // worktree was created, so a relative argument here (resolved against + // whatever directory the caller happens to be running in, not + // necessarily the worktree's own) can fail to match. Resolve to absolute + // up front so the unlock target is unambiguous regardless of cwd. + absPath, err := filepath.Abs(path) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if err := deps.releaseWorktree(context.Background(), worktrees.Options{}, absPath); err != nil { return writeExecUsageError(stderr, err.Error()) } if _, err := fmt.Fprintf(stdout, "released %s\n", path); err != nil { diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 93809c737..2e78e88b8 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha1" "encoding/hex" + "errors" "fmt" "io/fs" "os" @@ -161,7 +162,18 @@ func Release(ctx context.Context, options Options, path string) error { if runGit == nil { runGit = defaultRunGit } - if _, err := gitOutput(ctx, runGit, path, "worktree", "unlock", path); err != nil { + // 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 + } + } + if _, err := gitOutput(ctx, runGit, dir, "worktree", "unlock", path); err != nil { return fmt.Errorf("unlock git worktree: %w", err) } return nil @@ -423,7 +435,10 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { // 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 { - lastErr = fmt.Errorf("remove worktree %s: %w", entry.path, err) + // 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)) } } } diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 52e317839..dcac3731b 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -89,6 +89,9 @@ func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { func TestReleaseUnlocksWorktree(t *testing.T) { path := filepath.Join(t.TempDir(), "task-a") + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } runner := &fakeRunner{results: []CommandResult{{}}} if err := Release(context.Background(), Options{RunGit: runner.Run}, path); err != nil { @@ -105,8 +108,34 @@ func TestReleaseUnlocksWorktree(t *testing.T) { } } +func TestReleaseFallsBackToCwdWhenWorktreeDirMissing(t *testing.T) { + // A caller who deletes a locked worktree directory by hand instead of + // releasing it first leaves path itself gone; Release must still be able + // to run `git worktree unlock` (from the main repo, via options.Cwd) so + // the orphaned lock can be cleared and the entry later pruned. + missingPath := filepath.Join(t.TempDir(), "already-deleted") + repoRoot := t.TempDir() + runner := &fakeRunner{results: []CommandResult{{}}} + + if err := Release(context.Background(), Options{RunGit: runner.Run, Cwd: repoRoot}, missingPath); err != nil { + t.Fatalf("Release returned error: %v", err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected exactly one git call, got %#v", runner.calls) + } + if runner.calls[0].dir != repoRoot { + t.Fatalf("git worktree unlock dir = %q, want fallback to Cwd %q", runner.calls[0].dir, repoRoot) + } + if got := runner.commandLine(0); got != "git worktree unlock "+missingPath { + t.Fatalf("git worktree unlock command = %q, want the original path as the unlock target", got) + } +} + func TestReleasePropagatesGitFailure(t *testing.T) { path := filepath.Join(t.TempDir(), "task-a") + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } runner := &fakeRunner{results: []CommandResult{{ExitCode: 1, Stderr: "fatal: not a working tree"}}} err := Release(context.Background(), Options{RunGit: runner.Run}, path) @@ -394,6 +423,56 @@ func TestCleanReportsErrorOnFailedRemoval(t *testing.T) { } } +func TestCleanAggregatesMultipleFailedRemovals(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + + stalePathA := filepath.Join(repoDir, "stale-task-a") + stalePathB := filepath.Join(repoDir, "stale-task-b") + for _, path := range []string{stalePathA, stalePathB} { + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + for _, path := range []string{stalePathA, stalePathB} { + if err := os.Chtimes(path, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + stalePathA + "\n\nworktree " + stalePathB + "\n"}, + {ExitCode: 0}, // status --porcelain (clean) + {ExitCode: 1, Stderr: "fatal: unable to remove worktree A"}, // remove stalePathA + {ExitCode: 0}, // status --porcelain (clean) + {ExitCode: 1, Stderr: "fatal: unable to remove worktree B"}, // remove stalePathB + {ExitCode: 0}, // final prune + }, + } + + err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour) + if err == nil { + t.Fatal("expected Clean to report both failed removals") + } + // Both failures must survive in the returned error, not just the last one + // to occur — overwriting lastErr instead of joining would silently drop + // worktree A's failure once worktree B's removal is also attempted. + if !strings.Contains(err.Error(), "unable to remove worktree A") { + t.Errorf("error = %q, missing worktree A's failure", err.Error()) + } + if !strings.Contains(err.Error(), "unable to remove worktree B") { + t.Errorf("error = %q, missing worktree B's failure", err.Error()) + } +} + // An inspection failure (the root can't be stat'd or walked) must fail // closed: worktreeIsStale reports false rather than true, so an incomplete // inspection can never authorize a forced removal. From ab34fb60cec130a85533eee2e914c3f785ef140f Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:36:02 -0400 Subject: [PATCH 09/32] style(worktrees): align aggregation test comments to gofmt output --- internal/worktrees/worktrees_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index dcac3731b..3547f83ae 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -450,11 +450,11 @@ func TestCleanAggregatesMultipleFailedRemovals(t *testing.T) { results: []CommandResult{ {Stdout: repoRoot}, {Stdout: "worktree " + stalePathA + "\n\nworktree " + stalePathB + "\n"}, - {ExitCode: 0}, // status --porcelain (clean) + {ExitCode: 0}, // status --porcelain (clean) {ExitCode: 1, Stderr: "fatal: unable to remove worktree A"}, // remove stalePathA - {ExitCode: 0}, // status --porcelain (clean) + {ExitCode: 0}, // status --porcelain (clean) {ExitCode: 1, Stderr: "fatal: unable to remove worktree B"}, // remove stalePathB - {ExitCode: 0}, // final prune + {ExitCode: 0}, // final prune }, } From c83e81f2481c4e7f828dba330edd7607c0a47b65 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:03:11 -0400 Subject: [PATCH 10/32] fix(worktrees,cli): restore reuse lease and scope unlock to owned locks - Prepare re-locks a reused worktree so Clean's staleness heuristic cannot force-remove it while the new caller is still using it; a lock already held by a live external caller is kept in place and reported through the new Result.LockAcquired field. - exec --worktree only releases the lock its own Prepare call acquired and surfaces a failed release on stderr with the affected path instead of discarding the error. - worktrees release wires the resolved workspace root into Options.Cwd so the deleted-path recovery works outside the worktree directory. - The relative-path release test derives its expected value via filepath.Abs, matching the resolution the CLI uses, so macOS /var vs /private/var spellings no longer break it. --- internal/cli/exec.go | 26 ++++--- internal/cli/workflow_test.go | 112 ++++++++++++++++++++++++++- internal/cli/workflows.go | 13 +++- internal/worktrees/worktrees.go | 48 ++++++++++-- internal/worktrees/worktrees_test.go | 61 ++++++++++++++- 5 files changed, 237 insertions(+), 23 deletions(-) diff --git a/internal/cli/exec.go b/internal/cli/exec.go index e57a05332..ade682546 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -204,15 +204,23 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error()) } workspaceRoot = preparedWorktree.Path - // This run's own process is the only user of the worktree it just - // created or reused, so its lifetime is bound to this function: release - // the Prepare lock once it returns so Clean can reclaim the worktree - // later if it goes stale. (zero worktrees prepare hands the path to an - // external, longer-lived caller instead, so it can't release this way — - // that caller must run `zero worktrees release` itself when done.) - defer func() { - _ = deps.releaseWorktree(context.Background(), worktrees.Options{}, preparedWorktree.Path) - }() + // When this run's own Prepare call took the worktree lock, its + // lifetime is bound to this function: release the lock once it returns + // so Clean can reclaim the worktree later if it goes stale. A reused + // worktree whose lock an external `zero worktrees prepare` caller + // still holds reports LockAcquired=false; releasing it here would + // clear that caller's lease and expose its workspace to Clean, so the + // matching release stays that caller's responsibility. A failed unlock + // leaves a lock Clean will permanently skip, so it must not pass + // silently; the run's primary result has already been emitted by the + // time the defer runs, so surface it as a diagnostic. + if preparedWorktree.LockAcquired { + defer func() { + if releaseErr := deps.releaseWorktree(context.Background(), worktrees.Options{Cwd: trustRoot}, preparedWorktree.Path); releaseErr != nil { + fmt.Fprintf(stderr, "zero: failed to release worktree lock on %s: %v\n", preparedWorktree.Path, releaseErr) + } + }() + } } registry := newCoreRegistry(workspaceRoot) diff --git a/internal/cli/workflow_test.go b/internal/cli/workflow_test.go index b60da68c3..d5803e714 100644 --- a/internal/cli/workflow_test.go +++ b/internal/cli/workflow_test.go @@ -201,6 +201,15 @@ func TestRunWorktreesReleaseNormalizesRelativePath(t *testing.T) { } }() + // filepath.Abs resolves against os.Getwd, whose spelling of the temp dir + // can differ from t.TempDir()'s (macOS reports /private/var for /var), so + // derive the expected value through the same resolution instead of + // joining onto the lexical parent path. + expectedPath, err := filepath.Abs("task-a") + if err != nil { + t.Fatal(err) + } + var releasedPath string var stdout, stderr bytes.Buffer exitCode := runWithDeps([]string{"worktrees", "release", "task-a"}, &stdout, &stderr, appDeps{ @@ -213,8 +222,8 @@ func TestRunWorktreesReleaseNormalizesRelativePath(t *testing.T) { if exitCode != exitSuccess { t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) } - if releasedPath != worktreeDir { - t.Fatalf("released path = %q, want absolute %q", releasedPath, worktreeDir) + if releasedPath != expectedPath { + t.Fatalf("released path = %q, want absolute %q", releasedPath, expectedPath) } } @@ -236,6 +245,33 @@ func TestRunWorktreesReleaseRequiresPath(t *testing.T) { } } +func TestRunWorktreesReleaseWiresWorkspaceCwd(t *testing.T) { + // Release only consults Options.Cwd when the worktree directory itself is + // already gone (deleted by hand instead of released); git then has to run + // from the source repository to clear the orphaned lock. The CLI must + // wire the resolved workspace root through, or that advertised recovery + // path runs git from a possibly non-repository directory and the lock is + // never cleared (Clean skips locked entries). + root := t.TempDir() + var gotCwd string + + var stdout, stderr bytes.Buffer + exitCode := runWithDeps([]string{"worktrees", "release", filepath.Join(root, "already-deleted")}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return root, nil }, + releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error { + gotCwd = options.Cwd + return nil + }, + }) + + if exitCode != exitSuccess { + t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) + } + if gotCwd != root { + t.Fatalf("release Options.Cwd = %q, want workspace root %q", gotCwd, root) + } +} + func TestRunWorktreesReleaseReportsErrors(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -732,7 +768,7 @@ func TestRunExecWorktreeReleasesLockAfterRun(t *testing.T) { exitCode := runWithDeps([]string{"exec", "--worktree", "task-a", "hello"}, &stdout, &stderr, appDeps{ getwd: func() (string, error) { return root, nil }, prepareWorktree: func(ctx context.Context, options worktrees.Options) (worktrees.Result, error) { - return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234"}, nil + return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", LockAcquired: true}, nil }, releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error { releaseCalls++ @@ -763,6 +799,76 @@ func TestRunExecWorktreeReleasesLockAfterRun(t *testing.T) { } } +func TestRunExecWorktreeKeepsLockItDidNotAcquire(t *testing.T) { + // Prepare reports LockAcquired=false when it reused a worktree whose lock + // an external `zero worktrees prepare` caller still holds. Releasing that + // lock here would clear the external caller's lease and let a later Clean + // force-delete a workspace that caller is still using, so exec must only + // release the ownership its own invocation established. + root := t.TempDir() + worktreeDir := t.TempDir() + releaseCalls := 0 + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"exec", "--worktree", "task-a", "hello"}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return root, nil }, + prepareWorktree: func(ctx context.Context, options worktrees.Options) (worktrees.Result, error) { + return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", Reused: true, LockAcquired: false}, nil + }, + releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error { + releaseCalls++ + return nil + }, + resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) { + return execResolvedConfig(), nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return echoExecProvider{}, nil + }, + }) + + if exitCode != exitSuccess { + t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) + } + if releaseCalls != 0 { + t.Fatalf("releaseWorktree call count = %d, want 0 for a lock this run did not acquire", releaseCalls) + } +} + +func TestRunExecWorktreeSurfacesReleaseFailure(t *testing.T) { + // A failed unlock leaves a lock Clean permanently skips, recreating the + // disk leak silently; the failure must reach the user with the affected + // path so the leaked lock can be cleared by hand. + root := t.TempDir() + worktreeDir := t.TempDir() + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"exec", "--worktree", "task-a", "hello"}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return root, nil }, + prepareWorktree: func(ctx context.Context, options worktrees.Options) (worktrees.Result, error) { + return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", LockAcquired: true}, nil + }, + releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error { + return errors.New("unlock git worktree: boom") + }, + resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) { + return execResolvedConfig(), nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return echoExecProvider{}, nil + }, + }) + + if exitCode != exitSuccess { + t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) + } + if !strings.Contains(stderr.String(), worktreeDir) || !strings.Contains(stderr.String(), "boom") { + t.Fatalf("expected release failure with path on stderr, got %q", stderr.String()) + } +} + func TestRunExecRejectsForkWithWorktree(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/cli/workflows.go b/internal/cli/workflows.go index 2ccf649ae..3fd098c86 100644 --- a/internal/cli/workflows.go +++ b/internal/cli/workflows.go @@ -143,7 +143,18 @@ func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps if err != nil { return writeExecUsageError(stderr, err.Error()) } - if err := deps.releaseWorktree(context.Background(), worktrees.Options{}, absPath); err != nil { + // Release falls back to Options.Cwd as git's working directory when the + // worktree directory itself was deleted by hand instead of released; with + // no Cwd wired in, that recovery path runs `git worktree unlock` from + // wherever the caller happens to be, which outside the source repository + // leaves the orphaned lock behind forever (Clean skips locked entries). + // Resolve the workspace root best-effort: the ordinary existing-path case + // does not need it, so a resolution failure is not an error here. + releaseOptions := worktrees.Options{} + if workspaceRoot, rootErr := resolveWorkspaceRoot("", deps); rootErr == nil { + releaseOptions.Cwd = workspaceRoot + } + if err := deps.releaseWorktree(context.Background(), releaseOptions, absPath); err != nil { return writeExecUsageError(stderr, err.Error()) } if _, err := fmt.Fprintf(stdout, "released %s\n", path); err != nil { diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 2e78e88b8..5540bdb95 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -41,6 +41,10 @@ type Result struct { 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}$`) @@ -113,6 +117,17 @@ func Prepare(ctx context.Context, options Options) (Result, error) { 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 (an external prepare caller is still using the + // path) stays in place and is reported via LockAcquired=false so this + // caller knows the release duty is not its own. + acquired, err := lockWorktree(ctx, runGit, repoRoot, target) + if err != nil { + return Result{}, err + } + result.LockAcquired = acquired return result, nil } if err := os.MkdirAll(repoDir, 0o700); err != nil { @@ -135,18 +150,35 @@ func Prepare(ctx context.Context, options Options) (Result, error) { // 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. + if _, err := lockWorktree(ctx, runGit, repoRoot, target); err != nil { + return Result{}, err + } + // This call created the worktree, so it owns the lease regardless of what + // git reported for the lock itself. + result.LockAcquired = true + return result, nil +} + +// 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) (bool, error) { lockResult, err := runGit(ctx, repoRoot, "worktree", "lock", "--reason", "zero: active task worktree", target) if err != nil { - return Result{}, fmt.Errorf("lock git worktree: %w", err) + return false, fmt.Errorf("lock git worktree: %w", err) } - if lockResult.ExitCode != 0 { - message := strings.TrimSpace(firstNonEmpty(lockResult.Stderr, lockResult.Stdout)) - if message == "" { - message = fmt.Sprintf("git worktree lock exited with code %d", lockResult.ExitCode) - } - return Result{}, fmt.Errorf("lock git worktree: %s", message) + if lockResult.ExitCode == 0 { + return true, nil } - return result, 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) } // Release unlocks a worktree that Prepare locked, via `git worktree unlock`, diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 3547f83ae..d2783bcf7 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -85,6 +85,9 @@ func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { if got := runner.commandLine(4); got != "git worktree lock --reason zero: active task worktree "+result.Path { t.Fatalf("git worktree lock command = %q", got) } + if !result.LockAcquired { + t.Fatalf("LockAcquired = false, want true for a worktree this call created") + } } func TestReleaseUnlocksWorktree(t *testing.T) { @@ -162,6 +165,7 @@ func TestPrepareReusesExistingGitWorktree(t *testing.T) { {Stdout: "abc1234\n"}, {Stdout: sourceGit + "\n"}, {Stdout: sourceGit + "\n"}, + {}, }, } @@ -181,8 +185,61 @@ func TestPrepareReusesExistingGitWorktree(t *testing.T) { if result.Path != existing { t.Fatalf("Path = %q, want existing %q", result.Path, existing) } - if len(runner.calls) != 5 { - t.Fatalf("expected metadata git calls only, got %#v", runner.calls) + if len(runner.calls) != 6 { + t.Fatalf("expected metadata git calls plus lock, got %#v", runner.calls) + } + // The original lock may have been released by a prior run's exit, which + // would leave the reused worktree exposed to Clean's staleness heuristic + // while this caller is still using it: reuse must re-establish the lease. + if got := runner.commandLine(5); got != "git worktree lock --reason zero: active task worktree "+existing { + t.Fatalf("git worktree lock command = %q", got) + } + if !result.LockAcquired { + t.Fatalf("LockAcquired = false, want true for a lease this call took") + } +} + +func TestPrepareReusedWorktreeKeepsExternalLock(t *testing.T) { + // A reused worktree that is still locked belongs to a live external + // `zero worktrees prepare` caller. Prepare must leave that lease in place + // and report LockAcquired=false so `zero exec --worktree` does not release + // a lock it never acquired (which would let a later Clean force-delete a + // workspace the external caller is still using). + root := t.TempDir() + base := t.TempDir() + sourceGit := filepath.Join(root, ".git") + if err := os.MkdirAll(sourceGit, 0o700); err != nil { + t.Fatal(err) + } + existing := filepath.Join(base, "zero-worktree-"+repoKey(root), "reuse-me") + if err := os.MkdirAll(filepath.Join(existing, ".git"), 0o700); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: root + "\n"}, + {Stdout: "main\n"}, + {Stdout: "abc1234\n"}, + {Stdout: sourceGit + "\n"}, + {Stdout: sourceGit + "\n"}, + {ExitCode: 128, Stderr: "fatal: '" + existing + "' is already locked, reason: zero: active task worktree"}, + }, + } + + result, err := Prepare(context.Background(), Options{ + Cwd: root, + Name: "reuse-me", + BaseDir: base, + RunGit: runner.Run, + }) + if err != nil { + t.Fatalf("Prepare must tolerate an already-held lock, got error: %v", err) + } + if !result.Reused { + t.Fatalf("Reused = false, want true") + } + if result.LockAcquired { + t.Fatalf("LockAcquired = true, want false for a lease another caller holds") } } From cf0a1cf61dc9cb590969c59ad4cfcc1bb68f3c5f Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:27:53 -0400 Subject: [PATCH 11/32] fix(worktrees,cli): reject in-use leases, validate before cleanup, add release -C - Prepare rejects a worktree whose lock another run still holds, on both the reuse and the create-race paths, instead of handing a second live caller an unprotected shared checkout whose sole Git lock the first caller's exit would release. - The automatic stale-worktree pruning runs only after the request itself validates, so a rejected command (an invalid --name) has no destructive cleanup side effect; covered end to end with real git in both directions. - worktrees release accepts -C/--cwd naming the source repository, which the deleted-path recovery needs when launched outside the repo (the deleted worktree path is a one-way hash with no way back to its source). --- internal/cli/workflow_test.go | 36 ++++++++++++-- internal/cli/workflows.go | 32 +++++++++++-- internal/worktrees/worktrees.go | 39 ++++++++++----- internal/worktrees/worktrees_test.go | 72 +++++++++++++++++++++++----- 4 files changed, 143 insertions(+), 36 deletions(-) diff --git a/internal/cli/workflow_test.go b/internal/cli/workflow_test.go index d5803e714..985a508e8 100644 --- a/internal/cli/workflow_test.go +++ b/internal/cli/workflow_test.go @@ -272,6 +272,32 @@ func TestRunWorktreesReleaseWiresWorkspaceCwd(t *testing.T) { } } +func TestRunWorktreesReleaseHonorsExplicitCwd(t *testing.T) { + // The deleted-path recovery cannot derive the source repository from the + // worktree path (the directory is gone and its name is a one-way hash), + // so -C names it explicitly; the resolved root must reach Release as + // Options.Cwd regardless of where the command was launched. + launchDir := t.TempDir() + repoDir := t.TempDir() + var gotCwd string + + var stdout, stderr bytes.Buffer + exitCode := runWithDeps([]string{"worktrees", "release", "-C", repoDir, filepath.Join(repoDir, "already-deleted")}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return launchDir, nil }, + releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error { + gotCwd = options.Cwd + return nil + }, + }) + + if exitCode != exitSuccess { + t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) + } + if gotCwd != repoDir { + t.Fatalf("release Options.Cwd = %q, want explicit -C root %q", gotCwd, repoDir) + } +} + func TestRunWorktreesReleaseReportsErrors(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -800,11 +826,11 @@ func TestRunExecWorktreeReleasesLockAfterRun(t *testing.T) { } func TestRunExecWorktreeKeepsLockItDidNotAcquire(t *testing.T) { - // Prepare reports LockAcquired=false when it reused a worktree whose lock - // an external `zero worktrees prepare` caller still holds. Releasing that - // lock here would clear the external caller's lease and let a later Clean - // force-delete a workspace that caller is still using, so exec must only - // release the ownership its own invocation established. + // Defense in depth: Prepare now rejects an in-use lease outright, so a + // successful result always reports LockAcquired=true. Should that ever + // change, exec must still only release the ownership its own invocation + // established; releasing a lock it did not acquire would clear another + // caller's lease and let a later Clean force-delete a live workspace. root := t.TempDir() worktreeDir := t.TempDir() releaseCalls := 0 diff --git a/internal/cli/workflows.go b/internal/cli/workflows.go index 3fd098c86..798a83e29 100644 --- a/internal/cli/workflows.go +++ b/internal/cli/workflows.go @@ -114,13 +114,24 @@ func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDep // caller is responsible for running this once it is done. func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { var path string - for _, arg := range args { + var cwdFlag string + for index := 0; index < len(args); index++ { + arg := args[index] switch { case arg == "-h" || arg == "--help" || arg == "help": if err := writeWorktreesHelp(stdout); err != nil { return exitCrash } return exitSuccess + case arg == "-C" || arg == "--cwd": + value, next, err := nextFlagValue(args, index, arg) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + cwdFlag = value + index = next + case strings.HasPrefix(arg, "--cwd="): + cwdFlag = strings.TrimPrefix(arg, "--cwd=") case strings.HasPrefix(arg, "-"): return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees release flag %q", arg)) default: @@ -148,10 +159,19 @@ func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps // no Cwd wired in, that recovery path runs `git worktree unlock` from // wherever the caller happens to be, which outside the source repository // leaves the orphaned lock behind forever (Clean skips locked entries). - // Resolve the workspace root best-effort: the ordinary existing-path case - // does not need it, so a resolution failure is not an error here. + // -C/--cwd names the source repository explicitly for exactly that case, + // since the deleted worktree path itself carries no way back to the repo; + // without the flag, resolve the launch directory best-effort (the + // ordinary existing-path case does not need it, so a resolution failure + // is not an error here). releaseOptions := worktrees.Options{} - if workspaceRoot, rootErr := resolveWorkspaceRoot("", deps); rootErr == nil { + if cwdFlag != "" { + workspaceRoot, err := resolveWorkspaceRoot(cwdFlag, deps) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + releaseOptions.Cwd = workspaceRoot + } else if workspaceRoot, rootErr := resolveWorkspaceRoot("", deps); rootErr == nil { releaseOptions.Cwd = workspaceRoot } if err := deps.releaseWorktree(context.Background(), releaseOptions, absPath); err != nil { @@ -847,13 +867,15 @@ func formatCommitResult(result zerogit.CommitResult) string { func writeWorktreesHelp(w io.Writer) error { _, err := fmt.Fprint(w, `Usage: zero worktrees prepare [flags] [name] - zero worktrees release + zero worktrees release [flags] Prepares an isolated git worktree for a Zero task. prepare locks the worktree it creates so Zero's automatic cleanup never removes it out from under you. release unlocks a worktree prepare created, once you are done with it, so cleanup can reclaim it later if it goes stale. +If the worktree directory was deleted by hand, run release with -C pointing +at the source repository so the orphaned lock can still be cleared. Flags: --name Worktree name; defaults to a timestamped task name diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 5540bdb95..c3c315213 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -50,11 +50,6 @@ type Result struct { var worktreeNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$`) func Prepare(ctx context.Context, options Options) (Result, error) { - // Clean up stale worktrees (older than 24 hours) automatically to prevent disk space leaks. - if options.RunGit == nil { - _ = Clean(ctx, options, 24*time.Hour) - } - cwd, err := resolveCwd(options.Cwd) if err != nil { return Result{}, err @@ -75,6 +70,14 @@ func Prepare(ctx context.Context, options Options) (Result, error) { 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) @@ -120,14 +123,19 @@ func Prepare(ctx context.Context, options Options) (Result, error) { // 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 (an external prepare caller is still using the - // path) stays in place and is reported via LockAcquired=false so this - // caller knows the release duty is not its own. + // lock already held means another run is still using the path: two + // live runs must not share one supposedly isolated checkout (they + // would edit the same tree, and whichever exits first would release + // the single Git lock out from under the other), so reject it rather + // than hand the second caller an unprotected shared workspace. acquired, err := lockWorktree(ctx, runGit, repoRoot, target) if err != nil { return Result{}, err } - result.LockAcquired = acquired + 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 return result, nil } if err := os.MkdirAll(repoDir, 0o700); err != nil { @@ -149,12 +157,17 @@ func Prepare(ctx context.Context, options Options) (Result, error) { // 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. - if _, err := lockWorktree(ctx, runGit, repoRoot, target); err != nil { + // 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) + if err != nil { return Result{}, err } - // This call created the worktree, so it owns the lease regardless of what - // git reported for the lock itself. + 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 return result, nil } diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index d2783bcf7..a9df1d6ba 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -199,12 +199,12 @@ func TestPrepareReusesExistingGitWorktree(t *testing.T) { } } -func TestPrepareReusedWorktreeKeepsExternalLock(t *testing.T) { - // A reused worktree that is still locked belongs to a live external - // `zero worktrees prepare` caller. Prepare must leave that lease in place - // and report LockAcquired=false so `zero exec --worktree` does not release - // a lock it never acquired (which would let a later Clean force-delete a - // workspace the external caller is still using). +func TestPrepareRejectsWorktreeLockedByAnotherRun(t *testing.T) { + // A reused worktree that is still locked belongs to another live run. + // Handing that checkout to a second caller would let two runs edit one + // supposedly isolated tree, and whichever exits first would release the + // single Git lock out from under the other, so Prepare must reject the + // in-use lease instead of returning the path. root := t.TempDir() base := t.TempDir() sourceGit := filepath.Join(root, ".git") @@ -226,20 +226,66 @@ func TestPrepareReusedWorktreeKeepsExternalLock(t *testing.T) { }, } - result, err := Prepare(context.Background(), Options{ + _, err := Prepare(context.Background(), Options{ Cwd: root, Name: "reuse-me", BaseDir: base, RunGit: runner.Run, }) - if err != nil { - t.Fatalf("Prepare must tolerate an already-held lock, got error: %v", err) + if err == nil || !strings.Contains(err.Error(), "locked by another active run") { + t.Fatalf("Prepare must reject an in-use lease, got %v", err) + } +} + +// TestPrepareValidatesRequestBeforeCleanup pins the order of validation and +// the automatic stale-worktree pruning: a rejected request (an invalid +// --name) must not have destructive cleanup side effects before it reports +// its error. The second half proves the assertion has teeth: the same stale +// worktree IS pruned once a valid request runs. +func TestPrepareValidatesRequestBeforeCleanup(t *testing.T) { + ctx := context.Background() + repo := t.TempDir() + mustGit := func(args ...string) string { + t.Helper() + out, err := gitOutput(ctx, defaultRunGit, repo, args...) + if err != nil { + t.Skipf("git unavailable or failed (%v): %v", args, err) + } + return out } - if !result.Reused { - t.Fatalf("Reused = false, want true") + mustGit("init") + mustGit("-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "--allow-empty", "-m", "seed") + toplevel := filepath.Clean(mustGit("rev-parse", "--show-toplevel")) + + base := t.TempDir() + staleDir := filepath.Join(base, "zero-worktree-"+repoKey(toplevel), "stale-task") + if err := os.MkdirAll(filepath.Dir(staleDir), 0o700); err != nil { + t.Fatal(err) + } + mustGit("worktree", "add", "--detach", staleDir) + // Age every filesystem entry past the 24h staleness cutoff. + old := time.Now().Add(-48 * time.Hour) + if err := filepath.WalkDir(staleDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, old, old) + }); err != nil { + t.Fatal(err) + } + + if _, err := Prepare(ctx, Options{Cwd: repo, BaseDir: base, Name: "../escape"}); err == nil { + t.Fatal("expected invalid-name error") + } + if _, err := os.Stat(staleDir); err != nil { + t.Fatalf("rejected request must not prune worktrees, stale dir: %v", err) + } + + if _, err := Prepare(ctx, Options{Cwd: repo, BaseDir: base, Name: "fresh-task"}); err != nil { + t.Fatalf("valid Prepare: %v", err) } - if result.LockAcquired { - t.Fatalf("LockAcquired = true, want false for a lease another caller holds") + if _, err := os.Stat(staleDir); !os.IsNotExist(err) { + t.Fatalf("valid request should have pruned the stale worktree, stat err: %v", err) } } From e90ef18246ef98643147e5d541f1a34c8499e89b Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:08:23 -0400 Subject: [PATCH 12/32] test(worktrees): canonicalize test roots to physical spelling git records worktree paths in physical form, so the CI runners' symlinked (/var -> /private/var) and 8.3-short (RUNNER~1) temp spellings made Clean's containment check skip the test's stale entry and the pruning assertion fail on macOS and Windows. --- internal/worktrees/worktrees_test.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index a9df1d6ba..b40b88230 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -237,6 +237,17 @@ func TestPrepareRejectsWorktreeLockedByAnotherRun(t *testing.T) { } } +// physicalTestPath resolves a test directory to its physical spelling +// (symlinks and Windows 8.3 short names), matching how git records paths. +func physicalTestPath(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatalf("resolve %s: %v", path, err) + } + return resolved +} + // TestPrepareValidatesRequestBeforeCleanup pins the order of validation and // the automatic stale-worktree pruning: a rejected request (an invalid // --name) must not have destructive cleanup side effects before it reports @@ -244,7 +255,11 @@ func TestPrepareRejectsWorktreeLockedByAnotherRun(t *testing.T) { // worktree IS pruned once a valid request runs. func TestPrepareValidatesRequestBeforeCleanup(t *testing.T) { ctx := context.Background() - repo := t.TempDir() + // Canonicalize both roots up front: git records worktree paths in + // physical spelling, so a lexically different spelling of the same + // directory (macOS /var vs /private/var, Windows 8.3 short names on CI + // runners) would make Clean's containment check skip the entry. + repo := physicalTestPath(t, t.TempDir()) mustGit := func(args ...string) string { t.Helper() out, err := gitOutput(ctx, defaultRunGit, repo, args...) @@ -257,7 +272,7 @@ func TestPrepareValidatesRequestBeforeCleanup(t *testing.T) { mustGit("-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "--allow-empty", "-m", "seed") toplevel := filepath.Clean(mustGit("rev-parse", "--show-toplevel")) - base := t.TempDir() + base := physicalTestPath(t, t.TempDir()) staleDir := filepath.Join(base, "zero-worktree-"+repoKey(toplevel), "stale-task") if err := os.MkdirAll(filepath.Dir(staleDir), 0o700); err != nil { t.Fatal(err) From 10e9f9c6fe0220951e6d35ebf8afeceb49d2b49a Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:48:56 -0400 Subject: [PATCH 13/32] fix(worktrees): preserve orphaned commits, verify release ownership, canonicalize base dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from the latest review round: - Clean now creates a durable ref (refs/zero/orphaned-worktree/) for a detached worktree's HEAD before force-removing it, when that commit isn't already reachable from any other ref. Prepare always creates worktrees with `worktree add --detach`, so a commit made there had no ref pointing at it once the worktree was deleted, making it immediately eligible for git gc despite never having been merged/pushed elsewhere. - Clean resolves its configured base directory through EvalSymlinks before comparing it against git's reported worktree paths: `git worktree list --porcelain` reports each worktree's PHYSICAL location (resolving symlink components), so a symlinked --worktree-dir made every worktree created under it permanently unprunable. - Release now verifies path has a zero-worktree- ancestor directory component before running `git worktree unlock`, so it can't be used to clear the lock on a worktree a user or another tool manages by hand. The check doesn't need to know which --dir a given Prepare call used (nothing records that against a specific worktree, and the CLI never threads BaseDir through to Release) — the repoKey component is Prepare's actual ownership signature regardless of which directory it was created under. - Route the release command's printed path and exec's release-failure diagnostic through the existing CLI redaction helper, and split the worktrees help text into prepare-specific and release-specific flag sections (release only ever supported -C/--cwd, not the --name/--dir/ --json the shared block advertised). All four have regression tests confirmed to fail without their fix (two using real git worktrees, not just fakeRunner sequences). Build, vet, and gofmt clean on linux/windows/darwin. Co-Authored-By: Claude Sonnet 5 --- internal/cli/exec.go | 2 +- internal/cli/workflows.go | 8 +- internal/worktrees/worktrees.go | 94 +++++++++++ internal/worktrees/worktrees_test.go | 232 +++++++++++++++++++++++---- 4 files changed, 305 insertions(+), 31 deletions(-) diff --git a/internal/cli/exec.go b/internal/cli/exec.go index ade682546..e79599a61 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -217,7 +217,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in if preparedWorktree.LockAcquired { defer func() { if releaseErr := deps.releaseWorktree(context.Background(), worktrees.Options{Cwd: trustRoot}, preparedWorktree.Path); releaseErr != nil { - fmt.Fprintf(stderr, "zero: failed to release worktree lock on %s: %v\n", preparedWorktree.Path, releaseErr) + fmt.Fprintf(stderr, "zero: failed to release worktree lock on %s: %v\n", redactCLIString(preparedWorktree.Path), releaseErr) } }() } diff --git a/internal/cli/workflows.go b/internal/cli/workflows.go index 798a83e29..b9c1e15f9 100644 --- a/internal/cli/workflows.go +++ b/internal/cli/workflows.go @@ -177,7 +177,7 @@ func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps if err := deps.releaseWorktree(context.Background(), releaseOptions, absPath); err != nil { return writeExecUsageError(stderr, err.Error()) } - if _, err := fmt.Fprintf(stdout, "released %s\n", path); err != nil { + if _, err := fmt.Fprintf(stdout, "released %s\n", redactCLIString(path)); err != nil { return exitCrash } return exitSuccess @@ -877,11 +877,15 @@ once you are done with it, so cleanup can reclaim it later if it goes stale. If the worktree directory was deleted by hand, run release with -C pointing at the source repository so the orphaned lock can still be cleared. -Flags: +prepare flags: --name Worktree name; defaults to a timestamped task name --dir Base directory for Zero worktrees -C, --cwd Source repository directory --json Print JSON output + +release flags: + -C, --cwd Source repository directory (required if the + worktree directory was already deleted) -h, --help Show this help `) return err diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index c3c315213..6c7a6b6c9 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -218,12 +218,51 @@ func Release(ctx context.Context, options Options, path string) error { dir = cwd } } + if err := verifyZeroOwnedWorktree(ctx, runGit, dir, path); err != nil { + return err + } if _, err := gitOutput(ctx, runGit, dir, "worktree", "unlock", path); err != nil { return fmt.Errorf("unlock git worktree: %w", err) } return nil } +// verifyZeroOwnedWorktree confirms path has a zero-worktree- ancestor +// directory component, 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. This checks for the ancestor component itself rather than +// 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. gitCommonDir resolves the shared .git +// directory whether dir is the worktree itself or the main repository, so +// this needs no branching on which of Release's two cwd cases is in play; +// its parent is the same repoRoot Prepare/Clean use to compute repoKey. +func verifyZeroOwnedWorktree(ctx context.Context, runGit GitRunner, dir string, path string) error { + commonDir, err := gitCommonDir(ctx, runGit, dir) + if err != nil { + return fmt.Errorf("resolve repository for %s: %w", path, err) + } + repoRoot := filepath.Dir(commonDir) + want := "zero-worktree-" + repoKey(repoRoot) + + target := path + if resolved, err := filepath.EvalSymlinks(path); err == nil { + target = resolved + } else if abs, err := filepath.Abs(path); err == nil { + target = abs + } + for _, component := range strings.Split(filepath.Clean(target), string(filepath.Separator)) { + if component == want { + return nil + } + } + return fmt.Errorf("refusing to release %s: not a zero-managed worktree (expected an ancestor directory named %q)", path, want) +} + func DefaultBaseDir(env map[string]string) (string, error) { if runtime.GOOS == "windows" { if localAppData := strings.TrimSpace(envValue(env, "LOCALAPPDATA")); localAppData != "" { @@ -425,6 +464,17 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { 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 + } // 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 @@ -474,6 +524,10 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { // gets committed/cleaned (no longer dirty) or unlocked. continue } + if err := preserveUnreachableWorktreeHead(ctx, runGit, repoRoot, entry.path); err != nil { + lastErr = errors.Join(lastErr, fmt.Errorf("preserve worktree HEAD %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 @@ -537,6 +591,46 @@ func isUnderDir(path, dir string) bool { 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 { + head, err := gitOutput(ctx, runGit, worktreePath, "rev-parse", "HEAD") + if err != nil { + // No commit to preserve (an empty/unborn worktree, or one already + // gone) — nothing for this guard to do; let the caller proceed. + return nil + } + head = strings.TrimSpace(head) + 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 diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index b40b88230..95ffb6fee 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -91,22 +91,36 @@ func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { } func TestReleaseUnlocksWorktree(t *testing.T) { - path := filepath.Join(t.TempDir(), "task-a") - if err := os.Mkdir(path, 0o700); err != nil { + repoRoot := t.TempDir() + // gitCommonDir resolves its answer with EvalSymlinks, so the fake + // --git-common-dir response needs a real directory behind it. + if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o700); err != nil { t.Fatal(err) } - runner := &fakeRunner{results: []CommandResult{{}}} + // path must carry the zero-worktree- ancestor component Prepare + // actually creates: Release now refuses to unlock anything else. + path := filepath.Join(t.TempDir(), "zero-worktree-"+repoKey(repoRoot), "task-a") + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{results: []CommandResult{ + {Stdout: filepath.Join(repoRoot, ".git") + "\n"}, + {}, + }} if err := Release(context.Background(), Options{RunGit: runner.Run}, path); err != nil { t.Fatalf("Release returned error: %v", err) } - if len(runner.calls) != 1 { - t.Fatalf("expected exactly one git call, got %#v", runner.calls) + if len(runner.calls) != 2 { + t.Fatalf("expected exactly two git calls (ownership check, then unlock), got %#v", runner.calls) } - if runner.calls[0].dir != path { - t.Fatalf("git worktree unlock dir = %q, want %q", runner.calls[0].dir, path) + if got := runner.commandLine(0); got != "git rev-parse --git-common-dir" { + t.Fatalf("ownership-check command = %q", got) } - if got := runner.commandLine(0); got != "git worktree unlock "+path { + if runner.calls[1].dir != path { + t.Fatalf("git worktree unlock dir = %q, want %q", runner.calls[1].dir, path) + } + if got := runner.commandLine(1); got != "git worktree unlock "+path { t.Fatalf("git worktree unlock command = %q", got) } } @@ -116,24 +130,57 @@ func TestReleaseFallsBackToCwdWhenWorktreeDirMissing(t *testing.T) { // releasing it first leaves path itself gone; Release must still be able // to run `git worktree unlock` (from the main repo, via options.Cwd) so // the orphaned lock can be cleared and the entry later pruned. - missingPath := filepath.Join(t.TempDir(), "already-deleted") repoRoot := t.TempDir() - runner := &fakeRunner{results: []CommandResult{{}}} + if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o700); err != nil { + t.Fatal(err) + } + missingPath := filepath.Join(t.TempDir(), "zero-worktree-"+repoKey(repoRoot), "already-deleted") + runner := &fakeRunner{results: []CommandResult{ + {Stdout: filepath.Join(repoRoot, ".git") + "\n"}, + {}, + }} if err := Release(context.Background(), Options{RunGit: runner.Run, Cwd: repoRoot}, missingPath); err != nil { t.Fatalf("Release returned error: %v", err) } - if len(runner.calls) != 1 { - t.Fatalf("expected exactly one git call, got %#v", runner.calls) + if len(runner.calls) != 2 { + t.Fatalf("expected exactly two git calls (ownership check, then unlock), got %#v", runner.calls) } - if runner.calls[0].dir != repoRoot { - t.Fatalf("git worktree unlock dir = %q, want fallback to Cwd %q", runner.calls[0].dir, repoRoot) + if runner.calls[0].dir != repoRoot || runner.calls[1].dir != repoRoot { + t.Fatalf("git calls = %#v, want both to fall back to Cwd %q", runner.calls, repoRoot) } - if got := runner.commandLine(0); got != "git worktree unlock "+missingPath { + if got := runner.commandLine(1); got != "git worktree unlock "+missingPath { t.Fatalf("git worktree unlock command = %q, want the original path as the unlock target", got) } } +// TestReleaseRejectsNonZeroOwnedWorktree pins the fix for Release being +// usable 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, so a path with no zero-worktree- +// ancestor component must be refused before any unlock is attempted. +func TestReleaseRejectsNonZeroOwnedWorktree(t *testing.T) { + repoRoot := t.TempDir() + if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o700); err != nil { + t.Fatal(err) + } + manualWorktree := filepath.Join(t.TempDir(), "my-manual-worktree") + if err := os.MkdirAll(manualWorktree, 0o700); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{results: []CommandResult{ + {Stdout: filepath.Join(repoRoot, ".git") + "\n"}, + }} + + err := Release(context.Background(), Options{RunGit: runner.Run}, manualWorktree) + if err == nil || !strings.Contains(err.Error(), "not a zero-managed worktree") { + t.Fatalf("Release error = %v, want a not-zero-managed rejection", err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected only the ownership check, no unlock call, got %#v", runner.calls) + } +} + func TestReleasePropagatesGitFailure(t *testing.T) { path := filepath.Join(t.TempDir(), "task-a") if err := os.Mkdir(path, 0o700); err != nil { @@ -248,6 +295,65 @@ func physicalTestPath(t *testing.T, path string) string { return resolved } +// TestCleanPrunesStaleWorktreeUnderSymlinkedBaseDir pins the fix for a +// symlinked --worktree-dir: git worktree list --porcelain reports each +// worktree's PHYSICAL location, resolving any symlink component, so Clean +// comparing entries against a merely-absolute (not symlink-resolved) baseDir +// would reject every worktree created under a symlinked base and never prune +// it. Unlike the other Clean tests, base is deliberately handed to +// Prepare/Clean via its symlinked spelling, not its physical one (which +// physicalTestPath would normally produce), so this actually exercises the +// mismatch. +func TestCleanPrunesStaleWorktreeUnderSymlinkedBaseDir(t *testing.T) { + ctx := context.Background() + repo := physicalTestPath(t, t.TempDir()) + mustGit := func(args ...string) string { + t.Helper() + out, err := gitOutput(ctx, defaultRunGit, repo, args...) + if err != nil { + t.Skipf("git unavailable or failed (%v): %v", args, err) + } + return out + } + mustGit("init") + mustGit("-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "--allow-empty", "-m", "seed") + + realBase := physicalTestPath(t, t.TempDir()) + base := filepath.Join(t.TempDir(), "base-link") + if err := os.Symlink(realBase, base); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + if _, err := Prepare(ctx, Options{Cwd: repo, BaseDir: base, Name: "stale-task"}); err != nil { + t.Fatalf("Prepare: %v", err) + } + staleDir := filepath.Join(realBase, "zero-worktree-"+repoKey(repo), "stale-task") + if _, err := os.Stat(staleDir); err != nil { + t.Fatalf("expected worktree created at physical base path %s: %v", staleDir, err) + } + // Release the lock Prepare took and age every entry past the cutoff, so + // the worktree is both unlocked and stale. + if err := Release(ctx, Options{Cwd: repo}, staleDir); err != nil { + t.Fatalf("Release: %v", err) + } + old := time.Now().Add(-48 * time.Hour) + if err := filepath.WalkDir(staleDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, old, old) + }); err != nil { + t.Fatal(err) + } + + if err := Clean(ctx, Options{Cwd: repo, BaseDir: base}, 24*time.Hour); err != nil { + t.Fatalf("Clean: %v", err) + } + if _, err := os.Stat(staleDir); !os.IsNotExist(err) { + t.Fatalf("Clean should have pruned the stale worktree under the symlinked base dir, stat err: %v", err) + } +} + // TestPrepareValidatesRequestBeforeCleanup pins the order of validation and // the automatic stale-worktree pruning: a rejected request (an invalid // --name) must not have destructive cleanup side effects before it reports @@ -460,9 +566,11 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { results: []CommandResult{ {Stdout: repoRoot}, // rev-parse --show-toplevel {Stdout: "worktree " + youngPath + "\nworktree " + stalePath + "\n"}, // worktree list --porcelain - {ExitCode: 0}, // status --porcelain (clean) - {ExitCode: 0}, // worktree remove --force - {ExitCode: 0}, // worktree prune + {ExitCode: 0}, // status --porcelain (clean) + {Stdout: "deadbeef"}, // rev-parse HEAD + {Stdout: "refs/heads/main"}, // for-each-ref --contains=deadbeef (already reachable) + {ExitCode: 0}, // worktree remove --force + {ExitCode: 0}, // worktree prune }, } @@ -478,8 +586,8 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { } // Verify the calls made by Clean - if len(runner.calls) != 5 { - t.Fatalf("expected 5 git calls, got %d", len(runner.calls)) + if len(runner.calls) != 7 { + t.Fatalf("expected 7 git calls, got %d", len(runner.calls)) } if runner.commandLine(0) != "git rev-parse --show-toplevel" { t.Errorf("call 0 = %q", runner.commandLine(0)) @@ -491,12 +599,15 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { if runner.commandLine(2) != expectedStatusCall { t.Errorf("call 2 = %q, want %q", runner.commandLine(2), expectedStatusCall) } + if runner.commandLine(3) != "git rev-parse HEAD" { + t.Errorf("call 3 = %q, want the pre-removal HEAD-preservation check", runner.commandLine(3)) + } expectedRemoveCall := "git worktree remove --force " + filepath.Clean(stalePath) - if runner.commandLine(3) != expectedRemoveCall { - t.Errorf("call 3 = %q, want %q", runner.commandLine(3), expectedRemoveCall) + if runner.commandLine(5) != expectedRemoveCall { + t.Errorf("call 5 = %q, want %q", runner.commandLine(5), expectedRemoveCall) } - if runner.commandLine(4) != "git worktree prune" { - t.Errorf("call 4 = %q", runner.commandLine(4)) + if runner.commandLine(6) != "git worktree prune" { + t.Errorf("call 6 = %q", runner.commandLine(6)) } } @@ -526,8 +637,10 @@ func TestCleanReportsErrorOnFailedRemoval(t *testing.T) { results: []CommandResult{ {Stdout: repoRoot}, {Stdout: "worktree " + stalePath + "\n"}, - {ExitCode: 0}, // status --porcelain (clean) - {ExitCode: 1, Stderr: "fatal: unable to remove worktree: in use"}, + {ExitCode: 0}, // status --porcelain (clean) + {Stdout: "deadbeef"}, // rev-parse HEAD + {Stdout: "refs/heads/main"}, // for-each-ref --contains=deadbeef (already reachable) + {ExitCode: 1, Stderr: "fatal: unable to remove worktree: in use"}, // worktree remove --force {ExitCode: 0}, }, } @@ -568,9 +681,13 @@ func TestCleanAggregatesMultipleFailedRemovals(t *testing.T) { results: []CommandResult{ {Stdout: repoRoot}, {Stdout: "worktree " + stalePathA + "\n\nworktree " + stalePathB + "\n"}, - {ExitCode: 0}, // status --porcelain (clean) + {ExitCode: 0}, // status --porcelain (clean) + {Stdout: "deadbeefa"}, // rev-parse HEAD + {Stdout: "refs/heads/main"}, // for-each-ref --contains=deadbeefa (already reachable) {ExitCode: 1, Stderr: "fatal: unable to remove worktree A"}, // remove stalePathA - {ExitCode: 0}, // status --porcelain (clean) + {ExitCode: 0}, // status --porcelain (clean) + {Stdout: "deadbeefb"}, // rev-parse HEAD + {Stdout: "refs/heads/main"}, // for-each-ref --contains=deadbeefb (already reachable) {ExitCode: 1, Stderr: "fatal: unable to remove worktree B"}, // remove stalePathB {ExitCode: 0}, // final prune }, @@ -760,6 +877,65 @@ func TestCleanSkipsLockedZeroOwnedWorktree(t *testing.T) { // generated drafts, task artifacts) must still block force-removal: plain // `git status --porcelain` reports such a worktree as clean, but // worktreeIsDirty now also passes --ignored, so Clean must treat it as dirty. +// TestCleanPreservesUnreachableCommitBeforeRemoval pins the fix for a real +// data-loss case: Prepare creates every worktree with `worktree add --detach`, +// so a commit made there is reachable only through that worktree's own HEAD. +// If the worktree goes stale and clean before the commit is otherwise +// referenced, force-removing it must not let the commit become unreachable — +// Clean has to preserve it under a durable ref first. +func TestCleanPreservesUnreachableCommitBeforeRemoval(t *testing.T) { + ctx := context.Background() + repo := physicalTestPath(t, t.TempDir()) + mustGit := func(dir string, args ...string) string { + t.Helper() + out, err := gitOutput(ctx, defaultRunGit, dir, args...) + if err != nil { + t.Skipf("git unavailable or failed (%v): %v", args, err) + } + return out + } + mustGit(repo, "init") + mustGit(repo, "-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "--allow-empty", "-m", "seed") + + base := physicalTestPath(t, t.TempDir()) + if _, err := Prepare(ctx, Options{Cwd: repo, BaseDir: base, Name: "orphan-task"}); err != nil { + t.Fatalf("Prepare: %v", err) + } + staleDir := filepath.Join(base, "zero-worktree-"+repoKey(repo), "orphan-task") + + // Commit inside the worktree: this HEAD is not on any branch, so nothing + // outside the worktree itself points at it yet. + mustGit(staleDir, "-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "--allow-empty", "-m", "orphaned work") + orphanSHA := mustGit(staleDir, "rev-parse", "HEAD") + + if err := Release(ctx, Options{Cwd: repo}, staleDir); err != nil { + t.Fatalf("Release: %v", err) + } + old := time.Now().Add(-48 * time.Hour) + if err := filepath.WalkDir(staleDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, old, old) + }); err != nil { + t.Fatal(err) + } + + if err := Clean(ctx, Options{Cwd: repo, BaseDir: base}, 24*time.Hour); err != nil { + t.Fatalf("Clean: %v", err) + } + if _, err := os.Stat(staleDir); !os.IsNotExist(err) { + t.Fatalf("Clean should have pruned the stale worktree, stat err: %v", err) + } + if _, err := gitOutput(ctx, defaultRunGit, repo, "cat-file", "-e", orphanSHA); err != nil { + t.Fatalf("commit %s is no longer reachable after Clean: %v", orphanSHA, err) + } + preserved := mustGit(repo, "for-each-ref", "--contains="+orphanSHA, "--count=1", "--format=%(refname)") + if strings.TrimSpace(preserved) == "" { + t.Fatalf("commit %s survived only by luck (not yet GC'd); expected a durable ref to contain it", orphanSHA) + } +} + func TestCleanSkipsWorktreeWithOnlyIgnoredFiles(t *testing.T) { tempDir := t.TempDir() baseDir := filepath.Join(tempDir, "zero-worktrees") From aafb4b4f21cbe5dcffa40f8ab83302c6424e5c3a Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:48:59 -0400 Subject: [PATCH 14/32] fix(worktrees): recoverable PID leases and reclaimable released worktrees Two review findings on the cleanup lifecycle: - A lock left by an abnormal exit (SIGKILL, crash, power loss) was skipped by Clean forever, recreating the permanent disk leak this PR set out to fix. exec --worktree now records its PID in the lease reason; Clean expires a lease whose recorded owner is provably dead, unlocking only after the staleness, dirty, and HEAD-preservation guards all pass. Human locks and PID-less leases (external `worktrees prepare` owners) remain permanent until explicit release, and any ambiguity in the liveness probe counts as alive. - An explicitly released worktree holding only gitignored residue (node_modules, build output) was skipped at every age. Release is the owner's completion signal, so unlocked entries now block removal only on tracked/untracked changes; expired crashed leases keep the conservative --ignored probe since they never signaled completion. Co-Authored-By: Claude Fable 5 --- internal/cli/exec.go | 5 + internal/worktrees/worktrees.go | 153 ++++++++++++++++++---- internal/worktrees/worktrees_test.go | 186 ++++++++++++++++++++++++--- 3 files changed, 304 insertions(+), 40 deletions(-) diff --git a/internal/cli/exec.go b/internal/cli/exec.go index e79599a61..629374c93 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -199,6 +199,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in Name: options.worktreeName, BaseDir: options.worktreeDir, Now: deps.now, + // The worktree's lifetime is bound to this process (the deferred + // release below), so record the PID: if this process dies without + // releasing, Clean can expire the lease instead of skipping the + // locked worktree forever. + LeasePID: os.Getpid(), }) if err != nil { return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error()) diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 6c7a6b6c9..d878261ce 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -13,7 +13,9 @@ import ( "path/filepath" "regexp" "runtime" + "strconv" "strings" + "syscall" "time" ) @@ -32,6 +34,14 @@ type Options struct { 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 { @@ -128,7 +138,7 @@ func Prepare(ctx context.Context, options Options) (Result, error) { // would edit the same tree, and whichever exits first would release // the single Git lock out from under the other), so reject it rather // than hand the second caller an unprotected shared workspace. - acquired, err := lockWorktree(ctx, runGit, repoRoot, target) + acquired, err := lockWorktree(ctx, runGit, repoRoot, target, options.LeasePID) if err != nil { return Result{}, err } @@ -161,7 +171,7 @@ func Prepare(ctx context.Context, options Options) (Result, error) { // 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) + acquired, err := lockWorktree(ctx, runGit, repoRoot, target, options.LeasePID) if err != nil { return Result{}, err } @@ -172,12 +182,71 @@ func Prepare(ctx context.Context, options Options) (Result, error) { 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. +func processAlive(pid int) bool { + if pid <= 0 { + return true + } + proc, err := os.FindProcess(pid) + if err != nil { + // Windows: FindProcess opens the process and fails when it is gone. + return false + } + if runtime.GOOS == "windows" { + return true + } + err = proc.Signal(syscall.Signal(0)) + if err == nil { + return true + } + if errors.Is(err, os.ErrProcessDone) { + return false + } + // EPERM: the process exists but belongs to another user. + return errors.Is(err, syscall.EPERM) +} + // 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) (bool, error) { - lockResult, err := runGit(ctx, repoRoot, "worktree", "lock", "--reason", "zero: active task worktree", target) +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) } @@ -489,12 +558,6 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { cutoff := time.Now().Add(-maxAge) var lastErr error for _, entry := range parseWorktreeList(output) { - // A worktree a caller has explicitly locked (git worktree lock) is - // never a prune candidate, regardless of its mtime. - if entry.locked { - continue - } - // 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. @@ -503,6 +566,21 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { 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 + } + info, err := os.Stat(entry.path) if err != nil { if os.IsNotExist(err) { @@ -515,7 +593,13 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { } if worktreeIsStale(entry.path, cutoff) { - if worktreeIsDirty(ctx, runGit, entry.path) { + // An explicit release is the owner's completion signal, so a + // worktree holding only gitignored residue (node_modules, build + // output) after release is reclaimable — otherwise every released + // worktree with such artifacts leaks forever. An expired lease is + // NOT a completion signal (the task may have died mid-work), so + // there ignored files still count as live data. + if worktreeIsDirty(ctx, runGit, entry.path, expiredLease) { // 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 @@ -528,6 +612,15 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { 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 @@ -547,8 +640,9 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { } type worktreeEntry struct { - path string - locked bool + path string + locked bool + lockReason string } // parseWorktreeList reads `git worktree list --porcelain` output into one @@ -568,6 +662,7 @@ func parseWorktreeList(output string) []worktreeEntry { current = &worktreeEntry{path: filepath.Clean(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 { @@ -662,21 +757,29 @@ func worktreeIsStale(root string, cutoff time.Time) bool { return stale && walkErr == nil } -// worktreeIsDirty reports whether a worktree has uncommitted, untracked, or -// ignored changes, via `git status --porcelain --ignored` 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. --ignored is -// included because files matched by .gitignore (credentials, generated -// drafts, task artifacts) are real data a task can leave behind; without it, -// plain `git status --porcelain` reports a worktree holding only such files -// as clean, and Clean would force-remove it and silently discard them. +// 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) bool { - output, err := gitOutput(ctx, runGit, path, "status", "--porcelain", "--ignored") +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 } diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 95ffb6fee..f960ab4aa 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -595,7 +595,7 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { if runner.commandLine(1) != "git worktree list --porcelain" { t.Errorf("call 1 = %q", runner.commandLine(1)) } - expectedStatusCall := "git status --porcelain --ignored" + expectedStatusCall := "git status --porcelain" if runner.commandLine(2) != expectedStatusCall { t.Errorf("call 2 = %q, want %q", runner.commandLine(2), expectedStatusCall) } @@ -936,7 +936,12 @@ func TestCleanPreservesUnreachableCommitBeforeRemoval(t *testing.T) { } } -func TestCleanSkipsWorktreeWithOnlyIgnoredFiles(t *testing.T) { +func TestCleanReclaimsReleasedWorktreeWithOnlyIgnoredFiles(t *testing.T) { + // An explicit release is the owner's completion signal: an unlocked, + // stale worktree holding only gitignored residue (node_modules, build + // output) must be reclaimable, or every released worktree with such + // artifacts leaks disk forever. The dirty probe for unlocked entries + // therefore omits --ignored. tempDir := t.TempDir() baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") @@ -958,8 +963,11 @@ func TestCleanSkipsWorktreeWithOnlyIgnoredFiles(t *testing.T) { results: []CommandResult{ {Stdout: repoRoot}, {Stdout: "worktree " + ignoredOnlyPath + "\n"}, - {Stdout: "!! ignored-data\n"}, // status --porcelain --ignored: ignored file present - {ExitCode: 0}, // worktree prune + {ExitCode: 0}, // status --porcelain: ignored files invisible => clean + {Stdout: "deadbeef"}, // rev-parse HEAD + {Stdout: "refs/heads/main"}, // for-each-ref --contains (reachable) + {ExitCode: 0}, // worktree remove --force + {ExitCode: 0}, // worktree prune }, } @@ -967,19 +975,167 @@ func TestCleanSkipsWorktreeWithOnlyIgnoredFiles(t *testing.T) { t.Fatalf("Clean failed: %v", err) } - for i, call := range runner.calls { - if i == 2 { - want := "status --porcelain --ignored" - if got := strings.Join(call.args, " "); got != want { - t.Fatalf("status call args = %q, want %q", got, want) - } + if got, want := runner.commandLine(2), "git status --porcelain"; got != want { + t.Fatalf("status call = %q, want %q (released worktrees must not count ignored residue)", got, want) + } + removed := false + for _, call := range runner.calls { + if len(call.args) > 1 && call.args[0] == "worktree" && call.args[1] == "remove" { + removed = true } - if len(call.args) > 0 && call.args[0] == "remove" { - t.Fatalf("Clean removed a worktree with only ignored files: %v", call.args) + } + if !removed { + t.Fatal("Clean did not reclaim a released, stale worktree holding only ignored residue") + } +} + +// TestCleanRecoversExpiredLease: a Zero lease that records its owning PID is +// recoverable — if that process died without releasing (SIGKILL, crash), the +// lock must not protect the worktree forever. A stale, clean worktree behind +// a dead-owner lease is unlocked and removed; the dirty probe there keeps +// --ignored because a crashed task never signaled completion. +func TestCleanRecoversExpiredLease(t *testing.T) { + deadPID := deadProcessPID(t) + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + + crashedPath := filepath.Join(repoDir, "crashed-task") + if err := os.MkdirAll(crashedPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(crashedPath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + crashedPath + "\nlocked " + leaseReason(deadPID) + "\n"}, + {ExitCode: 0}, // status --porcelain --ignored: clean + {Stdout: "deadbeef"}, // rev-parse HEAD + {Stdout: "refs/heads/main"}, // for-each-ref --contains (reachable) + {ExitCode: 0}, // worktree unlock (lease recovery) + {ExitCode: 0}, // worktree remove --force + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + + if got, want := runner.commandLine(2), "git status --porcelain --ignored"; got != want { + t.Fatalf("status call = %q, want %q (a crashed lease never signaled completion)", got, want) + } + if got, want := runner.commandLine(5), "git worktree unlock "+filepath.Clean(crashedPath); got != want { + t.Fatalf("call 5 = %q, want lease recovery %q", got, want) + } + if got, want := runner.commandLine(6), "git worktree remove --force "+filepath.Clean(crashedPath); got != want { + t.Fatalf("call 6 = %q, want %q", got, want) + } +} + +// TestCleanSkipsExpiredLeaseWithIgnoredData: even behind a dead-owner lease, +// ignored files (credentials, generated drafts) may be all the crashed task +// left behind, so they still block removal. +func TestCleanSkipsExpiredLeaseWithIgnoredData(t *testing.T) { + deadPID := deadProcessPID(t) + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + + crashedPath := filepath.Join(repoDir, "crashed-task") + if err := os.MkdirAll(crashedPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(crashedPath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + crashedPath + "\nlocked " + leaseReason(deadPID) + "\n"}, + {Stdout: "!! ignored-data\n"}, // status --porcelain --ignored + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + for _, call := range runner.calls { + if len(call.args) > 1 && call.args[0] == "worktree" && (call.args[1] == "remove" || call.args[1] == "unlock") { + t.Fatalf("Clean touched a crashed worktree holding ignored data: %v", call.args) + } + } +} + +// TestCleanHonorsLiveLease: a lease whose recorded owner is still running is +// never expired, regardless of staleness. +func TestCleanHonorsLiveLease(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + + livePath := filepath.Join(repoDir, "live-task") + if err := os.MkdirAll(livePath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(livePath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + livePath + "\nlocked " + leaseReason(os.Getpid()) + "\n"}, + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + for _, call := range runner.calls { + if len(call.args) > 0 && (call.args[0] == "remove" || call.args[0] == "status") { + t.Fatalf("Clean touched a worktree behind a live lease: %v", call.args) } } } +// deadProcessPID returns the PID of a process that has already exited, for +// exercising lease expiry. PID reuse in the instant between exit and the +// assertion is vanishingly unlikely. +func deadProcessPID(t *testing.T) int { + t.Helper() + gitPath, err := exec.LookPath("git") + if err != nil { + t.Skip("git not available") + } + cmd := exec.Command(gitPath, "version") + if err := cmd.Run(); err != nil { + t.Fatalf("run git version: %v", err) + } + return cmd.Process.Pid +} + // worktreeIsDirty must count files matched by .gitignore as dirty content: a // worktree holding only ignored task data (credentials, generated drafts) has // nothing to show in plain `git status --porcelain` and would otherwise pass @@ -1007,7 +1163,7 @@ func TestWorktreeIsDirtyCountsIgnoredFilesAsDirty(t *testing.T) { run("add", ".gitignore") run("commit", "--quiet", "-m", "initial") - if worktreeIsDirty(context.Background(), defaultRunGit, dir) { + if worktreeIsDirty(context.Background(), defaultRunGit, dir, true) { t.Fatal("expected a clean worktree with no ignored files present to report clean") } @@ -1015,7 +1171,7 @@ func TestWorktreeIsDirtyCountsIgnoredFilesAsDirty(t *testing.T) { t.Fatal(err) } - if !worktreeIsDirty(context.Background(), defaultRunGit, dir) { + if !worktreeIsDirty(context.Background(), defaultRunGit, dir, true) { t.Fatal("expected an ignored-but-present file to count as dirty") } } @@ -1149,7 +1305,7 @@ func TestCleanSkipsDirtyStaleWorktree(t *testing.T) { // authorize a forced removal. func TestWorktreeIsDirtyFailsClosedOnInspectionError(t *testing.T) { runner := &fakeRunner{results: []CommandResult{{ExitCode: 1, Stderr: "fatal: not a git repository"}}} - if !worktreeIsDirty(context.Background(), runner.Run, t.TempDir()) { + if !worktreeIsDirty(context.Background(), runner.Run, t.TempDir(), true) { t.Fatal("expected worktreeIsDirty to fail closed on a status error") } } From 6c9a9aedd999b83dba2803184f3f06cd6d8e1814 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:49:29 -0400 Subject: [PATCH 15/32] fix(worktrees): address review feedback on lease detection and release safety Split dead-lease PID checking into posix/windows implementations so Windows can reliably tell a dead process from a live one. Fix a path canonicalization mismatch in two release tests. Derive release ownership from git worktree list instead of the git-dir parent, which was wrong for repos with a separate git-dir. Refuse to clear a lock that was not taken by Zero in the first place. --- internal/worktrees/worktrees.go | 84 ++++++++++++++----------- internal/worktrees/worktrees_posix.go | 28 +++++++++ internal/worktrees/worktrees_test.go | 65 ++++++++++++++----- internal/worktrees/worktrees_windows.go | 27 ++++++++ 4 files changed, 150 insertions(+), 54 deletions(-) create mode 100644 internal/worktrees/worktrees_posix.go create mode 100644 internal/worktrees/worktrees_windows.go diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index d878261ce..83f341622 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -15,7 +15,6 @@ import ( "runtime" "strconv" "strings" - "syscall" "time" ) @@ -217,28 +216,16 @@ func leasePID(reason string) (int, bool) { // 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. +// 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 } - proc, err := os.FindProcess(pid) - if err != nil { - // Windows: FindProcess opens the process and fails when it is gone. - return false - } - if runtime.GOOS == "windows" { - return true - } - err = proc.Signal(syscall.Signal(0)) - if err == nil { - return true - } - if errors.Is(err, os.ErrProcessDone) { - return false - } - // EPERM: the process exists but belongs to another user. - return errors.Is(err, syscall.EPERM) + return osProcessAlive(pid) } // lockWorktree takes the Clean-protection lease on target via `git worktree @@ -297,26 +284,32 @@ func Release(ctx context.Context, options Options, path string) error { } // verifyZeroOwnedWorktree confirms path has a zero-worktree- ancestor -// directory component, so Release cannot be used to clear the lock on a -// worktree a user (or another tool) manages by hand: the command is +// directory component, and that if git currently has it locked, the lock +// reason is one 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. This checks for the ancestor component itself rather than -// 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. gitCommonDir resolves the shared .git -// directory whether dir is the worktree itself or the main repository, so -// this needs no branching on which of Release's two cwd cases is in play; -// its parent is the same repoRoot Prepare/Clean use to compute repoKey. +// 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) error { - commonDir, err := gitCommonDir(ctx, runGit, dir) + output, err := gitOutput(ctx, runGit, dir, "worktree", "list", "--porcelain") if err != nil { return fmt.Errorf("resolve repository for %s: %w", path, err) } - repoRoot := filepath.Dir(commonDir) - want := "zero-worktree-" + repoKey(repoRoot) + entries := parseWorktreeList(output) + if len(entries) == 0 { + return fmt.Errorf("resolve repository for %s: git worktree list returned no entries", path) + } + want := "zero-worktree-" + repoKey(entries[0].path) target := path if resolved, err := filepath.EvalSymlinks(path); err == nil { @@ -324,12 +317,29 @@ func verifyZeroOwnedWorktree(ctx context.Context, runGit GitRunner, dir string, } else if abs, err := filepath.Abs(path); err == nil { target = abs } - for _, component := range strings.Split(filepath.Clean(target), string(filepath.Separator)) { + target = filepath.Clean(target) + + hasZeroComponent := false + for _, component := range strings.Split(target, string(filepath.Separator)) { if component == want { - return nil + hasZeroComponent = true + break } } - return fmt.Errorf("refusing to release %s: not a zero-managed worktree (expected an ancestor directory named %q)", path, want) + if !hasZeroComponent { + return fmt.Errorf("refusing to release %s: not a zero-managed worktree (expected an ancestor directory named %q)", path, want) + } + + for _, entry := range entries { + if entry.path != target { + continue + } + if entry.locked && !strings.HasPrefix(entry.lockReason, leaseReasonPrefix) { + return fmt.Errorf("refusing to release %s: locked with reason %q, not a zero lease", path, entry.lockReason) + } + break + } + return nil } func DefaultBaseDir(env map[string]string) (string, error) { diff --git a/internal/worktrees/worktrees_posix.go b/internal/worktrees/worktrees_posix.go new file mode 100644 index 000000000..323038dec --- /dev/null +++ b/internal/worktrees/worktrees_posix.go @@ -0,0 +1,28 @@ +//go:build !windows + +package worktrees + +import ( + "errors" + "os" + "syscall" +) + +// osProcessAlive reports whether pid is a live process on POSIX. Signal 0 +// does not deliver a signal; it only checks existence/permission. ESRCH means +// no such process (dead); EPERM means it exists but we may not signal it +// (alive). +func osProcessAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + err = proc.Signal(syscall.Signal(0)) + if err == nil { + return true + } + if errors.Is(err, os.ErrProcessDone) { + return false + } + return errors.Is(err, syscall.EPERM) +} diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index f960ab4aa..ac009df7d 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -91,20 +91,27 @@ func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { } func TestReleaseUnlocksWorktree(t *testing.T) { - repoRoot := t.TempDir() - // gitCommonDir resolves its answer with EvalSymlinks, so the fake - // --git-common-dir response needs a real directory behind it. - if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o700); err != nil { - t.Fatal(err) - } + // repoRoot must be resolved to its physical spelling up front: on a + // platform where the temp dir is itself a symlink (macOS /var -> + // /private/var), real `git worktree list --porcelain` reports the + // physical spelling, so computing repoKey from the lexical spelling here + // would produce a different key than verifyZeroOwnedWorktree derives in + // production, and Release would reject this genuinely Zero-owned fixture + // as not-zero-managed. + repoRoot := physicalTestPath(t, t.TempDir()) // path must carry the zero-worktree- ancestor component Prepare // actually creates: Release now refuses to unlock anything else. path := filepath.Join(t.TempDir(), "zero-worktree-"+repoKey(repoRoot), "task-a") if err := os.MkdirAll(path, 0o700); err != nil { t.Fatal(err) } + // The target entry carries Zero's own lease reason (as Prepare's lock + // call sets it), so the lock-reason check added alongside the ancestor + // check must let this release through rather than treating every locked + // entry as a manual, non-zero lock (see + // TestReleaseRejectsManuallyLockedWorktree for the rejecting case). runner := &fakeRunner{results: []CommandResult{ - {Stdout: filepath.Join(repoRoot, ".git") + "\n"}, + {Stdout: "worktree " + repoRoot + "\nworktree " + path + "\nlocked " + leaseReasonPrefix + "\n"}, {}, }} @@ -114,7 +121,7 @@ func TestReleaseUnlocksWorktree(t *testing.T) { if len(runner.calls) != 2 { t.Fatalf("expected exactly two git calls (ownership check, then unlock), got %#v", runner.calls) } - if got := runner.commandLine(0); got != "git rev-parse --git-common-dir" { + if got := runner.commandLine(0); got != "git worktree list --porcelain" { t.Fatalf("ownership-check command = %q", got) } if runner.calls[1].dir != path { @@ -130,13 +137,15 @@ func TestReleaseFallsBackToCwdWhenWorktreeDirMissing(t *testing.T) { // releasing it first leaves path itself gone; Release must still be able // to run `git worktree unlock` (from the main repo, via options.Cwd) so // the orphaned lock can be cleared and the entry later pruned. - repoRoot := t.TempDir() - if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o700); err != nil { - t.Fatal(err) - } + // repoRoot is resolved to its physical spelling for the same reason as + // TestReleaseUnlocksWorktree: real `git worktree list --porcelain` + // reports the physical spelling, so a lexical temp-dir spelling here + // would derive a different repoKey than production and reject this + // fixture. + repoRoot := physicalTestPath(t, t.TempDir()) missingPath := filepath.Join(t.TempDir(), "zero-worktree-"+repoKey(repoRoot), "already-deleted") runner := &fakeRunner{results: []CommandResult{ - {Stdout: filepath.Join(repoRoot, ".git") + "\n"}, + {Stdout: "worktree " + repoRoot + "\n"}, {}, }} @@ -161,15 +170,12 @@ func TestReleaseFallsBackToCwdWhenWorktreeDirMissing(t *testing.T) { // ancestor component must be refused before any unlock is attempted. func TestReleaseRejectsNonZeroOwnedWorktree(t *testing.T) { repoRoot := t.TempDir() - if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o700); err != nil { - t.Fatal(err) - } manualWorktree := filepath.Join(t.TempDir(), "my-manual-worktree") if err := os.MkdirAll(manualWorktree, 0o700); err != nil { t.Fatal(err) } runner := &fakeRunner{results: []CommandResult{ - {Stdout: filepath.Join(repoRoot, ".git") + "\n"}, + {Stdout: "worktree " + repoRoot + "\n"}, }} err := Release(context.Background(), Options{RunGit: runner.Run}, manualWorktree) @@ -181,6 +187,31 @@ func TestReleaseRejectsNonZeroOwnedWorktree(t *testing.T) { } } +// TestReleaseRejectsManuallyLockedWorktree pins the fix for Release being +// usable to clear a lock a user (or another tool) applied by hand to a +// worktree that otherwise sits inside Zero's zero-worktree- +// subtree: the ancestor-component check alone can't tell that lock apart +// from one of Zero's own leases, so Release must also refuse to unlock an +// entry whose recorded lock reason doesn't carry Zero's lease prefix. +func TestReleaseRejectsManuallyLockedWorktree(t *testing.T) { + repoRoot := physicalTestPath(t, t.TempDir()) + path := filepath.Join(t.TempDir(), "zero-worktree-"+repoKey(repoRoot), "task-a") + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{results: []CommandResult{ + {Stdout: "worktree " + repoRoot + "\nworktree " + path + "\nlocked do not touch, in use\n"}, + }} + + err := Release(context.Background(), Options{RunGit: runner.Run}, path) + if err == nil || !strings.Contains(err.Error(), "not a zero lease") { + t.Fatalf("Release error = %v, want a not-a-zero-lease rejection", err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected only the ownership check, no unlock call, got %#v", runner.calls) + } +} + func TestReleasePropagatesGitFailure(t *testing.T) { path := filepath.Join(t.TempDir(), "task-a") if err := os.Mkdir(path, 0o700); err != nil { diff --git a/internal/worktrees/worktrees_windows.go b/internal/worktrees/worktrees_windows.go new file mode 100644 index 000000000..73a3d75fb --- /dev/null +++ b/internal/worktrees/worktrees_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package worktrees + +import "golang.org/x/sys/windows" + +// osProcessAlive reports whether pid is a live process on Windows. It opens +// the process for limited query access; a successful open with a +// non-exited status means the PID is live. OpenProcess succeeding is not +// enough on its own: a handle can remain valid briefly after the process it +// named has exited, so GetExitCodeProcess must confirm STILL_ACTIVE before +// treating the PID as live. +func osProcessAlive(pid int) bool { + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + defer windows.CloseHandle(handle) + var code uint32 + if err := windows.GetExitCodeProcess(handle, &code); err != nil { + // Could not query; treat the open as proof of life (conservative: do + // not reclaim a lock we are unsure about). + return true + } + const stillActive = 259 // STILL_ACTIVE + return code == stillActive +} From 8419e9a89f555a7e4f3e525b5e647044f7ba4a60 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:18:00 -0400 Subject: [PATCH 16/32] fix(worktrees): canonicalize paths for clean/release ownership checks Compare Clean containment and Release ownership against physical path spellings so macOS /var vs /private/var and symlink TMPDIR layouts match git worktree list. Require a registered porcelain entry and a Zero lease reason before unlock; treat an already-unlocked Zero worktree as a no-op. --- internal/worktrees/worktrees.go | 115 +++++++++++++++++++-------- internal/worktrees/worktrees_test.go | 51 +++++++++--- 2 files changed, 122 insertions(+), 44 deletions(-) diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 83f341622..cf872ac24 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -275,6 +275,9 @@ func Release(ctx context.Context, options Options, path string) error { } } if err := verifyZeroOwnedWorktree(ctx, runGit, dir, path); err != nil { + if errors.Is(err, errAlreadyUnlocked) { + return nil + } return err } if _, err := gitOutput(ctx, runGit, dir, "worktree", "unlock", path); err != nil { @@ -284,22 +287,22 @@ func Release(ctx context.Context, options Options, path string) error { } // verifyZeroOwnedWorktree confirms path has a zero-worktree- ancestor -// directory component, and that if git currently has it locked, the lock -// reason is one 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. +// 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) error { output, err := gitOutput(ctx, runGit, dir, "worktree", "list", "--porcelain") if err != nil { @@ -311,13 +314,7 @@ func verifyZeroOwnedWorktree(ctx context.Context, runGit GitRunner, dir string, } want := "zero-worktree-" + repoKey(entries[0].path) - target := path - if resolved, err := filepath.EvalSymlinks(path); err == nil { - target = resolved - } else if abs, err := filepath.Abs(path); err == nil { - target = abs - } - target = filepath.Clean(target) + target := canonicalizePath(path) hasZeroComponent := false for _, component := range strings.Split(target, string(filepath.Separator)) { @@ -330,18 +327,52 @@ func verifyZeroOwnedWorktree(ctx context.Context, runGit GitRunner, dir string, 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- 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. + matched := false for _, entry := range entries { - if entry.path != target { + if canonicalizePath(entry.path) != target { continue } - if entry.locked && !strings.HasPrefix(entry.lockReason, leaseReasonPrefix) { + matched = true + 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) } break } + if !matched { + return fmt.Errorf("refusing to release %s: not a registered worktree of this repository", path) + } return 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) + } + if abs, err := filepath.Abs(path); err == nil { + return filepath.Clean(abs) + } + return filepath.Clean(path) +} + func DefaultBaseDir(env map[string]string) (string, error) { if runtime.GOOS == "windows" { if localAppData := strings.TrimSpace(envValue(env, "LOCALAPPDATA")); localAppData != "" { @@ -568,15 +599,21 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { cutoff := time.Now().Add(-maxAge) var lastErr error for _, entry := range parseWorktreeList(output) { + // 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. // "-other") can't match. - if !isUnderDir(entry.path, repoDir) { + if !isUnderDir(entryPath, repoDir) { continue } - // A locked worktree is never a prune candidate — with one recovery + // 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 @@ -591,7 +628,15 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { expiredLease = true } - info, err := os.Stat(entry.path) + // 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) { _, _ = runGit(ctx, repoRoot, "worktree", "prune") @@ -602,14 +647,14 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { continue } - if worktreeIsStale(entry.path, cutoff) { + if worktreeIsStale(statPath, cutoff) { // An explicit release is the owner's completion signal, so a // worktree holding only gitignored residue (node_modules, build - // output) after release is reclaimable — otherwise every released + // output) after release is reclaimable - otherwise every released // worktree with such artifacts leaks forever. An expired lease is // NOT a completion signal (the task may have died mid-work), so // there ignored files still count as live data. - if worktreeIsDirty(ctx, runGit, entry.path, expiredLease) { + if worktreeIsDirty(ctx, runGit, statPath, expiredLease) { // 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 @@ -618,7 +663,7 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { // gets committed/cleaned (no longer dirty) or unlocked. continue } - if err := preserveUnreachableWorktreeHead(ctx, runGit, repoRoot, entry.path); err != nil { + if err := preserveUnreachableWorktreeHead(ctx, runGit, repoRoot, statPath); err != nil { lastErr = errors.Join(lastErr, fmt.Errorf("preserve worktree HEAD %s: %w", entry.path, err)) continue } @@ -702,7 +747,7 @@ func isUnderDir(path, dir string) bool { // 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 +// 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 @@ -713,7 +758,7 @@ func preserveUnreachableWorktreeHead(ctx context.Context, runGit GitRunner, repo head, err := gitOutput(ctx, runGit, worktreePath, "rev-parse", "HEAD") if err != nil { // No commit to preserve (an empty/unborn worktree, or one already - // gone) — nothing for this guard to do; let the caller proceed. + // gone) - nothing for this guard to do; let the caller proceed. return nil } head = strings.TrimSpace(head) @@ -780,7 +825,7 @@ func worktreeIsStale(root string, cutoff time.Time) bool { // 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. +// 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. diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index ac009df7d..e6573cc22 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -29,7 +29,7 @@ func TestDefaultRunGitSeparatesStdoutAndStderr(t *testing.T) { t.Fatalf("Stderr should be empty on success, got %q", ok.Stderr) } - // A failing command's diagnostic must land on Stderr, not Stdout — the prior + // A failing command's diagnostic must land on Stderr, not Stdout - the prior // CombinedOutput merged them and left Stderr empty. bad, err := defaultRunGit(context.Background(), dir, "not-a-real-subcommand") if err != nil { @@ -97,11 +97,14 @@ func TestReleaseUnlocksWorktree(t *testing.T) { // physical spelling, so computing repoKey from the lexical spelling here // would produce a different key than verifyZeroOwnedWorktree derives in // production, and Release would reject this genuinely Zero-owned fixture - // as not-zero-managed. + // as not-zero-managed. The worktree path itself is physicalized for the + // same reason: Release compares the registered entry against the + // canonical user path, and a lexical /var spelling would not match. repoRoot := physicalTestPath(t, t.TempDir()) // path must carry the zero-worktree- ancestor component Prepare // actually creates: Release now refuses to unlock anything else. - path := filepath.Join(t.TempDir(), "zero-worktree-"+repoKey(repoRoot), "task-a") + base := physicalTestPath(t, t.TempDir()) + path := filepath.Join(base, "zero-worktree-"+repoKey(repoRoot), "task-a") if err := os.MkdirAll(path, 0o700); err != nil { t.Fatal(err) } @@ -141,11 +144,13 @@ func TestReleaseFallsBackToCwdWhenWorktreeDirMissing(t *testing.T) { // TestReleaseUnlocksWorktree: real `git worktree list --porcelain` // reports the physical spelling, so a lexical temp-dir spelling here // would derive a different repoKey than production and reject this - // fixture. + // fixture. The deleted path must still appear in the porcelain list + // (git keeps a prunable entry after a manual rm -rf) with Zero's lease + // reason, or the ownership check refuses the unlock. repoRoot := physicalTestPath(t, t.TempDir()) missingPath := filepath.Join(t.TempDir(), "zero-worktree-"+repoKey(repoRoot), "already-deleted") runner := &fakeRunner{results: []CommandResult{ - {Stdout: "worktree " + repoRoot + "\n"}, + {Stdout: "worktree " + repoRoot + "\nworktree " + missingPath + "\nlocked " + leaseReasonPrefix + "\n"}, {}, }} @@ -195,7 +200,11 @@ func TestReleaseRejectsNonZeroOwnedWorktree(t *testing.T) { // entry whose recorded lock reason doesn't carry Zero's lease prefix. func TestReleaseRejectsManuallyLockedWorktree(t *testing.T) { repoRoot := physicalTestPath(t, t.TempDir()) - path := filepath.Join(t.TempDir(), "zero-worktree-"+repoKey(repoRoot), "task-a") + // Use a physical base so the porcelain entry path matches the + // canonicalizePath comparison Release uses against git's physical + // spelling (macOS /var vs /private/var, symlink TMPDIR layouts). + base := physicalTestPath(t, t.TempDir()) + path := filepath.Join(base, "zero-worktree-"+repoKey(repoRoot), "task-a") if err := os.MkdirAll(path, 0o700); err != nil { t.Fatal(err) } @@ -212,6 +221,30 @@ func TestReleaseRejectsManuallyLockedWorktree(t *testing.T) { } } +// TestReleaseRejectsUnregisteredWorktree pins the requirement that Release +// only unlocks a path git currently has registered for the repository. A +// same-looking zero-worktree- path that never appeared in +// `git worktree list` must not reach unlock. +func TestReleaseRejectsUnregisteredWorktree(t *testing.T) { + repoRoot := physicalTestPath(t, t.TempDir()) + base := physicalTestPath(t, t.TempDir()) + path := filepath.Join(base, "zero-worktree-"+repoKey(repoRoot), "not-registered") + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{results: []CommandResult{ + {Stdout: "worktree " + repoRoot + "\n"}, + }} + + err := Release(context.Background(), Options{RunGit: runner.Run}, path) + if err == nil || !strings.Contains(err.Error(), "not a registered worktree") { + t.Fatalf("Release error = %v, want a not-registered rejection", err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected only the ownership check, no unlock call, got %#v", runner.calls) + } +} + func TestReleasePropagatesGitFailure(t *testing.T) { path := filepath.Join(t.TempDir(), "task-a") if err := os.Mkdir(path, 0o700); err != nil { @@ -729,7 +762,7 @@ func TestCleanAggregatesMultipleFailedRemovals(t *testing.T) { t.Fatal("expected Clean to report both failed removals") } // Both failures must survive in the returned error, not just the last one - // to occur — overwriting lastErr instead of joining would silently drop + // to occur - overwriting lastErr instead of joining would silently drop // worktree A's failure once worktree B's removal is also attempted. if !strings.Contains(err.Error(), "unable to remove worktree A") { t.Errorf("error = %q, missing worktree A's failure", err.Error()) @@ -912,7 +945,7 @@ func TestCleanSkipsLockedZeroOwnedWorktree(t *testing.T) { // data-loss case: Prepare creates every worktree with `worktree add --detach`, // so a commit made there is reachable only through that worktree's own HEAD. // If the worktree goes stale and clean before the commit is otherwise -// referenced, force-removing it must not let the commit become unreachable — +// referenced, force-removing it must not let the commit become unreachable - // Clean has to preserve it under a durable ref first. func TestCleanPreservesUnreachableCommitBeforeRemoval(t *testing.T) { ctx := context.Background() @@ -1021,7 +1054,7 @@ func TestCleanReclaimsReleasedWorktreeWithOnlyIgnoredFiles(t *testing.T) { } // TestCleanRecoversExpiredLease: a Zero lease that records its owning PID is -// recoverable — if that process died without releasing (SIGKILL, crash), the +// recoverable - if that process died without releasing (SIGKILL, crash), the // lock must not protect the worktree forever. A stale, clean worktree behind // a dead-owner lease is unlocked and removed; the dirty probe there keeps // --ignored because a crashed task never signaled completion. From b225c8aeed153b364a355ca7310a149985412d7f Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:38:31 -0400 Subject: [PATCH 17/32] fix(worktrees,secrets): address jatmn review findings on #632 - Prepare and Release now agree on repoKey regardless of which worktree (main or linked) Prepare runs from, by keying off git worktree list's first entry (always the main worktree) instead of --show-toplevel. A worktree prepared from a linked checkout previously failed its own ownership check on release and its lease could never be cleared. - osProcessAlive on Windows no longer treats every OpenProcess failure as "process is dead": only ERROR_ACCESS_DENIED (a live process this caller lacks rights to query) is now distinguished from a genuinely missing PID, so Clean can no longer force-remove an active worktree whose owning process it simply couldn't query. - The openai_key redaction pattern now recognizes sk-or-v1- (OpenRouter) alongside the existing sk-proj-/sk-svcacct-/sk-admin- prefixes, so hyphenated OpenAI-compatible provider keys are redacted again without reopening the sk- false-positive this pattern was narrowed to avoid. - Release/exec --worktree error text is redacted before reaching stderr, matching the already-redacted success path; ownership errors interpolate the caller-supplied path, which could carry a key-shaped segment. - canonicalizePath resolves symlinks through the nearest existing ancestor when the target itself no longer exists, so the documented `release -C` recovery path works again for a worktree deleted by hand under a symlinked --worktree-dir. - Prepare rolls back the worktree `git worktree add` just created if the subsequent lock call fails for a reason other than a concurrent racer, instead of leaking an unleased checkout until Clean's 24h staleness window reclaims it. Not addressed here: the P2 finding that worktree ownership is provable only by a directory-name convention plus a lock-reason prefix, both of which a user can reproduce by hand. A durable per-worktree ownership marker would close that gap, but internal/worktrees has been on main since #70, so a marker requirement could reject worktrees an already-installed zero created before this change existed. Needs a decision on migration before implementing. --- internal/cli/exec.go | 2 +- internal/cli/workflow_test.go | 66 +++++++++ internal/cli/workflows.go | 2 +- internal/secrets/scanner.go | 5 +- internal/secrets/scanner_test.go | 17 +++ internal/worktrees/worktrees.go | 91 ++++++++++++- internal/worktrees/worktrees_test.go | 133 ++++++++++++++++++- internal/worktrees/worktrees_windows.go | 18 ++- internal/worktrees/worktrees_windows_test.go | 43 ++++++ 9 files changed, 363 insertions(+), 14 deletions(-) create mode 100644 internal/worktrees/worktrees_windows_test.go diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 629374c93..0a34b9a1f 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -222,7 +222,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in if preparedWorktree.LockAcquired { defer func() { if releaseErr := deps.releaseWorktree(context.Background(), worktrees.Options{Cwd: trustRoot}, preparedWorktree.Path); releaseErr != nil { - fmt.Fprintf(stderr, "zero: failed to release worktree lock on %s: %v\n", redactCLIString(preparedWorktree.Path), releaseErr) + fmt.Fprintf(stderr, "zero: failed to release worktree lock on %s: %s\n", redactCLIString(preparedWorktree.Path), redactCLIString(releaseErr.Error())) } }() } diff --git a/internal/cli/workflow_test.go b/internal/cli/workflow_test.go index 985a508e8..a75ce5998 100644 --- a/internal/cli/workflow_test.go +++ b/internal/cli/workflow_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "path/filepath" "strings" @@ -315,6 +316,32 @@ func TestRunWorktreesReleaseReportsErrors(t *testing.T) { } } +func TestRunWorktreesReleaseRedactsErrorText(t *testing.T) { + // Release errors interpolate the caller-supplied path (see + // verifyZeroOwnedWorktree's "refusing to release %s" messages); unlike the + // success path, which redacts before printing, the error was previously + // forwarded to stderr verbatim, so a rejected path containing a key-shaped + // segment would reach terminal/model-visible output unredacted. + secret := "sk-proj-abcDEF123_ghiJKL456-mnoPQR789stu" + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"worktrees", "release", "/tmp/" + secret + "/task"}, &stdout, &stderr, appDeps{ + releaseWorktree: func(context.Context, worktrees.Options, string) error { + return fmt.Errorf("refusing to release /tmp/%s/task: not a registered worktree of this repository", secret) + }, + }) + + if exitCode != exitUsage { + t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode) + } + if strings.Contains(stderr.String(), secret) { + t.Fatalf("release error leaked unredacted key-shaped path: %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "[REDACTED]") { + t.Fatalf("expected redaction placeholder in error output, got %q", stderr.String()) + } +} + func TestRunVerifyTextAndJSON(t *testing.T) { cwd := t.TempDir() plan := verify.Plan{Root: cwd, Checks: []verify.Check{{ID: "go.test", Name: "Go tests", Command: []string{"go", "test", "./..."}}}} @@ -895,6 +922,45 @@ func TestRunExecWorktreeSurfacesReleaseFailure(t *testing.T) { } } +func TestRunExecWorktreeRedactsReleaseFailureText(t *testing.T) { + // The release path argument was already redacted before this diagnostic + // was added, but the error's own text was forwarded verbatim; Release's + // ownership errors interpolate the caller-supplied path (see + // verifyZeroOwnedWorktree), so a key-shaped path reaching this message + // leaked unredacted onto stderr. + root := t.TempDir() + worktreeDir := t.TempDir() + secret := "sk-proj-abcDEF123_ghiJKL456-mnoPQR789stu" + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"exec", "--worktree", "task-a", "hello"}, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return root, nil }, + prepareWorktree: func(ctx context.Context, options worktrees.Options) (worktrees.Result, error) { + return worktrees.Result{Name: "task-a", Path: worktreeDir, RepoRoot: root, SourceBranch: "main", SourceCommit: "abc1234", LockAcquired: true}, nil + }, + releaseWorktree: func(ctx context.Context, options worktrees.Options, path string) error { + return fmt.Errorf("refusing to release /tmp/%s/task: not a registered worktree of this repository", secret) + }, + resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) { + return execResolvedConfig(), nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return echoExecProvider{}, nil + }, + }) + + if exitCode != exitSuccess { + t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String()) + } + if strings.Contains(stderr.String(), secret) { + t.Fatalf("deferred release diagnostic leaked unredacted key-shaped path: %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "[REDACTED]") { + t.Fatalf("expected redaction placeholder in deferred release diagnostic, got %q", stderr.String()) + } +} + func TestRunExecRejectsForkWithWorktree(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/cli/workflows.go b/internal/cli/workflows.go index b9c1e15f9..b9298c32b 100644 --- a/internal/cli/workflows.go +++ b/internal/cli/workflows.go @@ -175,7 +175,7 @@ func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps releaseOptions.Cwd = workspaceRoot } if err := deps.releaseWorktree(context.Background(), releaseOptions, absPath); err != nil { - return writeExecUsageError(stderr, err.Error()) + return writeExecUsageError(stderr, redactCLIString(err.Error())) } if _, err := fmt.Fprintf(stdout, "released %s\n", redactCLIString(path)); err != nil { return exitCrash diff --git a/internal/secrets/scanner.go b/internal/secrets/scanner.go index 87efa90a3..cb4952817 100644 --- a/internal/secrets/scanner.go +++ b/internal/secrets/scanner.go @@ -45,9 +45,10 @@ var patterns = []pattern{ {"github_pat", regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}`)}, {"slack_token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`)}, {"google_api_key", regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}`)}, - // Distinguish modern prefixed keys (sk-proj- / sk-svcacct- / sk-admin-) from + // Distinguish modern prefixed keys (sk-proj- / sk-svcacct- / sk-admin-) and + // hyphenated OpenAI-compatible provider keys (sk-or-v1- for OpenRouter) from // normal kebab-case phrases, and match legacy sk- keys by length (>= 20). - {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-|admin-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}`)}, + {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-|admin-|or-v1-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}`)}, // Match the ENTIRE PEM/OpenSSH block (header THROUGH the END marker, body // included) so redaction removes the key material, not just the header. {"private_key_block", regexp.MustCompile(`(?s)-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----.*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----`)}, diff --git a/internal/secrets/scanner_test.go b/internal/secrets/scanner_test.go index 94f863f13..555d1c834 100644 --- a/internal/secrets/scanner_test.go +++ b/internal/secrets/scanner_test.go @@ -122,6 +122,23 @@ func TestScanDetectsModernPrefixedOpenAIKeys(t *testing.T) { } } +func TestScanDetectsHyphenatedOpenAICompatibleKeys(t *testing.T) { + // OpenRouter's sk-or-v1- keys are hyphenated like the modern sk-proj- + // family; the legacy sk- branch does not match a "-" right + // after "sk-", so this format needs its own explicit prefix branch. + key := "sk-or-v1-1234567890abcdef1234567890abcdef1234567890abcdef1234" + redacted, findings := Redact("token=" + key) + if len(findings) != 1 || findings[0].Type != "openai_key" { + t.Fatalf("expected one openai_key finding for %q, got %#v", key, findings) + } + if strings.Contains(redacted, key) { + t.Fatalf("key leaked after redaction: %q", redacted) + } + if !strings.Contains(redacted, "[REDACTED:openai_key]") { + t.Fatalf("missing typed placeholder for %q: %q", key, redacted) + } +} + func TestScanRedactsLongerKeysWithoutTailLeak(t *testing.T) { cases := []struct { wantType string diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index cf872ac24..fb916f54f 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -92,6 +92,19 @@ func Prepare(ctx context.Context, options Options) (Result, error) { 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") @@ -107,7 +120,7 @@ func Prepare(ctx context.Context, options Options) (Result, error) { return Result{}, fmt.Errorf("resolve worktree dir: %w", err) } - repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(primaryRoot)) target := filepath.Join(repoDir, name) result := Result{ Name: name, @@ -172,6 +185,20 @@ func Prepare(ctx context.Context, options Options) (Result, error) { // 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 { @@ -312,6 +339,9 @@ func verifyZeroOwnedWorktree(ctx context.Context, runGit GitRunner, dir string, 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) @@ -367,10 +397,45 @@ func canonicalizePath(path string) string { if resolved, err := filepath.EvalSymlinks(path); err == nil { return filepath.Clean(resolved) } - if abs, err := filepath.Abs(path); err == nil { - return filepath.Clean(abs) + 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 } - return filepath.Clean(path) } func DefaultBaseDir(env map[string]string) (string, error) { @@ -462,6 +527,24 @@ func gitOutput(ctx context.Context, runGit GitRunner, dir string, args ...string 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 { diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index e6573cc22..108a3ce27 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -49,6 +49,7 @@ func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { runner := &fakeRunner{ results: []CommandResult{ {Stdout: root + "\n"}, + {Stdout: "worktree " + root + "\n"}, {Stdout: "main\n"}, {Stdout: "abc1234\n"}, {}, @@ -76,13 +77,13 @@ func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { if !strings.HasPrefix(result.Path, filepath.Join(base, "zero-worktree-")) { t.Fatalf("Path = %q, want under base %q", result.Path, base) } - if got := runner.commandLine(3); got != "git worktree add --detach "+result.Path+" HEAD" { + if got := runner.commandLine(4); got != "git worktree add --detach "+result.Path+" HEAD" { t.Fatalf("git worktree command = %q", got) } // Prepare must lock every worktree it creates: this is what makes // entry.locked inside Clean protect zero's own worktrees, not just ones a // human locked by hand (see TestCleanSkipsLockedZeroOwnedWorktree). - if got := runner.commandLine(4); got != "git worktree lock --reason zero: active task worktree "+result.Path { + if got := runner.commandLine(5); got != "git worktree lock --reason zero: active task worktree "+result.Path { t.Fatalf("git worktree lock command = %q", got) } if !result.LockAcquired { @@ -272,6 +273,7 @@ func TestPrepareReusesExistingGitWorktree(t *testing.T) { runner := &fakeRunner{ results: []CommandResult{ {Stdout: root + "\n"}, + {Stdout: "worktree " + root + "\n"}, {Stdout: "main\n"}, {Stdout: "abc1234\n"}, {Stdout: sourceGit + "\n"}, @@ -296,13 +298,13 @@ func TestPrepareReusesExistingGitWorktree(t *testing.T) { if result.Path != existing { t.Fatalf("Path = %q, want existing %q", result.Path, existing) } - if len(runner.calls) != 6 { + if len(runner.calls) != 7 { t.Fatalf("expected metadata git calls plus lock, got %#v", runner.calls) } // The original lock may have been released by a prior run's exit, which // would leave the reused worktree exposed to Clean's staleness heuristic // while this caller is still using it: reuse must re-establish the lease. - if got := runner.commandLine(5); got != "git worktree lock --reason zero: active task worktree "+existing { + if got := runner.commandLine(6); got != "git worktree lock --reason zero: active task worktree "+existing { t.Fatalf("git worktree lock command = %q", got) } if !result.LockAcquired { @@ -329,6 +331,7 @@ func TestPrepareRejectsWorktreeLockedByAnotherRun(t *testing.T) { runner := &fakeRunner{ results: []CommandResult{ {Stdout: root + "\n"}, + {Stdout: "worktree " + root + "\n"}, {Stdout: "main\n"}, {Stdout: "abc1234\n"}, {Stdout: sourceGit + "\n"}, @@ -348,6 +351,45 @@ func TestPrepareRejectsWorktreeLockedByAnotherRun(t *testing.T) { } } +// TestPrepareRollsBackWorktreeOnLockFailure pins the fix for a newly created +// worktree being left behind, unleased, when the lock call after `git +// worktree add` fails for a reason other than a concurrent racer (an actual +// git failure, not "already locked"): nothing else can own a worktree this +// call just created, so Prepare must remove it rather than leaking it until +// Clean's staleness window eventually reclaims it. +func TestPrepareRollsBackWorktreeOnLockFailure(t *testing.T) { + root := t.TempDir() + base := t.TempDir() + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: root + "\n"}, + {Stdout: "worktree " + root + "\n"}, + {Stdout: "main\n"}, + {Stdout: "abc1234\n"}, + {}, // worktree add + {ExitCode: 1, Stderr: "fatal: disk full"}, // worktree lock + {}, // worktree remove --force (rollback) + }, + } + + _, err := Prepare(context.Background(), Options{ + Cwd: root, + Name: "review-api", + BaseDir: base, + RunGit: runner.Run, + }) + if err == nil || !strings.Contains(err.Error(), "disk full") { + t.Fatalf("Prepare must surface the lock failure, got %v", err) + } + if len(runner.calls) != 7 { + t.Fatalf("expected add, lock, and a rollback removal, got %#v", runner.calls) + } + lastCall := runner.calls[len(runner.calls)-1] + if lastCall.args[0] != "worktree" || lastCall.args[1] != "remove" { + t.Fatalf("last call = %#v, want a rollback `git worktree remove`", lastCall) + } +} + // physicalTestPath resolves a test directory to its physical spelling // (symlinks and Windows 8.3 short names), matching how git records paths. func physicalTestPath(t *testing.T, path string) string { @@ -359,6 +401,51 @@ func physicalTestPath(t *testing.T, path string) string { return resolved } +// TestPrepareFromLinkedWorktreeCanBeReleased pins the fix for Prepare and +// Release deriving different repoKeys when Prepare is invoked from a linked +// worktree instead of the main one. Before the fix, Prepare hashed +// --show-toplevel (the linked checkout's own root when run there), while +// Release always hashes the main worktree's root (via git worktree list +// --porcelain, whose first entry is always the main worktree); a worktree +// prepared from a linked checkout therefore failed its own ownership check +// on release and its lease could never be cleared. +func TestPrepareFromLinkedWorktreeCanBeReleased(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + ctx := context.Background() + mainRepo := physicalTestPath(t, t.TempDir()) + mustGit := func(dir string, args ...string) string { + t.Helper() + out, err := gitOutput(ctx, defaultRunGit, dir, args...) + if err != nil { + t.Skipf("git unavailable or failed (%v): %v", args, err) + } + return out + } + mustGit(mainRepo, "init") + mustGit(mainRepo, "-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "--allow-empty", "-m", "seed") + + // A linked worktree of mainRepo, checked out elsewhere on disk. Its own + // --show-toplevel is this directory, not mainRepo. + linkedWorktree := filepath.Join(t.TempDir(), "linked") + mustGit(mainRepo, "worktree", "add", "--detach", linkedWorktree, "HEAD") + + base := physicalTestPath(t, t.TempDir()) + result, err := Prepare(ctx, Options{Cwd: linkedWorktree, BaseDir: base, Name: "from-linked"}) + if err != nil { + t.Fatalf("Prepare from a linked worktree: %v", err) + } + wantComponent := "zero-worktree-" + repoKey(mainRepo) + if !strings.Contains(result.Path, wantComponent) { + t.Fatalf("Path = %q, want it keyed off the main worktree %q (component %q)", result.Path, mainRepo, wantComponent) + } + + if err := Release(ctx, Options{}, result.Path); err != nil { + t.Fatalf("Release of a worktree Prepare created from a linked worktree: %v", err) + } +} + // TestCleanPrunesStaleWorktreeUnderSymlinkedBaseDir pins the fix for a // symlinked --worktree-dir: git worktree list --porcelain reports each // worktree's PHYSICAL location, resolving any symlink component, so Clean @@ -418,6 +505,42 @@ func TestCleanPrunesStaleWorktreeUnderSymlinkedBaseDir(t *testing.T) { } } +// TestReleaseRecoversDeletedWorktreeUnderSymlinkedBaseDir pins the fix for +// the documented `release -C` recovery path failing when --worktree-dir was +// a symlink and the worktree itself was then deleted by hand: EvalSymlinks +// has nothing to resolve on a path that no longer exists, so the prior +// Abs+Clean fallback kept the symlinked (logical) spelling, which never +// matched git's physical-path porcelain entry, and the orphaned lock could +// never be released. canonicalizePath must resolve through the nearest +// existing ancestor (the symlink itself, since everything under it is gone) +// and reattach the missing tail. +func TestReleaseRecoversDeletedWorktreeUnderSymlinkedBaseDir(t *testing.T) { + ctx := context.Background() + repo := physicalTestPath(t, t.TempDir()) + realBase := physicalTestPath(t, t.TempDir()) + base := filepath.Join(t.TempDir(), "base-link") + if err := os.Symlink(realBase, base); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + // The worktree directory under the symlinked base was deleted by hand; + // nothing on disk exists below base itself. + deletedPath := filepath.Join(base, "zero-worktree-"+repoKey(repo), "already-deleted") + physicalPath := filepath.Join(realBase, "zero-worktree-"+repoKey(repo), "already-deleted") + + runner := &fakeRunner{results: []CommandResult{ + {Stdout: "worktree " + repo + "\nworktree " + physicalPath + "\nlocked " + leaseReasonPrefix + "\n"}, + {}, + }} + + if err := Release(ctx, Options{RunGit: runner.Run, Cwd: repo}, deletedPath); err != nil { + t.Fatalf("Release must recover a deleted worktree under a symlinked base dir: %v", err) + } + if got := runner.commandLine(1); got != "git worktree unlock "+deletedPath { + t.Fatalf("git worktree unlock command = %q", got) + } +} + // TestPrepareValidatesRequestBeforeCleanup pins the order of validation and // the automatic stale-worktree pruning: a rejected request (an invalid // --name) must not have destructive cleanup side effects before it reports @@ -491,6 +614,7 @@ func TestPrepareRejectsExistingWorktreeFromDifferentRepo(t *testing.T) { runner := &fakeRunner{ results: []CommandResult{ {Stdout: root + "\n"}, + {Stdout: "worktree " + root + "\n"}, {Stdout: "main\n"}, {Stdout: "abc1234\n"}, {Stdout: sourceGit + "\n"}, @@ -515,6 +639,7 @@ func TestPrepareValidatesNameAndExistingDirectory(t *testing.T) { runner := &fakeRunner{ results: []CommandResult{ {Stdout: root + "\n"}, + {Stdout: "worktree " + root + "\n"}, {Stdout: "main\n"}, {Stdout: "abc1234\n"}, }, diff --git a/internal/worktrees/worktrees_windows.go b/internal/worktrees/worktrees_windows.go index 73a3d75fb..f35474e86 100644 --- a/internal/worktrees/worktrees_windows.go +++ b/internal/worktrees/worktrees_windows.go @@ -2,7 +2,11 @@ package worktrees -import "golang.org/x/sys/windows" +import ( + "errors" + + "golang.org/x/sys/windows" +) // osProcessAlive reports whether pid is a live process on Windows. It opens // the process for limited query access; a successful open with a @@ -13,7 +17,7 @@ import "golang.org/x/sys/windows" func osProcessAlive(pid int) bool { handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) if err != nil { - return false + return openProcessErrorMeansAlive(err) } defer windows.CloseHandle(handle) var code uint32 @@ -25,3 +29,13 @@ func osProcessAlive(pid int) bool { const stillActive = 259 // STILL_ACTIVE return code == stillActive } + +// openProcessErrorMeansAlive classifies an OpenProcess failure. Only +// ERROR_ACCESS_DENIED means a process with this PID exists (owned by another +// user, or protected by policy) but we lack rights to query it - fail closed +// by treating that ambiguity as alive. Any other error (typically +// ERROR_INVALID_PARAMETER, for a PID that names no running process) means the +// PID genuinely does not exist. +func openProcessErrorMeansAlive(err error) bool { + return errors.Is(err, windows.ERROR_ACCESS_DENIED) +} diff --git a/internal/worktrees/worktrees_windows_test.go b/internal/worktrees/worktrees_windows_test.go new file mode 100644 index 000000000..d2fa2f9be --- /dev/null +++ b/internal/worktrees/worktrees_windows_test.go @@ -0,0 +1,43 @@ +//go:build windows + +package worktrees + +import ( + "os" + "os/exec" + "testing" + + "golang.org/x/sys/windows" +) + +func TestOpenProcessErrorMeansAliveOnAccessDenied(t *testing.T) { + if !openProcessErrorMeansAlive(windows.ERROR_ACCESS_DENIED) { + t.Fatal("ERROR_ACCESS_DENIED must be treated as alive: the PID could belong to a live process this caller lacks rights to query") + } +} + +func TestOpenProcessErrorMeansAliveOnInvalidParameter(t *testing.T) { + if openProcessErrorMeansAlive(windows.ERROR_INVALID_PARAMETER) { + t.Fatal("ERROR_INVALID_PARAMETER means no such process; must be treated as dead") + } +} + +func TestOsProcessAliveReportsLiveSelf(t *testing.T) { + if !osProcessAlive(os.Getpid()) { + t.Fatal("current process must report alive") + } +} + +func TestOsProcessAliveReportsDeadAfterExit(t *testing.T) { + cmd := exec.Command("cmd", "/C", "exit", "0") + if err := cmd.Start(); err != nil { + t.Fatalf("start child process: %v", err) + } + pid := cmd.Process.Pid + if err := cmd.Wait(); err != nil { + t.Fatalf("wait for child process: %v", err) + } + if osProcessAlive(pid) { + t.Fatal("exited process must not report alive") + } +} From 88ca1fe20742fa4a5c9df90e8cb91ea976250b6b Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:05:28 -0400 Subject: [PATCH 18/32] fix(worktrees): prove Prepare ownership with a git-admin marker Address review findings that path convention plus lease-reason prefix are forgeable by hand. Prepare now writes a zero-owner marker into the worktree admin dir; Release and Clean require it before force-touching a path. Clean also keys its owned subtree off the main worktree root so linked-checkout calls still prune the same bucket Prepare uses. Tests plant the marker and list the main worktree first so fixtures match production. --- internal/worktrees/worktrees.go | 119 +++++++++++++++++- internal/worktrees/worktrees_test.go | 177 +++++++++++++++++++++------ 2 files changed, 255 insertions(+), 41 deletions(-) diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index fb916f54f..94c868dd0 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -158,6 +158,9 @@ func Prepare(ctx context.Context, options Options) (Result, error) { 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 { + return Result{}, err + } return result, nil } if err := os.MkdirAll(repoDir, 0o700); err != nil { @@ -205,6 +208,9 @@ func Prepare(ctx context.Context, options Options) (Result, error) { 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 { + return Result{}, err + } return result, nil } @@ -313,6 +319,62 @@ func Release(ctx context.Context, options Options, path string) error { 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- 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) + } + if err := os.WriteFile(filepath.Join(gitDir, zeroOwnerMarkerFile), []byte(zeroOwnerMarkerContent), 0o600); 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 +} + // verifyZeroOwnedWorktree confirms path has a zero-worktree- 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 @@ -377,6 +439,25 @@ func verifyZeroOwnedWorktree(ctx context.Context, runGit GitRunner, dir string, if !strings.HasPrefix(entry.lockReason, leaseReasonPrefix) { return fmt.Errorf("refusing to release %s: locked with reason %q, not a zero lease", path, entry.lockReason) } + // The lease prefix is a public string a user can copy onto their own + // `git worktree lock` call for a worktree they created by hand under + // this same predictable directory, so it is a cheap first filter, not + // proof. When the worktree directory still exists, require the + // ownership marker Prepare actually persists before trusting it. If + // the directory is already gone (the documented `release -C` recovery + // path for a worktree deleted by hand), there is no marker left to + // check and nothing left for a forced removal to destroy, so the + // prefix match above is enough to let a genuinely orphaned zero lease + // still be cleared. + 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 !matched { @@ -668,20 +749,34 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { if resolved, err := filepath.EvalSymlinks(baseDir); err == nil { baseDir = resolved } - // 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. - repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) 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 parseWorktreeList(output) { + 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 @@ -746,6 +841,18 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { // gets committed/cleaned (no longer dirty) or unlocked. continue } + // The zero-worktree- path and (for the expired-lease + // branch above) the leaseReasonPrefix lock reason are both public + // conventions a user can reproduce by hand for a worktree of the + // same repository; neither is proof Zero created this one. + // Require the ownership marker Prepare itself persists before + // force-touching anything below. Any failure to verify it + // (including the marker simply being absent) is treated the same + // as "not ours": skip it, the same fail-closed stance as an + // unreadable worktree above. + if owned, err := hasOwnershipMarker(ctx, runGit, statPath); err != nil || !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 diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 108a3ce27..719544463 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -109,29 +109,37 @@ func TestReleaseUnlocksWorktree(t *testing.T) { if err := os.MkdirAll(path, 0o700); err != nil { t.Fatal(err) } + plantOwnershipMarker(t, path) // The target entry carries Zero's own lease reason (as Prepare's lock // call sets it), so the lock-reason check added alongside the ancestor // check must let this release through rather than treating every locked // entry as a manual, non-zero lock (see // TestReleaseRejectsManuallyLockedWorktree for the rejecting case). - runner := &fakeRunner{results: []CommandResult{ - {Stdout: "worktree " + repoRoot + "\nworktree " + path + "\nlocked " + leaseReasonPrefix + "\n"}, - {}, - }} + runner := &fakeRunner{ + autoAbsoluteGitDir: true, + results: []CommandResult{ + {Stdout: "worktree " + repoRoot + "\nworktree " + path + "\nlocked " + leaseReasonPrefix + "\n"}, + {}, + }, + } if err := Release(context.Background(), Options{RunGit: runner.Run}, path); err != nil { t.Fatalf("Release returned error: %v", err) } - if len(runner.calls) != 2 { - t.Fatalf("expected exactly two git calls (ownership check, then unlock), got %#v", runner.calls) + // list + absolute-git-dir (marker) + unlock + if len(runner.calls) != 3 { + t.Fatalf("expected list, marker check, then unlock, got %#v", runner.calls) } if got := runner.commandLine(0); got != "git worktree list --porcelain" { t.Fatalf("ownership-check command = %q", got) } - if runner.calls[1].dir != path { - t.Fatalf("git worktree unlock dir = %q, want %q", runner.calls[1].dir, path) + if got := runner.commandLine(1); got != "git rev-parse --absolute-git-dir" { + t.Fatalf("marker-check command = %q", got) + } + if runner.calls[2].dir != path { + t.Fatalf("git worktree unlock dir = %q, want %q", runner.calls[2].dir, path) } - if got := runner.commandLine(1); got != "git worktree unlock "+path { + if got := runner.commandLine(2); got != "git worktree unlock "+path { t.Fatalf("git worktree unlock command = %q", got) } } @@ -271,6 +279,7 @@ func TestPrepareReusesExistingGitWorktree(t *testing.T) { t.Fatal(err) } runner := &fakeRunner{ + autoAbsoluteGitDir: true, results: []CommandResult{ {Stdout: root + "\n"}, {Stdout: "worktree " + root + "\n"}, @@ -298,8 +307,9 @@ func TestPrepareReusesExistingGitWorktree(t *testing.T) { if result.Path != existing { t.Fatalf("Path = %q, want existing %q", result.Path, existing) } - if len(runner.calls) != 7 { - t.Fatalf("expected metadata git calls plus lock, got %#v", runner.calls) + // metadata calls + lock + ownership marker write (absolute-git-dir) + if len(runner.calls) != 8 { + t.Fatalf("expected metadata git calls plus lock and marker write, got %#v", runner.calls) } // The original lock may have been released by a prior run's exit, which // would leave the reused worktree exposed to Clean's staleness heuristic @@ -307,6 +317,9 @@ func TestPrepareReusesExistingGitWorktree(t *testing.T) { if got := runner.commandLine(6); got != "git worktree lock --reason zero: active task worktree "+existing { t.Fatalf("git worktree lock command = %q", got) } + if got := runner.commandLine(7); got != "git rev-parse --absolute-git-dir" { + t.Fatalf("marker write command = %q", got) + } if !result.LockAcquired { t.Fatalf("LockAcquired = false, want true for a lease this call took") } @@ -571,6 +584,18 @@ func TestPrepareValidatesRequestBeforeCleanup(t *testing.T) { t.Fatal(err) } mustGit("worktree", "add", "--detach", staleDir) + // Plant the ownership marker Clean requires before it will force-remove a + // path: without it, a hand-created worktree under the predictable + // zero-worktree-* layout must not be pruned. Real linked worktrees keep + // their admin dir behind a .git file (gitdir: ...), so resolve it the + // same way writeOwnershipMarker does rather than mkdir path/.git. + gitDir, err := gitOutput(ctx, defaultRunGit, staleDir, "rev-parse", "--absolute-git-dir") + if err != nil { + t.Fatalf("resolve stale worktree git dir: %v", err) + } + if err := os.WriteFile(filepath.Join(gitDir, zeroOwnerMarkerFile), []byte(zeroOwnerMarkerContent), 0o600); err != nil { + t.Fatalf("plant ownership marker: %v", err) + } // Age every filesystem entry past the 24h staleness cutoff. old := time.Now().Add(-48 * time.Hour) if err := filepath.WalkDir(staleDir, func(path string, _ os.DirEntry, err error) error { @@ -581,6 +606,16 @@ func TestPrepareValidatesRequestBeforeCleanup(t *testing.T) { }); err != nil { t.Fatal(err) } + // Also age the admin dir (outside the worktree tree) so nested activity + // under the marker file does not keep the worktree "fresh". + if err := filepath.WalkDir(gitDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, old, old) + }); err != nil { + t.Fatal(err) + } if _, err := Prepare(ctx, Options{Cwd: repo, BaseDir: base, Name: "../escape"}); err == nil { t.Fatal("expected invalid-name error") @@ -694,10 +729,22 @@ func TestDefaultBaseDirFallsBackForWindowsUserProfile(t *testing.T) { type fakeRunner struct { calls []gitCall results []CommandResult + // autoAbsoluteGitDir answers `rev-parse --absolute-git-dir` without + // consuming a queued result: it ensures dir/.git exists and returns that + // path. Tests plant zero-owner markers under that dir when they want + // Release/Clean to treat the worktree as Prepare-created. + autoAbsoluteGitDir bool } func (runner *fakeRunner) Run(ctx context.Context, dir string, args ...string) (CommandResult, error) { runner.calls = append(runner.calls, gitCall{dir: dir, args: append([]string{}, args...)}) + if runner.autoAbsoluteGitDir && len(args) >= 2 && args[0] == "rev-parse" && args[1] == "--absolute-git-dir" { + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0o700); err != nil { + return CommandResult{}, err + } + return CommandResult{Stdout: gitDir + "\n"}, nil + } if len(runner.results) == 0 { return CommandResult{}, nil } @@ -713,6 +760,19 @@ func (runner *fakeRunner) commandLine(index int) string { return "git " + strings.Join(runner.calls[index].args, " ") } +// plantOwnershipMarker writes Prepare's ownership marker under path's .git +// admin dir so Release/Clean treat the fixture as zero-created. +func plantOwnershipMarker(t *testing.T, path string) { + t.Helper() + gitDir := filepath.Join(path, ".git") + if err := os.MkdirAll(gitDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitDir, zeroOwnerMarkerFile), []byte(zeroOwnerMarkerContent), 0o600); err != nil { + t.Fatal(err) + } +} + type gitCall struct { dir string args []string @@ -744,17 +804,28 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { if err := os.MkdirAll(repoRoot, 0o755); err != nil { t.Fatal(err) } + // Markers first, then age the whole tree: planting after Chtimes would + // refresh the worktree mtime and make worktreeIsStale skip the prune. + plantOwnershipMarker(t, youngPath) + plantOwnershipMarker(t, stalePath) - // Change mtime of stale-task to be in the past (e.g. 2 days ago). + // Change mtime of stale-task (and its marker files) to be in the past. twoDaysAgo := time.Now().Add(-48 * time.Hour) - if err := os.Chtimes(stalePath, twoDaysAgo, twoDaysAgo); err != nil { + if err := filepath.WalkDir(stalePath, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, twoDaysAgo, twoDaysAgo) + }); err != nil { t.Fatal(err) } runner := &fakeRunner{ + autoAbsoluteGitDir: true, results: []CommandResult{ {Stdout: repoRoot}, // rev-parse --show-toplevel - {Stdout: "worktree " + youngPath + "\nworktree " + stalePath + "\n"}, // worktree list --porcelain + // Main worktree must be listed first: Clean keys repoDir off entries[0]. + {Stdout: "worktree " + repoRoot + "\nworktree " + youngPath + "\nworktree " + stalePath + "\n"}, {ExitCode: 0}, // status --porcelain (clean) {Stdout: "deadbeef"}, // rev-parse HEAD {Stdout: "refs/heads/main"}, // for-each-ref --contains=deadbeef (already reachable) @@ -774,9 +845,10 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { t.Fatalf("Clean failed: %v", err) } - // Verify the calls made by Clean - if len(runner.calls) != 7 { - t.Fatalf("expected 7 git calls, got %d", len(runner.calls)) + // toplevel + list + status(stale) + marker + HEAD + for-each-ref + remove + prune + // young is skipped as not-stale before any status/marker work. + if len(runner.calls) != 8 { + t.Fatalf("expected 8 git calls, got %d: %#v", len(runner.calls), runner.calls) } if runner.commandLine(0) != "git rev-parse --show-toplevel" { t.Errorf("call 0 = %q", runner.commandLine(0)) @@ -788,15 +860,18 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { if runner.commandLine(2) != expectedStatusCall { t.Errorf("call 2 = %q, want %q", runner.commandLine(2), expectedStatusCall) } - if runner.commandLine(3) != "git rev-parse HEAD" { - t.Errorf("call 3 = %q, want the pre-removal HEAD-preservation check", runner.commandLine(3)) + if runner.commandLine(3) != "git rev-parse --absolute-git-dir" { + t.Errorf("call 3 = %q, want ownership marker check", runner.commandLine(3)) + } + if runner.commandLine(4) != "git rev-parse HEAD" { + t.Errorf("call 4 = %q, want the pre-removal HEAD-preservation check", runner.commandLine(4)) } expectedRemoveCall := "git worktree remove --force " + filepath.Clean(stalePath) - if runner.commandLine(5) != expectedRemoveCall { - t.Errorf("call 5 = %q, want %q", runner.commandLine(5), expectedRemoveCall) + if runner.commandLine(6) != expectedRemoveCall { + t.Errorf("call 6 = %q, want %q", runner.commandLine(6), expectedRemoveCall) } - if runner.commandLine(6) != "git worktree prune" { - t.Errorf("call 6 = %q", runner.commandLine(6)) + if runner.commandLine(7) != "git worktree prune" { + t.Errorf("call 7 = %q", runner.commandLine(7)) } } @@ -817,15 +892,22 @@ func TestCleanReportsErrorOnFailedRemoval(t *testing.T) { if err := os.MkdirAll(repoRoot, 0o755); err != nil { t.Fatal(err) } + plantOwnershipMarker(t, stalePath) twoDaysAgo := time.Now().Add(-48 * time.Hour) - if err := os.Chtimes(stalePath, twoDaysAgo, twoDaysAgo); err != nil { + if err := filepath.WalkDir(stalePath, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, twoDaysAgo, twoDaysAgo) + }); err != nil { t.Fatal(err) } runner := &fakeRunner{ + autoAbsoluteGitDir: true, results: []CommandResult{ {Stdout: repoRoot}, - {Stdout: "worktree " + stalePath + "\n"}, + {Stdout: "worktree " + repoRoot + "\nworktree " + stalePath + "\n"}, {ExitCode: 0}, // status --porcelain (clean) {Stdout: "deadbeef"}, // rev-parse HEAD {Stdout: "refs/heads/main"}, // for-each-ref --contains=deadbeef (already reachable) @@ -859,17 +941,26 @@ func TestCleanAggregatesMultipleFailedRemovals(t *testing.T) { if err := os.MkdirAll(repoRoot, 0o755); err != nil { t.Fatal(err) } + for _, path := range []string{stalePathA, stalePathB} { + plantOwnershipMarker(t, path) + } twoDaysAgo := time.Now().Add(-48 * time.Hour) for _, path := range []string{stalePathA, stalePathB} { - if err := os.Chtimes(path, twoDaysAgo, twoDaysAgo); err != nil { + if err := filepath.WalkDir(path, func(p string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(p, twoDaysAgo, twoDaysAgo) + }); err != nil { t.Fatal(err) } } runner := &fakeRunner{ + autoAbsoluteGitDir: true, results: []CommandResult{ {Stdout: repoRoot}, - {Stdout: "worktree " + stalePathA + "\n\nworktree " + stalePathB + "\n"}, + {Stdout: "worktree " + repoRoot + "\nworktree " + stalePathA + "\n\nworktree " + stalePathB + "\n"}, {ExitCode: 0}, // status --porcelain (clean) {Stdout: "deadbeefa"}, // rev-parse HEAD {Stdout: "refs/heads/main"}, // for-each-ref --contains=deadbeefa (already reachable) @@ -1143,15 +1234,22 @@ func TestCleanReclaimsReleasedWorktreeWithOnlyIgnoredFiles(t *testing.T) { if err := os.MkdirAll(repoRoot, 0o755); err != nil { t.Fatal(err) } + plantOwnershipMarker(t, ignoredOnlyPath) twoDaysAgo := time.Now().Add(-48 * time.Hour) - if err := os.Chtimes(ignoredOnlyPath, twoDaysAgo, twoDaysAgo); err != nil { + if err := filepath.WalkDir(ignoredOnlyPath, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, twoDaysAgo, twoDaysAgo) + }); err != nil { t.Fatal(err) } runner := &fakeRunner{ + autoAbsoluteGitDir: true, results: []CommandResult{ {Stdout: repoRoot}, - {Stdout: "worktree " + ignoredOnlyPath + "\n"}, + {Stdout: "worktree " + repoRoot + "\nworktree " + ignoredOnlyPath + "\n"}, {ExitCode: 0}, // status --porcelain: ignored files invisible => clean {Stdout: "deadbeef"}, // rev-parse HEAD {Stdout: "refs/heads/main"}, // for-each-ref --contains (reachable) @@ -1197,15 +1295,22 @@ func TestCleanRecoversExpiredLease(t *testing.T) { if err := os.MkdirAll(repoRoot, 0o755); err != nil { t.Fatal(err) } + plantOwnershipMarker(t, crashedPath) twoDaysAgo := time.Now().Add(-48 * time.Hour) - if err := os.Chtimes(crashedPath, twoDaysAgo, twoDaysAgo); err != nil { + if err := filepath.WalkDir(crashedPath, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, twoDaysAgo, twoDaysAgo) + }); err != nil { t.Fatal(err) } runner := &fakeRunner{ + autoAbsoluteGitDir: true, results: []CommandResult{ {Stdout: repoRoot}, - {Stdout: "worktree " + crashedPath + "\nlocked " + leaseReason(deadPID) + "\n"}, + {Stdout: "worktree " + repoRoot + "\nworktree " + crashedPath + "\nlocked " + leaseReason(deadPID) + "\n"}, {ExitCode: 0}, // status --porcelain --ignored: clean {Stdout: "deadbeef"}, // rev-parse HEAD {Stdout: "refs/heads/main"}, // for-each-ref --contains (reachable) @@ -1219,14 +1324,16 @@ func TestCleanRecoversExpiredLease(t *testing.T) { t.Fatalf("Clean failed: %v", err) } + // status is still call 2; ownership marker check is call 3; then HEAD, + // for-each-ref, unlock, remove, prune. if got, want := runner.commandLine(2), "git status --porcelain --ignored"; got != want { t.Fatalf("status call = %q, want %q (a crashed lease never signaled completion)", got, want) } - if got, want := runner.commandLine(5), "git worktree unlock "+filepath.Clean(crashedPath); got != want { - t.Fatalf("call 5 = %q, want lease recovery %q", got, want) + if got, want := runner.commandLine(6), "git worktree unlock "+filepath.Clean(crashedPath); got != want { + t.Fatalf("call 6 = %q, want lease recovery %q", got, want) } - if got, want := runner.commandLine(6), "git worktree remove --force "+filepath.Clean(crashedPath); got != want { - t.Fatalf("call 6 = %q, want %q", got, want) + if got, want := runner.commandLine(7), "git worktree remove --force "+filepath.Clean(crashedPath); got != want { + t.Fatalf("call 7 = %q, want %q", got, want) } } From cd5991ea3f7a2ffd641c8b098eabc7bf8fe91d72 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:14:42 -0400 Subject: [PATCH 19/32] fix(cli): complete worktrees release in shell completions --- internal/cli/completions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/completions.go b/internal/cli/completions.go index 3620ad2ac..b11fcfe4c 100644 --- a/internal/cli/completions.go +++ b/internal/cli/completions.go @@ -84,7 +84,7 @@ var completionRoot = completionNode{ }}, {names: []string{"update"}}, {names: []string{"upgrade"}}, - {names: []string{"worktrees", "worktree"}, children: leafNodes("prepare")}, + {names: []string{"worktrees", "worktree"}, children: leafNodes("prepare", "release")}, {names: []string{"verify"}}, {names: []string{"trust"}, children: leafNodes("list", "remove")}, {names: []string{"eval"}, children: leafNodes("validate", "run", "bench")}, From 21bfdbc678e86f5f7702efaa08b8226ecd0bc85f Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:47:19 -0400 Subject: [PATCH 20/32] test(worktrees,cli): add coverage for linked-worktree Clean, forged lease rejection, and completions - TestCleanFromLinkedWorktreePrunesStaleWorktree: pins Clean deriving its owned-subtree key from the main worktree root (not the invoking linked checkout's --show-toplevel) so Prepare/Clean run from a linked worktree actually reclaim the worktrees Prepare created there. - TestReleaseRejectsForgedZeroLeaseWithoutOwnershipMarker: pins the ownership-marker requirement against the exact forgery jatmn described - a worktree under the predictable zero-worktree- path, manually locked with a reason that merely starts with the zero lease prefix. - Fix TestPrepareCreatesDetachedGitWorktree: its fake git runner ran out of canned results at Prepare's post-lock ownership-marker write, so writeOwnershipMarker resolved gitDir to "" and os.WriteFile wrote "zero-owner" as a relative path into the test process's real working directory instead of failing loudly. Give it autoAbsoluteGitDir like the other Prepare-exercising tests use and assert on the marker-write call. - completions_test.go: assert `worktrees`/`worktree` completions include `release` alongside `prepare`. --- internal/cli/completions_test.go | 2 + internal/worktrees/worktrees_test.go | 112 +++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/internal/cli/completions_test.go b/internal/cli/completions_test.go index fc2acdec6..c129a6af5 100644 --- a/internal/cli/completions_test.go +++ b/internal/cli/completions_test.go @@ -155,6 +155,8 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) { assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt") assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--skip-permissions-unsafe") + assertCandidates(t, byPath["worktrees"], "prepare", "release") + assertCandidates(t, byPath["worktree"], "prepare", "release") assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach") assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status") assertCandidates(t, byPath["sandbox grants"], "list", "allow", "deny", "revoke", "clear") diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 719544463..5aef7d3cb 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -46,7 +46,15 @@ func TestDefaultRunGitSeparatesStdoutAndStderr(t *testing.T) { func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { root := t.TempDir() base := t.TempDir() + // autoAbsoluteGitDir answers Prepare's post-lock ownership-marker write + // (`git rev-parse --absolute-git-dir`) for the newly created worktree + // without a canned result: without it, the fake runner falls off the end + // of results and returns an empty CommandResult, so writeOwnershipMarker + // resolves gitDir to "" and os.WriteFile writes "zero-owner" as a + // relative path into the test process's actual working directory instead + // of failing loudly. runner := &fakeRunner{ + autoAbsoluteGitDir: true, results: []CommandResult{ {Stdout: root + "\n"}, {Stdout: "worktree " + root + "\n"}, @@ -89,6 +97,15 @@ func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { if !result.LockAcquired { t.Fatalf("LockAcquired = false, want true for a worktree this call created") } + // Prepare must also persist the ownership marker so Release/Clean can + // tell this worktree apart from one a user created and locked by hand + // under the same predictable zero-worktree- path convention. + if got := runner.commandLine(6); got != "git rev-parse --absolute-git-dir" { + t.Fatalf("marker write command = %q", got) + } + if len(runner.calls) != 7 { + t.Fatalf("expected metadata calls plus lock and marker write, got %#v", runner.calls) + } } func TestReleaseUnlocksWorktree(t *testing.T) { @@ -144,6 +161,42 @@ func TestReleaseUnlocksWorktree(t *testing.T) { } } +// TestReleaseRejectsForgedZeroLeaseWithoutOwnershipMarker pins the fix for +// ownership being inferred from the public zero-worktree- path +// convention plus a lock reason that merely starts with leaseReasonPrefix: a +// user (or another tool) can create a same-repository worktree at that +// predictable path and lock it by hand with a reason like "zero: active task +// worktree manually-owned", which passes both the ancestor-component and +// HasPrefix checks. Only the ownership marker Prepare itself writes can tell +// that apart from a genuine Zero lease, so Release must still refuse to +// unlock it when the worktree directory exists but carries no marker. +func TestReleaseRejectsForgedZeroLeaseWithoutOwnershipMarker(t *testing.T) { + repoRoot := physicalTestPath(t, t.TempDir()) + base := physicalTestPath(t, t.TempDir()) + path := filepath.Join(base, "zero-worktree-"+repoKey(repoRoot), "manually-owned") + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + // Deliberately no plantOwnershipMarker call: this worktree exists and is + // locked with a reason that starts with leaseReasonPrefix, exactly what a + // user could reproduce by hand, but Prepare never created it. + runner := &fakeRunner{ + autoAbsoluteGitDir: true, + results: []CommandResult{ + {Stdout: "worktree " + repoRoot + "\nworktree " + path + "\nlocked " + leaseReasonPrefix + " manually-owned\n"}, + }, + } + + err := Release(context.Background(), Options{RunGit: runner.Run}, path) + if err == nil || !strings.Contains(err.Error(), "missing zero ownership marker") { + t.Fatalf("Release error = %v, want a missing-ownership-marker rejection", err) + } + // list + marker check, no unlock call. + if len(runner.calls) != 2 { + t.Fatalf("expected only the ownership check and marker read, no unlock call, got %#v", runner.calls) + } +} + func TestReleaseFallsBackToCwdWhenWorktreeDirMissing(t *testing.T) { // A caller who deletes a locked worktree directory by hand instead of // releasing it first leaves path itself gone; Release must still be able @@ -459,6 +512,65 @@ func TestPrepareFromLinkedWorktreeCanBeReleased(t *testing.T) { } } +// TestCleanFromLinkedWorktreePrunesStaleWorktree pins the fix for Clean +// keying its owned-subtree bucket off repoRoot (--show-toplevel for whatever +// worktree Clean itself runs from) instead of the main worktree's root: +// Prepare and Clean must agree on repoKey regardless of which checkout +// either call runs from, or a Clean invoked from a linked worktree computes +// a different repoDir than Prepare used and silently skips every +// zero-owned worktree for the repository. +func TestCleanFromLinkedWorktreePrunesStaleWorktree(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + ctx := context.Background() + mainRepo := physicalTestPath(t, t.TempDir()) + mustGit := func(dir string, args ...string) string { + t.Helper() + out, err := gitOutput(ctx, defaultRunGit, dir, args...) + if err != nil { + t.Skipf("git unavailable or failed (%v): %v", args, err) + } + return out + } + mustGit(mainRepo, "init") + mustGit(mainRepo, "-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "--allow-empty", "-m", "seed") + + // A linked worktree of mainRepo, checked out elsewhere on disk. Its own + // --show-toplevel is this directory, not mainRepo, so Clean run from here + // would compute repoKey(linkedWorktree) unless it instead keys off the + // main worktree's root the way Prepare does. + linkedWorktree := filepath.Join(t.TempDir(), "linked") + mustGit(mainRepo, "worktree", "add", "--detach", linkedWorktree, "HEAD") + + base := physicalTestPath(t, t.TempDir()) + result, err := Prepare(ctx, Options{Cwd: linkedWorktree, BaseDir: base, Name: "stale-from-linked"}) + if err != nil { + t.Fatalf("Prepare from a linked worktree: %v", err) + } + if err := Release(ctx, Options{Cwd: linkedWorktree}, result.Path); err != nil { + t.Fatalf("Release: %v", err) + } + old := time.Now().Add(-48 * time.Hour) + if err := filepath.WalkDir(result.Path, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, old, old) + }); err != nil { + t.Fatal(err) + } + + // Clean itself also runs from the linked worktree here, mirroring a + // Prepare/Clean invocation started from a linked checkout. + if err := Clean(ctx, Options{Cwd: linkedWorktree, BaseDir: base}, 24*time.Hour); err != nil { + t.Fatalf("Clean: %v", err) + } + if _, err := os.Stat(result.Path); !os.IsNotExist(err) { + t.Fatalf("Clean run from a linked worktree should have pruned the stale zero-owned worktree, stat err: %v", err) + } +} + // TestCleanPrunesStaleWorktreeUnderSymlinkedBaseDir pins the fix for a // symlinked --worktree-dir: git worktree list --porcelain reports each // worktree's PHYSICAL location, resolving any symlink component, so Clean From ca6509735372ddd67810104e22749916effe129a Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:41:03 -0400 Subject: [PATCH 21/32] fix(redaction,worktrees): sort extra secret values by length descending and canonicalize worktree paths --- internal/redaction/audit_fixes_test.go | 13 +++++++++++++ internal/redaction/redaction.go | 13 ++++++++++--- internal/worktrees/worktrees.go | 2 +- internal/worktrees/worktrees_test.go | 6 +++--- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/internal/redaction/audit_fixes_test.go b/internal/redaction/audit_fixes_test.go index c4431904f..8e953543d 100644 --- a/internal/redaction/audit_fixes_test.go +++ b/internal/redaction/audit_fixes_test.go @@ -61,6 +61,19 @@ func TestRedactString_CompoundKeyForms(t *testing.T) { } } +func TestRedactString_OverlappingExtraSecrets(t *testing.T) { + opts := Options{ + ExtraSecretValues: []string{"secret", "secret_token"}, + } + out := RedactString("my secret_token value", opts) + if strings.Contains(out, "_token") { + t.Fatalf("RedactString left partial fragment '_token': got %q", out) + } + if !strings.Contains(out, RedactedSecret) { + t.Fatalf("expected RedactedSecret in output: got %q", out) + } +} + func TestRedactString_AuthHeaderSchemes(t *testing.T) { o := Options{} // Opaque values (no token-format prefix) so only the header/colon logic can diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 310d28af9..a4c798cb7 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -6,6 +6,7 @@ import ( "net/url" "reflect" "regexp" + "sort" "strings" "unicode" ) @@ -156,9 +157,15 @@ func keyLooksSensitive(normalized string) bool { func RedactString(value string, options Options) string { replacement := replacement(options) redacted := value - for _, secret := range options.ExtraSecretValues { - if strings.TrimSpace(secret) != "" { - redacted = strings.ReplaceAll(redacted, secret, replacement) + if len(options.ExtraSecretValues) > 0 { + secrets := append([]string{}, options.ExtraSecretValues...) + sort.SliceStable(secrets, func(i, j int) bool { + return len(secrets[i]) > len(secrets[j]) + }) + for _, secret := range secrets { + if strings.TrimSpace(secret) != "" { + redacted = strings.ReplaceAll(redacted, secret, replacement) + } } } diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 94c868dd0..34b73a6fd 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -904,7 +904,7 @@ func parseWorktreeList(output string) []worktreeEntry { if current != nil { entries = append(entries, *current) } - current = &worktreeEntry{path: filepath.Clean(strings.TrimPrefix(line, "worktree "))} + 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")) diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 5aef7d3cb..9ee13e1ad 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -1747,13 +1747,13 @@ func TestParseWorktreeListTracksLockedState(t *testing.T) { if len(entries) != 3 { t.Fatalf("expected 3 entries, got %d: %#v", len(entries), entries) } - if entries[0].path != filepath.Clean("/a/one") || entries[0].locked { + if entries[0].path != canonicalizePath("/a/one") || entries[0].locked { t.Errorf("entries[0] = %#v", entries[0]) } - if entries[1].path != filepath.Clean("/a/two") || !entries[1].locked { + if entries[1].path != canonicalizePath("/a/two") || !entries[1].locked { t.Errorf("entries[1] = %#v", entries[1]) } - if entries[2].path != filepath.Clean("/a/three") || !entries[2].locked { + if entries[2].path != canonicalizePath("/a/three") || !entries[2].locked { t.Errorf("entries[2] = %#v", entries[2]) } } From d10611613ca644079a3199f50a734fa004fa1e09 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:44:00 -0400 Subject: [PATCH 22/32] fix(secrets,worktrees): restore Anthropic key redaction and handle legacy worktree cleanup --- internal/secrets/scanner.go | 9 +-- internal/secrets/scanner_test.go | 18 ++++++ internal/worktrees/worktrees.go | 52 ++++++++++++---- internal/worktrees/worktrees_test.go | 93 ++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 15 deletions(-) diff --git a/internal/secrets/scanner.go b/internal/secrets/scanner.go index cb4952817..c7f54f924 100644 --- a/internal/secrets/scanner.go +++ b/internal/secrets/scanner.go @@ -45,10 +45,11 @@ var patterns = []pattern{ {"github_pat", regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}`)}, {"slack_token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`)}, {"google_api_key", regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}`)}, - // Distinguish modern prefixed keys (sk-proj- / sk-svcacct- / sk-admin-) and - // hyphenated OpenAI-compatible provider keys (sk-or-v1- for OpenRouter) from - // normal kebab-case phrases, and match legacy sk- keys by length (>= 20). - {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-|admin-|or-v1-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}`)}, + // Distinguish modern prefixed keys (sk-proj- / sk-svcacct- / sk-admin-), + // Anthropic keys (sk-ant-apiNN- / sk-ant-), and hyphenated OpenAI-compatible + // provider keys (sk-or-v1- for OpenRouter) from normal kebab-case phrases, and + // match legacy sk- keys by length (>= 20). + {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-|admin-|or-v1-|ant-api\d{2}-|ant-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}`)}, // Match the ENTIRE PEM/OpenSSH block (header THROUGH the END marker, body // included) so redaction removes the key material, not just the header. {"private_key_block", regexp.MustCompile(`(?s)-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----.*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----`)}, diff --git a/internal/secrets/scanner_test.go b/internal/secrets/scanner_test.go index 555d1c834..2104af2be 100644 --- a/internal/secrets/scanner_test.go +++ b/internal/secrets/scanner_test.go @@ -139,6 +139,24 @@ func TestScanDetectsHyphenatedOpenAICompatibleKeys(t *testing.T) { } } +func TestScanDetectsAnthropicKeys(t *testing.T) { + for _, key := range []string{ + "sk-ant-api03-1234567890abcdefghijklmnopqrstuvwxyz-12345", + "sk-ant-1234567890abcdefghijklmnopqrstuvwxyz-12345", + } { + redacted, findings := Redact("token=" + key) + if len(findings) != 1 || findings[0].Type != "openai_key" { + t.Fatalf("expected one openai_key finding for %q, got %#v", key, findings) + } + if strings.Contains(redacted, key) { + t.Fatalf("key leaked after redaction: %q", redacted) + } + if !strings.Contains(redacted, "[REDACTED:openai_key]") { + t.Fatalf("missing typed placeholder for %q: %q", key, redacted) + } + } +} + func TestScanRedactsLongerKeysWithoutTailLeak(t *testing.T) { cases := []struct { wantType string diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 34b73a6fd..506f47ff0 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -159,7 +159,8 @@ func Prepare(ctx context.Context, options Options) (Result, error) { } result.LockAcquired = true if err := writeOwnershipMarker(ctx, runGit, target); err != nil { - return Result{}, err + _, unlockErr := gitOutput(ctx, runGit, repoRoot, "worktree", "unlock", target) + return Result{}, errors.Join(err, unlockErr) } return result, nil } @@ -209,7 +210,9 @@ func Prepare(ctx context.Context, options Options) (Result, error) { } result.LockAcquired = true if err := writeOwnershipMarker(ctx, runGit, target); err != nil { - return Result{}, err + _, 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 } @@ -375,6 +378,24 @@ func hasOwnershipMarker(ctx context.Context, runGit GitRunner, target string) (b 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- 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 @@ -817,6 +838,9 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { } if err != nil { if os.IsNotExist(err) { + if expiredLease { + _, _ = gitOutput(ctx, runGit, repoRoot, "worktree", "unlock", entry.path) + } _, _ = runGit(ctx, repoRoot, "worktree", "prune") } continue @@ -841,16 +865,22 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { // gets committed/cleaned (no longer dirty) or unlocked. continue } - // The zero-worktree- path and (for the expired-lease - // branch above) the leaseReasonPrefix lock reason are both public - // conventions a user can reproduce by hand for a worktree of the - // same repository; neither is proof Zero created this one. // Require the ownership marker Prepare itself persists before - // force-touching anything below. Any failure to verify it - // (including the marker simply being absent) is treated the same - // as "not ours": skip it, the same fail-closed stance as an - // unreadable worktree above. - if owned, err := hasOwnershipMarker(ctx, runGit, statPath); err != nil || !owned { + // force-touching anything below. For legacy Zero worktrees created + // before markers existed, verify they are in repoDir with a Zero + // lease and migrate them by writing the marker. + owned, err := hasOwnershipMarker(ctx, runGit, statPath) + if err != nil { + continue + } + if !owned { + if isLegacyZeroWorktree(ctx, runGit, statPath, repoDir, entry) { + if writeErr := writeOwnershipMarker(ctx, runGit, statPath); writeErr == nil { + owned = true + } + } + } + if !owned { continue } if err := preserveUnreachableWorktreeHead(ctx, runGit, repoRoot, statPath); err != nil { diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 9ee13e1ad..c3595dbee 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -1757,3 +1757,96 @@ func TestParseWorktreeListTracksLockedState(t *testing.T) { t.Errorf("entries[2] = %#v", entries[2]) } } + +func TestCleanUnlocksExpiredLeaseBeforePruningMissingDir(t *testing.T) { + deadPID := deadProcessPID(t) + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + missingPath := filepath.Join(repoDir, "missing-dead-task") + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + repoRoot + "\nworktree " + missingPath + "\nlocked " + leaseReason(deadPID) + "\n"}, + {ExitCode: 0}, // unlock + {ExitCode: 0}, // prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + + unlocked := false + pruned := false + for _, call := range runner.calls { + if len(call.args) >= 2 && call.args[0] == "worktree" { + if call.args[1] == "unlock" { + unlocked = true + } + if call.args[1] == "prune" { + pruned = true + } + } + } + if !unlocked || !pruned { + t.Errorf("expected unlock and prune for missing expired lease, unlocked=%v, pruned=%v, calls=%#v", unlocked, pruned, runner.calls) + } +} + +func TestCleanMigratesAndReclaimsLegacyZeroWorktrees(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + + legacyPath := filepath.Join(repoDir, "legacy-task") + if err := os.MkdirAll(legacyPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + // Note: plantOwnershipMarker is explicitly NOT called here, simulating a pre-upgrade worktree + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := filepath.WalkDir(legacyPath, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, twoDaysAgo, twoDaysAgo) + }); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + autoAbsoluteGitDir: true, + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + repoRoot + "\nworktree " + legacyPath + "\n"}, + {ExitCode: 0}, // status --porcelain --ignored: clean + {Stdout: "deadbeef"}, // rev-parse HEAD + {Stdout: "refs/heads/main"}, // for-each-ref --contains (reachable) + {ExitCode: 0}, // worktree remove --force + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + + removed := false + for _, call := range runner.calls { + if len(call.args) >= 2 && call.args[0] == "worktree" && call.args[1] == "remove" { + removed = true + } + } + if !removed { + t.Fatal("Clean did not reclaim a legacy pre-upgrade Zero worktree lacking an ownership marker") + } +} From 91e859d0910ecea520d3f22ddcb5e5ea64fff6a2 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:01:12 -0400 Subject: [PATCH 23/32] fix(secrets): add bash output redaction regression test for Anthropic API keys --- internal/tools/bash_secrets_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/tools/bash_secrets_test.go b/internal/tools/bash_secrets_test.go index b9491aa4d..6053e0e48 100644 --- a/internal/tools/bash_secrets_test.go +++ b/internal/tools/bash_secrets_test.go @@ -24,3 +24,13 @@ func TestFormatBashOutputLeavesCleanOutputAlone(t *testing.T) { t.Fatalf("clean output should not be altered, got %q", out) } } + +func TestFormatBashOutputRedactsAnthropicKey(t *testing.T) { + out := formatBashOutput("anthropic key: sk-ant-api03-1234567890abcdefghijklmnopqrstuvwxyz-12345\n", "", 0) + if strings.Contains(out, "sk-ant-api03") { + t.Fatalf("bash output leaked an Anthropic key: %q", out) + } + if !strings.Contains(out, "[REDACTED:openai_key]") { + t.Fatalf("expected typed redaction placeholder, got %q", out) + } +} From 66d315246d68157821aa8f57963a880731615728 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:08:41 -0400 Subject: [PATCH 24/32] fix(worktrees): touch worktree mtime on reuse to prevent stale pruning When Prepare reuses an existing worktree, it now touches the directory to refresh its mtime. This prevents Clean from force-removing a long-running but idle worktree (e.g. waiting on a model) that has no recent file changes but is still actively in use. Addresses the data-loss risk flagged in the PR review where mtime-only staleness + --force removal could discard live worktrees. Co-authored-by: cairn-code Co-Authored-By: cairn-code <282421612+cairn-code@users.noreply.github.com> --- internal/worktrees/worktrees.go | 12 +++++++ internal/worktrees/worktrees_test.go | 47 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 506f47ff0..bc5d4dfa2 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -158,6 +158,18 @@ func Prepare(ctx context.Context, options Options) (Result, error) { 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. + _ = os.Chtimes(target, time.Now(), time.Now()) + 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 if err := writeOwnershipMarker(ctx, runGit, target); err != nil { _, unlockErr := gitOutput(ctx, runGit, repoRoot, "worktree", "unlock", target) return Result{}, errors.Join(err, unlockErr) diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index c3595dbee..f45f6397d 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -1550,6 +1550,53 @@ func deadProcessPID(t *testing.T) int { // as clean and be force-removed by Clean's staleness heuristic. This exercises // the real git binary rather than the fake runner, so it also verifies // --ignored actually changes git's answer, not just the command we send. +// TestCleanHonorsTouchLiveness verifies that a stale worktree (mtime older +// than maxAge) is skipped by Clean when it was recently touched by Prepare, +// so a long-running but idle task that Prepare is still associated with is +// not wrongly force-removed. +func TestCleanHonorsTouchLiveness(t *testing.T) { + tempDir := t.TempDir() + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + + touchedPath := filepath.Join(repoDir, "touched-task") + if err := os.MkdirAll(touchedPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + // Age the worktree past maxAge so it would be pruned without the touch. + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(touchedPath, twoDaysAgo, twoDaysAgo); err != nil { + t.Fatal(err) + } + plantOwnershipMarker(t, touchedPath) + + runner := &fakeRunner{ + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + touchedPath + "\nlocked " + leaseReason(os.Getpid()) + "\n"}, + {ExitCode: 0}, // worktree prune (no-op) + }, + } + + // Touch the directory now, simulating Prepare being called for this worktree. + if err := os.Chtimes(touchedPath, time.Now(), time.Now()); err != nil { + t.Fatal(err) + } + + if err := Clean(context.Background(), Options{BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean: %v", err) + } + // The worktree should still exist: Clean skipped it because it's locked + // with a live PID, and touching it refreshes mtime. + if _, err := os.Stat(touchedPath); err != nil { + t.Fatalf("worktree %s was incorrectly removed: %v", touchedPath, err) + } +} + func TestWorktreeIsDirtyCountsIgnoredFilesAsDirty(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") From 9e8ec4741ab6ca3294f4e4cffcb0b675e691f38b Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:21:58 -0400 Subject: [PATCH 25/32] fix(worktrees): properly handle os.Chtimes error on reused worktree path Co-Authored-By: cairn-code <282421612+cairn-code@users.noreply.github.com> --- internal/worktrees/worktrees.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index bc5d4dfa2..8b25a4e8b 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -162,14 +162,10 @@ func Prepare(ctx context.Context, options Options) (Result, error) { // 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. - _ = os.Chtimes(target, time.Now(), time.Now()) - 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) + 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) } - result.LockAcquired = true if err := writeOwnershipMarker(ctx, runGit, target); err != nil { _, unlockErr := gitOutput(ctx, runGit, repoRoot, "worktree", "unlock", target) return Result{}, errors.Join(err, unlockErr) From f384f4e4273bdd1253436ccf7b52079a6ad9c9d1 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:10:14 -0400 Subject: [PATCH 26/32] fix(worktrees,secrets): unlock using porcelain entry path, set RepoRoot to primaryRoot, and add anthropic_key pattern Unlock using matched porcelain entry path, set Result.RepoRoot to primaryRoot in Prepare, add dedicated anthropic_key secret pattern, and canonicalize fake-runner test paths. Refs #632 --- internal/secrets/scanner.go | 7 ++--- internal/secrets/scanner_test.go | 6 ++-- internal/worktrees/worktrees.go | 43 +++++++++++----------------- internal/worktrees/worktrees_test.go | 10 +++---- 4 files changed, 27 insertions(+), 39 deletions(-) diff --git a/internal/secrets/scanner.go b/internal/secrets/scanner.go index c7f54f924..165d206f5 100644 --- a/internal/secrets/scanner.go +++ b/internal/secrets/scanner.go @@ -45,11 +45,8 @@ var patterns = []pattern{ {"github_pat", regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}`)}, {"slack_token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`)}, {"google_api_key", regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}`)}, - // Distinguish modern prefixed keys (sk-proj- / sk-svcacct- / sk-admin-), - // Anthropic keys (sk-ant-apiNN- / sk-ant-), and hyphenated OpenAI-compatible - // provider keys (sk-or-v1- for OpenRouter) from normal kebab-case phrases, and - // match legacy sk- keys by length (>= 20). - {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-|admin-|or-v1-|ant-api\d{2}-|ant-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}`)}, + {"anthropic_key", regexp.MustCompile(`\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}`)}, + {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-|admin-|or-v1-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}`)}, // Match the ENTIRE PEM/OpenSSH block (header THROUGH the END marker, body // included) so redaction removes the key material, not just the header. {"private_key_block", regexp.MustCompile(`(?s)-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----.*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----`)}, diff --git a/internal/secrets/scanner_test.go b/internal/secrets/scanner_test.go index 2104af2be..230bfbb16 100644 --- a/internal/secrets/scanner_test.go +++ b/internal/secrets/scanner_test.go @@ -145,13 +145,13 @@ func TestScanDetectsAnthropicKeys(t *testing.T) { "sk-ant-1234567890abcdefghijklmnopqrstuvwxyz-12345", } { redacted, findings := Redact("token=" + key) - if len(findings) != 1 || findings[0].Type != "openai_key" { - t.Fatalf("expected one openai_key finding for %q, got %#v", key, findings) + if len(findings) != 1 || findings[0].Type != "anthropic_key" { + t.Fatalf("expected one anthropic_key finding for %q, got %#v", key, findings) } if strings.Contains(redacted, key) { t.Fatalf("key leaked after redaction: %q", redacted) } - if !strings.Contains(redacted, "[REDACTED:openai_key]") { + if !strings.Contains(redacted, "[REDACTED:anthropic_key]") { t.Fatalf("missing typed placeholder for %q: %q", key, redacted) } } diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index 8b25a4e8b..b35b514a4 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -125,7 +125,7 @@ func Prepare(ctx context.Context, options Options) (Result, error) { result := Result{ Name: name, Path: target, - RepoRoot: repoRoot, + RepoRoot: primaryRoot, SourceBranch: branch, SourceCommit: commit, } @@ -318,13 +318,14 @@ func Release(ctx context.Context, options Options, path string) error { dir = cwd } } - if err := verifyZeroOwnedWorktree(ctx, runGit, dir, path); err != nil { + 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", path); err != nil { + if _, err := gitOutput(ctx, runGit, dir, "worktree", "unlock", matchedPath); err != nil { return fmt.Errorf("unlock git worktree: %w", err) } return nil @@ -421,14 +422,14 @@ func isLegacyZeroWorktree(ctx context.Context, runGit GitRunner, target string, // 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) error { +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) + 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) + 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 @@ -445,7 +446,7 @@ func verifyZeroOwnedWorktree(ctx context.Context, runGit GitRunner, dir string, } } if !hasZeroComponent { - return fmt.Errorf("refusing to release %s: not a zero-managed worktree (expected an ancestor directory named %q)", path, want) + 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 @@ -454,45 +455,35 @@ func verifyZeroOwnedWorktree(ctx context.Context, runGit GitRunner, dir string, // 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. - matched := false + var matchedPath string for _, entry := range entries { if canonicalizePath(entry.path) != target { continue } - matched = true + 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 + 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) + return "", fmt.Errorf("refusing to release %s: locked with reason %q, not a zero lease", path, entry.lockReason) } - // The lease prefix is a public string a user can copy onto their own - // `git worktree lock` call for a worktree they created by hand under - // this same predictable directory, so it is a cheap first filter, not - // proof. When the worktree directory still exists, require the - // ownership marker Prepare actually persists before trusting it. If - // the directory is already gone (the documented `release -C` recovery - // path for a worktree deleted by hand), there is no marker left to - // check and nothing left for a forced removal to destroy, so the - // prefix match above is enough to let a genuinely orphaned zero lease - // still be cleared. 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) + 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) + return "", fmt.Errorf("refusing to release %s: missing zero ownership marker (not created by `zero worktrees prepare`)", path) } } break } - if !matched { - return fmt.Errorf("refusing to release %s: not a registered worktree of this repository", path) + if matchedPath == "" { + return "", fmt.Errorf("refusing to release %s: not a registered worktree of this repository", path) } - return nil + return matchedPath, nil } // errAlreadyUnlocked is a sentinel for a Zero-managed path whose git lock is diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index f45f6397d..bdca35183 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -661,7 +661,7 @@ func TestReleaseRecoversDeletedWorktreeUnderSymlinkedBaseDir(t *testing.T) { if err := Release(ctx, Options{RunGit: runner.Run, Cwd: repo}, deletedPath); err != nil { t.Fatalf("Release must recover a deleted worktree under a symlinked base dir: %v", err) } - if got := runner.commandLine(1); got != "git worktree unlock "+deletedPath { + if got := runner.commandLine(1); got != "git worktree unlock "+physicalPath { t.Fatalf("git worktree unlock command = %q", got) } } @@ -781,8 +781,8 @@ func TestPrepareRejectsExistingWorktreeFromDifferentRepo(t *testing.T) { } func TestPrepareValidatesNameAndExistingDirectory(t *testing.T) { - root := t.TempDir() - base := t.TempDir() + root := physicalTestPath(t, t.TempDir()) + base := physicalTestPath(t, t.TempDir()) runner := &fakeRunner{ results: []CommandResult{ {Stdout: root + "\n"}, @@ -899,7 +899,7 @@ func fixedTime(value string) func() time.Time { } func TestCleanPrunesStaleWorktrees(t *testing.T) { - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) @@ -1847,7 +1847,7 @@ func TestCleanUnlocksExpiredLeaseBeforePruningMissingDir(t *testing.T) { } func TestCleanMigratesAndReclaimsLegacyZeroWorktrees(t *testing.T) { - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) From b8783ddc6242e944b4232c6d376fc2db90be0686 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:50:40 -0400 Subject: [PATCH 27/32] fix(tools): correct redaction placeholder assertion in Anthropic key test TestFormatBashOutputRedactsAnthropicKey checked for the openai_key placeholder instead of anthropic_key, a copy-paste leftover. The redaction itself was already correct; only the assertion was wrong. --- internal/tools/bash_secrets_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tools/bash_secrets_test.go b/internal/tools/bash_secrets_test.go index 6053e0e48..87f3a8295 100644 --- a/internal/tools/bash_secrets_test.go +++ b/internal/tools/bash_secrets_test.go @@ -30,7 +30,7 @@ func TestFormatBashOutputRedactsAnthropicKey(t *testing.T) { if strings.Contains(out, "sk-ant-api03") { t.Fatalf("bash output leaked an Anthropic key: %q", out) } - if !strings.Contains(out, "[REDACTED:openai_key]") { + if !strings.Contains(out, "[REDACTED:anthropic_key]") { t.Fatalf("expected typed redaction placeholder, got %q", out) } } From 29f1d8798092296421f58b5e70c02fab755f898b Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:41:49 -0400 Subject: [PATCH 28/32] test(worktrees): physicalize test paths for macOS tempdir symlink resolution --- internal/worktrees/worktrees_test.go | 30 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index bdca35183..9b73a5d11 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -44,8 +44,8 @@ func TestDefaultRunGitSeparatesStdoutAndStderr(t *testing.T) { } func TestPrepareCreatesDetachedGitWorktree(t *testing.T) { - root := t.TempDir() - base := t.TempDir() + root := physicalTestPath(t, t.TempDir()) + base := physicalTestPath(t, t.TempDir()) // autoAbsoluteGitDir answers Prepare's post-lock ownership-marker write // (`git rev-parse --absolute-git-dir`) for the newly created worktree // without a canned result: without it, the fake runner falls off the end @@ -210,7 +210,7 @@ func TestReleaseFallsBackToCwdWhenWorktreeDirMissing(t *testing.T) { // (git keeps a prunable entry after a manual rm -rf) with Zero's lease // reason, or the ownership check refuses the unlock. repoRoot := physicalTestPath(t, t.TempDir()) - missingPath := filepath.Join(t.TempDir(), "zero-worktree-"+repoKey(repoRoot), "already-deleted") + missingPath := filepath.Join(physicalTestPath(t, t.TempDir()), "zero-worktree-"+repoKey(repoRoot), "already-deleted") runner := &fakeRunner{results: []CommandResult{ {Stdout: "worktree " + repoRoot + "\nworktree " + missingPath + "\nlocked " + leaseReasonPrefix + "\n"}, {}, @@ -321,8 +321,8 @@ func TestReleasePropagatesGitFailure(t *testing.T) { } func TestPrepareReusesExistingGitWorktree(t *testing.T) { - root := t.TempDir() - base := t.TempDir() + root := physicalTestPath(t, t.TempDir()) + base := physicalTestPath(t, t.TempDir()) sourceGit := filepath.Join(root, ".git") if err := os.MkdirAll(sourceGit, 0o700); err != nil { t.Fatal(err) @@ -384,8 +384,8 @@ func TestPrepareRejectsWorktreeLockedByAnotherRun(t *testing.T) { // supposedly isolated tree, and whichever exits first would release the // single Git lock out from under the other, so Prepare must reject the // in-use lease instead of returning the path. - root := t.TempDir() - base := t.TempDir() + root := physicalTestPath(t, t.TempDir()) + base := physicalTestPath(t, t.TempDir()) sourceGit := filepath.Join(root, ".git") if err := os.MkdirAll(sourceGit, 0o700); err != nil { t.Fatal(err) @@ -745,10 +745,10 @@ func TestPrepareValidatesRequestBeforeCleanup(t *testing.T) { } func TestPrepareRejectsExistingWorktreeFromDifferentRepo(t *testing.T) { - root := t.TempDir() - base := t.TempDir() + root := physicalTestPath(t, t.TempDir()) + base := physicalTestPath(t, t.TempDir()) sourceGit := filepath.Join(root, ".git") - otherGit := filepath.Join(t.TempDir(), ".git") + otherGit := filepath.Join(physicalTestPath(t, t.TempDir()), ".git") for _, dir := range []string{sourceGit, otherGit} { if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) @@ -992,7 +992,7 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { // Clean must check ExitCode itself rather than trusting a nil error to mean // the removal succeeded. func TestCleanReportsErrorOnFailedRemoval(t *testing.T) { - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) @@ -1038,7 +1038,7 @@ func TestCleanReportsErrorOnFailedRemoval(t *testing.T) { } func TestCleanAggregatesMultipleFailedRemovals(t *testing.T) { - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) @@ -1334,7 +1334,7 @@ func TestCleanReclaimsReleasedWorktreeWithOnlyIgnoredFiles(t *testing.T) { // output) must be reclaimable, or every released worktree with such // artifacts leaks disk forever. The dirty probe for unlocked entries // therefore omits --ignored. - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) @@ -1395,7 +1395,7 @@ func TestCleanReclaimsReleasedWorktreeWithOnlyIgnoredFiles(t *testing.T) { // --ignored because a crashed task never signaled completion. func TestCleanRecoversExpiredLease(t *testing.T) { deadPID := deadProcessPID(t) - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) @@ -1807,7 +1807,7 @@ func TestParseWorktreeListTracksLockedState(t *testing.T) { func TestCleanUnlocksExpiredLeaseBeforePruningMissingDir(t *testing.T) { deadPID := deadProcessPID(t) - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") if err := os.MkdirAll(repoRoot, 0o755); err != nil { From c9a347aad1aa3e50fca309a726ad3ddf9a8c0e84 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:32:34 -0400 Subject: [PATCH 29/32] fix(secrets,worktrees): address CodeRabbit review findings Align RedactString token boundaries with secrets.Scan, strengthen the Anthropic bash redaction assertion, fix Clean fixtures so they reach the guards under test, and fail closed on ambiguous processAlive probes. --- internal/cli/sandbox_check_test.go | 4 +- internal/cli/workflow_test.go | 4 +- internal/cli/workflows.go | 7 +- internal/redaction/audit_fixes_test.go | 66 ++++++++++++++++++ internal/redaction/redaction.go | 25 ++++--- internal/tools/bash_secrets_test.go | 5 +- internal/worktrees/worktrees_posix.go | 16 +++-- internal/worktrees/worktrees_test.go | 97 ++++++++++++++++++++------ 8 files changed, 181 insertions(+), 43 deletions(-) diff --git a/internal/cli/sandbox_check_test.go b/internal/cli/sandbox_check_test.go index 45707c585..f7fb03ce8 100644 --- a/internal/cli/sandbox_check_test.go +++ b/internal/cli/sandbox_check_test.go @@ -152,7 +152,7 @@ func TestRunSandboxCheckMatchedGrantRedactsReason(t *testing.T) { if _, err := store.Grant(sandbox.GrantInput{ ToolName: "read_file", Decision: sandbox.GrantAllow, - Reason: "approved with sk-test-secret1234567890", + Reason: "approved with sk-proj-testsecret1234567890ab", }); err != nil { t.Fatalf("seed grant: %v", err) } @@ -162,7 +162,7 @@ func TestRunSandboxCheckMatchedGrantRedactsReason(t *testing.T) { if exitCode != exitSuccess { t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String()) } - if strings.Contains(stdout.String(), "sk-test-secret1234567890") { + if strings.Contains(stdout.String(), "sk-proj-testsecret1234567890ab") { t.Fatalf("grant reason leaked a secret into the snapshot:\n%s", stdout.String()) } var payload struct { diff --git a/internal/cli/workflow_test.go b/internal/cli/workflow_test.go index a75ce5998..ed9d73fa5 100644 --- a/internal/cli/workflow_test.go +++ b/internal/cli/workflow_test.go @@ -1060,7 +1060,7 @@ func TestRunChangesCommitAuto(t *testing.T) { Root: cwd, Branch: "main", Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}}, - Diff: "some diff content with ghp_SECRETKEYHERE", + Diff: "some diff content with ghp_1234567890abcdefghijklmnopqrstuvwxyz", } mockProv := &mockCommitMsgProvider{ @@ -1108,7 +1108,7 @@ func TestRunChangesCommitAuto(t *testing.T) { } // Verify that secret in the diff was redacted promptContent := mockProv.req.Messages[0].Content - if strings.Contains(promptContent, "ghp_SECRETKEYHERE") { + if strings.Contains(promptContent, "ghp_1234567890abcdefghijklmnopqrstuvwxyz") { t.Fatal("expected secret in diff to be redacted, but it was found in the prompt") } if !strings.Contains(promptContent, "[REDACTED]") && !strings.Contains(promptContent, "REDACTED") { diff --git a/internal/cli/workflows.go b/internal/cli/workflows.go index b9298c32b..5dcb72831 100644 --- a/internal/cli/workflows.go +++ b/internal/cli/workflows.go @@ -131,7 +131,7 @@ func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps cwdFlag = value index = next case strings.HasPrefix(arg, "--cwd="): - cwdFlag = strings.TrimPrefix(arg, "--cwd=") + cwdFlag = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd=")) case strings.HasPrefix(arg, "-"): return writeExecUsageError(stderr, fmt.Sprintf("unknown worktrees release flag %q", arg)) default: @@ -177,7 +177,9 @@ func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps if err := deps.releaseWorktree(context.Background(), releaseOptions, absPath); err != nil { return writeExecUsageError(stderr, redactCLIString(err.Error())) } - if _, err := fmt.Fprintf(stdout, "released %s\n", redactCLIString(path)); err != nil { + // Print the absolute path that was released so relative arguments still + // show an unambiguous target in the confirmation line. + if _, err := fmt.Fprintf(stdout, "released %s\n", redactCLIString(absPath)); err != nil { return exitCrash } return exitSuccess @@ -882,6 +884,7 @@ prepare flags: --dir Base directory for Zero worktrees -C, --cwd Source repository directory --json Print JSON output + -h, --help Show this help release flags: -C, --cwd Source repository directory (required if the diff --git a/internal/redaction/audit_fixes_test.go b/internal/redaction/audit_fixes_test.go index 8e953543d..e3a7ec4a6 100644 --- a/internal/redaction/audit_fixes_test.go +++ b/internal/redaction/audit_fixes_test.go @@ -148,6 +148,72 @@ func TestRedactString_PreservesFileLineColons(t *testing.T) { } } +// Patterns whose body class allows "-" must redact a secret that ends in "-" +// right before a delimiter: a trailing \b anchor would force the engine to +// drop that last character and leave the hyphen visible (parity with +// secrets.Scan). +func TestRedactString_TrailingHyphenWithoutTailLeak(t *testing.T) { + o := Options{} + cases := []string{ + "xoxb-1234567890-abcdefghi-", + "AIzaSyA1234567890abcdefghijklmnopqrstu-", + "sk-proj-abcDEF123_ghiJKL456-mnoPQR789st-", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fw-", + } + for _, secret := range cases { + in := "secret is " + secret + " end" + out := RedactString(in, o) + if strings.Contains(out, secret) { + t.Errorf("RedactString leaked trailing-hyphen secret %q: got %q", secret, out) + } + if strings.Contains(out, "- end") || strings.Contains(out, "-] end") { + t.Errorf("trailing hyphen leaked for %q: %q", secret, out) + } + want := "secret is " + RedactedSecret + " end" + if out != want { + t.Errorf("RedactString(%q) = %q, want %q", in, out, want) + } + } +} + +// A credential with more word characters appended right after its body must +// still have its real secret material redacted; the appended run itself is +// outside the body class and stays. A trailing \b would fail the whole match +// (parity with secrets.Scan). +func TestRedactString_CredentialWithAppendedSuffix(t *testing.T) { + o := Options{} + cases := []struct { + secret string + suffix string + }{ + {"AKIAIOSFODNN7EXAMPLE", "EXTRA"}, + {"ghp_1234567890abcdefghijklmnopqrstuvwxyz", "_suffix"}, + } + for _, tc := range cases { + in := "token=" + tc.secret + tc.suffix + " end" + out := RedactString(in, o) + if strings.Contains(out, tc.secret) { + t.Errorf("RedactString leaked credential %q: got %q", tc.secret, out) + } + // Bare "token" is a sensitive key, so the assignment/query path may + // redact the whole value including the suffix. Either outcome is fine + // as long as the credential itself is gone and a marker is present. + if !strings.Contains(out, RedactedSecret) { + t.Errorf("expected redaction marker for %q: got %q", in, out) + } + } + // Standalone (no sensitive-key assignment) so only textSecretPatterns run. + in := "aws key AKIAIOSFODNN7EXAMPLEEXTRA tail" + out := RedactString(in, o) + if strings.Contains(out, "AKIAIOSFODNN7EXAMPLE") { + t.Fatalf("appended-suffix AWS key leaked: %q", out) + } + want := "aws key " + RedactedSecret + "EXTRA tail" + if out != want { + t.Fatalf("RedactString(%q) = %q, want %q", in, out, want) + } +} + func TestRedactValue_CompoundKeys(t *testing.T) { o := Options{} in := map[string]any{ diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index a4c798cb7..1c1a531dd 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -68,16 +68,23 @@ var sensitiveKeys = map[string]struct{}{ "zero_api_key": {}, } +// textSecretPatterns mirror secrets.Scan for end-boundary behavior and the +// shared high-confidence shapes. A leading \b keeps each pattern from firing +// mid-word; a trailing \b is omitted so a secret followed by more word +// characters outside its body class (e.g. AKIA…EXAMPLEEXTRA) still matches, +// and a secret that ends in "-" (allowed by some body classes) is fully +// redacted rather than leaving the hyphen behind. glpat is redaction-only +// (not in secrets.Scan); ASIA temporary access keys are kept alongside AKIA. var textSecretPatterns = []*regexp.Regexp{ - regexp.MustCompile(`\bsk-(?:proj-)?[A-Za-z0-9._-]{12,}\b`), - regexp.MustCompile(`\bsk-ant-api\d{2}-[A-Za-z0-9._-]{12,}\b`), - regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{12,}\b`), - regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9_]{12,}\b`), - regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{12,}\b`), - regexp.MustCompile(`\bAIza[0-9A-Za-z_-]{12,}\b`), - regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{12,}\b`), - regexp.MustCompile(`\b(?:AKIA|ASIA)[A-Z0-9]{16}\b`), - regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`), + regexp.MustCompile(`\bsk-(?:proj-|svcacct-|admin-|or-v1-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}`), + regexp.MustCompile(`\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}`), + regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}`), + regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36,}`), + regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{12,}`), + regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}`), + regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`), + regexp.MustCompile(`\b(?:AKIA|ASIA)[A-Z0-9]{16}`), + regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`), } var ( diff --git a/internal/tools/bash_secrets_test.go b/internal/tools/bash_secrets_test.go index 87f3a8295..b263acb4a 100644 --- a/internal/tools/bash_secrets_test.go +++ b/internal/tools/bash_secrets_test.go @@ -26,8 +26,9 @@ func TestFormatBashOutputLeavesCleanOutputAlone(t *testing.T) { } func TestFormatBashOutputRedactsAnthropicKey(t *testing.T) { - out := formatBashOutput("anthropic key: sk-ant-api03-1234567890abcdefghijklmnopqrstuvwxyz-12345\n", "", 0) - if strings.Contains(out, "sk-ant-api03") { + key := "sk-ant-api03-1234567890abcdefghijklmnopqrstuvwxyz-12345" + out := formatBashOutput("anthropic key: "+key+"\n", "", 0) + if strings.Contains(out, key) { t.Fatalf("bash output leaked an Anthropic key: %q", out) } if !strings.Contains(out, "[REDACTED:anthropic_key]") { diff --git a/internal/worktrees/worktrees_posix.go b/internal/worktrees/worktrees_posix.go index 323038dec..0605ddf42 100644 --- a/internal/worktrees/worktrees_posix.go +++ b/internal/worktrees/worktrees_posix.go @@ -9,20 +9,24 @@ import ( ) // osProcessAlive reports whether pid is a live process on POSIX. Signal 0 -// does not deliver a signal; it only checks existence/permission. ESRCH means -// no such process (dead); EPERM means it exists but we may not signal it -// (alive). +// does not deliver a signal; it only checks existence/permission. Only ESRCH +// and os.ErrProcessDone mean the process is gone; EPERM means it exists but +// we may not signal it (alive). Any other error is treated as alive +// (fail-closed) so an ambiguous answer can only keep a lease, never expire +// one. func osProcessAlive(pid int) bool { proc, err := os.FindProcess(pid) if err != nil { - return false + // FindProcess rarely fails on Unix; fail closed if it does. + return true } err = proc.Signal(syscall.Signal(0)) if err == nil { return true } - if errors.Is(err, os.ErrProcessDone) { + if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { return false } - return errors.Is(err, syscall.EPERM) + // EPERM and any other error: process may still exist. + return true } diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 9b73a5d11..0d43225c4 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -1126,7 +1126,7 @@ func TestWorktreeIsStaleTrueForOldUntouchedTree(t *testing.T) { // only changes when an entry is added/removed/renamed directly inside it, not // when a long-running task edits an existing nested file. func TestCleanSkipsWorktreeWithRecentNestedActivity(t *testing.T) { - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) @@ -1167,7 +1167,8 @@ func TestCleanSkipsWorktreeWithRecentNestedActivity(t *testing.T) { runner := &fakeRunner{ results: []CommandResult{ {Stdout: repoRoot}, - {Stdout: "worktree " + activePath + "\n"}, + // Main worktree must be listed first: Clean keys repoDir off entries[0]. + {Stdout: "worktree " + repoRoot + "\nworktree " + activePath + "\n"}, {ExitCode: 0}, // worktree prune }, } @@ -1176,10 +1177,20 @@ func TestCleanSkipsWorktreeWithRecentNestedActivity(t *testing.T) { t.Fatalf("Clean failed: %v", err) } + // Nested activity makes worktreeIsStale false before any status/remove. + if got, want := runner.commandLine(1), "git worktree list --porcelain"; got != want { + t.Fatalf("call 1 = %q, want %q", got, want) + } + if got, want := runner.commandLine(2), "git worktree prune"; got != want { + t.Fatalf("call 2 = %q, want %q (not-stale skip before status/remove)", got, want) + } for _, call := range runner.calls { if len(call.args) > 0 && call.args[0] == "remove" { t.Fatalf("Clean removed an actively-edited worktree: %v", call.args) } + if len(call.args) > 0 && call.args[0] == "status" { + t.Fatalf("Clean probed status on a not-stale worktree: %v", call.args) + } } } @@ -1454,7 +1465,7 @@ func TestCleanRecoversExpiredLease(t *testing.T) { // left behind, so they still block removal. func TestCleanSkipsExpiredLeaseWithIgnoredData(t *testing.T) { deadPID := deadProcessPID(t) - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) @@ -1474,7 +1485,8 @@ func TestCleanSkipsExpiredLeaseWithIgnoredData(t *testing.T) { runner := &fakeRunner{ results: []CommandResult{ {Stdout: repoRoot}, - {Stdout: "worktree " + crashedPath + "\nlocked " + leaseReason(deadPID) + "\n"}, + // Main worktree must be listed first: Clean keys repoDir off entries[0]. + {Stdout: "worktree " + repoRoot + "\nworktree " + crashedPath + "\nlocked " + leaseReason(deadPID) + "\n"}, {Stdout: "!! ignored-data\n"}, // status --porcelain --ignored {ExitCode: 0}, // worktree prune }, @@ -1483,6 +1495,14 @@ func TestCleanSkipsExpiredLeaseWithIgnoredData(t *testing.T) { if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { t.Fatalf("Clean failed: %v", err) } + // Prove the dirty probe with --ignored actually ran (the guard under test), + // not that Clean skipped the entry for an unrelated ownership reason. + if got, want := runner.commandLine(2), "git status --porcelain --ignored"; got != want { + t.Fatalf("status call = %q, want %q", got, want) + } + if got, want := runner.commandLine(3), "git worktree prune"; got != want { + t.Fatalf("call 3 = %q, want %q", got, want) + } for _, call := range runner.calls { if len(call.args) > 1 && call.args[0] == "worktree" && (call.args[1] == "remove" || call.args[1] == "unlock") { t.Fatalf("Clean touched a crashed worktree holding ignored data: %v", call.args) @@ -1493,7 +1513,7 @@ func TestCleanSkipsExpiredLeaseWithIgnoredData(t *testing.T) { // TestCleanHonorsLiveLease: a lease whose recorded owner is still running is // never expired, regardless of staleness. func TestCleanHonorsLiveLease(t *testing.T) { - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) @@ -1513,7 +1533,8 @@ func TestCleanHonorsLiveLease(t *testing.T) { runner := &fakeRunner{ results: []CommandResult{ {Stdout: repoRoot}, - {Stdout: "worktree " + livePath + "\nlocked " + leaseReason(os.Getpid()) + "\n"}, + // Main worktree must be listed first: Clean keys repoDir off entries[0]. + {Stdout: "worktree " + repoRoot + "\nworktree " + livePath + "\nlocked " + leaseReason(os.Getpid()) + "\n"}, {ExitCode: 0}, // worktree prune }, } @@ -1521,6 +1542,13 @@ func TestCleanHonorsLiveLease(t *testing.T) { if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { t.Fatalf("Clean failed: %v", err) } + // Live lease short-circuits before status/remove: list then prune only. + if got, want := runner.commandLine(1), "git worktree list --porcelain"; got != want { + t.Fatalf("call 1 = %q, want %q", got, want) + } + if got, want := runner.commandLine(2), "git worktree prune"; got != want { + t.Fatalf("call 2 = %q, want %q (live lease skips status/remove)", got, want) + } for _, call := range runner.calls { if len(call.args) > 0 && (call.args[0] == "remove" || call.args[0] == "status") { t.Fatalf("Clean touched a worktree behind a live lease: %v", call.args) @@ -1544,18 +1572,12 @@ func deadProcessPID(t *testing.T) int { return cmd.Process.Pid } -// worktreeIsDirty must count files matched by .gitignore as dirty content: a -// worktree holding only ignored task data (credentials, generated drafts) has -// nothing to show in plain `git status --porcelain` and would otherwise pass -// as clean and be force-removed by Clean's staleness heuristic. This exercises -// the real git binary rather than the fake runner, so it also verifies -// --ignored actually changes git's answer, not just the command we send. // TestCleanHonorsTouchLiveness verifies that a stale worktree (mtime older // than maxAge) is skipped by Clean when it was recently touched by Prepare, // so a long-running but idle task that Prepare is still associated with is -// not wrongly force-removed. +// not wrongly force-removed. Fresh mtime alone is enough; no lock is required. func TestCleanHonorsTouchLiveness(t *testing.T) { - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) @@ -1573,11 +1595,22 @@ func TestCleanHonorsTouchLiveness(t *testing.T) { t.Fatal(err) } plantOwnershipMarker(t, touchedPath) + // Re-age the marker files planted above so only the subsequent touch + // refreshes liveness. + if err := filepath.WalkDir(touchedPath, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, twoDaysAgo, twoDaysAgo) + }); err != nil { + t.Fatal(err) + } runner := &fakeRunner{ results: []CommandResult{ {Stdout: repoRoot}, - {Stdout: "worktree " + touchedPath + "\nlocked " + leaseReason(os.Getpid()) + "\n"}, + // Main worktree must be listed first: Clean keys repoDir off entries[0]. + {Stdout: "worktree " + repoRoot + "\nworktree " + touchedPath + "\n"}, {ExitCode: 0}, // worktree prune (no-op) }, } @@ -1587,16 +1620,32 @@ func TestCleanHonorsTouchLiveness(t *testing.T) { t.Fatal(err) } - if err := Clean(context.Background(), Options{BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { t.Fatalf("Clean: %v", err) } - // The worktree should still exist: Clean skipped it because it's locked - // with a live PID, and touching it refreshes mtime. + // Fresh touch makes worktreeIsStale false before status/remove. + if got, want := runner.commandLine(1), "git worktree list --porcelain"; got != want { + t.Fatalf("call 1 = %q, want %q", got, want) + } + if got, want := runner.commandLine(2), "git worktree prune"; got != want { + t.Fatalf("call 2 = %q, want %q (fresh mtime skips status/remove)", got, want) + } + for _, call := range runner.calls { + if len(call.args) > 0 && (call.args[0] == "remove" || call.args[0] == "status") { + t.Fatalf("Clean touched a freshly-touched worktree: %v", call.args) + } + } if _, err := os.Stat(touchedPath); err != nil { t.Fatalf("worktree %s was incorrectly removed: %v", touchedPath, err) } } +// worktreeIsDirty must count files matched by .gitignore as dirty content: a +// worktree holding only ignored task data (credentials, generated drafts) has +// nothing to show in plain `git status --porcelain` and would otherwise pass +// as clean and be force-removed by Clean's staleness heuristic. This exercises +// the real git binary rather than the fake runner, so it also verifies +// --ignored actually changes git's answer, not just the command we send. func TestWorktreeIsDirtyCountsIgnoredFilesAsDirty(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") @@ -1718,7 +1767,7 @@ func TestCleanIgnoresWorktreeOutsideOwnedSubtree(t *testing.T) { // while waiting on a model, network, or user for far longer than the // staleness window, without ever writing to the tree again in that time. func TestCleanSkipsDirtyStaleWorktree(t *testing.T) { - tempDir := t.TempDir() + tempDir := physicalTestPath(t, t.TempDir()) baseDir := filepath.Join(tempDir, "zero-worktrees") repoRoot := filepath.Join(tempDir, "repo") repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) @@ -1738,7 +1787,8 @@ func TestCleanSkipsDirtyStaleWorktree(t *testing.T) { runner := &fakeRunner{ results: []CommandResult{ {Stdout: repoRoot}, - {Stdout: "worktree " + dirtyPath + "\n"}, + // Main worktree must be listed first: Clean keys repoDir off entries[0]. + {Stdout: "worktree " + repoRoot + "\nworktree " + dirtyPath + "\n"}, {Stdout: " M internal/pkg/handler.go\n"}, // status --porcelain: dirty {ExitCode: 0}, // worktree prune }, @@ -1748,6 +1798,13 @@ func TestCleanSkipsDirtyStaleWorktree(t *testing.T) { t.Fatalf("Clean failed: %v", err) } + // Prove the dirty status probe actually ran (the guard under test). + if got, want := runner.commandLine(2), "git status --porcelain"; got != want { + t.Fatalf("status call = %q, want %q", got, want) + } + if got, want := runner.commandLine(3), "git worktree prune"; got != want { + t.Fatalf("call 3 = %q, want %q", got, want) + } for _, call := range runner.calls { if len(call.args) > 0 && call.args[0] == "remove" { t.Fatalf("Clean removed a dirty worktree: %v", call.args) From 7be0e6fc1b72eb85bc74b54b7c3506cdd9a0554b Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:23:33 -0400 Subject: [PATCH 30/32] fix(secrets,worktrees): restore broad key redaction and legacy Clean safety Address human review on #855: keep sk- bodies with a digit filter instead of enumerated vendor prefixes, add a looser JWT form, restore the sk-test fixture, probe legacy ownership before dirty, treat non-INVALID Windows OpenProcess errors as alive, redact Abs/cwd release errors, and reclaim dead-owner leases on Prepare reuse. --- internal/cli/backends_test.go | 4 +- internal/cli/extensions_test.go | 2 +- internal/cli/hooks_manage_test.go | 2 +- internal/cli/mcp_commands_test.go | 2 +- internal/cli/sandbox_check_test.go | 4 +- internal/cli/workflow_test.go | 4 +- internal/cli/workflows.go | 4 +- internal/redaction/audit_fixes_test.go | 38 +++- internal/redaction/redaction.go | 28 ++- internal/redaction/redaction_test.go | 4 +- internal/secrets/boundary_test.go | 6 +- internal/secrets/scanner.go | 25 ++- internal/secrets/scanner_test.go | 56 ++++-- internal/selfverify/contracts_test.go | 2 +- internal/selfverify/selfverify_test.go | 2 +- internal/sessions/replay_test.go | 2 +- internal/verify/contracts_test.go | 2 +- internal/verify/verify_test.go | 4 +- internal/worktrees/worktrees.go | 88 ++++++-- internal/worktrees/worktrees_test.go | 190 +++++++++++++++--- internal/worktrees/worktrees_windows.go | 11 +- internal/worktrees/worktrees_windows_test.go | 15 ++ internal/zerocommands/backend_doctor_test.go | 2 +- .../zerocommands/backend_snapshots_test.go | 6 +- internal/zerocommands/contracts_test.go | 2 +- internal/zerogit/contracts_test.go | 2 +- internal/zerogit/zerogit_test.go | 10 +- 27 files changed, 407 insertions(+), 110 deletions(-) diff --git a/internal/cli/backends_test.go b/internal/cli/backends_test.go index 3a1f7fb4d..d86dfaf74 100644 --- a/internal/cli/backends_test.go +++ b/internal/cli/backends_test.go @@ -23,7 +23,7 @@ import ( func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T) { cwd := t.TempDir() - secret := "sk-proj-" + strings.Repeat("a", 24) + secret := "sk-proj-" + strings.Repeat("a", 23) + "0" deps := appDeps{ getwd: func() (string, error) { return cwd, nil }, resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) { @@ -161,7 +161,7 @@ func TestRunBackendsTextAndHelp(t *testing.T) { func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) { cwd := t.TempDir() - secret := "sk-proj-" + strings.Repeat("b", 24) + secret := "sk-proj-" + strings.Repeat("b", 23) + "0" deps := appDeps{ getwd: func() (string, error) { return cwd, nil }, resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) { diff --git a/internal/cli/extensions_test.go b/internal/cli/extensions_test.go index 00af4dd9b..0593ab576 100644 --- a/internal/cli/extensions_test.go +++ b/internal/cli/extensions_test.go @@ -78,7 +78,7 @@ func TestRunPluginsListsJSONAndText(t *testing.T) { } func TestRunHooksListsRedactedJSONAndText(t *testing.T) { - secret := "sk-proj-" + strings.Repeat("a", 24) + secret := "sk-proj-" + strings.Repeat("a", 23) + "0" result := hooks.LoadResult{ Config: hooks.Config{ Enabled: true, diff --git a/internal/cli/hooks_manage_test.go b/internal/cli/hooks_manage_test.go index 2fb08489c..96ea14f7a 100644 --- a/internal/cli/hooks_manage_test.go +++ b/internal/cli/hooks_manage_test.go @@ -87,7 +87,7 @@ func TestRunHooksAddRejectsUnknownEvent(t *testing.T) { func TestRunHooksAddJSONRedactsSecretArgs(t *testing.T) { cwd := t.TempDir() - secret := "sk-proj-" + strings.Repeat("z", 24) + secret := "sk-proj-" + strings.Repeat("z", 23) + "0" var stdout, stderr bytes.Buffer code := runHooksAdd([]string{"h1", "--event", "beforeTool", "--command", "sh", "--arg", "-c", "--arg", "echo " + secret, "--json"}, &stdout, &stderr, hooksManageDeps(cwd)) if code != exitSuccess { diff --git a/internal/cli/mcp_commands_test.go b/internal/cli/mcp_commands_test.go index df2b0c228..b9155a33c 100644 --- a/internal/cli/mcp_commands_test.go +++ b/internal/cli/mcp_commands_test.go @@ -516,7 +516,7 @@ func TestRunMCPRemovePreservesUnrelatedConfigFields(t *testing.T) { func TestRunMCPListRedactsURLCredentialsAndSensitiveQueryParams(t *testing.T) { cwd := t.TempDir() serverURL := "https://user:password@remote.example/mcp?access_token=secret-token&api_key=secret-key&safe=value#access_token=fragment-secret" - commandSecret := "sk-proj-" + strings.Repeat("a", 24) + commandSecret := "sk-proj-" + strings.Repeat("a", 23) + "0" deps := appDeps{ getwd: func() (string, error) { return cwd, nil }, resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) { diff --git a/internal/cli/sandbox_check_test.go b/internal/cli/sandbox_check_test.go index f7fb03ce8..45707c585 100644 --- a/internal/cli/sandbox_check_test.go +++ b/internal/cli/sandbox_check_test.go @@ -152,7 +152,7 @@ func TestRunSandboxCheckMatchedGrantRedactsReason(t *testing.T) { if _, err := store.Grant(sandbox.GrantInput{ ToolName: "read_file", Decision: sandbox.GrantAllow, - Reason: "approved with sk-proj-testsecret1234567890ab", + Reason: "approved with sk-test-secret1234567890", }); err != nil { t.Fatalf("seed grant: %v", err) } @@ -162,7 +162,7 @@ func TestRunSandboxCheckMatchedGrantRedactsReason(t *testing.T) { if exitCode != exitSuccess { t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String()) } - if strings.Contains(stdout.String(), "sk-proj-testsecret1234567890ab") { + if strings.Contains(stdout.String(), "sk-test-secret1234567890") { t.Fatalf("grant reason leaked a secret into the snapshot:\n%s", stdout.String()) } var payload struct { diff --git a/internal/cli/workflow_test.go b/internal/cli/workflow_test.go index ed9d73fa5..f47052393 100644 --- a/internal/cli/workflow_test.go +++ b/internal/cli/workflow_test.go @@ -93,7 +93,7 @@ func TestRunWorktreesPrepareReportsErrors(t *testing.T) { } func TestRunWorktreesPrepareRedactsPathsInOutput(t *testing.T) { - secret := "sk-proj-abcdefghijklmnopqrstuvwxyz" + secret := "sk-proj-abcdefghijklmnopqrstuvwxyz0" cwd := filepath.Join(t.TempDir(), secret, "repo") if err := os.MkdirAll(cwd, 0o700); err != nil { t.Fatal(err) @@ -427,7 +427,7 @@ func TestRunVerifyTextAndJSON(t *testing.T) { } func TestRunVerifyRedactsWorkspacePathsInOutput(t *testing.T) { - secret := "sk-proj-abcdefghijklmnopqrstuvwxyz" + secret := "sk-proj-abcdefghijklmnopqrstuvwxyz0" cwd := filepath.Join(t.TempDir(), secret, "workspace") if err := os.MkdirAll(cwd, 0o700); err != nil { t.Fatal(err) diff --git a/internal/cli/workflows.go b/internal/cli/workflows.go index 5dcb72831..854d71c3f 100644 --- a/internal/cli/workflows.go +++ b/internal/cli/workflows.go @@ -152,7 +152,7 @@ func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps // up front so the unlock target is unambiguous regardless of cwd. absPath, err := filepath.Abs(path) if err != nil { - return writeExecUsageError(stderr, err.Error()) + return writeExecUsageError(stderr, redactCLIString(err.Error())) } // Release falls back to Options.Cwd as git's working directory when the // worktree directory itself was deleted by hand instead of released; with @@ -168,7 +168,7 @@ func runWorktreesRelease(args []string, stdout io.Writer, stderr io.Writer, deps if cwdFlag != "" { workspaceRoot, err := resolveWorkspaceRoot(cwdFlag, deps) if err != nil { - return writeExecUsageError(stderr, err.Error()) + return writeExecUsageError(stderr, redactCLIString(err.Error())) } releaseOptions.Cwd = workspaceRoot } else if workspaceRoot, rootErr := resolveWorkspaceRoot("", deps); rootErr == nil { diff --git a/internal/redaction/audit_fixes_test.go b/internal/redaction/audit_fixes_test.go index e3a7ec4a6..dddf01249 100644 --- a/internal/redaction/audit_fixes_test.go +++ b/internal/redaction/audit_fixes_test.go @@ -135,12 +135,12 @@ func TestRedactString_MultiPartAuthSchemes(t *testing.T) { // redacted (by the token-format patterns), never the file:line prefix. func TestRedactString_PreservesFileLineColons(t *testing.T) { o := Options{} - in := " secret_test.go:12: token sk-proj-abcdefghijklmnopqrstuvwxyz" + in := " secret_test.go:12: token sk-proj-abcdefghijklmnopqrstuvwxyz0" out := RedactString(in, o) if !strings.Contains(out, "secret_test.go:12:") { t.Errorf("file:line prefix mangled: %q", out) } - if strings.Contains(out, "sk-proj-abcdefghijklmnopqrstuvwxyz") { + if strings.Contains(out, "sk-proj-abcdefghijklmnopqrstuvwxyz0") { t.Errorf("secret leaked: %q", out) } if !strings.Contains(out, RedactedSecret) { @@ -148,6 +148,40 @@ func TestRedactString_PreservesFileLineColons(t *testing.T) { } } +func TestRedactString_OpenAIKeyDigitFilterAndVendorPrefixes(t *testing.T) { + o := Options{} + // Vendor / fixture shapes that the enumerated-prefix approach missed. + for _, secret := range []string{ + "sk-test-secret1234567890", + "sk-fw-1SENTINEL_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sk-live-1SENTINELaaaaaaaaaa_bbbbbbbbbb-cccc", + } { + out := RedactString("approved with "+secret, o) + if strings.Contains(out, secret) { + t.Errorf("RedactString leaked %q: %q", secret, out) + } + } + // No-digit kebab phrase must survive (parity with secrets.Scan). + phrase := "sk-learn-machine-learning-model" + out := RedactString("testing "+phrase+" in text", o) + if !strings.Contains(out, phrase) { + t.Fatalf("digit filter over-redacted kebab phrase: %q", out) + } +} + +func TestRedactString_LooseJWTForms(t *testing.T) { + o := Options{} + for _, token := range []string{ + "eyJhbGciOiJIUzI1NiJ9.U0VOVElORUxwYXlsb2Fk.SENTINELsignature123", + "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.SENTINELencryptedkey.SENTINELiv", + } { + out := RedactString("auth="+token, o) + if strings.Contains(out, "eyJhbGci") { + t.Errorf("RedactString leaked jwt material from %q: %q", token, out) + } + } +} + // Patterns whose body class allows "-" must redact a secret that ends in "-" // right before a delimiter: a trailing \b anchor would force the engine to // drop that last character and leave the hyphen visible (parity with diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 1c1a531dd..0b5a8326e 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -68,6 +68,11 @@ var sensitiveKeys = map[string]struct{}{ "zero_api_key": {}, } +// openaiKeyPattern mirrors secrets.Scan's broad sk- body. Matches without a +// digit are left alone (kebab-case false positives); real keys always carry +// digits. Applied via ReplaceAllStringFunc rather than the plain list below. +var openaiKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`) + // textSecretPatterns mirror secrets.Scan for end-boundary behavior and the // shared high-confidence shapes. A leading \b keeps each pattern from firing // mid-word; a trailing \b is omitted so a secret followed by more word @@ -75,8 +80,9 @@ var sensitiveKeys = map[string]struct{}{ // and a secret that ends in "-" (allowed by some body classes) is fully // redacted rather than leaving the hyphen behind. glpat is redaction-only // (not in secrets.Scan); ASIA temporary access keys are kept alongside AKIA. +// openai keys are handled separately (digit filter). JWT has a strict form +// (both segments start with eyJ) and a looser three-segment form. var textSecretPatterns = []*regexp.Regexp{ - regexp.MustCompile(`\bsk-(?:proj-|svcacct-|admin-|or-v1-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}`), regexp.MustCompile(`\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}`), regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{22,}`), regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36,}`), @@ -85,6 +91,7 @@ var textSecretPatterns = []*regexp.Regexp{ regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`), regexp.MustCompile(`\b(?:AKIA|ASIA)[A-Z0-9]{16}`), regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`), + regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`), } var ( @@ -215,12 +222,31 @@ func RedactString(value string, options Options) string { } return parts[1] + parts[2] + "=" + replacement }) + // openai keys first so the digit filter can drop kebab-case false positives + // before any other pattern rewrites nearby text. + redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string { + if !secretMatchHasDigit(match) { + return match + } + return replacement + }) for _, pattern := range textSecretPatterns { redacted = pattern.ReplaceAllString(redacted, replacement) } return redacted } +// secretMatchHasDigit is the redaction-side twin of secrets.containsDigit: +// real sk- keys always embed a digit; pure letter/hyphen kebab phrases do not. +func secretMatchHasDigit(s string) bool { + for _, r := range s { + if r >= '0' && r <= '9' { + return true + } + } + return false +} + func RedactValue(value any, options Options) any { return redactReflect(reflect.ValueOf(value), redactionContext{ options: options, diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index 3ae228c8d..f8018daed 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -8,7 +8,7 @@ import ( func TestRedactStringCoversCommonSecretShapes(t *testing.T) { input := strings.Join([]string{ - `{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz"}`, + `{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz0"}`, "authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz123456", "https://zero:super-secret@example.test/path?token=glpat-abcdefghijklmnopqrstuvwxyz", "-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----", @@ -17,7 +17,7 @@ func TestRedactStringCoversCommonSecretShapes(t *testing.T) { got := RedactString(input, Options{ExtraSecretValues: []string{"super-secret"}}) for _, leaked := range []string{ - "sk-proj-abcdefghijklmnopqrstuvwxyz", + "sk-proj-abcdefghijklmnopqrstuvwxyz0", "ghp_abcdefghijklmnopqrstuvwxyz123456", "super-secret", "glpat-abcdefghijklmnopqrstuvwxyz", diff --git a/internal/secrets/boundary_test.go b/internal/secrets/boundary_test.go index e00e14e17..0df37ffb4 100644 --- a/internal/secrets/boundary_test.go +++ b/internal/secrets/boundary_test.go @@ -24,9 +24,9 @@ func TestScan_NoOverRedactionOnKebabWords(t *testing.T) { // the \b is satisfied by all of those, so the fix loses no real coverage. func TestScan_RealSecretsStillCaught(t *testing.T) { cases := []string{ - "export OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwx", - `token: "sk-abcdefghijklmnopqrstuvwxyz"`, - "key is sk-svcacct-abcdefghijklmnopqrstuvwx at the end", + "export OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwx0", + `token: "sk-abcdefghijklmnopqrstuvwxyz0"`, + "key is sk-svcacct-abcdefghijklmnopqrstuvwx0 at the end", "github_pat_11ABCDEFG0abcdefghijklmnopqrst", "creds AKIAIOSFODNN7EXAMPLE here", } diff --git a/internal/secrets/scanner.go b/internal/secrets/scanner.go index 165d206f5..40a475f22 100644 --- a/internal/secrets/scanner.go +++ b/internal/secrets/scanner.go @@ -46,11 +46,19 @@ var patterns = []pattern{ {"slack_token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`)}, {"google_api_key", regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}`)}, {"anthropic_key", regexp.MustCompile(`\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}`)}, - {"openai_key", regexp.MustCompile(`\bsk-(?:proj-|svcacct-|admin-|or-v1-)[A-Za-z0-9_-]{20,}|\bsk-[A-Za-z0-9]{20,}`)}, + // Broad body (allows - and _) so sk-proj-…, sk-or-v1-…, sk-fw-…, and + // legacy sk- all match. Scan drops matches with no digit so + // kebab-case false positives like sk-learn-machine-learning-model stay + // un-redacted (real keys always carry digits). + {"openai_key", regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`)}, // Match the ENTIRE PEM/OpenSSH block (header THROUGH the END marker, body // included) so redaction removes the key material, not just the header. {"private_key_block", regexp.MustCompile(`(?s)-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----.*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----`)}, + // Strict JWS (both header and payload are JSON, so both start with eyJ) + // plus a looser three-segment form for non-JSON payloads and the first + // three segments of a compact JWE. {"jwt", regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`)}, + {"jwt", regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`)}, } // Scan returns the distinct secrets found in text (deduplicated by match, @@ -62,6 +70,9 @@ func Scan(text string) []Finding { seen := map[string]Finding{} for _, p := range patterns { for _, m := range p.re.FindAllString(text, -1) { + if p.typ == "openai_key" && !containsDigit(m) { + continue + } if _, ok := seen[m]; !ok { seen[m] = Finding{Type: p.typ, Match: m} } @@ -83,6 +94,18 @@ func Scan(text string) []Finding { return out } +// containsDigit reports whether s has at least one ASCII digit. Used to drop +// openai_key regex hits that are pure kebab-case words (no digit) while still +// accepting every real sk- key format, which always embeds digits. +func containsDigit(s string) bool { + for _, r := range s { + if r >= '0' && r <= '9' { + return true + } + } + return false +} + // Redact replaces every detected secret in text with a typed placeholder and // returns the redacted text plus the findings. When nothing matches it returns // the input unchanged and a nil slice. diff --git a/internal/secrets/scanner_test.go b/internal/secrets/scanner_test.go index 230bfbb16..d201be1ba 100644 --- a/internal/secrets/scanner_test.go +++ b/internal/secrets/scanner_test.go @@ -123,19 +123,49 @@ func TestScanDetectsModernPrefixedOpenAIKeys(t *testing.T) { } func TestScanDetectsHyphenatedOpenAICompatibleKeys(t *testing.T) { - // OpenRouter's sk-or-v1- keys are hyphenated like the modern sk-proj- - // family; the legacy sk- branch does not match a "-" right - // after "sk-", so this format needs its own explicit prefix branch. - key := "sk-or-v1-1234567890abcdef1234567890abcdef1234567890abcdef1234" - redacted, findings := Redact("token=" + key) - if len(findings) != 1 || findings[0].Type != "openai_key" { - t.Fatalf("expected one openai_key finding for %q, got %#v", key, findings) - } - if strings.Contains(redacted, key) { - t.Fatalf("key leaked after redaction: %q", redacted) - } - if !strings.Contains(redacted, "[REDACTED:openai_key]") { - t.Fatalf("missing typed placeholder for %q: %q", key, redacted) + // Vendor-prefixed keys (OpenRouter sk-or-v1-, Fireworks sk-fw-, live keys) + // use hyphens beyond the enumerated prefixes. The broad sk-[A-Za-z0-9_-] + // pattern plus the digit filter must catch them without listing every vendor. + for _, key := range []string{ + "sk-or-v1-1234567890abcdef1234567890abcdef1234567890abcdef1234", + // Vendor prefixes not in any allow-list; digit filter still accepts them. + "sk-fw-1SENTINEL_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sk-live-1SENTINELaaaaaaaaaa_bbbbbbbbbb-cccc", + "sk-test-secret1234567890", + } { + redacted, findings := Redact("token=" + key) + if len(findings) != 1 || findings[0].Type != "openai_key" { + t.Fatalf("expected one openai_key finding for %q, got %#v", key, findings) + } + if strings.Contains(redacted, key) { + t.Fatalf("key leaked after redaction: %q", redacted) + } + if !strings.Contains(redacted, "[REDACTED:openai_key]") { + t.Fatalf("missing typed placeholder for %q: %q", key, redacted) + } + } +} + +func TestScanDetectsLooseJWTForms(t *testing.T) { + // Strict form requires the second segment to start with eyJ (JSON payload). + // Compact JWS with a non-JSON payload and the first three segments of a + // compact JWE still need to redact. + cases := []string{ + "eyJhbGciOiJIUzI1NiJ9.U0VOVElORUxwYXlsb2Fk.SENTINELsignature123", + "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.SENTINELencryptedkey.SENTINELiv.SENTINELciphertext.SENTINELtag12345", + } + for _, token := range cases { + redacted, findings := Redact("auth=" + token) + if len(findings) == 0 { + t.Fatalf("expected jwt finding for %q, got none", token) + } + if findings[0].Type != "jwt" { + t.Fatalf("expected jwt type for %q, got %#v", token, findings) + } + // At least the leading three segments (or the full three-part JWS) must go. + if strings.Contains(redacted, "eyJhbGci") { + t.Fatalf("jwt header leaked after redaction: %q", redacted) + } } } diff --git a/internal/selfverify/contracts_test.go b/internal/selfverify/contracts_test.go index c6f8237cd..c87ce12f8 100644 --- a/internal/selfverify/contracts_test.go +++ b/internal/selfverify/contracts_test.go @@ -9,7 +9,7 @@ import ( ) func TestSnapshotFromReportPreservesAttemptsAndRedactsRemediation(t *testing.T) { - secret := "sk-proj-abcdefghijklmnopqrstuvwxyz" + secret := "sk-proj-abcdefghijklmnopqrstuvwxyz0" report := Report{ Root: "/repo/" + secret, StartedAt: "2026-06-06T11:00:00Z", diff --git a/internal/selfverify/selfverify_test.go b/internal/selfverify/selfverify_test.go index a53301220..323cbfac9 100644 --- a/internal/selfverify/selfverify_test.go +++ b/internal/selfverify/selfverify_test.go @@ -110,7 +110,7 @@ func TestRunDefaultsToOneAttempt(t *testing.T) { func TestRunStopsOnRemediatorErrorAndRedacts(t *testing.T) { root := t.TempDir() - secret := "sk-proj-abcdefghijklmnopqrstuvwxyz" + secret := "sk-proj-abcdefghijklmnopqrstuvwxyz0" plan := verify.Plan{Root: root, Checks: []verify.Check{{ID: "go.test", Name: "Go tests", Command: []string{"go", "test", "./..."}}}} runner := &fakeVerifyRunner{results: []verify.CommandResult{{ExitCode: 1, Stdout: "FAIL\n"}}} diff --git a/internal/sessions/replay_test.go b/internal/sessions/replay_test.go index dc7ee4b1b..2a36196e3 100644 --- a/internal/sessions/replay_test.go +++ b/internal/sessions/replay_test.go @@ -131,7 +131,7 @@ func TestStorePlansCompactionWindow(t *testing.T) { } func TestStoreCompactionShapesSensitivePermissionEvents(t *testing.T) { - secret := "sk-proj-abcdefghijklmnopqrstuvwxyz" + secret := "sk-proj-abcdefghijklmnopqrstuvwxyz0" store := NewStore(StoreOptions{RootDir: t.TempDir()}) session, err := store.Create(CreateInput{SessionID: "compactsafe"}) if err != nil { diff --git a/internal/verify/contracts_test.go b/internal/verify/contracts_test.go index 9826b189b..6bd8d3a54 100644 --- a/internal/verify/contracts_test.go +++ b/internal/verify/contracts_test.go @@ -8,7 +8,7 @@ import ( ) func TestSnapshotFromReportRedactsLogsAndBuildsEvents(t *testing.T) { - secret := "sk-proj-abcdefghijklmnopqrstuvwxyz" + secret := "sk-proj-abcdefghijklmnopqrstuvwxyz0" report := Report{ Root: "/workspace/" + secret, StartedAt: "2026-06-06T10:00:00Z", diff --git a/internal/verify/verify_test.go b/internal/verify/verify_test.go index 5a9291614..7db1b2e21 100644 --- a/internal/verify/verify_test.go +++ b/internal/verify/verify_test.go @@ -91,7 +91,7 @@ func TestRunParsesStructuredFailureSummary(t *testing.T) { ExitCode: 1, Stdout: strings.Join([]string{ "--- FAIL: TestSecret (0.00s)", - " secret_test.go:12: token sk-proj-abcdefghijklmnopqrstuvwxyz", + " secret_test.go:12: token sk-proj-abcdefghijklmnopqrstuvwxyz0", "FAIL", }, "\n"), }}} @@ -108,7 +108,7 @@ func TestRunParsesStructuredFailureSummary(t *testing.T) { if !strings.Contains(lines, "TestSecret") || !strings.Contains(lines, "[REDACTED]") { t.Fatalf("expected redacted failure summary lines, got %q", lines) } - if strings.Contains(lines, "sk-proj-abcdefghijklmnopqrstuvwxyz") { + if strings.Contains(lines, "sk-proj-abcdefghijklmnopqrstuvwxyz0") { t.Fatalf("failure summary leaked secret: %q", lines) } if report.Results[0].TestSummary == nil { diff --git a/internal/worktrees/worktrees.go b/internal/worktrees/worktrees.go index b35b514a4..b42d2ee7a 100644 --- a/internal/worktrees/worktrees.go +++ b/internal/worktrees/worktrees.go @@ -145,15 +145,28 @@ func Prepare(ctx context.Context, options Options) (Result, error) { // 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 means another run is still using the path: two - // live runs must not share one supposedly isolated checkout (they - // would edit the same tree, and whichever exits first would release - // the single Git lock out from under the other), so reject it rather - // than hand the second caller an unprotected shared workspace. + // 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) } @@ -294,6 +307,35 @@ func lockWorktree(ctx context.Context, runGit GitRunner, repoRoot string, target 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 @@ -849,13 +891,25 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { } if worktreeIsStale(statPath, cutoff) { - // An explicit release is the owner's completion signal, so a - // worktree holding only gitignored residue (node_modules, build - // output) after release is reclaimable - otherwise every released - // worktree with such artifacts leaks forever. An expired lease is - // NOT a completion signal (the task may have died mid-work), so - // there ignored files still count as live data. - if worktreeIsDirty(ctx, runGit, statPath, expiredLease) { + // 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 @@ -864,16 +918,8 @@ func Clean(ctx context.Context, options Options, maxAge time.Duration) error { // gets committed/cleaned (no longer dirty) or unlocked. continue } - // Require the ownership marker Prepare itself persists before - // force-touching anything below. For legacy Zero worktrees created - // before markers existed, verify they are in repoDir with a Zero - // lease and migrate them by writing the marker. - owned, err := hasOwnershipMarker(ctx, runGit, statPath) - if err != nil { - continue - } if !owned { - if isLegacyZeroWorktree(ctx, runGit, statPath, repoDir, entry) { + if legacy { if writeErr := writeOwnershipMarker(ctx, runGit, statPath); writeErr == nil { owned = true } diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 0d43225c4..99f1db0a3 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -417,6 +417,66 @@ func TestPrepareRejectsWorktreeLockedByAnotherRun(t *testing.T) { } } +// TestPrepareReclaimsDeadOwnerLeaseOnReuse: a Zero lease whose recorded PID is +// dead must not brick the worktree name. Prepare's reuse path unlocks and +// re-locks so a SIGKILLed prior run does not force a manual release. +func TestPrepareReclaimsDeadOwnerLeaseOnReuse(t *testing.T) { + root := physicalTestPath(t, t.TempDir()) + base := physicalTestPath(t, t.TempDir()) + sourceGit := filepath.Join(root, ".git") + if err := os.MkdirAll(sourceGit, 0o700); err != nil { + t.Fatal(err) + } + existing := filepath.Join(base, "zero-worktree-"+repoKey(root), "reuse-me") + if err := os.MkdirAll(filepath.Join(existing, ".git"), 0o700); err != nil { + t.Fatal(err) + } + deadPID := deadProcessPID(t) + runner := &fakeRunner{ + autoAbsoluteGitDir: true, + results: []CommandResult{ + {Stdout: root + "\n"}, + {Stdout: "worktree " + root + "\n"}, + {Stdout: "main\n"}, + {Stdout: "abc1234\n"}, + {Stdout: sourceGit + "\n"}, + {Stdout: sourceGit + "\n"}, + {ExitCode: 128, Stderr: "fatal: '" + existing + "' is already locked"}, + // reclaimDeadOwnerLease: list shows a dead-owner Zero lease + {Stdout: "worktree " + root + "\nworktree " + existing + "\nlocked " + leaseReason(deadPID) + "\n"}, + {ExitCode: 0}, // unlock + {ExitCode: 0}, // re-lock + }, + } + + result, err := Prepare(context.Background(), Options{ + Cwd: root, + Name: "reuse-me", + BaseDir: base, + LeasePID: os.Getpid(), + RunGit: runner.Run, + }) + if err != nil { + t.Fatalf("Prepare should reclaim a dead-owner lease, got %v", err) + } + if !result.Reused || !result.LockAcquired { + t.Fatalf("expected reused+locked result, got %#v", result) + } + unlocked := false + locked := 0 + for _, call := range runner.calls { + if len(call.args) >= 2 && call.args[0] == "worktree" && call.args[1] == "unlock" { + unlocked = true + } + if len(call.args) >= 2 && call.args[0] == "worktree" && call.args[1] == "lock" { + locked++ + } + } + if !unlocked || locked < 2 { + t.Fatalf("expected unlock then re-lock, unlocked=%v lockCalls=%d calls=%#v", unlocked, locked, runner.calls) + } +} + // TestPrepareRollsBackWorktreeOnLockFailure pins the fix for a newly created // worktree being left behind, unleased, when the lock call after `git // worktree add` fails for a reason other than a concurrent racer (an actual @@ -872,6 +932,28 @@ func (runner *fakeRunner) commandLine(index int) string { return "git " + strings.Join(runner.calls[index].args, " ") } +// firstStatusCommand returns the first `git status ...` call, if any. Clean +// inserts ownership-marker checks before the dirty probe, so status is no +// longer at a fixed call index. +func (runner *fakeRunner) firstStatusCommand() string { + for i := range runner.calls { + if line := runner.commandLine(i); strings.HasPrefix(line, "git status ") { + return line + } + } + return "" +} + +// hasGitCommand reports whether any recorded call equals want (full "git …" form). +func (runner *fakeRunner) hasGitCommand(want string) bool { + for i := range runner.calls { + if runner.commandLine(i) == want { + return true + } + } + return false +} + // plantOwnershipMarker writes Prepare's ownership marker under path's .git // admin dir so Release/Clean treat the fixture as zero-created. func plantOwnershipMarker(t *testing.T, path string) { @@ -957,33 +1039,23 @@ func TestCleanPrunesStaleWorktrees(t *testing.T) { t.Fatalf("Clean failed: %v", err) } - // toplevel + list + status(stale) + marker + HEAD + for-each-ref + remove + prune // young is skipped as not-stale before any status/marker work. - if len(runner.calls) != 8 { - t.Fatalf("expected 8 git calls, got %d: %#v", len(runner.calls), runner.calls) - } + // For the stale path: marker check, then status, then HEAD preservation, remove, prune. if runner.commandLine(0) != "git rev-parse --show-toplevel" { t.Errorf("call 0 = %q", runner.commandLine(0)) } if runner.commandLine(1) != "git worktree list --porcelain" { t.Errorf("call 1 = %q", runner.commandLine(1)) } - expectedStatusCall := "git status --porcelain" - if runner.commandLine(2) != expectedStatusCall { - t.Errorf("call 2 = %q, want %q", runner.commandLine(2), expectedStatusCall) - } - if runner.commandLine(3) != "git rev-parse --absolute-git-dir" { - t.Errorf("call 3 = %q, want ownership marker check", runner.commandLine(3)) - } - if runner.commandLine(4) != "git rev-parse HEAD" { - t.Errorf("call 4 = %q, want the pre-removal HEAD-preservation check", runner.commandLine(4)) + if got, want := runner.firstStatusCommand(), "git status --porcelain"; got != want { + t.Errorf("status call = %q, want %q", got, want) } expectedRemoveCall := "git worktree remove --force " + filepath.Clean(stalePath) - if runner.commandLine(6) != expectedRemoveCall { - t.Errorf("call 6 = %q, want %q", runner.commandLine(6), expectedRemoveCall) + if !runner.hasGitCommand(expectedRemoveCall) { + t.Errorf("missing remove call %q in %#v", expectedRemoveCall, runner.calls) } - if runner.commandLine(7) != "git worktree prune" { - t.Errorf("call 7 = %q", runner.commandLine(7)) + if !runner.hasGitCommand("git worktree prune") { + t.Errorf("missing prune call in %#v", runner.calls) } } @@ -1385,7 +1457,7 @@ func TestCleanReclaimsReleasedWorktreeWithOnlyIgnoredFiles(t *testing.T) { t.Fatalf("Clean failed: %v", err) } - if got, want := runner.commandLine(2), "git status --porcelain"; got != want { + if got, want := runner.firstStatusCommand(), "git status --porcelain"; got != want { t.Fatalf("status call = %q, want %q (released worktrees must not count ignored residue)", got, want) } removed := false @@ -1447,16 +1519,14 @@ func TestCleanRecoversExpiredLease(t *testing.T) { t.Fatalf("Clean failed: %v", err) } - // status is still call 2; ownership marker check is call 3; then HEAD, - // for-each-ref, unlock, remove, prune. - if got, want := runner.commandLine(2), "git status --porcelain --ignored"; got != want { + if got, want := runner.firstStatusCommand(), "git status --porcelain --ignored"; got != want { t.Fatalf("status call = %q, want %q (a crashed lease never signaled completion)", got, want) } - if got, want := runner.commandLine(6), "git worktree unlock "+filepath.Clean(crashedPath); got != want { - t.Fatalf("call 6 = %q, want lease recovery %q", got, want) + if !runner.hasGitCommand("git worktree unlock " + filepath.Clean(crashedPath)) { + t.Fatalf("missing lease recovery unlock, calls=%#v", runner.calls) } - if got, want := runner.commandLine(7), "git worktree remove --force "+filepath.Clean(crashedPath); got != want { - t.Fatalf("call 7 = %q, want %q", got, want) + if !runner.hasGitCommand("git worktree remove --force " + filepath.Clean(crashedPath)) { + t.Fatalf("missing remove after lease recovery, calls=%#v", runner.calls) } } @@ -1497,11 +1567,11 @@ func TestCleanSkipsExpiredLeaseWithIgnoredData(t *testing.T) { } // Prove the dirty probe with --ignored actually ran (the guard under test), // not that Clean skipped the entry for an unrelated ownership reason. - if got, want := runner.commandLine(2), "git status --porcelain --ignored"; got != want { + if got, want := runner.firstStatusCommand(), "git status --porcelain --ignored"; got != want { t.Fatalf("status call = %q, want %q", got, want) } - if got, want := runner.commandLine(3), "git worktree prune"; got != want { - t.Fatalf("call 3 = %q, want %q", got, want) + if !runner.hasGitCommand("git worktree prune") { + t.Fatalf("missing prune call, calls=%#v", runner.calls) } for _, call := range runner.calls { if len(call.args) > 1 && call.args[0] == "worktree" && (call.args[1] == "remove" || call.args[1] == "unlock") { @@ -1799,14 +1869,14 @@ func TestCleanSkipsDirtyStaleWorktree(t *testing.T) { } // Prove the dirty status probe actually ran (the guard under test). - if got, want := runner.commandLine(2), "git status --porcelain"; got != want { + if got, want := runner.firstStatusCommand(), "git status --porcelain"; got != want { t.Fatalf("status call = %q, want %q", got, want) } - if got, want := runner.commandLine(3), "git worktree prune"; got != want { - t.Fatalf("call 3 = %q, want %q", got, want) + if !runner.hasGitCommand("git worktree prune") { + t.Fatalf("missing prune call, calls=%#v", runner.calls) } for _, call := range runner.calls { - if len(call.args) > 0 && call.args[0] == "remove" { + if len(call.args) > 1 && call.args[0] == "worktree" && call.args[1] == "remove" { t.Fatalf("Clean removed a dirty worktree: %v", call.args) } } @@ -1932,7 +2002,7 @@ func TestCleanMigratesAndReclaimsLegacyZeroWorktrees(t *testing.T) { results: []CommandResult{ {Stdout: repoRoot}, {Stdout: "worktree " + repoRoot + "\nworktree " + legacyPath + "\n"}, - {ExitCode: 0}, // status --porcelain --ignored: clean + {ExitCode: 0}, // status --porcelain --ignored: clean (legacy gets includeIgnored) {Stdout: "deadbeef"}, // rev-parse HEAD {Stdout: "refs/heads/main"}, // for-each-ref --contains (reachable) {ExitCode: 0}, // worktree remove --force @@ -1944,6 +2014,11 @@ func TestCleanMigratesAndReclaimsLegacyZeroWorktrees(t *testing.T) { t.Fatalf("Clean failed: %v", err) } + // Legacy unlocked worktrees must use --ignored: they predate Release, so + // unlocked is not a completion signal. + if got, want := runner.firstStatusCommand(), "git status --porcelain --ignored"; got != want { + t.Fatalf("status call = %q, want %q (legacy dirty probe must count ignored files)", got, want) + } removed := false for _, call := range runner.calls { if len(call.args) >= 2 && call.args[0] == "worktree" && call.args[1] == "remove" { @@ -1954,3 +2029,52 @@ func TestCleanMigratesAndReclaimsLegacyZeroWorktrees(t *testing.T) { t.Fatal("Clean did not reclaim a legacy pre-upgrade Zero worktree lacking an ownership marker") } } + +// TestCleanSkipsLegacyWorktreeWithIgnoredData: a pre-upgrade unlocked worktree +// with only gitignored contents (.env, node_modules) must not be force-removed. +// Clean used to probe without --ignored first and treated legacy as released. +func TestCleanSkipsLegacyWorktreeWithIgnoredData(t *testing.T) { + tempDir := physicalTestPath(t, t.TempDir()) + baseDir := filepath.Join(tempDir, "zero-worktrees") + repoRoot := filepath.Join(tempDir, "repo") + repoDir := filepath.Join(baseDir, "zero-worktree-"+repoKey(repoRoot)) + + legacyPath := filepath.Join(repoDir, "legacy-task") + if err := os.MkdirAll(legacyPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(repoRoot, 0o755); err != nil { + t.Fatal(err) + } + twoDaysAgo := time.Now().Add(-48 * time.Hour) + if err := filepath.WalkDir(legacyPath, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, twoDaysAgo, twoDaysAgo) + }); err != nil { + t.Fatal(err) + } + + runner := &fakeRunner{ + autoAbsoluteGitDir: true, + results: []CommandResult{ + {Stdout: repoRoot}, + {Stdout: "worktree " + repoRoot + "\nworktree " + legacyPath + "\n"}, + {Stdout: "!! .env\n"}, // status --porcelain --ignored: ignored residue + {ExitCode: 0}, // worktree prune + }, + } + + if err := Clean(context.Background(), Options{Cwd: repoRoot, BaseDir: baseDir, RunGit: runner.Run}, 24*time.Hour); err != nil { + t.Fatalf("Clean failed: %v", err) + } + if got, want := runner.firstStatusCommand(), "git status --porcelain --ignored"; got != want { + t.Fatalf("status call = %q, want %q", got, want) + } + for _, call := range runner.calls { + if len(call.args) >= 2 && call.args[0] == "worktree" && call.args[1] == "remove" { + t.Fatalf("Clean must not remove a legacy worktree with ignored data, calls=%#v", runner.calls) + } + } +} diff --git a/internal/worktrees/worktrees_windows.go b/internal/worktrees/worktrees_windows.go index f35474e86..ae7c90fd7 100644 --- a/internal/worktrees/worktrees_windows.go +++ b/internal/worktrees/worktrees_windows.go @@ -31,11 +31,10 @@ func osProcessAlive(pid int) bool { } // openProcessErrorMeansAlive classifies an OpenProcess failure. Only -// ERROR_ACCESS_DENIED means a process with this PID exists (owned by another -// user, or protected by policy) but we lack rights to query it - fail closed -// by treating that ambiguity as alive. Any other error (typically -// ERROR_INVALID_PARAMETER, for a PID that names no running process) means the -// PID genuinely does not exist. +// ERROR_INVALID_PARAMETER means the PID names no running process (dead). +// Every other error - access denied under another user, handle pressure, +// out-of-memory, and any other ambiguity - fails closed as alive, matching +// processAlive's contract and the POSIX sibling. func openProcessErrorMeansAlive(err error) bool { - return errors.Is(err, windows.ERROR_ACCESS_DENIED) + return !errors.Is(err, windows.ERROR_INVALID_PARAMETER) } diff --git a/internal/worktrees/worktrees_windows_test.go b/internal/worktrees/worktrees_windows_test.go index d2fa2f9be..6dcb194c0 100644 --- a/internal/worktrees/worktrees_windows_test.go +++ b/internal/worktrees/worktrees_windows_test.go @@ -22,6 +22,21 @@ func TestOpenProcessErrorMeansAliveOnInvalidParameter(t *testing.T) { } } +func TestOpenProcessErrorMeansAliveOnAmbiguousErrors(t *testing.T) { + // Any error other than INVALID_PARAMETER is ambiguity under + // processAlive's fail-closed contract (handle pressure, OOM, etc.). + for _, err := range []error{ + windows.ERROR_ACCESS_DENIED, + windows.ERROR_NOT_ENOUGH_MEMORY, + windows.ERROR_NO_SYSTEM_RESOURCES, + windows.ERROR_TOO_MANY_OPEN_FILES, + } { + if !openProcessErrorMeansAlive(err) { + t.Fatalf("%v must be treated as alive", err) + } + } +} + func TestOsProcessAliveReportsLiveSelf(t *testing.T) { if !osProcessAlive(os.Getpid()) { t.Fatal("current process must report alive") diff --git a/internal/zerocommands/backend_doctor_test.go b/internal/zerocommands/backend_doctor_test.go index 1ea9db56e..845f40be2 100644 --- a/internal/zerocommands/backend_doctor_test.go +++ b/internal/zerocommands/backend_doctor_test.go @@ -11,7 +11,7 @@ import ( ) func TestNewBackendDoctorReportSurfacesDiagnosticsAndActions(t *testing.T) { - secret := "sk-proj-" + strings.Repeat("a", 24) + secret := "sk-proj-" + strings.Repeat("a", 23) + "0" report := NewBackendDoctorReport(BackendDoctorInput{ MCP: config.MCPConfig{Servers: map[string]config.MCPServerConfig{ "remote": { diff --git a/internal/zerocommands/backend_snapshots_test.go b/internal/zerocommands/backend_snapshots_test.go index ff3951ecc..a0be71d87 100644 --- a/internal/zerocommands/backend_snapshots_test.go +++ b/internal/zerocommands/backend_snapshots_test.go @@ -89,7 +89,7 @@ func TestMCPServerSnapshotWithCountsMergesRuntimeCounts(t *testing.T) { } func TestMCPServerSnapshotRedactsSecretCommand(t *testing.T) { - secret := "sk-proj-" + strings.Repeat("d", 24) + secret := "sk-proj-" + strings.Repeat("d", 23) + "0" server := mcp.Server{ Name: "leaky", Type: mcp.ServerTypeStdio, @@ -201,7 +201,7 @@ func TestHookSnapshotFromDefinitionPreservesPositionForRedactedArgs(t *testing.T } func TestHookSnapshotFromDefinitionRedactsSecretCommand(t *testing.T) { - secret := "sk-proj-" + strings.Repeat("c", 24) + secret := "sk-proj-" + strings.Repeat("c", 23) + "0" def := hooks.Definition{ ID: "hook-secret-command", Event: hooks.EventAfterTool, @@ -400,7 +400,7 @@ func TestPluginSnapshotFromPluginCollapsesSlicesToCounts(t *testing.T) { } func TestPluginSnapshotRedactsOperatorFacingStrings(t *testing.T) { - secret := "sk-proj-" + strings.Repeat("e", 24) + secret := "sk-proj-" + strings.Repeat("e", 23) + "0" plugin := plugins.LoadedPlugin{ ID: "plugin-" + secret, Name: "Docs " + secret, diff --git a/internal/zerocommands/contracts_test.go b/internal/zerocommands/contracts_test.go index 3da774376..4e5479f77 100644 --- a/internal/zerocommands/contracts_test.go +++ b/internal/zerocommands/contracts_test.go @@ -52,7 +52,7 @@ func TestConfigSnapshotRedactsProviderURLsAndResolvesAPIModels(t *testing.T) { } func TestConfigSnapshotRedactsProviderWarnings(t *testing.T) { - secret := "sk-proj-abcdefghijklmnopqrstuvwxyz" + secret := "sk-proj-abcdefghijklmnopqrstuvwxyz0" resolved := config.ResolvedConfig{ Providers: []config.ProviderProfile{ { diff --git a/internal/zerogit/contracts_test.go b/internal/zerogit/contracts_test.go index b8aee3981..672c232ee 100644 --- a/internal/zerogit/contracts_test.go +++ b/internal/zerogit/contracts_test.go @@ -6,7 +6,7 @@ import ( ) func TestSnapshotFromSummaryRedactsDiffAndBuildsEvents(t *testing.T) { - secret := "sk-proj-abcdefghijklmnopqrstuvwxyz" + secret := "sk-proj-abcdefghijklmnopqrstuvwxyz0" summary := ChangeSummary{ Root: "/repo/" + secret, Branch: "feature/" + secret, diff --git a/internal/zerogit/zerogit_test.go b/internal/zerogit/zerogit_test.go index cfb479e6e..1e177faaf 100644 --- a/internal/zerogit/zerogit_test.go +++ b/internal/zerogit/zerogit_test.go @@ -23,7 +23,7 @@ func TestInspectSummarizesChangesAndRedactsDiff(t *testing.T) { {}, {}, {Stdout: " internal/verify/verify.go | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n"}, - {Stdout: "diff --git a/internal/verify/verify.go b/internal/verify/verify.go\n+token sk-proj-abcdefghijklmnopqrstuvwxyz\n"}, + {Stdout: "diff --git a/internal/verify/verify.go b/internal/verify/verify.go\n+token sk-proj-abcdefghijklmnopqrstuvwxyz0\n"}, }} summary, err := Inspect(context.Background(), InspectOptions{ @@ -50,7 +50,7 @@ func TestInspectSummarizesChangesAndRedactsDiff(t *testing.T) { if summary.Files[1].Path != "internal/zerogit/zerogit.go" || summary.Files[1].Status != "untracked" || !summary.Files[1].Untracked { t.Fatalf("unexpected untracked file summary: %#v", summary.Files[1]) } - if strings.Contains(summary.Diff, "sk-proj-abcdefghijklmnopqrstuvwxyz") || !strings.Contains(summary.Diff, "[REDACTED]") { + if strings.Contains(summary.Diff, "sk-proj-abcdefghijklmnopqrstuvwxyz0") || !strings.Contains(summary.Diff, "[REDACTED]") { t.Fatalf("expected redacted diff, got %q", summary.Diff) } if !summary.Truncated { @@ -279,8 +279,8 @@ func TestInspectBaseRefUsesThreeDotDiff(t *testing.T) { {Stdout: "feature/m5\n"}, // rev-parse --abbrev-ref HEAD {Stdout: "abc1234\n"}, // rev-parse --short HEAD {Stdout: "M\ta.txt\nA\tb.txt\n"}, // diff --name-status main...HEAD - {Stdout: " a.txt | 1 +\n b.txt | 1 +\n 2 files changed, 2 insertions(+)\n"}, // diff --stat main...HEAD - {Stdout: "diff --git a/internal/changes/changes.go b/internal/changes/changes.go\n+token sk-proj-abcdefghijklmnopqrstuvwxyz\n"}, // diff main...HEAD + {Stdout: " a.txt | 1 +\n b.txt | 1 +\n 2 files changed, 2 insertions(+)\n"}, // diff --stat main...HEAD + {Stdout: "diff --git a/internal/changes/changes.go b/internal/changes/changes.go\n+token sk-proj-abcdefghijklmnopqrstuvwxyz0\n"}, // diff main...HEAD }} summary, err := Inspect(context.Background(), InspectOptions{ @@ -311,7 +311,7 @@ func TestInspectBaseRefUsesThreeDotDiff(t *testing.T) { if summary.Files[1].Path != "b.txt" || summary.Files[1].Status != "added" { t.Fatalf("unexpected second file: %#v", summary.Files[1]) } - if strings.Contains(summary.Diff, "sk-proj-abcdefghijklmnopqrstuvwxyz") || !strings.Contains(summary.Diff, "[REDACTED]") { + if strings.Contains(summary.Diff, "sk-proj-abcdefghijklmnopqrstuvwxyz0") || !strings.Contains(summary.Diff, "[REDACTED]") { t.Fatalf("expected redacted diff, got %q", summary.Diff) } if !summary.Truncated { From ebc83b75389a066e133a45bf3aa01cd0704b20e5 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:32:03 -0400 Subject: [PATCH 31/32] fix(secrets,redaction): always redact known OpenAI key prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alphabet-only sk-proj-/sk-svcacct-/sk-admin- tokens are still credentials; keep the digit filter only for unknown sk- vendor forms so kebab phrases like sk-learn-… stay un-redacted. --- internal/redaction/audit_fixes_test.go | 11 +++++++++++ internal/redaction/redaction.go | 21 +++++++++++++++------ internal/secrets/scanner.go | 16 ++++++++++++++-- internal/secrets/scanner_test.go | 18 ++++++++++++++++++ 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/internal/redaction/audit_fixes_test.go b/internal/redaction/audit_fixes_test.go index dddf01249..7f42db23b 100644 --- a/internal/redaction/audit_fixes_test.go +++ b/internal/redaction/audit_fixes_test.go @@ -161,6 +161,17 @@ func TestRedactString_OpenAIKeyDigitFilterAndVendorPrefixes(t *testing.T) { t.Errorf("RedactString leaked %q: %q", secret, out) } } + // Known OpenAI prefixes redact even with an alphabet-only body. + for _, secret := range []string{ + "sk-proj-abcdefghijklmnopqrstuvwxyz", + "sk-svcacct-abcdefghijklmnopqrstuvwx", + "sk-admin-abcdefghijklmnopqrstuvwxyz", + } { + out := RedactString("token="+secret, o) + if strings.Contains(out, secret) { + t.Errorf("alphabet-only known OpenAI form leaked %q: %q", secret, out) + } + } // No-digit kebab phrase must survive (parity with secrets.Scan). phrase := "sk-learn-machine-learning-model" out := RedactString("testing "+phrase+" in text", o) diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 0b5a8326e..29a767dc2 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -68,9 +68,10 @@ var sensitiveKeys = map[string]struct{}{ "zero_api_key": {}, } -// openaiKeyPattern mirrors secrets.Scan's broad sk- body. Matches without a -// digit are left alone (kebab-case false positives); real keys always carry -// digits. Applied via ReplaceAllStringFunc rather than the plain list below. +// openaiKeyPattern mirrors secrets.Scan's broad sk- body. Known OpenAI +// prefixes (sk-proj-/sk-svcacct-/sk-admin-) are always redacted; other sk- +// matches without a digit are left alone (kebab-case false positives). +// Applied via ReplaceAllStringFunc rather than the plain list below. var openaiKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`) // textSecretPatterns mirror secrets.Scan for end-boundary behavior and the @@ -222,10 +223,10 @@ func RedactString(value string, options Options) string { } return parts[1] + parts[2] + "=" + replacement }) - // openai keys first so the digit filter can drop kebab-case false positives + // openai keys first so the filter can drop kebab-case false positives // before any other pattern rewrites nearby text. redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string { - if !secretMatchHasDigit(match) { + if !knownOpenAIKeyPrefix(match) && !secretMatchHasDigit(match) { return match } return replacement @@ -236,8 +237,16 @@ func RedactString(value string, options Options) string { return redacted } +// knownOpenAIKeyPrefix is the redaction-side twin of secrets.knownOpenAIKeyPrefix: +// known OpenAI-issued forms redact even with an alphabet-only body. +func knownOpenAIKeyPrefix(match string) bool { + return strings.HasPrefix(match, "sk-proj-") || + strings.HasPrefix(match, "sk-svcacct-") || + strings.HasPrefix(match, "sk-admin-") +} + // secretMatchHasDigit is the redaction-side twin of secrets.containsDigit: -// real sk- keys always embed a digit; pure letter/hyphen kebab phrases do not. +// unknown sk- vendor keys always embed a digit; pure letter/hyphen kebab phrases do not. func secretMatchHasDigit(s string) bool { for _, r := range s { if r >= '0' && r <= '9' { diff --git a/internal/secrets/scanner.go b/internal/secrets/scanner.go index 40a475f22..ffea2febb 100644 --- a/internal/secrets/scanner.go +++ b/internal/secrets/scanner.go @@ -70,7 +70,10 @@ func Scan(text string) []Finding { seen := map[string]Finding{} for _, p := range patterns { for _, m := range p.re.FindAllString(text, -1) { - if p.typ == "openai_key" && !containsDigit(m) { + // Drop pure kebab FPs (sk-learn-…) unless the match is a known + // OpenAI-issued prefix form (sk-proj-/sk-svcacct-/sk-admin-), which + // we always treat as credentials even when the body has no digit. + if p.typ == "openai_key" && !knownOpenAIKeyPrefix(m) && !containsDigit(m) { continue } if _, ok := seen[m]; !ok { @@ -96,7 +99,7 @@ func Scan(text string) []Finding { // containsDigit reports whether s has at least one ASCII digit. Used to drop // openai_key regex hits that are pure kebab-case words (no digit) while still -// accepting every real sk- key format, which always embeds digits. +// accepting vendor keys that always embed digits. func containsDigit(s string) bool { for _, r := range s { if r >= '0' && r <= '9' { @@ -106,6 +109,15 @@ func containsDigit(s string) bool { return false } +// knownOpenAIKeyPrefix reports whether match is a known OpenAI-issued sk- +// form. Those prefixes are redacted even when the body has no digit; unknown +// sk- vendor forms still need a digit so kebab phrases stay un-redacted. +func knownOpenAIKeyPrefix(match string) bool { + return strings.HasPrefix(match, "sk-proj-") || + strings.HasPrefix(match, "sk-svcacct-") || + strings.HasPrefix(match, "sk-admin-") +} + // Redact replaces every detected secret in text with a typed placeholder and // returns the redacted text plus the findings. When nothing matches it returns // the input unchanged and a nil slice. diff --git a/internal/secrets/scanner_test.go b/internal/secrets/scanner_test.go index d201be1ba..c5a1c96f8 100644 --- a/internal/secrets/scanner_test.go +++ b/internal/secrets/scanner_test.go @@ -283,3 +283,21 @@ func TestScanIgnoresKebabCaseStartingWithSk(t *testing.T) { t.Errorf("expected no match for non-secret kebab-case phrase %q, got: %#v", phrase, findings) } } + +func TestScanDetectsAlphabetOnlyKnownOpenAIPrefixes(t *testing.T) { + // Known OpenAI-issued prefixes redact even when the body has no digit. + // Unknown sk- forms still require a digit (see TestScanIgnoresKebabCase…). + for _, key := range []string{ + "sk-proj-abcdefghijklmnopqrstuvwxyz", + "sk-svcacct-abcdefghijklmnopqrstuvwx", + "sk-admin-abcdefghijklmnopqrstuvwxyz", + } { + redacted, findings := Redact("token=" + key) + if len(findings) != 1 || findings[0].Type != "openai_key" { + t.Fatalf("expected one openai_key for alphabet-only %q, got %#v", key, findings) + } + if strings.Contains(redacted, key) { + t.Fatalf("alphabet-only known OpenAI form leaked: %q", redacted) + } + } +} From d2030d6b957eff3970e7ba426e3452aabf522a35 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:59:28 -0400 Subject: [PATCH 32/32] Preserve digit-free legacy keys during redaction. Refs #855 --- internal/redaction/audit_fixes_test.go | 4 ++-- internal/redaction/redaction.go | 9 +++++---- internal/redaction/redaction_test.go | 4 ++-- internal/secrets/boundary_test.go | 6 +++--- internal/secrets/scanner.go | 15 ++++++++------- internal/tui/command_output_test.go | 8 ++++---- internal/tui/command_polish_test.go | 4 ++-- internal/tui/model_test.go | 4 ++-- internal/worktrees/worktrees_test.go | 7 +++++++ 9 files changed, 35 insertions(+), 26 deletions(-) diff --git a/internal/redaction/audit_fixes_test.go b/internal/redaction/audit_fixes_test.go index 7f42db23b..28f9fc0f9 100644 --- a/internal/redaction/audit_fixes_test.go +++ b/internal/redaction/audit_fixes_test.go @@ -135,12 +135,12 @@ func TestRedactString_MultiPartAuthSchemes(t *testing.T) { // redacted (by the token-format patterns), never the file:line prefix. func TestRedactString_PreservesFileLineColons(t *testing.T) { o := Options{} - in := " secret_test.go:12: token sk-proj-abcdefghijklmnopqrstuvwxyz0" + in := " secret_test.go:12: token sk-proj-abcdefghijklmnopqrstuvwxyz" out := RedactString(in, o) if !strings.Contains(out, "secret_test.go:12:") { t.Errorf("file:line prefix mangled: %q", out) } - if strings.Contains(out, "sk-proj-abcdefghijklmnopqrstuvwxyz0") { + if strings.Contains(out, "sk-proj-abcdefghijklmnopqrstuvwxyz") { t.Errorf("secret leaked: %q", out) } if !strings.Contains(out, RedactedSecret) { diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 29a767dc2..24685eca9 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -70,7 +70,8 @@ var sensitiveKeys = map[string]struct{}{ // openaiKeyPattern mirrors secrets.Scan's broad sk- body. Known OpenAI // prefixes (sk-proj-/sk-svcacct-/sk-admin-) are always redacted; other sk- -// matches without a digit are left alone (kebab-case false positives). +// digit-free matches with an interior hyphen are left alone (kebab-case false +// positives), while digit-free legacy sk- credentials are still redacted. // Applied via ReplaceAllStringFunc rather than the plain list below. var openaiKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`) @@ -226,7 +227,8 @@ func RedactString(value string, options Options) string { // openai keys first so the filter can drop kebab-case false positives // before any other pattern rewrites nearby text. redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string { - if !knownOpenAIKeyPrefix(match) && !secretMatchHasDigit(match) { + if !knownOpenAIKeyPrefix(match) && !secretMatchHasDigit(match) && + strings.Contains(strings.TrimPrefix(match, "sk-"), "-") { return match } return replacement @@ -245,8 +247,7 @@ func knownOpenAIKeyPrefix(match string) bool { strings.HasPrefix(match, "sk-admin-") } -// secretMatchHasDigit is the redaction-side twin of secrets.containsDigit: -// unknown sk- vendor keys always embed a digit; pure letter/hyphen kebab phrases do not. +// secretMatchHasDigit is the redaction-side twin of secrets.containsDigit. func secretMatchHasDigit(s string) bool { for _, r := range s { if r >= '0' && r <= '9' { diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go index f8018daed..3ae228c8d 100644 --- a/internal/redaction/redaction_test.go +++ b/internal/redaction/redaction_test.go @@ -8,7 +8,7 @@ import ( func TestRedactStringCoversCommonSecretShapes(t *testing.T) { input := strings.Join([]string{ - `{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz0"}`, + `{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz"}`, "authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz123456", "https://zero:super-secret@example.test/path?token=glpat-abcdefghijklmnopqrstuvwxyz", "-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----", @@ -17,7 +17,7 @@ func TestRedactStringCoversCommonSecretShapes(t *testing.T) { got := RedactString(input, Options{ExtraSecretValues: []string{"super-secret"}}) for _, leaked := range []string{ - "sk-proj-abcdefghijklmnopqrstuvwxyz0", + "sk-proj-abcdefghijklmnopqrstuvwxyz", "ghp_abcdefghijklmnopqrstuvwxyz123456", "super-secret", "glpat-abcdefghijklmnopqrstuvwxyz", diff --git a/internal/secrets/boundary_test.go b/internal/secrets/boundary_test.go index 0df37ffb4..e00e14e17 100644 --- a/internal/secrets/boundary_test.go +++ b/internal/secrets/boundary_test.go @@ -24,9 +24,9 @@ func TestScan_NoOverRedactionOnKebabWords(t *testing.T) { // the \b is satisfied by all of those, so the fix loses no real coverage. func TestScan_RealSecretsStillCaught(t *testing.T) { cases := []string{ - "export OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwx0", - `token: "sk-abcdefghijklmnopqrstuvwxyz0"`, - "key is sk-svcacct-abcdefghijklmnopqrstuvwx0 at the end", + "export OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwx", + `token: "sk-abcdefghijklmnopqrstuvwxyz"`, + "key is sk-svcacct-abcdefghijklmnopqrstuvwx at the end", "github_pat_11ABCDEFG0abcdefghijklmnopqrst", "creds AKIAIOSFODNN7EXAMPLE here", } diff --git a/internal/secrets/scanner.go b/internal/secrets/scanner.go index ffea2febb..50886992d 100644 --- a/internal/secrets/scanner.go +++ b/internal/secrets/scanner.go @@ -47,9 +47,9 @@ var patterns = []pattern{ {"google_api_key", regexp.MustCompile(`\bAIza[0-9A-Za-z\-_]{35,}`)}, {"anthropic_key", regexp.MustCompile(`\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}`)}, // Broad body (allows - and _) so sk-proj-…, sk-or-v1-…, sk-fw-…, and - // legacy sk- all match. Scan drops matches with no digit so - // kebab-case false positives like sk-learn-machine-learning-model stay - // un-redacted (real keys always carry digits). + // legacy sk- all match. Scan drops digit-free matches only when + // the body contains an interior hyphen, which preserves kebab-case false + // positives without excluding digit-free legacy credentials. {"openai_key", regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`)}, // Match the ENTIRE PEM/OpenSSH block (header THROUGH the END marker, body // included) so redaction removes the key material, not just the header. @@ -73,7 +73,8 @@ func Scan(text string) []Finding { // Drop pure kebab FPs (sk-learn-…) unless the match is a known // OpenAI-issued prefix form (sk-proj-/sk-svcacct-/sk-admin-), which // we always treat as credentials even when the body has no digit. - if p.typ == "openai_key" && !knownOpenAIKeyPrefix(m) && !containsDigit(m) { + if p.typ == "openai_key" && !knownOpenAIKeyPrefix(m) && !containsDigit(m) && + strings.Contains(strings.TrimPrefix(m, "sk-"), "-") { continue } if _, ok := seen[m]; !ok { @@ -97,9 +98,9 @@ func Scan(text string) []Finding { return out } -// containsDigit reports whether s has at least one ASCII digit. Used to drop -// openai_key regex hits that are pure kebab-case words (no digit) while still -// accepting vendor keys that always embed digits. +// containsDigit reports whether s has at least one ASCII digit. Together with +// an interior-hyphen check, it distinguishes kebab-case false positives from +// digit-free legacy sk- credentials. func containsDigit(s string) bool { for _, r := range s { if r >= '0' && r <= '9' { diff --git a/internal/tui/command_output_test.go b/internal/tui/command_output_test.go index ba36235c0..018f11185 100644 --- a/internal/tui/command_output_test.go +++ b/internal/tui/command_output_test.go @@ -98,7 +98,7 @@ func TestFormatCommandCardRedactsTokenLikeText(t *testing.T) { {Key: "api key", Value: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz"}, }, Lines: []string{ - "google token AIza1234567890abcdef", + "google token AIza1234567890abcdefghijklmnopqrstuvwxyz", }, Rows: []commandRow{ {Text: "shell used sk-proj-row-secret-value"}, @@ -111,7 +111,7 @@ func TestFormatCommandCardRedactsTokenLikeText(t *testing.T) { for _, secret := range []string{ "sk-proj-summary-secret-value", "sk-ant-api03-abcdefghijklmnopqrstuvwxyz", - "AIza1234567890abcdef", + "AIza1234567890abcdefghijklmnopqrstuvwxyz", "sk-proj-row-secret-value", "sk-proj-action-secret-value", } { @@ -187,7 +187,7 @@ func TestFormatCommandOutputRedactsTokenLikeText(t *testing.T) { Lines: []string{ "bash [allow] - sk-proj-sensitive-token-value approved shell", "anthropic: sk-ant-api03-abcdefghijklmnopqrstuvwxyz", - "google: AIza1234567890abcdef", + "google: AIza1234567890abcdefghijklmnopqrstuvwxyz", }, }}, }) @@ -195,7 +195,7 @@ func TestFormatCommandOutputRedactsTokenLikeText(t *testing.T) { for _, secret := range []string{ "sk-proj-sensitive-token-value", "sk-ant-api03-abcdefghijklmnopqrstuvwxyz", - "AIza1234567890abcdef", + "AIza1234567890abcdefghijklmnopqrstuvwxyz", } { if strings.Contains(got, secret) { t.Fatalf("expected token-like text to be redacted, got:\n%s", got) diff --git a/internal/tui/command_polish_test.go b/internal/tui/command_polish_test.go index 2758171fe..bfaf10def 100644 --- a/internal/tui/command_polish_test.go +++ b/internal/tui/command_polish_test.go @@ -253,7 +253,7 @@ func TestContextAndPermissionsCommandsRenderProductState(t *testing.T) { if _, err := store.Grant(sandbox.GrantInput{ ToolName: "bash", Decision: sandbox.GrantAllow, - Reason: "sk-proj-sensitive approved shell", + Reason: "sk-proj-sensitive-credential-value approved shell", }); err != nil { t.Fatalf("Grant returned error: %v", err) } @@ -312,7 +312,7 @@ func TestContextAndPermissionsCommandsRenderProductState(t *testing.T) { } { assertContains(t, permissionText, want) } - assertNotContains(t, permissionText, "sk-proj-sensitive") + assertNotContains(t, permissionText, "sk-proj-sensitive-credential-value") assertNotContains(t, permissionText, "status: ok") assertNotContains(t, permissionText, "Permission mode:") } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 2c4dbfb13..622e5caad 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -372,7 +372,7 @@ func TestPermissionsCommandListsPersistentSandboxGrants(t *testing.T) { if _, err := store.Grant(sandbox.GrantInput{ ToolName: "bash", Decision: sandbox.GrantAllow, - Reason: "sk-proj-sensitive trusted shell", + Reason: "sk-proj-sensitive-credential-value trusted shell", }); err != nil { t.Fatalf("Grant bash returned error: %v", err) } @@ -406,7 +406,7 @@ func TestPermissionsCommandListsPersistentSandboxGrants(t *testing.T) { } { assertContains(t, text, want) } - assertNotContains(t, text, "sk-proj-sensitive") + assertNotContains(t, text, "sk-proj-sensitive-credential-value") assertNotContains(t, text, "status: ok") assertNotContains(t, text, "Permission mode:") } diff --git a/internal/worktrees/worktrees_test.go b/internal/worktrees/worktrees_test.go index 99f1db0a3..f824eb72d 100644 --- a/internal/worktrees/worktrees_test.go +++ b/internal/worktrees/worktrees_test.go @@ -403,6 +403,8 @@ func TestPrepareRejectsWorktreeLockedByAnotherRun(t *testing.T) { {Stdout: sourceGit + "\n"}, {Stdout: sourceGit + "\n"}, {ExitCode: 128, Stderr: "fatal: '" + existing + "' is already locked, reason: zero: active task worktree"}, + // reclaimDeadOwnerLease must preserve a lease owned by a live PID. + {Stdout: "worktree " + root + "\nworktree " + existing + "\nlocked " + leaseReason(os.Getpid()) + "\n"}, }, } @@ -415,6 +417,11 @@ func TestPrepareRejectsWorktreeLockedByAnotherRun(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "locked by another active run") { t.Fatalf("Prepare must reject an in-use lease, got %v", err) } + for _, call := range runner.calls { + if len(call.args) >= 2 && call.args[0] == "worktree" && call.args[1] == "unlock" { + t.Fatalf("Prepare unlocked a live owner's lease: %#v", runner.calls) + } + } } // TestPrepareReclaimsDeadOwnerLeaseOnReuse: a Zero lease whose recorded PID is