Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project>/.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/<xx>/<yyy>/X/<bundle-id>.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).
Expand Down
36 changes: 34 additions & 2 deletions internal/scanner/walk.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package scanner
import (
"context"
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand Down
58 changes: 58 additions & 0 deletions internal/scanner/walk_traversal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading