Skip to content

Commit 2c4620d

Browse files
committed
fix: address review feedback for undo feature
- Use stable stash commit hash instead of stash@{0} ref - Restore auto-stashed changes after successful operations - Use nanosecond precision for snapshot timestamps to avoid collisions - Fix StashPop to use 'git stash apply' for commit hash support - Update test names to accurately reflect behavior
1 parent 09ea77b commit 2c4620d

8 files changed

Lines changed: 144 additions & 82 deletions

File tree

cmd/cascade.go

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,26 @@ func runCascade(cmd *cobra.Command, args []string) error {
7878
}
7979

8080
// Save undo snapshot (unless dry-run)
81+
var stashRef string
8182
if !cascadeDryRunFlag {
82-
if err := saveUndoSnapshot(g, cfg, branches, nil, "cascade", "gh stack cascade"); err != nil {
83-
fmt.Printf("Warning: could not save undo state: %v\n", err)
83+
var saveErr error
84+
stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "cascade", "gh stack cascade")
85+
if saveErr != nil {
86+
fmt.Printf("Warning: could not save undo state: %v\n", saveErr)
8487
}
8588
}
8689

87-
return doCascade(g, cfg, branches, cascadeDryRunFlag)
90+
err = doCascade(g, cfg, branches, cascadeDryRunFlag)
91+
92+
// Restore auto-stashed changes after successful operation
93+
if err == nil && stashRef != "" {
94+
fmt.Println("Restoring auto-stashed changes...")
95+
if popErr := g.StashPop(stashRef); popErr != nil {
96+
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", stashRef[:7], popErr)
97+
}
98+
}
99+
100+
return err
88101
}
89102

90103
func doCascade(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool) error {
@@ -201,27 +214,30 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
201214
// It auto-stashes any uncommitted changes if the working tree is dirty.
202215
// branches: branches that will be modified (rebased)
203216
// deletedBranches: branches that will be deleted (for sync)
204-
func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, deletedBranches []*tree.Node, operation, command string) error {
217+
// Returns the stash ref (commit hash) if changes were stashed, empty string otherwise.
218+
func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, deletedBranches []*tree.Node, operation, command string) (string, error) {
205219
gitDir := g.GetGitDir()
206220

207221
// Get current branch for original head
208222
currentBranch, err := g.CurrentBranch()
209223
if err != nil {
210-
return err
224+
return "", err
211225
}
212226

213227
// Create snapshot
214228
snapshot := undo.NewSnapshot(operation, command, currentBranch)
215229

216230
// Auto-stash if dirty
231+
var stashRef string
217232
dirty, err := g.IsDirty()
218233
if err != nil {
219-
return err
234+
return "", err
220235
}
221236
if dirty {
222-
stashRef, stashErr := g.Stash("gh-stack auto-stash before " + operation)
237+
var stashErr error
238+
stashRef, stashErr = g.Stash("gh-stack auto-stash before " + operation)
223239
if stashErr != nil {
224-
return fmt.Errorf("failed to stash changes: %w", stashErr)
240+
return "", fmt.Errorf("failed to stash changes: %w", stashErr)
225241
}
226242
if stashRef != "" {
227243
snapshot.StashRef = stashRef
@@ -231,52 +247,58 @@ func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, del
231247

232248
// Capture state of branches that will be modified
233249
for _, node := range branches {
234-
bs, err := captureBranchState(g, cfg, node.Name)
235-
if err != nil {
250+
bs, captureErr := captureBranchState(g, cfg, node.Name)
251+
if captureErr != nil {
236252
// Non-fatal: log warning and continue
237-
fmt.Printf("Warning: could not capture state for %s: %v\n", node.Name, err)
253+
fmt.Printf("Warning: could not capture state for %s: %v\n", node.Name, captureErr)
238254
continue
239255
}
240256
snapshot.Branches[node.Name] = bs
241257
}
242258

243259
// Capture state of branches that will be deleted
244260
for _, node := range deletedBranches {
245-
bs, err := captureBranchState(g, cfg, node.Name)
246-
if err != nil {
247-
fmt.Printf("Warning: could not capture state for deleted branch %s: %v\n", node.Name, err)
261+
bs, captureErr := captureBranchState(g, cfg, node.Name)
262+
if captureErr != nil {
263+
fmt.Printf("Warning: could not capture state for deleted branch %s: %v\n", node.Name, captureErr)
248264
continue
249265
}
250266
snapshot.DeletedBranches[node.Name] = bs
251267
}
252268

253269
// Save snapshot
254-
return undo.Save(gitDir, snapshot)
270+
if saveErr := undo.Save(gitDir, snapshot); saveErr != nil {
271+
return "", saveErr
272+
}
273+
return stashRef, nil
255274
}
256275

257276
// saveUndoSnapshotByName is like saveUndoSnapshot but takes branch names instead of tree nodes.
258277
// Useful for sync where we don't always have tree nodes.
259-
func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string, deletedBranchNames []string, operation, command string) error {
278+
// Returns the stash ref (commit hash) if changes were stashed, empty string otherwise.
279+
func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string, deletedBranchNames []string, operation, command string) (string, error) {
260280
gitDir := g.GetGitDir()
261281

262282
// Get current branch for original head
263283
currentBranch, err := g.CurrentBranch()
264284
if err != nil {
265-
return err
285+
return "", err
266286
}
267287

268288
// Create snapshot
269289
snapshot := undo.NewSnapshot(operation, command, currentBranch)
270290

271291
// Auto-stash if dirty
292+
var stashRef string
272293
dirty, err := g.IsDirty()
273294
if err != nil {
274-
return err
295+
return "", err
275296
}
276297
if dirty {
277-
stashRef, stashErr := g.Stash("gh-stack auto-stash before " + operation)
298+
var stashErr error
299+
stashRef, stashErr = g.Stash("gh-stack auto-stash before " + operation)
278300
if stashErr != nil {
279-
return fmt.Errorf("failed to stash changes: %w", stashErr)
301+
return "", fmt.Errorf("failed to stash changes: %w", stashErr)
280302
}
281303
if stashRef != "" {
282304
snapshot.StashRef = stashRef
@@ -286,26 +308,29 @@ func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string
286308

287309
// Capture state of branches that will be modified
288310
for _, name := range branchNames {
289-
bs, err := captureBranchState(g, cfg, name)
290-
if err != nil {
291-
fmt.Printf("Warning: could not capture state for %s: %v\n", name, err)
311+
bs, captureErr := captureBranchState(g, cfg, name)
312+
if captureErr != nil {
313+
fmt.Printf("Warning: could not capture state for %s: %v\n", name, captureErr)
292314
continue
293315
}
294316
snapshot.Branches[name] = bs
295317
}
296318

297319
// Capture state of branches that will be deleted
298320
for _, name := range deletedBranchNames {
299-
bs, err := captureBranchState(g, cfg, name)
300-
if err != nil {
301-
fmt.Printf("Warning: could not capture state for deleted branch %s: %v\n", name, err)
321+
bs, captureErr := captureBranchState(g, cfg, name)
322+
if captureErr != nil {
323+
fmt.Printf("Warning: could not capture state for deleted branch %s: %v\n", name, captureErr)
302324
continue
303325
}
304326
snapshot.DeletedBranches[name] = bs
305327
}
306328

307329
// Save snapshot
308-
return undo.Save(gitDir, snapshot)
330+
if saveErr := undo.Save(gitDir, snapshot); saveErr != nil {
331+
return "", saveErr
332+
}
333+
return stashRef, nil
309334
}
310335

311336
// captureBranchState captures the current state of a single branch.

cmd/submit.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,33 @@ func runSubmit(cmd *cobra.Command, args []string) error {
113113
}
114114

115115
// Save undo snapshot (unless dry-run)
116+
var stashRef string
116117
if !submitDryRunFlag {
117-
if err := saveUndoSnapshot(g, cfg, branches, nil, "submit", "gh stack submit"); err != nil {
118-
fmt.Printf("Warning: could not save undo state: %v\n", err)
118+
var saveErr error
119+
stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "submit", "gh stack submit")
120+
if saveErr != nil {
121+
fmt.Printf("Warning: could not save undo state: %v\n", saveErr)
119122
}
120123
}
121124

122125
// Phase 1: Cascade
123126
fmt.Println("=== Phase 1: Cascade ===")
124-
if err := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, branchNames); err != nil {
125-
return err // Conflict or error - state saved, user can continue
127+
if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, branchNames); cascadeErr != nil {
128+
return cascadeErr // Conflict or error - state saved, user can continue
126129
}
127130

128131
// Phases 2 & 3
129-
return doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag, submitWebFlag)
132+
err = doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag, submitWebFlag)
133+
134+
// Restore auto-stashed changes after successful operation
135+
if err == nil && stashRef != "" {
136+
fmt.Println("Restoring auto-stashed changes...")
137+
if popErr := g.StashPop(stashRef); popErr != nil {
138+
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", stashRef[:7], popErr)
139+
}
140+
}
141+
142+
return err
130143
}
131144

132145
// doSubmitPushAndPR handles push and PR creation/update phases.

cmd/sync.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,13 @@ func runSync(cmd *cobra.Command, args []string) error {
100100

101101
// Save undo snapshot of all tracked branches (unless dry-run)
102102
// This captures state before any modifications (fetch, delete, rebase)
103+
var stashRef string
103104
if !syncDryRunFlag {
104105
allBranches, listErr := cfg.ListTrackedBranches()
105106
if listErr == nil && len(allBranches) > 0 {
106-
if saveErr := saveUndoSnapshotByName(g, cfg, allBranches, nil, "sync", "gh stack sync"); saveErr != nil {
107+
var saveErr error
108+
stashRef, saveErr = saveUndoSnapshotByName(g, cfg, allBranches, nil, "sync", "gh stack sync")
109+
if saveErr != nil {
107110
fmt.Printf("Warning: could not save undo state: %v\n", saveErr)
108111
}
109112
}
@@ -350,6 +353,14 @@ func runSync(cmd *cobra.Command, args []string) error {
350353
}
351354
}
352355

356+
// Restore auto-stashed changes after successful operation
357+
if stashRef != "" {
358+
fmt.Println("Restoring auto-stashed changes...")
359+
if popErr := g.StashPop(stashRef); popErr != nil {
360+
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", stashRef[:7], popErr)
361+
}
362+
}
363+
353364
fmt.Println("\nSync complete!")
354365
return nil
355366
}

cmd/undo.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,11 @@ func runUndo(cmd *cobra.Command, args []string) error {
219219
}
220220
}
221221

222-
// Restore stash
222+
// Restore stash using the specific stash commit hash
223223
if snapshot.StashRef != "" {
224224
fmt.Println(" Restoring stashed changes...")
225-
if err := g.StashPop(); err != nil {
226-
fmt.Printf(" Warning: could not cleanly restore stashed changes. Your changes are still in %s.\n", snapshot.StashRef)
225+
if err := g.StashPop(snapshot.StashRef); err != nil {
226+
fmt.Printf(" Warning: could not cleanly restore stashed changes. Your changes are still in stash (commit %s).\n", snapshot.StashRef[:7])
227227
}
228228
}
229229

e2e/submit_test.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func TestSubmitUpdateOnlyDryRun(t *testing.T) {
146146
}
147147
}
148148

149-
func TestSubmitWithDirtyWorkTreeAutoStashes(t *testing.T) {
149+
func TestSubmitDryRunAllowsDirtyWorkTree(t *testing.T) {
150150
env := NewTestEnvWithRemote(t)
151151
env.MustRun("init")
152152

@@ -158,13 +158,12 @@ func TestSubmitWithDirtyWorkTreeAutoStashes(t *testing.T) {
158158
env.WriteFile("dirty.txt", "uncommitted")
159159
env.Git("add", "dirty.txt")
160160

161-
// Submit should auto-stash and succeed (at least the cascade phase)
162-
// Note: without GH_TOKEN, PR creation will fail, but cascade phase should work
161+
// Submit --dry-run should succeed even with dirty working tree
162+
// (dry-run skips the undo snapshot which includes auto-stashing)
163163
result := env.Run("submit", "--dry-run")
164164

165-
// In dry-run mode, auto-stash still happens but no actual rebases
166165
if result.Failed() {
167-
t.Errorf("submit dry-run should succeed with auto-stash, got: %s", result.Stderr)
166+
t.Errorf("submit --dry-run should succeed with dirty tree, got: %s", result.Stderr)
168167
}
169168
}
170169

e2e/undo_test.go

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ func TestUndoArchivesSnapshot(t *testing.T) {
183183
}
184184
}
185185

186-
func TestUndoWithAutoStash(t *testing.T) {
186+
func TestCascadeWithAutoStashRestoresAfterSuccess(t *testing.T) {
187187
env := NewTestEnv(t)
188188
env.MustRun("init")
189189

@@ -198,22 +198,19 @@ func TestUndoWithAutoStash(t *testing.T) {
198198
// Create uncommitted changes
199199
env.WriteFile("uncommitted.txt", "uncommitted content\n")
200200

201-
// Cascade should auto-stash
201+
// Cascade should auto-stash and then restore after success
202202
result := env.MustRun("cascade")
203203
if !result.ContainsStdout("Auto-stashed") {
204204
t.Error("expected auto-stash message")
205205
}
206+
if !result.ContainsStdout("Restoring auto-stashed") {
207+
t.Error("expected restore message after successful cascade")
208+
}
206209

207-
// Working tree should be clean (stashed)
208-
env.AssertClean()
209-
210-
// Undo should restore stash
211-
env.MustRun("undo", "--force")
212-
213-
// Check that uncommitted file is back
210+
// Uncommitted file should be restored after successful cascade
214211
content, err := os.ReadFile(filepath.Join(env.WorkDir, "uncommitted.txt"))
215212
if err != nil {
216-
t.Errorf("uncommitted file should be restored: %v", err)
213+
t.Errorf("uncommitted file should be present after cascade: %v", err)
217214
} else if string(content) != "uncommitted content\n" {
218215
t.Errorf("uncommitted file has wrong content: %q", content)
219216
}

internal/git/git.go

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,11 @@ func (g *Git) CreateBranchAt(name, sha string) error {
293293
return g.runSilent("branch", name, sha)
294294
}
295295

296-
// Stash creates a stash with the given message and returns the stash reference.
296+
// Stash creates a stash with the given message and returns the stash commit hash.
297297
// Returns an empty string if there was nothing to stash.
298298
// Includes untracked files (-u) to capture all working tree changes.
299+
// The returned hash is stable and can be used to restore this specific stash
300+
// even if other stashes are created later.
299301
func (g *Git) Stash(message string) (string, error) {
300302
// Check if there's anything to stash first
301303
dirty, err := g.IsDirty()
@@ -307,18 +309,33 @@ func (g *Git) Stash(message string) (string, error) {
307309
}
308310

309311
// Create the stash with -u to include untracked files
310-
if err := g.runSilent("stash", "push", "-u", "-m", message); err != nil {
311-
return "", err
312+
if stashErr := g.runSilent("stash", "push", "-u", "-m", message); stashErr != nil {
313+
return "", stashErr
312314
}
313315

314-
// Get the stash reference (stash@{0} after a successful push)
315-
return "stash@{0}", nil
316+
// Resolve the created stash to a stable identifier (its commit hash)
317+
// This is more reliable than stash@{0} which can shift if user creates more stashes
318+
out, parseErr := g.run("rev-parse", "stash@{0}")
319+
if parseErr != nil {
320+
return "", parseErr
321+
}
322+
return out, nil
316323
}
317324

318-
// StashPop pops the most recent stash entry.
319-
// Returns an error if there are conflicts or no stash entries.
320-
func (g *Git) StashPop() error {
321-
return g.runInteractive("stash", "pop")
325+
// StashPop restores a specific stash entry when a reference is provided.
326+
// If ref is empty, it pops the most recent stash entry (matching `git stash pop`).
327+
// When a commit hash is provided (from Stash()), uses apply since pop only accepts stash refs.
328+
// Note: apply doesn't remove the stash entry, but this is acceptable for undo purposes.
329+
// Returns an error if there are conflicts or no matching stash entry.
330+
func (g *Git) StashPop(ref string) error {
331+
if ref == "" {
332+
return g.runInteractive("stash", "pop")
333+
}
334+
335+
// git stash pop only accepts stash references (stash@{n}), not commit hashes.
336+
// Use apply instead which accepts commit hashes.
337+
// Note: This leaves the stash entry in place, which is fine for our use case.
338+
return g.runInteractive("stash", "apply", ref)
322339
}
323340

324341
// StashList returns true if there are any stash entries.

0 commit comments

Comments
 (0)