Repository: git-town/git-town

Issue:
Sync with compress should not recreate commits if there are no changes
### Discussed in https://github.com/git-town/git-town/discussions/4314

<div type='discussions-op-text'>

<sup>Originally posted by **erik-rw** December 13, 2024</sup>
Using the compress sync feature strategy. The docs say

> When using the compress sync strategy, [git town sync](https://www.git-town.com/commands/sync) first merges the tracking and parent branches and then [compresses](https://www.git-town.com/commands/compress) the synced branch.

which git town does perfectly. 

But if there are no changes (not locally or remotely), the existing commit is still replaced with a new one. This triggers another (unnecessary) CI run.

I wonder if this is expected behavior or if there should be a way to avoid the recreation.</div>

Discussion:
@kevgo: This is a great idea. Git Town already knows how to determine if a branch is in sync with its parent or tracking branch. It should be possible to skip sync operations that wouldn't change anything.@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/sync/sync_feature_branch.go b/internal/cmd/sync/sync_feature_branch.go
index 23f4aa5ee..9a18030f5 100644
--- a/internal/cmd/sync/sync_feature_branch.go
+++ b/internal/cmd/sync/sync_feature_branch.go
@@ -74,18 +74,14 @@ func syncFeatureParentBranch(syncStrategy configdomain.SyncStrategy, args featur
 func FeatureTrackingBranchProgram(trackingBranch gitdomain.RemoteBranchName, syncStrategy configdomain.SyncStrategy, args FeatureTrackingArgs) {
 	switch syncStrategy {
 	case configdomain.SyncStrategyCompress:
-		args.Program.Value.Add(&opcodes.MergeIntoCurrentBranch{BranchToMerge: trackingBranch.BranchName()})
-		if firstCommitMessage, has := args.FirstCommitMessage.Get(); has {
-			args.Program.Value.Add(&opcodes.BranchCurrentResetToParent{CurrentBranch: args.LocalName})
-			args.Program.Value.Add(&opcodes.CommitWithMessage{
-				AuthorOverride: None[gitdomain.Author](),
-				CommitHook:     configdomain.CommitHookEnabled,
-				Message:        firstCommitMessage,
-			})
-		}
-		if args.Offline.IsFalse() {
-			args.Program.Value.Add(&opcodes.PushCurrentBranchForceIfNeeded{CurrentBranch: args.LocalName, ForceIfIncludes: false})
-		}
+		args.Program.Value.Add(
+			&opcodes.CompressMergeTrackingBranch{
+				CurrentBranch:  args.LocalName,
+				CommitMessage:  args.FirstCommitMessage,
+				Offline:        args.Offline,
+				TrackingBranch: trackingBranch,
+			},
+		)
 	case configdomain.SyncStrategyMerge:
 		args.Program.Value.Add(&opcodes.MergeIntoCurrentBranch{BranchToMerge: trackingBranch.BranchName()})
 	case configdomain.SyncStrategyRebase:
diff --git a/internal/vm/opcodes/all.go b/internal/vm/opcodes/all.go
index b9e3751f8..9ae99dcd7 100644
--- a/internal/vm/opcodes/all.go
+++ b/internal/vm/opcodes/all.go
@@ -47,6 +47,7 @@ func All() []shared.Opcode {
 		&CommitRevert{},
 		&CommitWithMessage{},
 		&Commit{},
+		&CompressMergeTrackingBranch{},
 		&ConfigRemove{},
 		&ConfigSet{},
 		&ConflictPhantomFinalize{},
diff --git a/internal/vm/opcodes/compress_merge_tracking_branch.go b/internal/vm/opcodes/compress_merge_tracking_branch.go
new file mode 100644
index 000000000..cd26e89a4
--- /dev/null
+++ b/internal/vm/opcodes/compress_merge_tracking_branch.go
@@ -0,0 +1,36 @@
+package opcodes
+
+import (
+	"github.com/git-town/git-town/v20/internal/config/configdomain"
+	"github.com/git-town/git-town/v20/internal/git/gitdomain"
+	"github.com/git-town/git-town/v20/internal/vm/shared"
+	. "github.com/git-town/git-town/v20/pkg/prelude"
+)
+
+// CompressMergeTrackingBranch merges the tracking branch when syncing using the "compress" strategy.
+type CompressMergeTrackingBranch struct {
+	CommitMessage           Option[gitdomain.CommitMessage]
+	CurrentBranch           gitdomain.LocalBranchName
+	Offline                 configdomain.Offline
+	TrackingBranch          gitdomain.RemoteBranchName
+	undeclaredOpcodeMethods `exhaustruct:"optional"`
+}
+
+func (self *CompressMergeTrackingBranch) Run(args shared.RunArgs) error {
+	opcodes := []shared.Opcode{
+		&MergeIntoCurrentBranch{BranchToMerge: self.TrackingBranch.BranchName()},
+	}
+	if firstCommitMessage, has := self.CommitMessage.Get(); has {
+		opcodes = append(opcodes, &BranchCurrentResetToParent{CurrentBranch: self.CurrentBranch})
+		opcodes = append(opcodes, &CommitWithMessage{
+			AuthorOverride: None[gitdomain.Author](),
+			CommitHook:     configdomain.CommitHookEnabled,
+			Message:        firstCommitMessage,
+		})
+	}
+	if self.Offline.IsFalse() {
+		opcodes = append(opcodes, &PushCurrentBranchForceIfNeeded{CurrentBranch: self.CurrentBranch, ForceIfIncludes: false})
+	}
+	args.PrependOpcodes(opcodes...)
+	return nil
+}



Test patch:
diff --git a/internal/vm/statefile/save_load_test.go b/internal/vm/statefile/save_load_test.go
index 08a112e45..ef8df2060 100644
--- a/internal/vm/statefile/save_load_test.go
+++ b/internal/vm/statefile/save_load_test.go
@@ -80,6 +80,7 @@ func TestLoadSave(t *testing.T) {
 				&opcodes.CommitRevert{SHA: "123456"},
 				&opcodes.CommitRevertIfNeeded{SHA: "123456"},
 				&opcodes.CommitWithMessage{AuthorOverride: Some(gitdomain.Author("user@acme.com")), Message: "my message", CommitHook: configdomain.CommitHookEnabled},
+				&opcodes.CompressMergeTrackingBranch{CommitMessage: Some(gitdomain.CommitMessage("commit message")), CurrentBranch: "branch", Offline: true, TrackingBranch: "origin/branch"},
 				&opcodes.ConfigRemove{Key: configdomain.KeyOffline, Scope: configdomain.ConfigScopeLocal},
 				&opcodes.ConfigSet{Key: configdomain.KeyOffline, Scope: configdomain.ConfigScopeLocal, Value: "1"},
 				&opcodes.ConflictPhantomResolveAll{ParentBranch: Some(gitdomain.NewLocalBranchName("parent")), ParentSHA: Some(gitdomain.NewSHA("123456")), Resolution: gitdomain.ConflictResolutionOurs},
@@ -376,6 +377,15 @@ func TestLoadSave(t *testing.T) {
       },
       "type": "CommitWithMessage"
     },
+    {
+      "data": {
+        "CommitMessage": "commit message",
+        "CurrentBranch": "branch",
+        "Offline": true,
+        "TrackingBranch": "origin/branch"
+      },
+      "type": "CompressMergeTrackingBranch"
+    },
     {
       "data": {
         "Key": "git-town.offline",