From d0a6bc8a9738b92c5f6f82d8e3c5ceea41b5f00e Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:31:58 -0700 Subject: [PATCH] feat(clean): add --check flag to fail when cleanup is pending Closes #387 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/clean.go | 21 ++++++-- cmd/clean_test.go | 95 ++++++++++++++++++++++++++++++++++-- web/src/pages/docs/cli.astro | 3 ++ 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/cmd/clean.go b/cmd/clean.go index 04a30d8..aad78df 100644 --- a/cmd/clean.go +++ b/cmd/clean.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -71,18 +72,21 @@ clean never touches your config file, installed extensions, crash reports RunE: func(cmd *cobra.Command, _ []string) error { force, _ := cmd.Flags().GetBool("force") asJSON, _ := cmd.Flags().GetBool("json") - return runClean(cmd.OutOrStdout(), cleanTargets(dataDir()), force, asJSON) + check, _ := cmd.Flags().GetBool("check") + return runClean(cmd.OutOrStdout(), cleanTargets(dataDir()), force, asJSON, check) }, } cmd.Flags().Bool("force", false, "Delete the transient data (without this flag, clean only previews)") cmd.Flags().Bool("json", false, "Print the clean report as JSON") + cmd.Flags().Bool("check", false, "Exit with an error when transient data is present") return cmd } // runClean scans each target, then either previews it or removes it. A // missing target is reported and skipped rather than treated as an error, so // running clean on a fresh machine is a no-op. -func runClean(out io.Writer, targets []cleanTarget, force, asJSON bool) error { +func runClean(out io.Writer, targets []cleanTarget, force, asJSON, check bool) error { + force = force && !check report := cleanReport{ Forced: force, Targets: make([]cleanTargetReport, 0, len(targets)), @@ -127,7 +131,13 @@ func runClean(out io.Writer, targets []cleanTarget, force, asJSON bool) error { if asJSON { enc := json.NewEncoder(out) enc.SetIndent("", " ") - return enc.Encode(report) + if err := enc.Encode(report); err != nil { + return err + } + if check && report.PresentCount > 0 { + return errCleanTargetsPresent + } + return nil } fmt.Fprintln(out) @@ -140,9 +150,14 @@ func runClean(out io.Writer, targets []cleanTarget, force, asJSON bool) error { return nil } fmt.Fprintf(out, "%s across %d location(s). Run with --force to delete.\n", humanizeBytes(report.TotalBytes), report.PresentCount) + if check { + return errCleanTargetsPresent + } return nil } +var errCleanTargetsPresent = errors.New("transient data is present") + // dirSize returns the total size of all files under path. The second return // value reports whether path exists; a missing path yields (0, false, nil). func dirSize(path string) (int64, bool, error) { diff --git a/cmd/clean_test.go b/cmd/clean_test.go index 2a8eec7..3a06338 100644 --- a/cmd/clean_test.go +++ b/cmd/clean_test.go @@ -38,14 +38,22 @@ func writeFile(t *testing.T, path, content string) { } func execClean(t *testing.T, dataDir string, args ...string) string { + t.Helper() + out, err := execCleanErr(t, dataDir, args...) + require.NoError(t, err) + return out +} + +func execCleanErr(t *testing.T, dataDir string, args ...string) (string, error) { t.Helper() cmd := newCleanCmdWithDeps(func() string { return dataDir }) var out bytes.Buffer + var errOut bytes.Buffer cmd.SetOut(&out) - cmd.SetErr(&out) + cmd.SetErr(&errOut) cmd.SetArgs(args) - require.NoError(t, cmd.Execute()) - return out.String() + err := cmd.Execute() + return out.String(), err } func TestCleanPreviewListsTargetsWithoutDeleting(t *testing.T) { @@ -101,6 +109,40 @@ func TestCleanMissingDirsForce(t *testing.T) { assert.Contains(t, out, "Reclaimed 0 B.") } +func TestCleanCheckMissingDirsSucceeds(t *testing.T) { + dataDir := t.TempDir() + + out := execClean(t, dataDir, "--check") + + assert.Contains(t, out, "not present") + assert.Contains(t, out, "Nothing to clean.") +} + +func TestCleanCheckReturnsErrorWhenTargetsPresent(t *testing.T) { + dataDir := seedCleanData(t) + + out, err := execCleanErr(t, dataDir, "--check") + + require.ErrorIs(t, err, errCleanTargetsPresent) + assert.Contains(t, out, "sessions") + assert.Contains(t, out, "diagnostics") + assert.Contains(t, out, "Run with --force to delete.") + assert.DirExists(t, filepath.Join(dataDir, "sessions")) + assert.DirExists(t, filepath.Join(dataDir, "diagnostics")) +} + +func TestCleanCheckForceDoesNotDelete(t *testing.T) { + dataDir := seedCleanData(t) + + out, err := execCleanErr(t, dataDir, "--check", "--force") + + require.ErrorIs(t, err, errCleanTargetsPresent) + assert.NotContains(t, out, "removed") + assert.Contains(t, out, "Run with --force to delete.") + assert.DirExists(t, filepath.Join(dataDir, "sessions")) + assert.DirExists(t, filepath.Join(dataDir, "diagnostics")) +} + func TestCleanJSONPreviewReportsTargetsWithoutDeleting(t *testing.T) { dataDir := seedCleanData(t) @@ -152,6 +194,53 @@ func TestCleanJSONMissingDirs(t *testing.T) { } } +func TestCleanJSONCheckSucceedsWhenNothingPresent(t *testing.T) { + dataDir := t.TempDir() + + out := execClean(t, dataDir, "--check", "--json") + + var report cleanReport + require.NoError(t, json.Unmarshal([]byte(out), &report)) + assert.False(t, report.Forced) + assert.Equal(t, 0, report.PresentCount) +} + +func TestCleanJSONCheckReturnsErrorWhenTargetsPresent(t *testing.T) { + dataDir := seedCleanData(t) + + out, err := execCleanErr(t, dataDir, "--check", "--json") + + require.ErrorIs(t, err, errCleanTargetsPresent) + var report cleanReport + require.NoError(t, json.Unmarshal([]byte(out), &report)) + assert.False(t, report.Forced) + assert.Equal(t, int64(len("session")+len("diag")), report.TotalBytes) + assert.Equal(t, 2, report.PresentCount) + for _, target := range report.Targets { + assert.True(t, target.Exists) + assert.False(t, target.Removed) + } + assert.DirExists(t, filepath.Join(dataDir, "sessions")) + assert.DirExists(t, filepath.Join(dataDir, "diagnostics")) +} + +func TestCleanJSONCheckForceDoesNotDelete(t *testing.T) { + dataDir := seedCleanData(t) + + out, err := execCleanErr(t, dataDir, "--check", "--force", "--json") + + require.ErrorIs(t, err, errCleanTargetsPresent) + var report cleanReport + require.NoError(t, json.Unmarshal([]byte(out), &report)) + assert.False(t, report.Forced) + for _, target := range report.Targets { + assert.True(t, target.Exists) + assert.False(t, target.Removed) + } + assert.DirExists(t, filepath.Join(dataDir, "sessions")) + assert.DirExists(t, filepath.Join(dataDir, "diagnostics")) +} + func TestCleanRejectsArgs(t *testing.T) { cmd := newCleanCmdWithDeps(func() string { return t.TempDir() }) cmd.SetOut(&bytes.Buffer{}) diff --git a/web/src/pages/docs/cli.astro b/web/src/pages/docs/cli.astro index dde219a..fe1a731 100644 --- a/web/src/pages/docs/cli.astro +++ b/web/src/pages/docs/cli.astro @@ -95,6 +95,9 @@ grut keys --json
# Preview what would be removed
grut clean
+# Fail if transient data is present without deleting it
+grut clean --check
+
# Delete the transient data
grut clean --force