Repository: git-town/git-town

Issue:
catch when git stash does nothing 
There are rare cases where you have uncommitted files, but stashing them results in no changes. An example of this is using `.gitattributes` to enforce a particular type of end of line. In that case, when you add all, nothing actually gets added and thus you have nothing to stash
```
[cr-3] git fetch --prune

[cr-3] git add -A
warning: CRLF will be replaced by LF in dist/cucumber.js.
The file will have its original line endings in your working directory.

[cr-3] git stash
No local changes to save

```
In this case we should not pop at the end as this would mean you are popping something unrelated from the stash.

Discussion:
@kevgo: This is now available as part of [Git Town 20.2](https://github.com/git-town/git-town/releases/tag/v20.2.0).

Solution patch:
diff --git a/internal/cmd/append.go b/internal/cmd/append.go
index 1b55ce1bd..cc7299f7a 100644
--- a/internal/cmd/append.go
+++ b/internal/cmd/append.go
@@ -406,6 +406,7 @@ func appendProgram(data appendFeatureData, finalMessages stringslice.Collector,
 		previousBranchCandidates := []Option[gitdomain.LocalBranchName]{Some(data.initialBranch), data.previousBranch}
 		cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 			DryRun:                   data.dryRun,
+			InitialStashSize:         data.stashSize,
 			RunInGitRoot:             true,
 			StashOpenChanges:         data.hasOpenChanges,
 			PreviousBranchCandidates: previousBranchCandidates,
diff --git a/internal/cmd/cmdhelpers/wrap.go b/internal/cmd/cmdhelpers/wrap.go
index 904fce8e0..5bb4894b8 100644
--- a/internal/cmd/cmdhelpers/wrap.go
+++ b/internal/cmd/cmdhelpers/wrap.go
@@ -20,13 +20,14 @@ func Wrap(program Mutable[program.Program], options WrapOptions) {
 	}
 	if options.StashOpenChanges {
 		program.Value.Prepend(&opcodes.StashOpenChanges{})
-		program.Value.Add(&opcodes.StashPopIfNeeded{})
+		program.Value.Add(&opcodes.StashPopIfNeeded{InitialStashSize: options.InitialStashSize})
 	}
 }
 
 // WrapOptions represents the options given to Wrap.
 type WrapOptions struct {
 	DryRun                   configdomain.DryRun
+	InitialStashSize         gitdomain.StashSize
 	PreviousBranchCandidates []Option[gitdomain.LocalBranchName]
 	RunInGitRoot             bool
 	StashOpenChanges         bool
diff --git a/internal/cmd/compress.go b/internal/cmd/compress.go
index 1de64d3aa..07b95e8c5 100644
--- a/internal/cmd/compress.go
+++ b/internal/cmd/compress.go
@@ -316,6 +316,7 @@ func compressProgram(data compressBranchesData, commitHook configdomain.CommitHo
 	previousBranchCandidates := []Option[gitdomain.LocalBranchName]{data.previousBranch}
 	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 		DryRun:                   data.dryRun,
+		InitialStashSize:         data.stashSize,
 		RunInGitRoot:             true,
 		StashOpenChanges:         data.hasOpenChanges,
 		PreviousBranchCandidates: previousBranchCandidates,
diff --git a/internal/cmd/delete.go b/internal/cmd/delete.go
index cecba2b03..152908fde 100644
--- a/internal/cmd/delete.go
+++ b/internal/cmd/delete.go
@@ -291,6 +291,7 @@ func deleteProgram(data deleteData, finalMessages stringslice.Collector) (runPro
 	localBranchNameToDelete, hasLocalBranchToDelete := data.branchToDeleteInfo.LocalName.Get()
 	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 		DryRun:                   data.dryRun,
+		InitialStashSize:         data.stashSize,
 		RunInGitRoot:             true,
 		StashOpenChanges:         hasLocalBranchToDelete && data.initialBranch != localBranchNameToDelete && data.hasOpenChanges,
 		PreviousBranchCandidates: []Option[gitdomain.LocalBranchName]{data.previousBranch, Some(data.initialBranch)},
diff --git a/internal/cmd/detach.go b/internal/cmd/detach.go
index 28c6d6005..e22cfab9e 100644
--- a/internal/cmd/detach.go
+++ b/internal/cmd/detach.go
@@ -357,6 +357,7 @@ func detachProgram(data detachData, finalMessages stringslice.Collector) program
 	}
 	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 		DryRun:                   data.dryRun,
+		InitialStashSize:         data.stashSize,
 		RunInGitRoot:             true,
 		StashOpenChanges:         false,
 		PreviousBranchCandidates: []Option[gitdomain.LocalBranchName]{data.previousBranch},
diff --git a/internal/cmd/merge.go b/internal/cmd/merge.go
index b803a2d48..d75b892df 100644
--- a/internal/cmd/merge.go
+++ b/internal/cmd/merge.go
@@ -335,6 +335,7 @@ func mergeProgram(data mergeData, dryRun configdomain.DryRun) program.Program {
 	previousBranchCandidates := []Option[gitdomain.LocalBranchName]{data.previousBranch}
 	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 		DryRun:                   dryRun,
+		InitialStashSize:         data.stashSize,
 		RunInGitRoot:             true,
 		StashOpenChanges:         data.hasOpenChanges,
 		PreviousBranchCandidates: previousBranchCandidates,
diff --git a/internal/cmd/prepend.go b/internal/cmd/prepend.go
index e3561de71..b01311780 100644
--- a/internal/cmd/prepend.go
+++ b/internal/cmd/prepend.go
@@ -456,6 +456,7 @@ func prependProgram(data prependData, finalMessages stringslice.Collector) progr
 		previousBranchCandidates := []Option[gitdomain.LocalBranchName]{data.previousBranch}
 		cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 			DryRun:                   data.dryRun,
+			InitialStashSize:         data.stashSize,
 			RunInGitRoot:             true,
 			StashOpenChanges:         data.hasOpenChanges,
 			PreviousBranchCandidates: previousBranchCandidates,
diff --git a/internal/cmd/propose.go b/internal/cmd/propose.go
index c22cc24df..0d458da31 100644
--- a/internal/cmd/propose.go
+++ b/internal/cmd/propose.go
@@ -375,6 +375,7 @@ func proposeProgram(repo execute.OpenRepoResult, data proposeData) program.Progr
 		previousBranchCandidates := []Option[gitdomain.LocalBranchName]{data.previousBranch}
 		cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 			DryRun:                   data.dryRun,
+			InitialStashSize:         data.stashSize,
 			RunInGitRoot:             true,
 			StashOpenChanges:         data.hasOpenChanges,
 			PreviousBranchCandidates: previousBranchCandidates,
diff --git a/internal/cmd/rename.go b/internal/cmd/rename.go
index ad92ec999..3287d450f 100644
--- a/internal/cmd/rename.go
+++ b/internal/cmd/rename.go
@@ -313,6 +313,7 @@ func renameProgram(data renameData, finalMessages stringslice.Collector) program
 	previousBranchCandidates := []Option[gitdomain.LocalBranchName]{Some(data.newBranch), data.previousBranch}
 	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 		DryRun:                   data.dryRun,
+		InitialStashSize:         data.stashSize,
 		RunInGitRoot:             false,
 		StashOpenChanges:         false,
 		PreviousBranchCandidates: previousBranchCandidates,
diff --git a/internal/cmd/ship/always_merge.go b/internal/cmd/ship/always_merge.go
index eeb4feb86..903a60b3b 100644
--- a/internal/cmd/ship/always_merge.go
+++ b/internal/cmd/ship/always_merge.go
@@ -40,6 +40,7 @@ func shipProgramAlwaysMerge(prog Mutable[program.Program], sharedData sharedShip
 	previousBranchCandidates := []Option[gitdomain.LocalBranchName]{sharedData.previousBranch}
 	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 		DryRun:                   sharedData.dryRun,
+		InitialStashSize:         sharedData.stashSize,
 		RunInGitRoot:             true,
 		StashOpenChanges:         !sharedData.isShippingInitialBranch && sharedData.hasOpenChanges,
 		PreviousBranchCandidates: previousBranchCandidates,
diff --git a/internal/cmd/ship/api.go b/internal/cmd/ship/api.go
index 2aafd1b03..766b0bd0a 100644
--- a/internal/cmd/ship/api.go
+++ b/internal/cmd/ship/api.go
@@ -83,6 +83,7 @@ func shipAPIProgram(prog Mutable[program.Program], sharedData sharedShipData, ap
 	previousBranchCandidates := []Option[gitdomain.LocalBranchName]{sharedData.previousBranch}
 	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 		DryRun:                   sharedData.dryRun,
+		InitialStashSize:         sharedData.stashSize,
 		RunInGitRoot:             true,
 		StashOpenChanges:         !sharedData.isShippingInitialBranch && sharedData.hasOpenChanges,
 		PreviousBranchCandidates: previousBranchCandidates,
diff --git a/internal/cmd/ship/ff.go b/internal/cmd/ship/ff.go
index 4168aaed5..c7bdb994a 100644
--- a/internal/cmd/ship/ff.go
+++ b/internal/cmd/ship/ff.go
@@ -40,6 +40,7 @@ func shipProgramFastForward(prog Mutable[program.Program], sharedData sharedShip
 	previousBranchCandidates := []Option[gitdomain.LocalBranchName]{sharedData.previousBranch}
 	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 		DryRun:                   sharedData.dryRun,
+		InitialStashSize:         sharedData.stashSize,
 		RunInGitRoot:             true,
 		StashOpenChanges:         !sharedData.isShippingInitialBranch && sharedData.hasOpenChanges,
 		PreviousBranchCandidates: previousBranchCandidates,
diff --git a/internal/cmd/ship/squash_merge.go b/internal/cmd/ship/squash_merge.go
index 0ee8948f2..3724b97af 100644
--- a/internal/cmd/ship/squash_merge.go
+++ b/internal/cmd/ship/squash_merge.go
@@ -62,6 +62,7 @@ func shipProgramSquashMerge(prog Mutable[program.Program], sharedData sharedShip
 	previousBranchCandidates := []Option[gitdomain.LocalBranchName]{sharedData.previousBranch}
 	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 		DryRun:                   sharedData.dryRun,
+		InitialStashSize:         sharedData.stashSize,
 		RunInGitRoot:             true,
 		StashOpenChanges:         !sharedData.isShippingInitialBranch && sharedData.hasOpenChanges,
 		PreviousBranchCandidates: previousBranchCandidates,
diff --git a/internal/cmd/swap.go b/internal/cmd/swap.go
index 68e51fc3c..7765ba509 100644
--- a/internal/cmd/swap.go
+++ b/internal/cmd/swap.go
@@ -388,6 +388,7 @@ func swapProgram(data swapData, finalMessages stringslice.Collector) program.Pro
 	}
 	cmdhelpers.Wrap(prog, cmdhelpers.WrapOptions{
 		DryRun:                   data.dryRun,
+		InitialStashSize:         data.stashSize,
 		RunInGitRoot:             true,
 		StashOpenChanges:         false,
 		PreviousBranchCandidates: []Option[gitdomain.LocalBranchName]{data.previousBranch},
diff --git a/internal/cmd/sync/cmd.go b/internal/cmd/sync/cmd.go
index 3eb287c0f..0720255c0 100644
--- a/internal/cmd/sync/cmd.go
+++ b/internal/cmd/sync/cmd.go
@@ -153,6 +153,7 @@ func executeSync(syncAllBranches configdomain.AllBranches, syncStack configdomai
 	}
 	cmdhelpers.Wrap(runProgram, cmdhelpers.WrapOptions{
 		DryRun:                   dryRun,
+		InitialStashSize:         data.stashSize,
 		RunInGitRoot:             true,
 		StashOpenChanges:         data.hasOpenChanges,
 		PreviousBranchCandidates: previousbranchCandidates,
diff --git a/internal/undo/undo_finished_program.go b/internal/undo/undo_finished_program.go
index afba4ff17..f0afaf936 100644
--- a/internal/undo/undo_finished_program.go
+++ b/internal/undo/undo_finished_program.go
@@ -43,6 +43,7 @@ func CreateUndoForFinishedProgram(args CreateUndoProgramArgs) program.Program {
 	}
 	cmdhelpers.Wrap(result, cmdhelpers.WrapOptions{
 		DryRun:                   args.RunState.DryRun,
+		InitialStashSize:         args.RunState.BeginStashSize,
 		RunInGitRoot:             true,
 		StashOpenChanges:         args.RunState.IsFinished() && args.HasOpenChanges,
 		PreviousBranchCandidates: previousBranchCandidates,
diff --git a/internal/undo/undostash/stash_diff.go b/internal/undo/undostash/stash_diff.go
index bb0a61ec1..48551ab07 100644
--- a/internal/undo/undostash/stash_diff.go
+++ b/internal/undo/undostash/stash_diff.go
@@ -26,7 +26,7 @@ func NewStashDiff(before, after gitdomain.StashSize) StashDiff {
 func (self StashDiff) Program() program.Program {
 	result := program.Program{}
 	for ; self.EntriesAdded > 0; self.EntriesAdded-- {
-		result.Add(&opcodes.StashPopIfNeeded{})
+		result.Add(&opcodes.StashPopIfExists{})
 	}
 	return result
 }
diff --git a/internal/vm/opcodes/all.go b/internal/vm/opcodes/all.go
index c60f1d129..9ca00a286 100644
--- a/internal/vm/opcodes/all.go
+++ b/internal/vm/opcodes/all.go
@@ -96,6 +96,7 @@ func All() []shared.Opcode {
 		&SnapshotInitialUpdateLocalSHA{},
 		&StashDrop{},
 		&StashOpenChanges{},
+		&StashPopIfExists{},
 		&StashPopIfNeeded{},
 		&StashPop{},
 		&SyncFeatureBranchCompress{},
diff --git a/internal/vm/opcodes/stash_pop_if_exists.go b/internal/vm/opcodes/stash_pop_if_exists.go
new file mode 100644
index 000000000..6fbfd934b
--- /dev/null
+++ b/internal/vm/opcodes/stash_pop_if_exists.go
@@ -0,0 +1,25 @@
+package opcodes
+
+import (
+	"github.com/git-town/git-town/v20/internal/vm/shared"
+)
+
+// StashPopIfExists restores stashed away changes into the workspace.
+type StashPopIfExists struct {
+	undeclaredOpcodeMethods `exhaustruct:"optional"`
+}
+
+func (self *StashPopIfExists) Run(args shared.RunArgs) error {
+	stashSize, err := args.Git.StashSize(args.Backend)
+	if err != nil {
+		return err
+	}
+	if stashSize == 0 && !args.Config.Value.NormalConfig.DryRun {
+		return nil
+	}
+	args.PrependOpcodes(
+		&StashPop{},
+		&ChangesUnstageAll{},
+	)
+	return nil
+}
diff --git a/internal/vm/opcodes/stash_pop_if_needed.go b/internal/vm/opcodes/stash_pop_if_needed.go
index 4412a2e6b..ea3030f94 100644
--- a/internal/vm/opcodes/stash_pop_if_needed.go
+++ b/internal/vm/opcodes/stash_pop_if_needed.go
@@ -1,11 +1,13 @@
 package opcodes
 
 import (
+	"github.com/git-town/git-town/v20/internal/git/gitdomain"
 	"github.com/git-town/git-town/v20/internal/vm/shared"
 )
 
-// StashPopIfNeeded restores stashed away changes into the workspace.
+// StashPopIfExists restores stashed away changes into the workspace.
 type StashPopIfNeeded struct {
+	InitialStashSize        gitdomain.StashSize
 	undeclaredOpcodeMethods `exhaustruct:"optional"`
 }
 
@@ -14,12 +16,11 @@ func (self *StashPopIfNeeded) Run(args shared.RunArgs) error {
 	if err != nil {
 		return err
 	}
-	if stashSize == 0 && !args.Config.Value.NormalConfig.DryRun {
+	if stashSize <= self.InitialStashSize && !args.Config.Value.NormalConfig.DryRun {
 		return nil
 	}
 	args.PrependOpcodes(
-		&StashPop{},
-		&ChangesUnstageAll{},
+		&StashPopIfExists{},
 	)
 	return nil
 }



Test patch:
diff --git a/internal/vm/program/program_test.go b/internal/vm/program/program_test.go
index 5e50b671f..0c7a23437 100644
--- a/internal/vm/program/program_test.go
+++ b/internal/vm/program/program_test.go
@@ -184,9 +184,9 @@ func TestProgram(t *testing.T) {
 		t.Run("prepend a populated list", func(t *testing.T) {
 			t.Parallel()
 			give := program.Program{&opcodes.MergeAbort{}}
-			other := program.Program{&opcodes.StashOpenChanges{}, &opcodes.StashPopIfNeeded{}}
+			other := program.Program{&opcodes.StashOpenChanges{}, &opcodes.StashPopIfExists{}}
 			give.PrependProgram(other)
-			want := []shared.Opcode{&opcodes.StashOpenChanges{}, &opcodes.StashPopIfNeeded{}, &opcodes.MergeAbort{}}
+			want := []shared.Opcode{&opcodes.StashOpenChanges{}, &opcodes.StashPopIfExists{}, &opcodes.MergeAbort{}}
 			must.Eq(t, want, give)
 		})
 		t.Run("prepend an empty list", func(t *testing.T) {
diff --git a/internal/vm/statefile/save_load_test.go b/internal/vm/statefile/save_load_test.go
index 78816235f..c6729b507 100644
--- a/internal/vm/statefile/save_load_test.go
+++ b/internal/vm/statefile/save_load_test.go
@@ -126,7 +126,8 @@ func TestLoadSave(t *testing.T) {
 				&opcodes.SnapshotInitialUpdateLocalSHAIfNeeded{Branch: "branch"},
 				&opcodes.StashDrop{},
 				&opcodes.StashPop{},
-				&opcodes.StashPopIfNeeded{},
+				&opcodes.StashPopIfExists{},
+				&opcodes.StashPopIfNeeded{InitialStashSize: 2},
 				&opcodes.StashOpenChanges{},
 				&opcodes.SyncFeatureBranchCompress{CommitMessage: Some(gitdomain.CommitMessage("commit message")), CurrentBranch: "branch", Offline: true, InitialParentName: gitdomain.NewLocalBranchNameOption("parent"), InitialParentSHA: Some(gitdomain.NewSHA("111111")), TrackingBranch: Some(gitdomain.NewRemoteBranchName("origin/branch"))},
 				&opcodes.SyncFeatureBranchMerge{Branch: "branch", InitialParentName: gitdomain.NewLocalBranchNameOption("original-parent"), InitialParentSHA: Some(gitdomain.NewSHA("123456")), TrackingBranch: Some(gitdomain.NewRemoteBranchName("origin/branch"))},
@@ -671,6 +672,12 @@ func TestLoadSave(t *testing.T) {
     },
     {
       "data": {},
+      "type": "StashPopIfExists"
+    },
+    {
+      "data": {
+        "InitialStashSize": 2
+      },
       "type": "StashPopIfNeeded"
     },
     {