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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
>
Expand Down
65 changes: 63 additions & 2 deletions internal/undo/undo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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/.
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
88 changes: 88 additions & 0 deletions internal/undo/undo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading