From 36391c2cdeb47f78fdc25a850e22222d54eb1c92 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:07:33 -0700 Subject: [PATCH] feat(cmd): add `grut clean` to remove transient session and diagnostic data grut accumulates saved session state under DataDir/sessions and watchdog diagnostic logs under DataDir/diagnostics over time, with no built-in way to reclaim that space short of deleting directories by hand. Add a `grut clean` command. Without --force it previews each target, its size, and the total space it would reclaim. With --force it deletes them. Missing targets are reported and skipped, so running on a fresh machine is a no-op. clean deliberately leaves alone anything that isn't regenerable cache: the config file, installed extensions, crash reports (owned by `grut report --clear`), the MCP audit log, and the first-run marker. Closes #342 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 07f6eb49-d72f-4f2a-9285-065b5d835f5b --- cmd/clean.go | 151 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/clean_test.go | 149 +++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + 3 files changed, 301 insertions(+) create mode 100644 cmd/clean.go create mode 100644 cmd/clean_test.go diff --git a/cmd/clean.go b/cmd/clean.go new file mode 100644 index 00000000..b77b39af --- /dev/null +++ b/cmd/clean.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/jongio/grut/internal/config" + "github.com/spf13/cobra" +) + +// dataDirFunc resolves grut's data directory. It is injected so tests can +// point the clean command at a temporary directory instead of the real one. +type dataDirFunc func() string + +// cleanTarget is a directory that "grut clean" is allowed to remove. +type cleanTarget struct { + label string + path string +} + +// cleanTargets returns the transient directories clean manages, rooted at +// dataDir. Only regenerable state is included: saved sessions and watchdog +// diagnostics. The user config file, installed extensions, crash reports +// (owned by "grut report --clear"), the MCP audit log, and the first-run +// marker all live elsewhere under the data or config directory and are left +// untouched. +func cleanTargets(dataDir string) []cleanTarget { + return []cleanTarget{ + {label: "sessions", path: filepath.Join(dataDir, "sessions")}, + {label: "diagnostics", path: filepath.Join(dataDir, "diagnostics")}, + } +} + +func newCleanCmd() *cobra.Command { + return newCleanCmdWithDeps(config.DataDir) +} + +func newCleanCmdWithDeps(dataDir dataDirFunc) *cobra.Command { + cmd := &cobra.Command{ + Use: "clean", + Short: "Remove grut's transient session and diagnostic data", + Long: `Remove grut's transient data: saved session state and watchdog +diagnostic logs. + +Without --force, clean previews what would be removed and how much disk +space it would reclaim. Pass --force to delete. + +clean never touches your config file, installed extensions, crash reports +(use "grut report --clear" for those), or the MCP audit log.`, + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + force, _ := cmd.Flags().GetBool("force") + return runClean(cmd.OutOrStdout(), cleanTargets(dataDir()), force) + }, + } + cmd.Flags().Bool("force", false, "Delete the transient data (without this flag, clean only previews)") + 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 bool) error { + var total int64 + present := 0 + for _, t := range targets { + size, exists, err := dirSize(t.path) + if err != nil { + return fmt.Errorf("scanning %s: %w", t.label, err) + } + if !exists { + fmt.Fprintf(out, " %-12s not present\n", t.label) + continue + } + present++ + total += size + if force { + if err := os.RemoveAll(t.path); err != nil { + return fmt.Errorf("removing %s: %w", t.label, err) + } + fmt.Fprintf(out, " %-12s removed %s\n", t.label, humanizeBytes(size)) + continue + } + fmt.Fprintf(out, " %-12s %-10s %s\n", t.label, humanizeBytes(size), t.path) + } + + fmt.Fprintln(out) + if force { + fmt.Fprintf(out, "Reclaimed %s.\n", humanizeBytes(total)) + return nil + } + if present == 0 { + fmt.Fprintln(out, "Nothing to clean.") + return nil + } + fmt.Fprintf(out, "%s across %d location(s). Run with --force to delete.\n", humanizeBytes(total), present) + return nil +} + +// 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) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return 0, false, nil + } + return 0, false, err + } + if !info.IsDir() { + return info.Size(), true, nil + } + + var total int64 + walkErr := filepath.WalkDir(path, func(_ string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + fi, err := d.Info() + if err != nil { + return err + } + total += fi.Size() + return nil + }) + if walkErr != nil { + return 0, true, walkErr + } + return total, true, nil +} + +// humanizeBytes formats a byte count using binary units (KiB, MiB, ...). +func humanizeBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for size := n / unit; size >= unit; size /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/cmd/clean_test.go b/cmd/clean_test.go new file mode 100644 index 00000000..8b1c28aa --- /dev/null +++ b/cmd/clean_test.go @@ -0,0 +1,149 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedCleanData creates a data dir populated with the transient targets clean +// removes plus the excluded entries it must preserve. It returns the data dir +// path. +func seedCleanData(t *testing.T) string { + t.Helper() + dataDir := t.TempDir() + + // Targets clean should remove. + writeFile(t, filepath.Join(dataDir, "sessions", "abc", "state.json"), "session") + writeFile(t, filepath.Join(dataDir, "diagnostics", "watchdog.log"), "diag") + + // Entries clean must leave alone. + writeFile(t, filepath.Join(dataDir, "crashes", "crash-1.txt"), "crash") + writeFile(t, filepath.Join(dataDir, "extensions", "ext", "main.lua"), "ext") + writeFile(t, filepath.Join(dataDir, "mcp-audit.log"), "audit") + writeFile(t, filepath.Join(dataDir, ".first-run-done"), "") + + return dataDir +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +func execClean(t *testing.T, dataDir string, args ...string) string { + t.Helper() + cmd := newCleanCmdWithDeps(func() string { return dataDir }) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + require.NoError(t, cmd.Execute()) + return out.String() +} + +func TestCleanPreviewListsTargetsWithoutDeleting(t *testing.T) { + dataDir := seedCleanData(t) + + out := execClean(t, dataDir) + + assert.Contains(t, out, "sessions") + assert.Contains(t, out, "diagnostics") + assert.Contains(t, out, "Run with --force to delete.") + + // Preview must not delete anything. + assert.DirExists(t, filepath.Join(dataDir, "sessions")) + assert.DirExists(t, filepath.Join(dataDir, "diagnostics")) +} + +func TestCleanForceRemovesTargets(t *testing.T) { + dataDir := seedCleanData(t) + + out := execClean(t, dataDir, "--force") + + assert.Contains(t, out, "removed") + assert.Contains(t, out, "Reclaimed") + assert.NoDirExists(t, filepath.Join(dataDir, "sessions")) + assert.NoDirExists(t, filepath.Join(dataDir, "diagnostics")) +} + +func TestCleanForcePreservesExcludedEntries(t *testing.T) { + dataDir := seedCleanData(t) + + execClean(t, dataDir, "--force") + + assert.FileExists(t, filepath.Join(dataDir, "crashes", "crash-1.txt")) + assert.FileExists(t, filepath.Join(dataDir, "extensions", "ext", "main.lua")) + assert.FileExists(t, filepath.Join(dataDir, "mcp-audit.log")) + assert.FileExists(t, filepath.Join(dataDir, ".first-run-done")) +} + +func TestCleanMissingDirsPreview(t *testing.T) { + dataDir := t.TempDir() // nothing seeded + + out := execClean(t, dataDir) + + assert.Contains(t, out, "not present") + assert.Contains(t, out, "Nothing to clean.") +} + +func TestCleanMissingDirsForce(t *testing.T) { + dataDir := t.TempDir() + + out := execClean(t, dataDir, "--force") + + assert.Contains(t, out, "Reclaimed 0 B.") +} + +func TestCleanRejectsArgs(t *testing.T) { + cmd := newCleanCmdWithDeps(func() string { return t.TempDir() }) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"unexpected"}) + + require.Error(t, cmd.Execute()) +} + +func TestRootRegistersClean(t *testing.T) { + root, cleanup := newRootCommand() + defer cleanup() + + cleanCmd, _, err := root.Find([]string{"clean"}) + require.NoError(t, err) + require.NotNil(t, cleanCmd) + assert.Equal(t, "clean", cleanCmd.Name()) +} + +func TestDirSize(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a.txt"), "hello") // 5 bytes + writeFile(t, filepath.Join(dir, "sub", "b.txt"), "world") // 5 bytes + + size, exists, err := dirSize(dir) + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, int64(10), size) + + _, missing, err := dirSize(filepath.Join(dir, "nope")) + require.NoError(t, err) + assert.False(t, missing) +} + +func TestHumanizeBytes(t *testing.T) { + cases := map[int64]string{ + 0: "0 B", + 512: "512 B", + 1024: "1.0 KiB", + 1536: "1.5 KiB", + 1024 * 1024: "1.0 MiB", + 5 * 1024 * 1024: "5.0 MiB", + } + for in, want := range cases { + assert.Equal(t, want, humanizeBytes(in)) + } +} diff --git a/cmd/root.go b/cmd/root.go index 9040d589..95ed5e40 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -357,6 +357,7 @@ Environment: rootCmd.AddCommand(newRunCmd()) rootCmd.AddCommand(newReportCmd()) rootCmd.AddCommand(newConfigCmd()) + rootCmd.AddCommand(newCleanCmd()) // cleanup releases profiling resources. It is idempotent — safe to call // multiple times (subsequent calls are no-ops).