From 73069931ab5597e33848421a06f36ebb597a8994 Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Fri, 30 Oct 2026 10:19:23 +0900 Subject: [PATCH] feat: check only the named paths in clean --item and add clean --json clean --item rescanned the whole --path scope before keeping the named artifacts, taking ~28s on a home directory. The scan now carries the item paths in its context: the project walk enters only the directories leading to them, only they are sized, and brew's dry-run runs only for the Homebrew item, so a single item is checked in about a second with the same result. clean --json (with --yes) prints each item's result and the trashed and freed totals; errors before deleting stay on stderr as text. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 2 + docs/SPEC.md | 42 +++++- internal/cli/clean.go | 131 ++++++++++++----- internal/cli/clean_test.go | 76 ++++++++++ internal/cli/pipeline.go | 12 ++ internal/output/json.go | 58 ++++++++ internal/scanner/global.go | 2 +- internal/scanner/global_homebrew.go | 15 ++ internal/scanner/items_test.go | 162 ++++++++++++++++++++++ internal/scanner/scanner.go | 21 +++ internal/scanner/walk.go | 22 +++ plugins/devclean/skills/devclean/SKILL.md | 11 +- 12 files changed, 509 insertions(+), 45 deletions(-) create mode 100644 internal/scanner/items_test.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5e829f1..6ed0a81 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,8 @@ Two scanner families share the `Scanner` interface: Scanners report progress via context-attached callbacks: the walk batch reports under a single "projects" label, stat scanners under their own names. +**Limited scan**: `scanner.WithItems` attaches a set of paths to the context (`clean --item`). The walk enters only the directories that are an item or an ancestor of one, still running every check on them with all their entries read, so an item is matched in the same project context as in a full scan. `sizePending` measures only the items, and the Homebrew dry-run runs only when an item is the Homebrew item. Other results may come back unsized; the pipeline drops them before classifying. `internal/scanner/items_test.go` checks the limited result against the full one for every directory of a fixture. + **Sizing** collects these figures per artifact through an in-process walk (`scanner.Measure`): - **disk** — allocated blocks (`st_blocks×512`), sparse-accurate and matching `du`. Directories contribute their own blocks (real on ext4, ~0 on APFS). diff --git a/docs/SPEC.md b/docs/SPEC.md index f423ed4..30f0e3c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -111,6 +111,9 @@ devclean clean --eco node --min-size 50MB # Clean only the named artifact(s) devclean clean --eco global --item ~/.npm --yes --include-caution +# Results as JSON for scripting / AI agents +devclean clean --item ~/workspace/app/node_modules --yes --json + # Also run ecosystem-native cleanup commands (e.g. xcrun simctl delete unavailable) devclean clean --eco xcode --vendor-cleanup --yes ``` @@ -134,6 +137,11 @@ field of `scan --json`; a leading `~/` is expanded and relative paths resolve against the working directory. Other filters and the [`--yes` safety gate](#non-interactive-safety---yes----include-caution) still apply. +With `--item`, `clean` checks again only the named paths instead of the whole +scan scope: the project walk enters only the directories on the way to them +and only they are sized, so each is found and classified as a full scan would. +Homebrew's dry-run runs only when an item is the Homebrew item. + `clean` exits with status 1 before deleting anything when any `--item` matches no scanned artifact, or when `--yes` would leave a named artifact uncleaned (each is listed with its reason): @@ -164,6 +172,38 @@ terminal it exits with status 1 before scanning: Error: stdin is not a terminal: pass --yes to clean without prompts, or --dry-run --yes to preview ``` +#### JSON (`--json`) + +`clean --json` prints one JSON object instead of the text output, and needs +`--yes` (it has no prompts to answer). Errors before deleting are not JSON: +they go to stderr as text with exit status 1, as in text mode. + +```json +{ + "dry_run": false, + "trashed_size": 691171328, + "freed_size": 0, + "results": [ + { + "path": "/Users/you/workspace/my-app/node_modules", + "ecosystem": "node", + "size": 691171328, + "result": "trashed" + } + ], + "skipped": [ + { "path": "/Users/you/workspace/my-app/.venv", "reason": "caution" } + ] +} +``` + +- `results` — every artifact cleaned (or, with `--dry-run`, previewed): `path`, `ecosystem`, `size`, `label` when known, `result` and, for a failure, `error`. +- `result` — `trashed` (moved to the Trash), `freed` (deleted permanently, or reclaimed by the item's own command), `failed`; with `--dry-run`, `would_clean` or `would_fail`. +- `trashed_size`, `freed_size` — total `size` of the `trashed` and the `freed` results, hard-linked blocks counted once; `0` with `--dry-run`. +- `skipped` — artifacts the `--yes` safety gate kept, with `reason` `caution` or `protected`; omitted when none. +- `vendor_cleanups` — with `--vendor-cleanup`, each command's `ecosystem`, `description`, `command` and `result` (`ran`, `failed` with `error`, or `would_run` with `--dry-run`); omitted when none ran. +- Exit status is 0 when items fail during deletion, as in text mode; check each `result`. + #### Vendor Cleanups Some ecosystems ship official cleanup commands that are stricter or safer than @@ -200,7 +240,7 @@ The clean command uses a tree selector matching scan's output style: #### Flow -1. Scan and classify (with progress spinner) +1. Scan and classify (with progress spinner; with `--item`, only the named paths) 2. Tree selector: select projects/artifacts interactively 3. Choose: Move to Trash / Permanently delete / Cancel 4. Execute with per-artifact results — items with an attached `DeleteMethod` run it instead of path removal (see [Deletion Strategy](ARCHITECTURE.md#deletion-strategy)) diff --git a/internal/cli/clean.go b/internal/cli/clean.go index 35dc2b9..9828dfe 100644 --- a/internal/cli/clean.go +++ b/internal/cli/clean.go @@ -13,6 +13,7 @@ import ( "github.com/mattn/go-isatty" "github.com/ohing504/devclean/internal/cleaner" "github.com/ohing504/devclean/internal/model" + "github.com/ohing504/devclean/internal/output" "github.com/ohing504/devclean/internal/pathutil" "github.com/ohing504/devclean/internal/scanner" "github.com/ohing504/devclean/internal/ui" @@ -32,6 +33,7 @@ func newCleanCmd() *cobra.Command { includeCaution bool vendorCleanup bool items []string + jsonOutput bool ) cmd := &cobra.Command{ @@ -42,24 +44,49 @@ func newCleanCmd() *cobra.Command { devclean clean --eco node --safe --dry-run devclean clean --eco node --force devclean clean --eco global --item ~/.npm --yes --include-caution # only this artifact - devclean clean --eco xcode --vendor-cleanup --yes # also runs 'xcrun simctl delete unavailable'`, + devclean clean --eco xcode --vendor-cleanup --yes # also runs 'xcrun simctl delete unavailable' + devclean clean --item ~/workspace/app/node_modules --yes --json # results as JSON`, RunE: func(cmd *cobra.Command, args []string) error { minSize, err := parseMinSize(minSizeStr) if err != nil { return err } + // JSON output has no prompts to answer. + if jsonOutput && !yes { + cmd.SilenceUsage = true + return errors.New("--json needs --yes: pass --yes to clean without prompts, or --dry-run --yes to preview") + } + // say prints the text output, which --json replaces. + say := func(format string, a ...any) { + if !jsonOutput { + fmt.Printf(format, a...) + } + } + out := output.CleanOutput{DryRun: dryRun} + writeJSON := func() error { + if !jsonOutput { + return nil + } + return output.WriteCleanJSON(cmd.OutOrStdout(), out) + } // Without a terminal on stdin the selector and confirmation cannot // prompt; fail so a script or agent sees why nothing was cleaned. if !yes && !isatty.IsTerminal(os.Stdin.Fd()) { cmd.SilenceUsage = true return errors.New("stdin is not a terminal: pass --yes to clean without prompts, or --dry-run --yes to preview") } + var resolved []string + for _, it := range items { + resolved = append(resolved, resolveItem(it)) + } results, err := runScanPipeline(ScanPipelineOptions{ + Items: resolved, Path: scanPath, Ecos: ecos, Status: status, SafeOnly: safeOnly, MinSize: minSize, + Quiet: jsonOutput, }) if err != nil { return err @@ -74,11 +101,11 @@ func newCleanCmd() *cobra.Command { } if len(results) == 0 { - fmt.Println("No items to clean.") + say("No items to clean.\n") if vendorCleanup { - runVendorCleanups(vendorEcos(ecos, nil), dryRun) + out.VendorCleanups = runVendorCleanups(vendorEcos(ecos, nil), dryRun, say) } - return nil + return writeJSON() } var toClean []model.ScanResult @@ -93,7 +120,7 @@ func newCleanCmd() *cobra.Command { } } if skippedCaution > 0 { - fmt.Printf( + say( "%s\n", ui.DimStyle.Render(fmt.Sprintf( "Skipped %d caution item(s) — pass --include-caution to remove them with --yes.", @@ -111,6 +138,9 @@ func newCleanCmd() *cobra.Command { cmd.SilenceUsage = true return fmt.Errorf("--item artifacts not cleaned with --yes: %s", strings.Join(named, ", ")) } + for _, sk := range skipped { + out.Skipped = append(out.Skipped, output.SkippedItem{Path: sk.Result.Path, Reason: skipCode[sk.Reason]}) + } } else { // Interactive tree selection result := ui.RunTreeSelector(results) @@ -122,18 +152,18 @@ func newCleanCmd() *cobra.Command { } if len(toClean) == 0 { - fmt.Println("No items selected.") + say("No items selected.\n") if vendorCleanup { - runVendorCleanups(vendorEcos(ecos, toClean), dryRun) + out.VendorCleanups = runVendorCleanups(vendorEcos(ecos, toClean), dryRun, say) } - return nil + return writeJSON() } // Show summary totalSize := model.DedupedTotal(toClean) if dryRun { - fmt.Printf( + say( "\n%s %d items (%s)\n\n", ui.ProjectStyle.Render("Would clean:"), len(toClean), model.HumanSize(totalSize), @@ -142,21 +172,23 @@ func newCleanCmd() *cobra.Command { dc := cleaner.New(cleaner.Options{DryRun: true}) groups := model.GroupByProject(toClean) for _, p := range groups { - fmt.Printf(" %s (%s)\n", ui.ProjectName(p), model.HumanSize(p.TotalSize)) + say(" %s (%s)\n", ui.ProjectName(p), model.HumanSize(p.TotalSize)) for _, r := range p.Items { - fmt.Printf(" %s %s (%s)\n", ui.DimStyle.Render("•"), ui.ArtifactName(r, p.Path), ui.DimStyle.Render(model.HumanSize(r.Size))) + say(" %s %s (%s)\n", ui.DimStyle.Render("•"), ui.ArtifactName(r, p.Path), ui.DimStyle.Render(model.HumanSize(r.Size))) if r.Delete != nil { - fmt.Printf(" %s\n", ui.DimStyle.Render("runs: "+r.Delete.Display)) + say(" %s\n", ui.DimStyle.Render("runs: "+r.Delete.Display)) } - if err := dc.Clean(cmd.Context(), r); err != nil { - fmt.Printf(" %s\n", ui.ErrStyle.Render("would fail: "+err.Error())) + err := dc.Clean(cmd.Context(), r) + if err != nil { + say(" %s\n", ui.ErrStyle.Render("would fail: "+err.Error())) } + out.Results = append(out.Results, cleanItem(r, output.CleanWouldClean, output.CleanWouldFail, err)) } } if vendorCleanup { - runVendorCleanups(vendorEcos(ecos, toClean), dryRun) + out.VendorCleanups = runVendorCleanups(vendorEcos(ecos, toClean), dryRun, say) } - return nil + return writeJSON() } // Choose delete method if not --yes. Items with a DeleteMethod run @@ -220,23 +252,28 @@ func newCleanCmd() *cobra.Command { groups := model.GroupByProject(toClean) for _, p := range groups { - fmt.Printf("\n %s\n", ui.ProjectStyle.Render(ui.ProjectName(p))) + say("\n %s\n", ui.ProjectStyle.Render(ui.ProjectName(p))) for _, r := range p.Items { name := ui.ArtifactName(r, p.Path) err := c.Clean(cmd.Context(), r) + done := output.CleanFreed if err != nil { failed++ - fmt.Printf(" %s %s — %v\n", ui.ErrStyle.Render("✗"), name, err) + say(" %s %s — %v\n", ui.ErrStyle.Render("✗"), name, err) } else { if r.Delete == nil && !force { trashed = append(trashed, r) + done = output.CleanTrashed } else { freed = append(freed, r) } - fmt.Printf(" %s %s (%s)\n", ui.SafeStyle.Render("✔"), name, model.HumanSize(r.Size)) + say(" %s %s (%s)\n", ui.SafeStyle.Render("✔"), name, model.HumanSize(r.Size)) } + out.Results = append(out.Results, cleanItem(r, done, output.CleanFailed, err)) } } + out.TrashedSize = model.DedupedTotal(trashed) + out.FreedSize = model.DedupedTotal(freed) var reclaimed []string if len(freed) > 0 { @@ -249,19 +286,19 @@ func newCleanCmd() *cobra.Command { if len(reclaimed) > 0 { summary += " (" + strings.Join(reclaimed, ", ") + ")" } - fmt.Printf("\n%s\n", ui.SafeStyle.Render(summary)) + say("\n%s\n", ui.SafeStyle.Render(summary)) if hint := hiddenInTrashHint(trashed); hint != "" { - fmt.Println(ui.DimStyle.Render(hint)) + say("%s\n", ui.DimStyle.Render(hint)) } if failed > 0 { - fmt.Printf("%s\n", ui.ErrStyle.Render(fmt.Sprintf("%d items failed", failed))) + say("%s\n", ui.ErrStyle.Render(fmt.Sprintf("%d items failed", failed))) } if vendorCleanup { - runVendorCleanups(vendorEcos(ecos, toClean), dryRun) + out.VendorCleanups = runVendorCleanups(vendorEcos(ecos, toClean), dryRun, say) } - return nil + return writeJSON() }, } @@ -275,6 +312,7 @@ func newCleanCmd() *cobra.Command { cmd.Flags().BoolVar(&yes, "yes", false, "skip confirmation and interactive selection (deletes only safe items unless --include-caution)") cmd.Flags().BoolVar(&includeCaution, "include-caution", false, "with --yes, also delete caution items (shared impact or hard to regenerate); protected is still never deleted") cmd.Flags().StringArrayVar(&items, "item", nil, "clean only the artifact at this path (the \"path\" field of 'devclean scan --json'); repeat for more") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "JSON output for scripting and AI agents (needs --yes)") cmd.Flags().BoolVar(&vendorCleanup, "vendor-cleanup", false, "also run ecosystem-native cleanup commands (e.g. 'xcrun simctl delete unavailable' for xcode)") return cmd @@ -291,6 +329,18 @@ const ( reasonProtected = "protected, never deleted" ) +// skipCode is the --json reason of each skippedItem.Reason. +var skipCode = map[string]string{reasonCaution: "caution", reasonProtected: "protected"} + +// cleanItem is the --json result of cleaning r: done, or failed with err. +func cleanItem(r model.ScanResult, done, failed string, err error) output.CleanItem { + it := output.CleanItem{Path: r.Path, Ecosystem: r.Ecosystem, Size: r.Size, Label: r.Label, Result: done} + if err != nil { + it.Result, it.Error = failed, err.Error() + } + return it +} + // selectForYes chooses which results a non-interactive (--yes) clean deletes. // It deletes only `safe` (auto-regenerated) items by default; `caution` items // carry shared impact or hold state that is slow or impossible to regenerate, @@ -375,11 +425,11 @@ func vendorEcos(ecos []string, toClean []model.ScanResult) []model.Ecosystem { } // runVendorCleanups executes vendor-native cleanup actions for the given -// ecosystems (none = nothing to run). In dry-run mode it prints what would -// run without executing. -func runVendorCleanups(ecosystems []model.Ecosystem, dryRun bool) { +// ecosystems (none = nothing to run), printing each with say, and returns +// their results. In dry-run mode it reports what would run without executing. +func runVendorCleanups(ecosystems []model.Ecosystem, dryRun bool, say func(string, ...any)) []output.VendorResult { if len(ecosystems) == 0 { - return + return nil } scanners := scanner.DefaultRegistry().ForEcosystems(ecosystems) @@ -399,23 +449,28 @@ func runVendorCleanups(ecosystems []model.Ecosystem, dryRun bool) { } if len(actions) == 0 { - return + return nil } - fmt.Printf("\n%s\n", ui.ProjectStyle.Render("Vendor cleanup:")) + say("\n%s\n", ui.ProjectStyle.Render("Vendor cleanup:")) + var results []output.VendorResult for _, p := range actions { + res := output.VendorResult{Ecosystem: p.eco, Description: p.action.Description, Command: p.action.Display} header := fmt.Sprintf(" [%s] %s", p.eco, p.action.Description) - fmt.Println(ui.DimStyle.Render(" " + p.action.Display)) + say("%s\n", ui.DimStyle.Render(" "+p.action.Display)) if dryRun { - fmt.Printf("%s %s\n", header, ui.DimStyle.Render("(dry-run)")) - continue - } - if err := p.action.Run(context.Background()); err != nil { - fmt.Printf("%s — %s\n", header, ui.ErrStyle.Render(err.Error())) - continue + res.Result = "would_run" + say("%s %s\n", header, ui.DimStyle.Render("(dry-run)")) + } else if err := p.action.Run(context.Background()); err != nil { + res.Result, res.Error = "failed", err.Error() + say("%s — %s\n", header, ui.ErrStyle.Render(err.Error())) + } else { + res.Result = "ran" + say("%s %s\n", header, ui.SafeStyle.Render("✔")) } - fmt.Printf("%s %s\n", header, ui.SafeStyle.Render("✔")) + results = append(results, res) } + return results } // hiddenInTrashHint tells how to see trashed items whose name starts with a diff --git a/internal/cli/clean_test.go b/internal/cli/clean_test.go index f3845f3..0165673 100644 --- a/internal/cli/clean_test.go +++ b/internal/cli/clean_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "encoding/json" "os" "path/filepath" "runtime" @@ -10,6 +11,7 @@ import ( "testing" "github.com/ohing504/devclean/internal/model" + "github.com/ohing504/devclean/internal/output" "github.com/ohing504/devclean/internal/pathutil" ) @@ -245,3 +247,77 @@ func TestCleanShowsLabelLikeScan(t *testing.T) { }) } } + +// runCleanJSON runs clean with --json and returns the decoded output and any +// text printed besides it. +func runCleanJSON(t *testing.T, args ...string) (output.CleanOutput, string, error) { + t.Helper() + cmd := newCleanCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(append(args, "--json")) + var err error + text := captureStdout(t, func() { err = cmd.Execute() }) + var out output.CleanOutput + if err == nil { + if derr := json.Unmarshal(buf.Bytes(), &out); derr != nil { + t.Fatalf("output is not JSON: %v\n%s", derr, buf.String()) + } + } + return out, text, err +} + +// TestCleanJSONReportsEachItem verifies --json reports each cleaned item with +// its result and the freed and trashed totals, and prints nothing else. +func TestCleanJSONReportsEachItem(t *testing.T) { + root, proj := nodeProject(t, "app") + dist := filepath.Join(proj, "dist") + + out, text, err := runCleanJSON(t, "--eco", "node", "--path", root, "--item", dist, "--yes", "--force") + if err != nil { + t.Fatal(err) + } + if text != "" { + t.Errorf("--json printed text besides the JSON:\n%s", text) + } + if len(out.Results) != 1 || out.Results[0].Path != dist || out.Results[0].Result != output.CleanFreed { + t.Errorf("results = %+v, want %s freed", out.Results, dist) + } + if out.FreedSize != out.Results[0].Size || out.TrashedSize != 0 || out.DryRun { + t.Errorf("freed_size %d, trashed_size %d, dry_run %v; want %d, 0, false", out.FreedSize, out.TrashedSize, out.DryRun, out.Results[0].Size) + } + assertGone(t, dist) +} + +// TestCleanJSONDryRunDeletesNothing verifies a --dry-run --json reports what +// would be cleaned and what --yes skips, and deletes nothing. +func TestCleanJSONDryRunDeletesNothing(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, "app/pyproject.toml", "app/.venv/lib/", "app/pkg/__pycache__/m.pyc") + cache := filepath.Join(root, "app", "pkg", "__pycache__") + venv := filepath.Join(root, "app", ".venv") + + out, _, err := runCleanJSON(t, "--eco", "python", "--path", root, "--dry-run", "--yes") + if err != nil { + t.Fatal(err) + } + if !out.DryRun || len(out.Results) != 1 || out.Results[0].Path != cache || out.Results[0].Result != output.CleanWouldClean { + t.Errorf("dry_run %v, results %+v; want %s would_clean", out.DryRun, out.Results, cache) + } + if want := []output.SkippedItem{{Path: venv, Reason: "caution"}}; !slices.Equal(out.Skipped, want) { + t.Errorf("skipped = %+v, want %+v", out.Skipped, want) + } + assertExists(t, cache) +} + +// TestCleanJSONNeedsYes verifies --json without --yes fails before scanning. +func TestCleanJSONNeedsYes(t *testing.T) { + root, proj := nodeProject(t, "app") + + _, _, err := runCleanJSON(t, "--eco", "node", "--path", root) + if err == nil || !strings.Contains(err.Error(), "--json needs --yes") { + t.Fatalf("want the --json needs --yes error, got %v", err) + } + assertExists(t, filepath.Join(proj, "dist")) +} diff --git a/internal/cli/pipeline.go b/internal/cli/pipeline.go index 87f92ea..a7dde6f 100644 --- a/internal/cli/pipeline.go +++ b/internal/cli/pipeline.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "slices" "syscall" "github.com/mattn/go-isatty" @@ -41,6 +42,9 @@ type ScanPipelineOptions struct { SafeOnly bool MinSize int64 // drop artifacts smaller than this many bytes (0 = no filter) Quiet bool // skip spinner (for JSON output) + // Items, when set, limits the scan to the artifacts at these absolute, + // clean paths (see scanner.WithItems); other artifacts are not returned. + Items []string } // runScanPipeline scans, classifies, and filters results according to opts. @@ -61,6 +65,9 @@ func runScanPipeline(opts ScanPipelineOptions) ([]model.ScanResult, error) { // actually abort the walk instead of being dead code. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + if opts.Items != nil { + ctx = scanner.WithItems(ctx, opts.Items) + } // Spinner frames are terminal control codes; keep them out of piped output. showSpinner := !opts.Quiet && isatty.IsTerminal(os.Stdout.Fd()) @@ -84,6 +91,11 @@ func runScanPipeline(opts ScanPipelineOptions) ([]model.ScanResult, error) { if err != nil { return nil, err } + if opts.Items != nil { + results = model.FilterResults(results, func(r model.ScanResult) bool { + return slices.Contains(opts.Items, r.Path) + }) + } if showSpinner { sp = ui.NewSpinner("Classifying...") diff --git a/internal/output/json.go b/internal/output/json.go index 511b6fb..6bc2f7a 100644 --- a/internal/output/json.go +++ b/internal/output/json.go @@ -26,3 +26,61 @@ func WriteJSON(w io.Writer, results []model.ScanResult) error { enc.SetIndent("", " ") return enc.Encode(out) } + +// Clean item results, as in CleanItem.Result. +const ( + CleanTrashed = "trashed" // moved to the Trash: the space is freed when it is emptied + CleanFreed = "freed" // deleted permanently, or reclaimed by its DeleteMethod + CleanFailed = "failed" // not removed; Error says why + CleanWouldClean = "would_clean" // --dry-run: a real run would remove it + CleanWouldFail = "would_fail" // --dry-run: a real run would refuse it; Error says why +) + +// CleanOutput is the top-level JSON structure for clean results. +type CleanOutput struct { + DryRun bool `json:"dry_run"` + // TrashedSize and FreedSize total the items moved to the Trash and the + // items freed, each with blocks shared by hard links counted once. + TrashedSize int64 `json:"trashed_size"` + FreedSize int64 `json:"freed_size"` + Results []CleanItem `json:"results"` + Skipped []SkippedItem `json:"skipped,omitempty"` + VendorCleanups []VendorResult `json:"vendor_cleanups,omitempty"` +} + +// CleanItem is one artifact clean tried, or would try under --dry-run. +type CleanItem struct { + Path string `json:"path"` + Ecosystem model.Ecosystem `json:"ecosystem"` + Size int64 `json:"size"` + Label string `json:"label,omitempty"` + Result string `json:"result"` + Error string `json:"error,omitempty"` +} + +// SkippedItem is an artifact the --yes safety gate kept: Reason is "caution" +// or "protected". +type SkippedItem struct { + Path string `json:"path"` + Reason string `json:"reason"` +} + +// VendorResult is one --vendor-cleanup command: Result is "ran", "failed" +// (Error says why) or, under --dry-run, "would_run". +type VendorResult struct { + Ecosystem string `json:"ecosystem"` + Description string `json:"description"` + Command string `json:"command"` + Result string `json:"result"` + Error string `json:"error,omitempty"` +} + +// WriteCleanJSON writes clean results as formatted JSON. +func WriteCleanJSON(w io.Writer, out CleanOutput) error { + if out.Results == nil { + out.Results = []CleanItem{} // an empty list, not null + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(out) +} diff --git a/internal/scanner/global.go b/internal/scanner/global.go index 28cca49..3e7f39d 100644 --- a/internal/scanner/global.go +++ b/internal/scanner/global.go @@ -305,7 +305,7 @@ func (s *GlobalScanner) Scan(ctx context.Context, root string) ([]model.ScanResu // needs a scan root covering home. var homebrew <-chan homebrewScan if isUnderRoot(home, root) { - if brew, err := s.lookPath("brew"); err == nil { + if brew, err := s.lookPath("brew"); err == nil && s.homebrewWanted(ctx, brew, home) { homebrew = s.startHomebrewScan(ctx, brew) } } diff --git a/internal/scanner/global_homebrew.go b/internal/scanner/global_homebrew.go index 9bbf055..fd1d4bf 100644 --- a/internal/scanner/global_homebrew.go +++ b/internal/scanner/global_homebrew.go @@ -51,6 +51,21 @@ type homebrewScan struct { err error } +// homebrewWanted reports whether the scan needs the Homebrew item. A limited +// scan (WithItems) skips brew's slow dry-run unless an item is the path the +// item takes or the catalog entry it replaces or notes an error on; if brew +// cannot tell its cache, the dry-run runs and reports the error. +func (s *GlobalScanner) homebrewWanted(ctx context.Context, brew, home string) bool { + items := scanItems(ctx) + if items == nil || items[filepath.Join(home, homebrewCacheRelPath)] { + return true + } + ctx, cancel := context.WithTimeout(ctx, homebrewTimeout) + defer cancel() + out, err := s.runCommand(ctx, homebrewEnv, brew, "--cache") + return err != nil || items[strings.TrimSpace(string(out))] +} + // startHomebrewScan runs scanHomebrew concurrently with catalog sizing. func (s *GlobalScanner) startHomebrewScan(ctx context.Context, brew string) <-chan homebrewScan { ch := make(chan homebrewScan, 1) diff --git a/internal/scanner/items_test.go b/internal/scanner/items_test.go new file mode 100644 index 0000000..0f85333 --- /dev/null +++ b/internal/scanner/items_test.go @@ -0,0 +1,162 @@ +package scanner + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/ohing504/devclean/internal/model" +) + +// itemsFixture builds a home with walk artifacts in every position the walk +// treats differently (nested project, any-depth rule, hidden ancestor, pruned +// tree, artifact inside an artifact) and stat-scanner caches, and returns a +// registry over it whose brew is stubbed. dryRuns counts brew dry-runs. +func itemsFixture(t *testing.T) (home string, reg *Registry, dryRuns *atomic.Int32) { + t.Helper() + home = t.TempDir() + t.Setenv("HOME", home) + for _, rel := range []string{ + "workspace/app/package.json", + "workspace/app/node_modules/x/index.js", + "workspace/app/node_modules/pkg/dist/index.js", + "workspace/app/dist/main.js", + "workspace/app/src/main.ts", + "workspace/app/sub/package.json", + "workspace/app/sub/node_modules/y/index.js", + "workspace/py/pyproject.toml", + "workspace/py/pkg/a/__pycache__/m.pyc", + "workspace/py/.venv/lib/site.py", + "workspace/.hidden/proj/package.json", + "workspace/.hidden/proj/node_modules/z/index.js", + "Library/pnpm/store/v10/index/i", + "Library/pnpm/store/v10/files/pkg/package.json", + "Library/pnpm/store/v10/files/pkg/node_modules/w/index.js", + ".npm/_cacache/c", + "Library/Caches/Homebrew/downloads/d", + ".cache/huggingface/hub/models--org--name/snapshots/s/f", + } { + p := filepath.Join(home, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + dryRuns = &atomic.Int32{} + global := NewGlobalScanner() + global.TmpRoot = filepath.Join(home, "tmp") + global.AppDirs = nil + global.LookPath = func(file string) (string, error) { + if file == "brew" { + return "/stub/brew", nil + } + return "", fs.ErrNotExist + } + global.RunCommand = func(_ context.Context, _ []string, _ string, args ...string) ([]byte, error) { + if args[len(args)-1] == "--cache" { + return []byte(filepath.Join(home, homebrewCacheRelPath) + "\n"), nil + } + dryRuns.Add(1) + return []byte("This operation would free approximately 1.5MB of disk space.\n"), nil + } + + reg = NewRegistry() + for _, w := range walkEcosystemTable { + reg.Register(newWalkScanner(w)) + } + reg.Register(NewDockerScanner()) + reg.Register(global) + reg.Register(NewLLMScanner()) + return home, reg, dryRuns +} + +// withoutFuncs drops the function fields, which reflect.DeepEqual cannot +// compare unless nil. +func withoutFuncs(rs []model.ScanResult) []model.ScanResult { + out := make([]model.ScanResult, len(rs)) + for i, r := range rs { + if r.Delete != nil { + d := *r.Delete + d.Run = nil + r.Delete = &d + } + r.Release = nil + out[i] = r + } + return out +} + +func resultsAt(rs []model.ScanResult, path string) []model.ScanResult { + var out []model.ScanResult + for _, r := range rs { + if r.Path == path { + out = append(out, r) + } + } + return out +} + +// TestScanWithItemsMatchesFullScan verifies a scan limited to one path finds +// the artifact at it exactly as a full scan does, and nothing where a full scan +// finds nothing, for every directory of the fixture. +func TestScanWithItemsMatchesFullScan(t *testing.T) { + home, reg, _ := itemsFixture(t) + full, err := reg.ScanAll(t.Context(), home) + if err != nil { + t.Fatal(err) + } + // The fixture is only a test if the full scan finds these and skips the + // hidden, pruned and nested node_modules. + if len(full) != 9 { + t.Fatalf("full scan found %d artifacts, want 9: %v", len(full), full) + } + + var dirs []string + _ = filepath.WalkDir(home, func(p string, d fs.DirEntry, err error) error { + if err == nil && d.IsDir() { + dirs = append(dirs, p) + } + return nil + }) + for _, dir := range dirs { + t.Run(strings.TrimPrefix(dir, home), func(t *testing.T) { + limited, err := reg.ScanAll(WithItems(t.Context(), []string{dir}), home) + if err != nil { + t.Fatal(err) + } + want, got := withoutFuncs(resultsAt(full, dir)), withoutFuncs(resultsAt(limited, dir)) + if !reflect.DeepEqual(got, want) { + t.Errorf("limited scan at %s:\n got %+v\nwant %+v", dir, got, want) + } + }) + } +} + +// TestScanWithItemsSkipsBrewDryRun verifies a limited scan runs brew's slow +// dry-run only when an item is the Homebrew item. +func TestScanWithItemsSkipsBrewDryRun(t *testing.T) { + home, reg, dryRuns := itemsFixture(t) + for _, tc := range []struct { + item string + want int32 + }{ + {filepath.Join(home, ".npm"), 0}, + {filepath.Join(home, homebrewCacheRelPath), 1}, + } { + dryRuns.Store(0) + if _, err := reg.ScanAll(WithItems(t.Context(), []string{tc.item}), home); err != nil { + t.Fatal(err) + } + if got := dryRuns.Load(); got != tc.want { + t.Errorf("item %s: %d brew dry-runs, want %d", tc.item, got, tc.want) + } + } +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 0165551..2d17aec 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -102,6 +102,27 @@ func ReportProgress(ctx context.Context, count int) { } } +type itemsKey struct{} + +// WithItems limits a scan to the artifacts at paths (absolute and clean): the +// walk enters only the directories on the way to them and only they are +// sized. Every artifact at those paths is found with the same fields as by an +// unlimited scan; other results may be returned unsized, for the caller to +// drop. +func WithItems(ctx context.Context, paths []string) context.Context { + set := make(map[string]bool, len(paths)) + for _, p := range paths { + set[p] = true + } + return context.WithValue(ctx, itemsKey{}, set) +} + +// scanItems returns the paths set by WithItems, or nil for an unlimited scan. +func scanItems(ctx context.Context) map[string]bool { + set, _ := ctx.Value(itemsKey{}).(map[string]bool) + return set +} + // ScanWith runs a subset of scanners and collects results. func (r *Registry) ScanWith(ctx context.Context, root string, scanners []Scanner) ([]model.ScanResult, error) { return r.ScanWithProgress(ctx, root, scanners, nil) diff --git a/internal/scanner/walk.go b/internal/scanner/walk.go index fba467b..589d3b3 100644 --- a/internal/scanner/walk.go +++ b/internal/scanner/walk.go @@ -156,6 +156,7 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model. var mu sync.Mutex // guards results var results []model.ScanResult numTables := len(tables) + items := scanItems(ctx) // fstree does the traversal, in parallel while walkTokens has a free slot; // the engine only decides what a directory is. Each directory hands its @@ -168,6 +169,12 @@ func runWalk(ctx context.Context, root string, tables []walkEcosystem) ([]model. // ancestor contexts (dir's own context is added only if we descend). // Size is filled in afterwards by sizePending. Enter: func(dir string, info fs.FileInfo, stack []projectContext) bool { + // A limited scan skips every directory that is neither an item nor + // on the way to one. The rest go through the same checks, with + // every entry read, as in an unlimited scan. + if items != nil && !leadsToItem(dir, items) { + return false + } name := filepath.Base(dir) if rule, tableIdx, projRoot, ok := matchArtifact(stack, dir, name, numTables); ok { result := model.ScanResult{ @@ -321,6 +328,16 @@ func isPnpmStoreVersionDir(name string) bool { return true } +// leadsToItem reports whether dir is one of items or an ancestor of one. +func leadsToItem(dir string, items map[string]bool) bool { + for it := range items { + if isUnderRoot(it, dir) { + return true + } + } + return false +} + // sizeWorkerCap bounds concurrent sizing walks so a large scan does not // traverse every artifact tree at once. sizeTokens enforces it across the // pools of all scanners and the sub-walks inside one artifact. @@ -337,6 +354,8 @@ func sizePending(ctx context.Context, results []model.ScanResult) error { // sizeTokens bounds the concurrent measures; the workers only hand them out. workers := min(sizeWorkerCap, len(results)) + // A limited scan (WithItems) sizes only the items. + items := scanItems(ctx) sources := newCloneSources() idx := make(chan int) var wg sync.WaitGroup @@ -344,6 +363,9 @@ func sizePending(ctx context.Context, results []model.ScanResult) error { wg.Go(func() { for i := range idx { r := &results[i] + if items != nil && !items[r.Path] { + continue + } var clones *cloneSources if r.CloneAware && cloneSizingSupported { clones = sources diff --git a/plugins/devclean/skills/devclean/SKILL.md b/plugins/devclean/skills/devclean/SKILL.md index 3326126..e4e2c46 100644 --- a/plugins/devclean/skills/devclean/SKILL.md +++ b/plugins/devclean/skills/devclean/SKILL.md @@ -52,14 +52,14 @@ Safety levels: Name every approved artifact with `--item` (its `path` field), and preview first: ```bash -devclean clean --item [--item ...] --dry-run --yes -devclean clean --item [--item ...] --yes +devclean clean --item [--item ...] --dry-run --yes --json +devclean clean --item [--item ...] --yes --json ``` Scope: - Always pass `--item`. `clean --yes` without it deletes every `safe` artifact in the scanned scope. -- Pass the same `--path` and `--eco` as the scan. `clean` scans again before deleting; without them it rescans `~` (slow), and an `--item` outside the scope matches nothing. +- Pass the same `--path` and `--eco` as the scan. `clean` checks the named paths again before deleting, and an `--item` outside the scope matches nothing. - Always pass `--yes`. When the user wants to pick interactively, give them the `devclean clean` command to run in their terminal. Flags that need the user's approval: @@ -70,11 +70,12 @@ Flags that need the user's approval: Failures: -- Exit status 1 before deletion lists each `--item` that matched nothing or would be skipped, with the reason. Report it; do not retry with broader flags. +- Exit status 1 before deletion lists each `--item` that matched nothing or would be skipped, with the reason, on stderr as text. Report it; do not retry with broader flags. +- A `result` of `failed` (`would_fail` in the preview) carries the `error`; the exit status stays 0. ## 5. Report -- Freed size, and whether the artifacts went to Trash or were deleted permanently, from the `clean` output. +- Freed size, and whether the artifacts went to Trash or were deleted permanently: `trashed_size` and `freed_size` of the `clean --json` output. - Artifacts that failed, with their errors. - Every `recommendation` on the cleaned artifacts (for example, run `pnpm store prune` after deleting pnpm `node_modules`). - For Trash moves: the space is freed when the Trash is emptied.