Repository: git-town/git-town

Issue:
Handle phantom merge conflicts for stacks with a perennial branch at the root
The current logic for detecting phantom merge conflicts assumes that the stack is always rooted in the main branch. This breaks down when the root of the stack is another perennial branch.

This issue tracks extending phantom merge conflict detection to support stacks that are rooted on any perennial branch, not just main.

https://github.com/git-town/git-town/blob/ed11ea8ae267fddd571240c2cd5228074068458a/internal/git/phantom_merge_conflict.go#L59

https://github.com/git-town/git-town/blob/ed11ea8ae267fddd571240c2cd5228074068458a/internal/git/phantom_merge_conflict.go#L47

Hints from issue comments:
@kevgo: This is now available in [v21.4](https://github.com/git-town/git-town/releases/tag/v21.4.0).

Solution patch:
diff --git a/internal/config/configdomain/lineage.go b/internal/config/configdomain/lineage.go
index fda14ec54..01d5277ff 100644
--- a/internal/config/configdomain/lineage.go
+++ b/internal/config/configdomain/lineage.go
@@ -220,6 +220,18 @@ func (self Lineage) RemoveBranch(branch gitdomain.LocalBranchName) Lineage {
 	return self
 }
 
+// Root provides the oldest ancestor of the given branch.
+func (self Lineage) Root(branch gitdomain.LocalBranchName) gitdomain.LocalBranchName {
+	current := branch
+	for {
+		parent, found := self.data[current]
+		if !found {
+			return current
+		}
+		current = parent
+	}
+}
+
 // Roots provides the branches with children and no parents.
 func (self Lineage) Roots() gitdomain.LocalBranchNames {
 	roots := gitdomain.LocalBranchNames{}
diff --git a/internal/git/commands.go b/internal/git/commands.go
index 434875829..314c91bd3 100644
--- a/internal/git/commands.go
+++ b/internal/git/commands.go
@@ -475,12 +475,12 @@ func (self *Commands) FetchUpstream(runner subshelldomain.Runner, branch gitdoma
 	return runner.Run("git", "fetch", gitdomain.RemoteUpstream.String(), branch.String())
 }
 
-func (self *Commands) FileConflictFullInfo(querier subshelldomain.Querier, quickInfo FileConflictQuickInfo, parentLocation gitdomain.Location, mainBranch gitdomain.LocalBranchName) (FileConflictFullInfo, error) {
-	mainBlob := None[BlobInfo]()
+func (self *Commands) FileConflictFullInfo(querier subshelldomain.Querier, quickInfo FileConflictQuickInfo, parentLocation gitdomain.Location, rootBranch gitdomain.LocalBranchName) (FileConflictFullInfo, error) {
+	rootBlob := None[BlobInfo]()
 	parentBlob := None[BlobInfo]()
 	if currentBranchBlobInfo, has := quickInfo.CurrentBranchChange.Get(); has {
 		var err error
-		mainBlob, err = self.ContentBlobInfo(querier, mainBranch.Location(), currentBranchBlobInfo.FilePath)
+		rootBlob, err = self.ContentBlobInfo(querier, rootBranch.Location(), currentBranchBlobInfo.FilePath)
 		if err != nil {
 			return FileConflictFullInfo{}, err
 		}
@@ -491,16 +491,16 @@ func (self *Commands) FileConflictFullInfo(querier subshelldomain.Querier, quick
 	}
 	result := FileConflictFullInfo{
 		Current: quickInfo.CurrentBranchChange,
-		Main:    mainBlob,
 		Parent:  parentBlob,
+		Root:    rootBlob,
 	}
 	return result, nil
 }
 
-func (self *Commands) FileConflictFullInfos(querier subshelldomain.Querier, quickInfos []FileConflictQuickInfo, parentLocation gitdomain.Location, mainBranch gitdomain.LocalBranchName) ([]FileConflictFullInfo, error) {
+func (self *Commands) FileConflictFullInfos(querier subshelldomain.Querier, quickInfos []FileConflictQuickInfo, parentLocation gitdomain.Location, rootBranch gitdomain.LocalBranchName) ([]FileConflictFullInfo, error) {
 	result := make([]FileConflictFullInfo, len(quickInfos))
 	for q, quickInfo := range quickInfos {
-		fullInfo, err := self.FileConflictFullInfo(querier, quickInfo, parentLocation, mainBranch)
+		fullInfo, err := self.FileConflictFullInfo(querier, quickInfo, parentLocation, rootBranch)
 		if err != nil {
 			return result, err
 		}
diff --git a/internal/git/phantom_merge_conflict.go b/internal/git/phantom_merge_conflict.go
index 8915c29a6..7b01ab262 100644
--- a/internal/git/phantom_merge_conflict.go
+++ b/internal/git/phantom_merge_conflict.go
@@ -44,9 +44,8 @@ var UnmergedStages = []UnmergedStage{
 // complete information about a file with merge conflicts, to determine whether it is a pantom merge conflict
 type FileConflictFullInfo struct {
 	Current Option[BlobInfo] // info about the file on the current branch
-	// TODO: rename to Root and provide the root branch of the stack here, instead of the main branch
-	Main   Option[BlobInfo] // info about the file on the main branch
-	Parent Option[BlobInfo] // info about the file on the original parent
+	Parent  Option[BlobInfo] // info about the file on the original parent
+	Root    Option[BlobInfo] // info about the file on the root branch
 }
 
 // describes a file within an unresolved merge conflict that experiences a phantom merge conflict
@@ -54,11 +53,11 @@ type PhantomMergeConflict struct {
 	FilePath string
 }
 
-func DetectPhantomMergeConflicts(conflictInfos []FileConflictFullInfo, parentBranchOpt Option[gitdomain.LocalBranchName], mainBranch gitdomain.LocalBranchName) []PhantomMergeConflict {
+func DetectPhantomMergeConflicts(conflictInfos []FileConflictFullInfo, parentBranchOpt Option[gitdomain.LocalBranchName], rootBranch gitdomain.LocalBranchName) []PhantomMergeConflict {
 	result := []PhantomMergeConflict{}
 	parentBranch, hasParentBranch := parentBranchOpt.Get()
-	if !hasParentBranch || parentBranch == mainBranch {
-		// branches that don't have a parent or whose parent is the main branch cannot have phantom merge conflicts
+	if !hasParentBranch || parentBranch == rootBranch {
+		// branches that don't have a parent or whose parent is the root branch cannot have phantom merge conflicts
 		return []PhantomMergeConflict{}
 	}
 	for _, conflictInfo := range conflictInfos {
@@ -67,8 +66,8 @@ func DetectPhantomMergeConflicts(conflictInfos []FileConflictFullInfo, parentBra
 		if !hasInitialParentInfo || !hasCurrentInfo || currentInfo.Permission != initialParentInfo.Permission {
 			continue
 		}
-		if !reflect.DeepEqual(conflictInfo.Main, conflictInfo.Parent) {
-			// main and parent don't have the exact same version of the file --> not a phantom merge conflict
+		if !reflect.DeepEqual(conflictInfo.Root, conflictInfo.Parent) {
+			// root and parent don't have the exact same version of the file --> not a phantom merge conflict
 			continue
 		}
 		result = append(result, PhantomMergeConflict{
diff --git a/internal/vm/opcodes/conflict_phantom_resolve_all.go b/internal/vm/opcodes/conflict_phantom_resolve_all.go
index c0dc62ba5..ac5357a07 100644
--- a/internal/vm/opcodes/conflict_phantom_resolve_all.go
+++ b/internal/vm/opcodes/conflict_phantom_resolve_all.go
@@ -11,6 +11,7 @@ import (
 )
 
 type ConflictPhantomResolveAll struct {
+	CurrentBranch           gitdomain.LocalBranchName
 	ParentBranch            Option[gitdomain.LocalBranchName]
 	ParentSHA               Option[gitdomain.SHA]
 	Resolution              gitdomain.ConflictResolution
@@ -38,12 +39,12 @@ func (self *ConflictPhantomResolveAll) Run(args shared.RunArgs) error {
 	if err != nil {
 		return err
 	}
-	mainBranch := args.Config.Value.ValidatedConfigData.MainBranch
-	fullInfos, err := args.Git.FileConflictFullInfos(args.Backend, quickInfos, parentSHA.Location(), mainBranch)
+	rootBranch := args.Config.Value.NormalConfig.Lineage.Root(self.CurrentBranch)
+	fullInfos, err := args.Git.FileConflictFullInfos(args.Backend, quickInfos, parentSHA.Location(), rootBranch)
 	if err != nil {
 		return err
 	}
-	phantomMergeConflicts := git.DetectPhantomMergeConflicts(fullInfos, self.ParentBranch, mainBranch)
+	phantomMergeConflicts := git.DetectPhantomMergeConflicts(fullInfos, self.ParentBranch, rootBranch)
 	newOpcodes := make([]shared.Opcode, len(phantomMergeConflicts)+1)
 	for p, phantomMergeConflict := range phantomMergeConflicts {
 		newOpcodes[p] = &ConflictPhantomResolve{
diff --git a/internal/vm/opcodes/merge_parent_resolve_phantom_conflicts.go b/internal/vm/opcodes/merge_parent_resolve_phantom_conflicts.go
index e22370005..d2b4867e7 100644
--- a/internal/vm/opcodes/merge_parent_resolve_phantom_conflicts.go
+++ b/internal/vm/opcodes/merge_parent_resolve_phantom_conflicts.go
@@ -8,6 +8,7 @@ import (
 
 // MergeParentResolvePhantomConflicts merges the given parent branch into the current branch.
 type MergeParentResolvePhantomConflicts struct {
+	CurrentBranch           gitdomain.LocalBranchName
 	CurrentParent           gitdomain.BranchName              // the currently active parent, after all remotely deleted parents were removed
 	InitialParentName       Option[gitdomain.LocalBranchName] // name of the original parent when Git Town started
 	InitialParentSHA        Option[gitdomain.SHA]             // SHA of the original parent when Git Town started
@@ -17,9 +18,10 @@ type MergeParentResolvePhantomConflicts struct {
 func (self *MergeParentResolvePhantomConflicts) Run(args shared.RunArgs) error {
 	if err := args.Git.MergeBranchNoEdit(args.Frontend, self.CurrentParent); err != nil {
 		args.PrependOpcodes(&ConflictPhantomResolveAll{
-			ParentBranch: self.InitialParentName,
-			ParentSHA:    self.InitialParentSHA,
-			Resolution:   gitdomain.ConflictResolutionOurs,
+			CurrentBranch: self.CurrentBranch,
+			ParentBranch:  self.InitialParentName,
+			ParentSHA:     self.InitialParentSHA,
+			Resolution:    gitdomain.ConflictResolutionOurs,
 		})
 	}
 	return nil
diff --git a/internal/vm/opcodes/sync_feature_branch_merge.go b/internal/vm/opcodes/sync_feature_branch_merge.go
index 8d729c9cf..f9c67f158 100644
--- a/internal/vm/opcodes/sync_feature_branch_merge.go
+++ b/internal/vm/opcodes/sync_feature_branch_merge.go
@@ -47,6 +47,7 @@ func (self *SyncFeatureBranchMerge) Run(args shared.RunArgs) error {
 				}
 				if !isInSync {
 					program = append(program, &MergeParentResolvePhantomConflicts{
+						CurrentBranch:     self.Branch,
 						CurrentParent:     parentToMerge,
 						InitialParentName: self.InitialParentName,
 						InitialParentSHA:  self.InitialParentSHA,
@@ -62,6 +63,7 @@ func (self *SyncFeatureBranchMerge) Run(args shared.RunArgs) error {
 				}
 				if !isInSync {
 					program = append(program, &MergeParentResolvePhantomConflicts{
+						CurrentBranch:     self.Branch,
 						CurrentParent:     parentTrackingBranch.BranchName(),
 						InitialParentName: self.InitialParentName,
 						InitialParentSHA:  self.InitialParentSHA,



Test patch:
diff --git a/internal/config/configdomain/lineage_test.go b/internal/config/configdomain/lineage_test.go
index 70a07fa75..ffb01a05c 100644
--- a/internal/config/configdomain/lineage_test.go
+++ b/internal/config/configdomain/lineage_test.go
@@ -667,6 +667,34 @@ func TestLineage(t *testing.T) {
 		})
 	})
 
+	t.Run("Root", func(t *testing.T) {
+		t.Parallel()
+		t.Run("stack", func(t *testing.T) {
+			t.Parallel()
+			lineage := configdomain.NewLineageWith(configdomain.LineageData{
+				three: two,
+				two:   one,
+				one:   main,
+			})
+			have := lineage.Root(three)
+			must.Eq(t, main, have)
+		})
+		t.Run("one ancestor", func(t *testing.T) {
+			t.Parallel()
+			lineage := configdomain.NewLineageWith(configdomain.LineageData{
+				one: main,
+			})
+			have := lineage.Root(one)
+			must.Eq(t, main, have)
+		})
+		t.Run("no ancestors", func(t *testing.T) {
+			t.Parallel()
+			lineage := configdomain.NewLineageWith(configdomain.LineageData{})
+			have := lineage.Root(two)
+			must.Eq(t, two, have)
+		})
+	})
+
 	t.Run("Roots", func(t *testing.T) {
 		t.Parallel()
 		t.Run("multiple roots with child branches", func(t *testing.T) {
diff --git a/internal/git/commands_test.go b/internal/git/commands_test.go
index b82063bd7..c387fd88e 100644
--- a/internal/git/commands_test.go
+++ b/internal/git/commands_test.go
@@ -987,7 +987,7 @@ func TestBackendCommands(t *testing.T) {
 			t.Parallel()
 			fullInfos := []git.FileConflictFullInfo{
 				{
-					Main: Some(git.BlobInfo{
+					Root: Some(git.BlobInfo{
 						FilePath:   "file",
 						Permission: "100755",
 						SHA:        "111111",
@@ -1014,7 +1014,7 @@ func TestBackendCommands(t *testing.T) {
 			t.Parallel()
 			fullInfos := []git.FileConflictFullInfo{
 				{
-					Main: Some(git.BlobInfo{
+					Root: Some(git.BlobInfo{
 						FilePath:   "file",
 						Permission: "100755",
 						SHA:        "111111",
@@ -1039,7 +1039,7 @@ func TestBackendCommands(t *testing.T) {
 			t.Parallel()
 			fullInfos := []git.FileConflictFullInfo{
 				{
-					Main: Some(git.BlobInfo{
+					Root: Some(git.BlobInfo{
 						FilePath:   "file",
 						Permission: "100755",
 						SHA:        "111111",
@@ -1064,7 +1064,7 @@ func TestBackendCommands(t *testing.T) {
 			t.Parallel()
 			fullInfos := []git.FileConflictFullInfo{
 				{
-					Main: Some(git.BlobInfo{
+					Root: Some(git.BlobInfo{
 						FilePath:   "file-1",
 						Permission: "100755",
 						SHA:        "222222",
diff --git a/internal/state/runstate/save_load_test.go b/internal/state/runstate/save_load_test.go
index 44b186f43..6bb2577be 100644
--- a/internal/state/runstate/save_load_test.go
+++ b/internal/state/runstate/save_load_test.go
@@ -85,7 +85,7 @@ func TestLoadSave(t *testing.T) {
 				&opcodes.CommitWithMessage{AuthorOverride: Some(gitdomain.Author("user@acme.com")), Message: "my message", CommitHook: configdomain.CommitHookEnabled},
 				&opcodes.ConfigRemove{Key: configdomain.KeyOffline, Scope: configdomain.ConfigScopeLocal},
 				&opcodes.ConfigSet{Key: configdomain.KeyOffline, Scope: configdomain.ConfigScopeLocal, Value: "1"},
-				&opcodes.ConflictPhantomResolveAll{ParentBranch: gitdomain.NewLocalBranchNameOption("parent"), ParentSHA: Some(gitdomain.NewSHA("123456")), Resolution: gitdomain.ConflictResolutionOurs},
+				&opcodes.ConflictPhantomResolveAll{CurrentBranch: "current", ParentBranch: gitdomain.NewLocalBranchNameOption("parent"), ParentSHA: Some(gitdomain.NewSHA("123456")), Resolution: gitdomain.ConflictResolutionOurs},
 				&opcodes.ConflictPhantomFinalize{},
 				&opcodes.ConflictPhantomResolve{FilePath: "file", Resolution: gitdomain.ConflictResolutionOurs},
 				&opcodes.ConnectorProposalMerge{Branch: "branch", CommitMessage: Some(gitdomain.CommitMessage("commit message")), Proposal: forgedomain.Proposal{Data: forgedomain.BitbucketCloudProposalData{ProposalData: forgedomain.ProposalData{Body: Some("body"), MergeWithAPI: true, Number: 123, Source: "source", Target: "target", Title: "title", URL: "url"}}, ForgeType: forgedomain.ForgeTypeBitbucket}},
@@ -102,7 +102,7 @@ func TestLoadSave(t *testing.T) {
 				&opcodes.MergeAbort{},
 				&opcodes.MergeAlwaysProgram{Branch: "branch", CommitMessage: Some(gitdomain.CommitMessage("commit message"))},
 				&opcodes.MergeContinue{},
-				&opcodes.MergeParentResolvePhantomConflicts{CurrentParent: "parent", InitialParentName: gitdomain.NewLocalBranchNameOption("original-parent"), InitialParentSHA: Some(gitdomain.NewSHA("123456"))},
+				&opcodes.MergeParentResolvePhantomConflicts{CurrentBranch: "current", CurrentParent: "parent", InitialParentName: gitdomain.NewLocalBranchNameOption("original-parent"), InitialParentSHA: Some(gitdomain.NewSHA("123456"))},
 				&opcodes.MergeSquashProgram{Authors: []gitdomain.Author{"author 1 <one@acme.com>", "author 2 <two@acme.com>"}, Branch: "branch", CommitMessage: Some(gitdomain.CommitMessage("commit message")), Parent: "parent"},
 				&opcodes.MessageQueue{Message: "message"},
 				&opcodes.ProgramEndOfBranch{},
@@ -409,6 +409,7 @@ func TestLoadSave(t *testing.T) {
     },
     {
       "data": {
+        "CurrentBranch": "current",
         "ParentBranch": "parent",
         "ParentSHA": "123456",
         "Resolution": "ours"
@@ -532,6 +533,7 @@ func TestLoadSave(t *testing.T) {
     },
     {
       "data": {
+        "CurrentBranch": "current",
         "CurrentParent": "parent",
         "InitialParentName": "original-parent",
         "InitialParentSHA": "123456"