diff --git a/docs/SPEC.md b/docs/SPEC.md index 3410438..0069535 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -109,6 +109,9 @@ devclean clean --eco node --force --yes # Skip artifacts smaller than 50 MB devclean clean --eco node --min-size 50MB +# Clean only the named artifact(s) +devclean clean --eco global --item ~/.npm --yes --include-caution + # Also run ecosystem-native cleanup commands (e.g. xcrun simctl delete unavailable) devclean clean --eco xcode --vendor-cleanup --yes ``` @@ -125,6 +128,25 @@ artifacts crowd out the real targets. Sizes use SI/IEC suffixes: `devclean scan --min-size 100MB` keeps only artifacts ≥ 100 MB. +#### `--item` + +`clean --item ` keeps only the scanned artifact at that path, so a +non-interactive run can target one artifact the other filters cannot separate. +Repeat the flag for more; a comma is part of the path. Paths are the `path` +field of `scan --json`; a leading `~/` is expanded and relative paths resolve +against the working directory. Other filters and the `--yes` safety gate still +apply: a `caution` item needs `--include-caution`, and a `protected` item is +never deleted. + +`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): + +```text +Error: --item matched no scanned artifact: /Users/me/.npmx (artifact paths are the "path" field of 'devclean scan --json'; --eco, --path, --status, --safe and --min-size also narrow what is scanned) +Error: --item artifacts not cleaned with --yes: /Users/me/app/.venv (caution, pass --include-caution) +``` + #### Non-interactive safety (`--yes` / `--include-caution`) `--yes` skips the interactive selector for scripting and AI-agent use. To keep a @@ -144,7 +166,7 @@ Without `--yes`, `clean` needs a terminal on stdin to prompt. When stdin is not terminal it exits with status 1 before scanning: ```text -Error: stdin is not a terminal: pass --yes to clean without prompts (add --dry-run to preview) +Error: stdin is not a terminal: pass --yes to clean without prompts, or --dry-run --yes to preview ``` #### Vendor Cleanups diff --git a/internal/cli/clean.go b/internal/cli/clean.go index b65a5b6..d6aa171 100644 --- a/internal/cli/clean.go +++ b/internal/cli/clean.go @@ -30,6 +30,7 @@ func newCleanCmd() *cobra.Command { yes bool includeCaution bool vendorCleanup bool + items []string ) cmd := &cobra.Command{ @@ -39,6 +40,7 @@ func newCleanCmd() *cobra.Command { devclean clean --eco node --status dormant --yes 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'`, RunE: func(cmd *cobra.Command, args []string) error { minSize, err := parseMinSize(minSizeStr) @@ -49,7 +51,7 @@ func newCleanCmd() *cobra.Command { // 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 (add --dry-run to preview)") + return errors.New("stdin is not a terminal: pass --yes to clean without prompts, or --dry-run --yes to preview") } results, err := runScanPipeline(ScanPipelineOptions{ Path: scanPath, @@ -61,6 +63,14 @@ func newCleanCmd() *cobra.Command { if err != nil { return err } + if len(items) > 0 { + var missing []string + results, missing = keepItems(results, items) + if len(missing) > 0 { + cmd.SilenceUsage = true + return fmt.Errorf("--item matched no scanned artifact: %s (artifact paths are the \"path\" field of 'devclean scan --json'; --eco, --path, --status, --safe and --min-size also narrow what is scanned)", strings.Join(missing, ", ")) + } + } if len(results) == 0 { fmt.Println("No items to clean.") @@ -73,8 +83,14 @@ func newCleanCmd() *cobra.Command { var toClean []model.ScanResult if yes { + var skipped []skippedItem + toClean, skipped = selectForYes(results, includeCaution) var skippedCaution int - toClean, skippedCaution = selectForYes(results, includeCaution) + for _, sk := range skipped { + if sk.Reason == reasonCaution { + skippedCaution++ + } + } if skippedCaution > 0 { fmt.Printf( "%s\n", @@ -84,6 +100,16 @@ func newCleanCmd() *cobra.Command { )), ) } + // A named artifact the safety gate keeps is a failed request, not + // a choice: fail before deleting so a script sees it. + if len(items) > 0 && len(skipped) > 0 { + named := make([]string, len(skipped)) + for i, sk := range skipped { + named[i] = fmt.Sprintf("%s (%s)", sk.Result.Path, sk.Reason) + } + cmd.SilenceUsage = true + return fmt.Errorf("--item artifacts not cleaned with --yes: %s", strings.Join(named, ", ")) + } } else { // Interactive tree selection result := ui.RunTreeSelector(results) @@ -255,36 +281,85 @@ func newCleanCmd() *cobra.Command { cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview without deleting") 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(&vendorCleanup, "vendor-cleanup", false, "also run ecosystem-native cleanup commands (e.g. 'xcrun simctl delete unavailable' for xcode)") return cmd } +// skippedItem is a result the --yes safety gate kept, with the reason. +type skippedItem struct { + Result model.ScanResult + Reason string +} + +const ( + reasonCaution = "caution, pass --include-caution" + reasonProtected = "protected, never deleted" +) + // 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, // so removing them without a human looking is opt-in via includeCaution. // `protected` items (git-tracked dirty, or protected safety) are never deleted. // This keeps a single mis-classified catalog entry from becoming silent data -// loss under --yes. It returns the items to clean and how many caution items -// were skipped (for the user-facing notice). -func selectForYes(results []model.ScanResult, includeCaution bool) (toClean []model.ScanResult, skippedCaution int) { +// loss under --yes. It returns the items to clean and the items it kept, each +// with the reason (for the user-facing notice and --item failures). +func selectForYes(results []model.ScanResult, includeCaution bool) (toClean []model.ScanResult, skipped []skippedItem) { for _, r := range results { - if r.Protected { - continue - } - switch r.Safety { - case model.SafetySafe: + switch { + case r.Protected: + skipped = append(skipped, skippedItem{r, reasonProtected}) + case r.Safety == model.SafetySafe, r.Safety == model.SafetyCaution && includeCaution: toClean = append(toClean, r) - case model.SafetyCaution: - if includeCaution { - toClean = append(toClean, r) - } else { - skippedCaution++ - } + case r.Safety == model.SafetyCaution: + skipped = append(skipped, skippedItem{r, reasonCaution}) + default: + skipped = append(skipped, skippedItem{r, reasonProtected}) + } + } + return toClean, skipped +} + +// resolveItem turns an --item argument into the absolute path it names. A +// leading "~/" is expanded; without a home dir it stays literal, so the item +// fails to match instead of resolving against the working directory. +func resolveItem(item string) string { + p := item + if home := pathutil.CachedHomeDir(); home != "" { + if rest, ok := strings.CutPrefix(p, "~/"); ok { + p = filepath.Join(home, rest) + } + } + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p +} + +// keepItems narrows results to the artifacts whose path is one of items, so a +// non-interactive clean can target single artifacts the other filters cannot +// separate. Relative items resolve against the working directory. missing +// lists the items that matched no result, as given. +func keepItems(results []model.ScanResult, items []string) (kept []model.ScanResult, missing []string) { + matched := make(map[string]bool, len(items)) // resolved path → matched + for _, it := range items { + matched[resolveItem(it)] = false + } + for _, r := range results { + p := filepath.Clean(r.Path) + if _, ok := matched[p]; ok { + kept = append(kept, r) + matched[p] = true + } + } + for _, it := range items { + if !matched[resolveItem(it)] { + missing = append(missing, it) } } - return toClean, skippedCaution + return kept, missing } // vendorEcos resolves the vendor-cleanup scope: the explicit --eco selection, diff --git a/internal/cli/clean_test.go b/internal/cli/clean_test.go index 508fefd..0ffa955 100644 --- a/internal/cli/clean_test.go +++ b/internal/cli/clean_test.go @@ -1,10 +1,15 @@ package cli import ( + "bytes" + "os" + "path/filepath" "slices" + "strings" "testing" "github.com/ohing504/devclean/internal/model" + "github.com/ohing504/devclean/internal/pathutil" ) // TestSelectForYes verifies the non-interactive (--yes) safety gate: only safe @@ -23,8 +28,8 @@ func TestSelectForYes(t *testing.T) { if len(toClean) != 1 || toClean[0].Path != "/safe" { t.Errorf("expected only /safe, got %v", paths(toClean)) } - if skipped != 1 { - t.Errorf("expected 1 skipped caution, got %d", skipped) + if n := countReason(skipped, reasonCaution); n != 1 { + t.Errorf("expected 1 skipped caution, got %d", n) } }) @@ -33,8 +38,8 @@ func TestSelectForYes(t *testing.T) { if got := paths(toClean); len(got) != 2 || !contains(got, "/safe") || !contains(got, "/caution") { t.Errorf("expected /safe and /caution, got %v", got) } - if skipped != 0 { - t.Errorf("expected 0 skipped, got %d", skipped) + if n := countReason(skipped, reasonCaution); n != 0 { + t.Errorf("expected 0 skipped caution, got %d", n) } // Protected safety and git-protected must never appear. for _, r := range toClean { @@ -52,6 +57,148 @@ func TestSelectForYes(t *testing.T) { }) } +func countReason(skipped []skippedItem, reason string) int { + n := 0 + for _, sk := range skipped { + if sk.Reason == reason { + n++ + } + } + return n +} + +// writeFiles creates each "/"-separated rel path under root as a file, or as +// a directory when it ends in "/". +func writeFiles(t *testing.T, root string, rels ...string) { + t.Helper() + for _, rel := range rels { + p := filepath.Join(root, filepath.FromSlash(rel)) + if strings.HasSuffix(rel, "/") { + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + continue + } + 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) + } + } +} + +// nodeProject creates a node project named name with node_modules and dist +// under a temp root and returns the root and the project dir. +func nodeProject(t *testing.T, name string) (root, proj string) { + t.Helper() + root = t.TempDir() + writeFiles(t, root, name+"/package.json", name+"/node_modules/x/index.js", name+"/dist/main.js") + return root, filepath.Join(root, name) +} + +func assertGone(t *testing.T, p string) { + t.Helper() + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("%s should be deleted, stat err = %v", p, err) + } +} + +func assertExists(t *testing.T, p string) { + t.Helper() + if _, err := os.Stat(p); err != nil { + t.Errorf("%s must be kept: %v", p, err) + } +} + +func runClean(t *testing.T, args ...string) error { + t.Helper() + cmd := newCleanCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(args) + return cmd.Execute() +} + +// TestCleanItemDeletesOnlyNamedArtifact verifies --item narrows a --yes clean +// to the named artifact and leaves the project's other artifacts in place. +func TestCleanItemDeletesOnlyNamedArtifact(t *testing.T) { + root, proj := nodeProject(t, "app") + + if err := runClean(t, "--eco", "node", "--path", root, "--item", filepath.Join(proj, "dist"), "--yes", "--force"); err != nil { + t.Fatal(err) + } + assertGone(t, filepath.Join(proj, "dist")) + assertExists(t, filepath.Join(proj, "node_modules")) +} + +// TestCleanItemUnmatchedFailsBeforeDeleting verifies an --item that names no +// scanned artifact fails the command and deletes nothing, even alongside a +// matching one. +func TestCleanItemUnmatchedFailsBeforeDeleting(t *testing.T) { + root, proj := nodeProject(t, "app") + missing := filepath.Join(proj, "build") + + err := runClean(t, "--eco", "node", "--path", root, + "--item", filepath.Join(proj, "dist"), "--item", missing, "--yes", "--force") + if err == nil || !strings.Contains(err.Error(), missing) { + t.Fatalf("want an error naming %s, got %v", missing, err) + } + assertExists(t, filepath.Join(proj, "dist")) +} + +// TestCleanItemGatedBySafetyFails verifies a named caution artifact that --yes +// would skip fails the command with the reason and deletes nothing. +func TestCleanItemGatedBySafetyFails(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, "app/pyproject.toml", "app/.venv/lib/") + venv := filepath.Join(root, "app", ".venv") + + err := runClean(t, "--eco", "python", "--path", root, "--item", venv, "--yes", "--force") + if err == nil || !strings.Contains(err.Error(), "--include-caution") { + t.Fatalf("want an error naming --include-caution, got %v", err) + } + assertExists(t, venv) +} + +// TestCleanItemKeepsCommaInPath verifies --item takes a path with a comma as +// one path. +func TestCleanItemKeepsCommaInPath(t *testing.T) { + root, proj := nodeProject(t, "a,b") + + if err := runClean(t, "--eco", "node", "--path", root, "--item", filepath.Join(proj, "dist"), "--yes", "--force"); err != nil { + t.Fatal(err) + } + assertGone(t, filepath.Join(proj, "dist")) +} + +// TestKeepItemsResolvesHomeAndRelativePaths verifies a "~/" item and a +// relative item match the absolute scan paths they name, and an unmatched item +// is reported as given. +func TestKeepItemsResolvesHomeAndRelativePaths(t *testing.T) { + home := pathutil.CachedHomeDir() + if home == "" { + t.Skip("no home directory") + } + cwd := t.TempDir() + t.Chdir(cwd) + results := []model.ScanResult{ + {Path: filepath.Join(home, ".npm")}, + {Path: filepath.Join(cwd, "app", "dist")}, + {Path: filepath.Join(cwd, "app", "node_modules")}, + } + + kept, missing := keepItems(results, []string{"~/.npm", "app/dist/", "nope"}) + + want := []string{filepath.Join(home, ".npm"), filepath.Join(cwd, "app", "dist")} + if !slices.Equal(paths(kept), want) { + t.Errorf("kept = %v, want %v", paths(kept), want) + } + if !slices.Equal(missing, []string{"nope"}) { + t.Errorf("missing = %v, want [nope]", missing) + } +} + func paths(rs []model.ScanResult) []string { out := make([]string, len(rs)) for i, r := range rs {