diff --git a/README.md b/README.md index 9e9da99..fcd1b76 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ Before any destructive operation, gh-stack automatically captures a snapshot of - Stack configuration (parent, PR number, fork point) - Any auto-stashed uncommitted changes -Snapshots are stored in `.git/stack-undo/` and archived to `.git/stack-undo/done/` after successful undo. +Snapshots are stored in `.git/stack-undo/` and archived to `.git/stack-undo/done/` after successful undo. Snapshots are automatically pruned to keep at most 50 pending and 50 archived, with the oldest removed first. No manual cleanup is required. > [!NOTE] > diff --git a/internal/undo/undo.go b/internal/undo/undo.go index 6d8b3b4..ed57b80 100644 --- a/internal/undo/undo.go +++ b/internal/undo/undo.go @@ -15,6 +15,14 @@ const ( undoDir = "stack-undo" archiveDir = "done" timeFormat = "20060102T150405.000000000Z" // Compact ISO8601 with nanoseconds to avoid collisions + + // maxActiveSnapshots is the maximum number of pending undo snapshots to keep. + // Oldest snapshots are pruned automatically when this limit is exceeded. + maxActiveSnapshots = 50 + + // maxArchivedSnapshots is the maximum number of archived (used) snapshots to keep. + // Oldest archived snapshots are pruned automatically when this limit is exceeded. + maxArchivedSnapshots = 50 ) // ErrNoSnapshot is returned when no undo snapshot exists. @@ -52,6 +60,7 @@ func NewSnapshot(operation, command, originalHead string) *Snapshot { } // Save persists the snapshot to .git/stack-undo/{timestamp}-{operation}.json. +// Automatically prunes old snapshots if the count exceeds maxActiveSnapshots. func Save(gitDir string, snapshot *Snapshot) error { dir := filepath.Join(gitDir, undoDir) if err := os.MkdirAll(dir, 0755); err != nil { @@ -65,7 +74,12 @@ func Save(gitDir string, snapshot *Snapshot) error { if err != nil { return err } - return os.WriteFile(path, data, 0644) + if err := os.WriteFile(path, data, 0644); err != nil { + return err + } + + // Prune old snapshots if we exceed the limit + return pruneDir(dir, maxActiveSnapshots) } // LoadLatest reads the most recent snapshot from .git/stack-undo/. @@ -125,6 +139,7 @@ func Load(path string) (*Snapshot, error) { } // Archive moves a snapshot file to the done/ subdirectory. +// Automatically prunes old archived snapshots if the count exceeds maxArchivedSnapshots. func Archive(gitDir, snapshotPath string) error { archivePath := filepath.Join(gitDir, undoDir, archiveDir) if err := os.MkdirAll(archivePath, 0755); err != nil { @@ -133,7 +148,12 @@ func Archive(gitDir, snapshotPath string) error { filename := filepath.Base(snapshotPath) dest := filepath.Join(archivePath, filename) - return os.Rename(snapshotPath, dest) + if err := os.Rename(snapshotPath, dest); err != nil { + return err + } + + // Prune old archived snapshots if we exceed the limit + return pruneDir(archivePath, maxArchivedSnapshots) } // List returns all available (non-archived) snapshots, sorted newest first. @@ -195,3 +215,44 @@ func Remove(snapshotPath string) error { } return err } + +// pruneDir removes the oldest .json files in dir if the count exceeds max. +// Files are sorted by name (which starts with a timestamp), so oldest are deleted first. +func pruneDir(dir string, max int) error { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + // Filter to only .json files + var jsonFiles []os.DirEntry + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") { + jsonFiles = append(jsonFiles, entry) + } + } + + // Nothing to prune + if len(jsonFiles) <= max { + return nil + } + + // Sort ascending by name (oldest first, since filenames start with timestamp) + sort.Slice(jsonFiles, func(i, j int) bool { + return jsonFiles[i].Name() < jsonFiles[j].Name() + }) + + // Delete oldest files until we're at or below max + toDelete := len(jsonFiles) - max + for i := 0; i < toDelete; i++ { + path := filepath.Join(dir, jsonFiles[i].Name()) + if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) { + return removeErr + } + } + + return nil +} diff --git a/internal/undo/undo_test.go b/internal/undo/undo_test.go index 195a535..159ed4e 100644 --- a/internal/undo/undo_test.go +++ b/internal/undo/undo_test.go @@ -320,3 +320,91 @@ func TestRemove(t *testing.T) { t.Errorf("Remove non-existent should not error: %v", err) } } + +func TestSavePrunesOldSnapshots(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + // Create 55 snapshots (exceeds the 50 limit) + for i := 0; i < 55; i++ { + snapshot := undo.NewSnapshot("cascade", "gh stack cascade", "main") + // Use distinct timestamps to ensure unique filenames + snapshot.Timestamp = time.Date(2024, 1, 1, 0, 0, i, 0, time.UTC) + snapshot.Branches["feature"] = undo.BranchState{SHA: "abc"} + if err := undo.Save(gitDir, snapshot); err != nil { + t.Fatalf("Save %d failed: %v", i, err) + } + } + + // Verify only 50 snapshots remain + snapshots, err := undo.List(gitDir) + if err != nil { + t.Fatal(err) + } + + if len(snapshots) != 50 { + t.Errorf("Expected 50 snapshots after pruning, got %d", len(snapshots)) + } + + // Verify the oldest 5 were pruned (timestamps 0-4 should be gone) + // The newest should be timestamp second=54, oldest kept should be second=5 + if len(snapshots) > 0 { + newest := snapshots[0] + if newest.Timestamp.Second() != 54 { + t.Errorf("Expected newest snapshot to have second=54, got %d", newest.Timestamp.Second()) + } + oldest := snapshots[len(snapshots)-1] + if oldest.Timestamp.Second() != 5 { + t.Errorf("Expected oldest kept snapshot to have second=5, got %d", oldest.Timestamp.Second()) + } + } +} + +func TestArchivePrunesOldSnapshots(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + // Create and archive 55 snapshots (exceeds the 50 limit) + for i := 0; i < 55; i++ { + snapshot := undo.NewSnapshot("cascade", "gh stack cascade", "main") + snapshot.Timestamp = time.Date(2024, 1, 1, 0, 0, i, 0, time.UTC) + snapshot.Branches["feature"] = undo.BranchState{SHA: "abc"} + if err := undo.Save(gitDir, snapshot); err != nil { + t.Fatalf("Save %d failed: %v", i, err) + } + + // Get the path and archive it + _, path, err := undo.LoadLatest(gitDir) + if err != nil { + t.Fatalf("LoadLatest %d failed: %v", i, err) + } + if err := undo.Archive(gitDir, path); err != nil { + t.Fatalf("Archive %d failed: %v", i, err) + } + } + + // Verify only 50 archived snapshots remain + doneDir := filepath.Join(gitDir, "stack-undo", "done") + entries, err := os.ReadDir(doneDir) + if err != nil { + t.Fatalf("Failed to read done dir: %v", err) + } + + // Count only .json files + jsonCount := 0 + for _, entry := range entries { + if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" { + jsonCount++ + } + } + + if jsonCount != 50 { + t.Errorf("Expected 50 archived snapshots after pruning, got %d", jsonCount) + } +}