Skip to content
Merged
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: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
42 changes: 41 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
131 changes: 93 additions & 38 deletions internal/cli/clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -32,6 +33,7 @@ func newCleanCmd() *cobra.Command {
includeCaution bool
vendorCleanup bool
items []string
jsonOutput bool
)

cmd := &cobra.Command{
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.",
Expand All @@ -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)
Expand All @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
},
}

Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
Loading
Loading