From 8bd6283cf92e7e8b4a8744e58197009fc3910c92 Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Sat, 7 Nov 2026 15:24:44 +0900 Subject: [PATCH] feat: report artifacts inside Claude Code worktrees as caution Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018n8bPd5boqPRf3FdWigV8c --- docs/ARCHITECTURE.md | 2 +- docs/SPEC.md | 2 + internal/scanner/walk.go | 36 ++++++++++++++- internal/scanner/walk_traversal_test.go | 58 +++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6ed0a81..204f1c2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,7 +63,7 @@ A `walkEcosystem` table declares how one ecosystem participates in the shared wa | any-depth name | `__pycache__`, `.venv` | matches the directory name anywhere under the nearest project root | | any-depth suffix | `*.egg-info` | matches a directory-name suffix anywhere under the nearest project root | -Per directory, the engine matches artifact rules against the **nearest** enclosing project root of each active ecosystem (table order, first match wins), emits the artifact and skips its subtree on a match, and otherwise reads the directory once to detect new project roots (added to the context stack it hands its subdirectories) before descending into its child directories. The walk runs on 8 walk slots of its own (separate from the sizing slots, so the walk is not starved while the stat scanners size); results are sorted by (table order, path) at the end, so they do not depend on the split. Artifact matching runs **before** the hidden-directory check so compound rules ending in a hidden segment (`android/.gradle`) still match; unmatched hidden directories below the scan root are descended into only when an active ecosystem lists the name as an artifact. +Per directory, the engine matches artifact rules against the **nearest** enclosing project root of each active ecosystem (table order, first match wins), emits the artifact and skips its subtree on a match, and otherwise reads the directory once to detect new project roots (added to the context stack it hands its subdirectories) before descending into its child directories. The walk runs on 8 walk slots of its own (separate from the sizing slots, so the walk is not starved while the stat scanners size); results are sorted by (table order, path) at the end, so they do not depend on the split. Artifact matching runs **before** the hidden-directory check so compound rules ending in a hidden segment (`android/.gradle`) still match; unmatched hidden directories below the scan root are descended into only when an active ecosystem lists the name as an artifact. The one exception is `.claude` outside the home directory, entered only to reach its `worktrees` directory; the engine lowers `safe` artifacts inside to `caution`. Tables can also: diff --git a/docs/SPEC.md b/docs/SPEC.md index a437781..0b6e9b0 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -588,6 +588,8 @@ Each entry that is `caution` carries a consequence-of-deletion note in `recommen Only genuine caches under those trees (e.g. `~/.cargo/registry`, `~/Library/Application Support/Cursor/Cache`) or dedicated cache dirs remain eligible. +**Claude Code worktrees**: project scans enter `/.claude/worktrees` and report the artifacts of the projects inside (never the `~/.claude` tree or a worktree itself). An artifact that would be `safe` is `caution` there, because a session using the worktree must reinstall or rebuild. + **Homebrew**: with brew installed, reported as one item reclaimed by `HOMEBREW_NO_AUTOREMOVE=1 brew cleanup` (the cleanup brew runs itself after upgrades: old formula versions, stale downloads, logs; casks excluded). Size is brew's dry-run estimate. The item replaces the `~/Library/Caches/Homebrew` entry when `brew --cache` is that path; with nothing to free, only the directory is reported. **Browser Temp (macOS)**: Chromium-family browsers (Chrome, Brave, Edge, Arc, Vivaldi, …) copy their own bundle to `/private/var/folders///X/.code_sign_clone/` on launch to verify their code signature, removing it on normal exit. Force-killed processes — typically headless automation like lighthouse or puppeteer — leave zombie copies that accumulate (observed: 92 copies / 156 GB of mostly shared blocks). diff --git a/internal/scanner/walk.go b/internal/scanner/walk.go index 589d3b3..32939ab 100644 --- a/internal/scanner/walk.go +++ b/internal/scanner/walk.go @@ -3,6 +3,7 @@ package scanner import ( "context" "io/fs" + "os" "path/filepath" "slices" "sort" @@ -118,6 +119,19 @@ func (w *walkScanner) Scan(ctx context.Context, root string) ([]model.ScanResult return runWalk(ctx, root, []walkEcosystem{w.table}) } +const ( + claudeDirName = ".claude" + claudeWorktreesDirName = "worktrees" + + claudeWorktreeRecommendation = "inside a Claude Code worktree; a session using it must reinstall dependencies or rebuild" +) + +// inClaudeWorktree reports whether dir lies inside a Claude Code worktree. +func inClaudeWorktree(dir string) bool { + sep := string(filepath.Separator) + return strings.Contains(dir, sep+claudeDirName+sep+claudeWorktreesDirName+sep) +} + // projectContext is one entry of the walk's active-project stack: a detected // project root plus the artifact rules applying beneath it. type projectContext struct { @@ -153,6 +167,8 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model. } } + home, _ := os.UserHomeDir() // empty on error: no parent directory equals it + var mu sync.Mutex // guards results var results []model.ScanResult numTables := len(tables) @@ -192,6 +208,10 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model. result.Recommendation = n.Recommendation result.CloneAware, result.CloneSource = n.CloneAware, n.CloneSource } + if inClaudeWorktree(dir) && result.Safety == model.SafetySafe { + result.Safety = model.SafetyCaution + result.Recommendation = claudeWorktreeRecommendation + } mu.Lock() results = append(results, result) ReportProgress(ctx, len(results)) @@ -204,8 +224,20 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model. if installedPackageDirNames[name] { return false } - // The root is always entered: the hidden-dir rule applies below it. - return dir == root || !strings.HasPrefix(name, ".") || hiddenDescend[name] + // The root is always entered: the hidden-dir rules apply below it. + if dir == root { + return true + } + // A project's .claude is entered only to reach its worktrees. + // ~/.claude is user state and stays excluded. + parent := filepath.Dir(dir) + if name == claudeDirName { + return parent != home + } + if filepath.Base(parent) == claudeDirName && parent != root { + return name == claudeWorktreesDirName + } + return !strings.HasPrefix(name, ".") || hiddenDescend[name] }, Entries: func(dir string, entries []fs.DirEntry, stack []projectContext) ([]projectContext, bool) { names := make(map[string]bool, len(entries)) diff --git a/internal/scanner/walk_traversal_test.go b/internal/scanner/walk_traversal_test.go index 43a16c8..e94f069 100644 --- a/internal/scanner/walk_traversal_test.go +++ b/internal/scanner/walk_traversal_test.go @@ -165,6 +165,64 @@ func TestWalkScan_HiddenNamedRootIsScanned(t *testing.T) { } } +// TestWalkScan_ClaudeWorktreeArtifactIsCaution pins that a safe artifact in a +// Claude Code worktree is reported, as caution with its consequence. +func TestWalkScan_ClaudeWorktreeArtifactIsCaution(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + root := t.TempDir() + worktree := filepath.Join(root, "proj", ".claude", "worktrees", "wt") + touch(t, filepath.Join(worktree, "package.json")) + deps := filepath.Join(worktree, "node_modules") + mkdirAll(t, deps) + + results, err := WalkScan(context.Background(), root, model.EcoNode) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Path != deps { + t.Fatalf("got %v, want only %s", paths(results), deps) + } + if results[0].Safety != model.SafetyCaution || results[0].Recommendation == "" { + t.Errorf("got safety %s, recommendation %q; want caution with a recommendation", results[0].Safety, results[0].Recommendation) + } +} + +// TestWalkScan_ClaudeDirOutsideWorktreesIsSkipped pins that entering .claude +// for worktrees does not expose its other directories. +func TestWalkScan_ClaudeDirOutsideWorktreesIsSkipped(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + root := t.TempDir() + skill := filepath.Join(root, "proj", ".claude", "skills", "s") + touch(t, filepath.Join(skill, "package.json")) + mkdirAll(t, filepath.Join(skill, "node_modules")) + + results, err := WalkScan(context.Background(), root, model.EcoNode) + if err != nil { + t.Fatal(err) + } + if len(results) != 0 { + t.Errorf("got %v, want no results", paths(results)) + } +} + +// TestWalkScan_HomeClaudeDirIsSkipped pins that ~/.claude stays excluded, even +// for a worktrees directory inside it. +func TestWalkScan_HomeClaudeDirIsSkipped(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + worktree := filepath.Join(home, ".claude", "worktrees", "wt") + touch(t, filepath.Join(worktree, "package.json")) + mkdirAll(t, filepath.Join(worktree, "node_modules")) + + results, err := WalkScan(context.Background(), home, model.EcoNode) + if err != nil { + t.Fatal(err) + } + if len(results) != 0 { + t.Errorf("got %v, want no results", paths(results)) + } +} + // TestWalkScan_InstalledPackageRootIsPruned pins that a scan root named like // an installed-package tree is not read: a package's own node_modules inside // it is not a deletion target.