Repository: git-town/git-town

Issue:
Syncing parent (other than default branch) results in duplicated commits
### Steps to reproduce

I've noticed for the past months that syncing with git town isn't what it used to be.

Yesterday it happened to me twice.

I was working on a feature, and at some point, I decided to prepend and beam some commits. 

This worked great. 

But then I went back to that newly created parent branch (which got the beamed commits) and modified those. There were some mistakes, I amended the commits, and synced. All good.

I went back to the feature branch and ran sync there and then I noticed something weird.

Before, the history was like this:

commit1-from-parent
commit2-from-parent
feature-commit1
feature-commit2

But after the syncing, it was like this:

commit1-from-parent
commit2-from-parent
commit2-from-parent
feature-commit1
feature-commit2

You can see that the second commit from parent is duplicated. And when I inspected the diff of that commit, it did the opposite as what I did in the parent branch. Basically, it brought back the change how it initially was. In my case, it reintroduced the back I solved in the parent.  

Having a hard time reproducing this properly. Maybe you can help me how to do it.

I have the feeling this is related to the way syncing happens. Where it applies the commits on top of it and then force pushes it, in the hope it resolves. But in cases where the original commit changed, it leaves a weird state.



### Command output

```shell
.
```

### Additional information

_No response_

Hints from issue comments:
@kevgo: Thanks for reaching out about this. This behavior might be a side effect of the way Git Town currently beams commits when prepending branches. The end-to-end test that demonstrates this in action is [here](https://github.com/git-town/git-town/blob/main/features/prepend/beam/rebase.feature).

- prepend an empty branch
- cherry-pick the commits to beam into the new branch
- rebase the old branch against its new parent

The point is, Git Town doesn't remove the beamed commits from the old branch. They disappear from the old branch on their own after the rebase. I was under the assumption that the rebase indeed removes them. Based on your feedback, it appears that's not really the case. The commits seem to remain in the old branch, and Git is simply hiding them to avoid displaying the same commit twice in a branch. 

So, when somebody amends the commits in new branch, they are now different from their counterparts in the old branch, and Git displays the commits in the old branch again. 

Since Git commits aren't diffs but full copies of the affected file before and after (the diff is calculated on demand if needed), the commits in the old branch appear as undoing changes from the earlier commits in the new branch.

A possible solution is to delete the beamed commits from the original branch.@kevgo: I haven't been able to re-create the problem on my end, so a reproduction would help.@kevgo: I've tried this workflow and it didn't do much:

- prepend an empty branch
- cherry-pick the commits to beam into the new branch
- rebase the old branch against its new parent
- remove the beamed commits from the old branch

Maybe this order works better:

- prepend an empty branch
- cherry-pick the commits to beam into the new branch
- remove the beamed commits from the old branch
  - the beamed commits on the new branch are not visible on the old branch yet, so removing them from the old branch will for sure remove the old commits
- rebase the old branch against its new parent
@ruudk: FWIW: I'm using the rebase sync strategy. 

I'll try a reproducer in the coming days.
@kevgo: I have managed to create an end-to-end test where a beamed commit [resurfaces on the old branch](https://github.com/git-town/git-town/blob/f3203d9323b3eaea4acf83f1db0678a090194f41/features/prepend/beam/rebase_same_file.feature#L125) after amending it on the new branch.@kevgo: Okay, I just merged a PR that physically removes the beamed commits from the old branch. This prevents them from re-appearing in my end-to-end tests, this should also work in real life.

This is additional commit surgery, so there is possibility for additional merge commits. There might be some opportunities to auto-resolve them, but probably not always.@ruudk: Who's awesome? You're awesome!

Thanks!😊 @kevgo: This is now available as part of [Git Town 21](https://github.com/git-town/git-town/releases/tag/v21.0.0).@ruudk: I know this is going to be a lousy report, but for the last weeks, I noticed more issues with sync rebase.

For example, I'm sync with main, on a feature branch, and there is some code there. Then time passes, and somebody removes code from main. Then time passes, and I run `sync` on my feature branch, and suddenly that code is now back into my branch. Sometimes without me noticing. 

Today somebody told me: "why are you adding this file? is that part of this PR?" and it was something that another person removed. 

This is frightening because I really on Git Town and somehow expect it to work (no blame, but just how I trust it after all this time).

Maybe you've heard more of this. The next time it happens I will try to see if I can somehow reproduce it.@ruudk: I think I know what happened. 

* Create feature branch from main
* Do changes to files A B C
* On main, file B gets removed
* On feature branch, do a sync.
* Because I did changes to B, it now added file B back.

This might be that I was just too fast with resolving a merge conflict. Or, the merge conflict didn't happen, and it just blindly added B back.

@kevgo: If `main` has a commit that deletes a file while your branch modifies that same file, you should indeed hit a merge conflict.

We recently fixed some bugs where Git Town was mislabeling real merge conflicts as phantom ones and then auto-resolved them the wrong way. Could it be that you ran into that? Are you on v21.4.3?

Solution patch:
diff --git a/internal/git/gitdomain/commits.go b/internal/git/gitdomain/commits.go
index 5318b3f55..a92d3e3a1 100644
--- a/internal/git/gitdomain/commits.go
+++ b/internal/git/gitdomain/commits.go
@@ -1,5 +1,7 @@
 package gitdomain
 
+import . "github.com/git-town/git-town/v20/pkg/prelude"
+
 type Commits []Commit
 
 // ContainsSHA indicates whether this commits list contains a commit with the given SHA.
@@ -12,6 +14,15 @@ func (self Commits) ContainsSHA(sha SHA) bool {
 	return false
 }
 
+func (self Commits) FindByCommitMessage(message CommitMessage) Option[Commit] {
+	for _, commit := range self {
+		if commit.Message == message {
+			return Some(commit)
+		}
+	}
+	return None[Commit]()
+}
+
 func (self Commits) Messages() CommitMessages {
 	result := make(CommitMessages, len(self))
 	for c, commit := range self {



Test patch:
diff --git a/internal/git/gitdomain/commits_test.go b/internal/git/gitdomain/commits_test.go
new file mode 100644
index 000000000..81cbef084
--- /dev/null
+++ b/internal/git/gitdomain/commits_test.go
@@ -0,0 +1,39 @@
+package gitdomain_test
+
+import (
+	"testing"
+
+	"github.com/git-town/git-town/v20/internal/git/gitdomain"
+	"github.com/shoenig/test/must"
+)
+
+func TestCommits(t *testing.T) {
+	t.Parallel()
+
+	t.Run("FindByCommitMessage", func(t *testing.T) {
+		t.Parallel()
+		t.Run("contains a commit with the message", func(t *testing.T) {
+			t.Parallel()
+			commits := gitdomain.Commits{
+				{
+					Message: "commit 1",
+					SHA:     "111111",
+				},
+				{
+					Message: "commit 2",
+					SHA:     "222222",
+				},
+			}
+			have, has := commits.FindByCommitMessage("commit 2").Get()
+			must.True(t, has)
+			must.EqOp(t, have.Message, "commit 2")
+			must.EqOp(t, have.SHA, "222222")
+		})
+		t.Run("does not contain the commit", func(t *testing.T) {
+			t.Parallel()
+			commits := gitdomain.Commits{}
+			have := commits.FindByCommitMessage("zonk")
+			must.True(t, have.IsNone())
+		})
+	})
+}
diff --git a/internal/test/commands/test_commands.go b/internal/test/commands/test_commands.go
index fc54d9ebb..11fda48ed 100644
--- a/internal/test/commands/test_commands.go
+++ b/internal/test/commands/test_commands.go
@@ -65,15 +65,18 @@ func (self *TestCommands) CommitSHA(querier gitdomain.Querier, title string, bra
 	panic(fmt.Errorf("no commit with title %q found", title))
 }
 
-func (self *TestCommands) CommitSHAs() map[string]gitdomain.SHA {
-	result := map[string]gitdomain.SHA{}
+func (self *TestCommands) CommitSHAs() gitdomain.Commits {
+	result := gitdomain.Commits{}
 	output := self.MustQuery("git", "log", "--all", "--format=%H %s")
 	if output == "" {
 		return result
 	}
 	for _, line := range strings.Split(output, "\n") {
 		sha, commitMessage, _ := strings.Cut(line, " ")
-		result[commitMessage] = gitdomain.NewSHA(sha)
+		result = append(result, gitdomain.Commit{
+			Message: gitdomain.CommitMessage(commitMessage),
+			SHA:     gitdomain.NewSHA(sha),
+		})
 	}
 	return result
 }
@@ -468,15 +471,15 @@ func (self *TestCommands) SHAforBranch(branch gitdomain.LocalBranchName) gitdoma
 }
 
 // SHAForCommit provides the SHA for the commit with the given name.
-func (self *TestCommands) SHAsForCommit(name string) gitdomain.SHAs {
+func (self *TestCommands) SHAsForCommit(message gitdomain.CommitMessage) gitdomain.SHAs {
 	output := self.MustQuery("git", "reflog", "--format=%H %s")
 	if output == "" {
-		panic(fmt.Sprintf("cannot find the SHA of commit %q", name))
+		panic(fmt.Sprintf("cannot find the SHA of commit %q", message))
 	}
 	shasWithMessage := make(gitdomain.SHAs, 0, 1)
 	for _, text := range strings.Split(output, "\n") {
 		shaText, commitMessage, found := strings.Cut(text, " ")
-		if found && commitMessage == name {
+		if found && commitMessage == message.String() {
 			sha := gitdomain.NewSHA(shaText)
 			shasWithMessage = append(shasWithMessage, sha)
 		}
diff --git a/internal/test/cucumber/scenario_state.go b/internal/test/cucumber/scenario_state.go
index 30c7e42cf..9ba2fd800 100644
--- a/internal/test/cucumber/scenario_state.go
+++ b/internal/test/cucumber/scenario_state.go
@@ -16,13 +16,13 @@ import (
 // ScenarioState constains the state that is shared by all steps within a scenario.
 type ScenarioState struct {
 	// commits before the end-to-end test executed the most recent subshell command
-	beforeRunDevSHAs Option[map[string]gitdomain.SHA]
+	beforeRunDevSHAs Option[gitdomain.Commits]
 
 	// commits at the origin remote before the end-to-end test executed the most recent subshell command
-	beforeRunOriginSHAs Option[map[string]gitdomain.SHA]
+	beforeRunOriginSHAs Option[gitdomain.Commits]
 
 	// commits at the worktree-repo before the end-to-end test executed the most recent shell command
-	beforeRunWorktreeSHAs Option[map[string]gitdomain.SHA]
+	beforeRunWorktreeSHAs Option[gitdomain.Commits]
 
 	// the Fixture used in the current scenario
 	fixture fixture.Fixture
@@ -41,19 +41,19 @@ type ScenarioState struct {
 	// because it might contain non-existing remote branches or miss existing remote branches.
 	// An example is when origin removes a branch. initialDevSHAs will still list it
 	// because the developer workspace hasn't fetched updates yet.
-	initialDevSHAs Option[map[string]gitdomain.SHA]
+	initialDevSHAs Option[gitdomain.Commits]
 
 	// the lineage before the end-to-end test executed the first subshell command
 	initialLineage Option[datatable.DataTable]
 
 	// commits that existed at the origin repo before the end-to-end test executed the first subshell command
-	initialOriginSHAs Option[map[string]gitdomain.SHA]
+	initialOriginSHAs Option[gitdomain.Commits]
 
 	// the Git tags before the end-to-end test executed the first subshell command
 	initialTags Option[datatable.DataTable]
 
 	// commits at the worktree repo before the end-to-end test executed the first subshell command
-	initialWorktreeSHAs Option[map[string]gitdomain.SHA]
+	initialWorktreeSHAs Option[gitdomain.Commits]
 
 	// whether the developer workspace contains a Git repository
 	insideGitRepo bool
diff --git a/internal/test/cucumber/steps.go b/internal/test/cucumber/steps.go
index 0ae552b6e..f808b4502 100644
--- a/internal/test/cucumber/steps.go
+++ b/internal/test/cucumber/steps.go
@@ -98,18 +98,18 @@ func defineSteps(sc *godog.ScenarioContext) {
 			fixture.DevRepo.GetOrPanic().Verbose = true
 		}
 		state := ScenarioState{
-			beforeRunDevSHAs:      None[map[string]gitdomain.SHA](),
-			beforeRunOriginSHAs:   None[map[string]gitdomain.SHA](),
-			beforeRunWorktreeSHAs: None[map[string]gitdomain.SHA](),
+			beforeRunDevSHAs:      None[gitdomain.Commits](),
+			beforeRunOriginSHAs:   None[gitdomain.Commits](),
+			beforeRunWorktreeSHAs: None[gitdomain.Commits](),
 			fixture:               fixture,
 			initialBranches:       None[datatable.DataTable](),
 			initialCommits:        None[datatable.DataTable](),
 			initialCurrentBranch:  None[gitdomain.LocalBranchName](),
-			initialDevSHAs:        None[map[string]gitdomain.SHA](),
+			initialDevSHAs:        None[gitdomain.Commits](),
 			initialLineage:        None[datatable.DataTable](),
-			initialOriginSHAs:     None[map[string]gitdomain.SHA](),
+			initialOriginSHAs:     None[gitdomain.Commits](),
 			initialTags:           None[datatable.DataTable](),
-			initialWorktreeSHAs:   None[map[string]gitdomain.SHA](),
+			initialWorktreeSHAs:   None[gitdomain.Commits](),
 			insideGitRepo:         true,
 			runExitCode:           None[int](),
 			runExitCodeChecked:    false,
@@ -140,18 +140,18 @@ func defineSteps(sc *godog.ScenarioContext) {
 			fixture.OriginRepo.GetOrPanic().Verbose = true
 		}
 		state := ScenarioState{
-			beforeRunDevSHAs:      None[map[string]gitdomain.SHA](),
-			beforeRunOriginSHAs:   None[map[string]gitdomain.SHA](),
-			beforeRunWorktreeSHAs: None[map[string]gitdomain.SHA](),
+			beforeRunDevSHAs:      None[gitdomain.Commits](),
+			beforeRunOriginSHAs:   None[gitdomain.Commits](),
+			beforeRunWorktreeSHAs: None[gitdomain.Commits](),
 			fixture:               fixture,
 			initialBranches:       None[datatable.DataTable](),
 			initialCommits:        None[datatable.DataTable](),
 			initialCurrentBranch:  None[gitdomain.LocalBranchName](),
-			initialDevSHAs:        None[map[string]gitdomain.SHA](),
+			initialDevSHAs:        None[gitdomain.Commits](),
 			initialLineage:        None[datatable.DataTable](),
-			initialOriginSHAs:     None[map[string]gitdomain.SHA](),
+			initialOriginSHAs:     None[gitdomain.Commits](),
 			initialTags:           None[datatable.DataTable](),
-			initialWorktreeSHAs:   None[map[string]gitdomain.SHA](),
+			initialWorktreeSHAs:   None[gitdomain.Commits](),
 			insideGitRepo:         true,
 			runExitCode:           None[int](),
 			runExitCodeChecked:    false,
@@ -183,18 +183,18 @@ func defineSteps(sc *godog.ScenarioContext) {
 		devRepo.RemoveRemote(gitdomain.RemoteOrigin)
 		fixture.OriginRepo = MutableNone[commands.TestCommands]()
 		state := ScenarioState{
-			beforeRunDevSHAs:      None[map[string]gitdomain.SHA](),
-			beforeRunOriginSHAs:   None[map[string]gitdomain.SHA](),
-			beforeRunWorktreeSHAs: None[map[string]gitdomain.SHA](),
+			beforeRunDevSHAs:      None[gitdomain.Commits](),
+			beforeRunOriginSHAs:   None[gitdomain.Commits](),
+			beforeRunWorktreeSHAs: None[gitdomain.Commits](),
 			fixture:               fixture,
 			initialBranches:       None[datatable.DataTable](),
 			initialCommits:        None[datatable.DataTable](),
 			initialCurrentBranch:  None[gitdomain.LocalBranchName](),
-			initialDevSHAs:        None[map[string]gitdomain.SHA](),
+			initialDevSHAs:        None[gitdomain.Commits](),
 			initialLineage:        None[datatable.DataTable](),
-			initialOriginSHAs:     None[map[string]gitdomain.SHA](),
+			initialOriginSHAs:     None[gitdomain.Commits](),
 			initialTags:           None[datatable.DataTable](),
-			initialWorktreeSHAs:   None[map[string]gitdomain.SHA](),
+			initialWorktreeSHAs:   None[gitdomain.Commits](),
 			insideGitRepo:         true,
 			runExitCode:           None[int](),
 			runExitCodeChecked:    false,
@@ -338,9 +338,9 @@ func defineSteps(sc *godog.ScenarioContext) {
 		devRepo := state.fixture.DevRepo.GetOrPanic()
 		actualContent := strings.TrimSpace(devRepo.FileContent(file))
 		expectedText := handlebars.Expand(expectedContent.Content, handlebars.ExpandArgs{
-			InitialDevSHAs:         state.initialDevSHAs.GetOrPanic(),
-			InitialOriginSHAsOpt:   state.initialOriginSHAs,
-			InitialWorktreeSHAsOpt: state.initialWorktreeSHAs,
+			InitialDevCommits:      state.initialDevSHAs.GetOrPanic(),
+			InitialOriginCommits:   state.initialOriginSHAs,
+			InitialWorktreeCommits: state.initialWorktreeSHAs,
 			LocalRepo:              devRepo,
 			RemoteRepo:             state.fixture.OriginRepo.Value,
 			WorktreeRepo:           state.fixture.SecondWorktree.Value,
@@ -490,9 +490,9 @@ func defineSteps(sc *godog.ScenarioContext) {
 		table := output.RenderExecutedGitCommands(commands, input)
 		dataTable := datatable.FromGherkin(input)
 		expanded := dataTable.Expand(handlebars.ExpandArgs{
-			InitialDevSHAs:         state.initialDevSHAs.GetOrPanic(),
-			InitialOriginSHAsOpt:   state.initialOriginSHAs,
-			InitialWorktreeSHAsOpt: state.initialWorktreeSHAs,
+			InitialDevCommits:      state.initialDevSHAs.GetOrPanic(),
+			InitialOriginCommits:   state.initialOriginSHAs,
+			InitialWorktreeCommits: state.initialWorktreeSHAs,
 			LocalRepo:              devRepo,
 			RemoteRepo:             state.fixture.OriginRepo.Value,
 			WorktreeRepo:           state.fixture.SecondWorktree.Value,
@@ -617,18 +617,18 @@ func defineSteps(sc *godog.ScenarioContext) {
 			UpstreamRepo:   MutableNone[commands.TestCommands](),
 		}
 		state := ScenarioState{
-			beforeRunDevSHAs:      None[map[string]gitdomain.SHA](),
-			beforeRunOriginSHAs:   None[map[string]gitdomain.SHA](),
-			beforeRunWorktreeSHAs: None[map[string]gitdomain.SHA](),
+			beforeRunDevSHAs:      None[gitdomain.Commits](),
+			beforeRunOriginSHAs:   None[gitdomain.Commits](),
+			beforeRunWorktreeSHAs: None[gitdomain.Commits](),
 			fixture:               fixture,
 			initialBranches:       None[datatable.DataTable](),
 			initialCommits:        None[datatable.DataTable](),
 			initialCurrentBranch:  None[gitdomain.LocalBranchName](),
-			initialDevSHAs:        None[map[string]gitdomain.SHA](),
+			initialDevSHAs:        None[gitdomain.Commits](),
 			initialLineage:        None[datatable.DataTable](),
-			initialOriginSHAs:     None[map[string]gitdomain.SHA](),
+			initialOriginSHAs:     None[gitdomain.Commits](),
 			initialTags:           None[datatable.DataTable](),
-			initialWorktreeSHAs:   None[map[string]gitdomain.SHA](),
+			initialWorktreeSHAs:   None[gitdomain.Commits](),
 			insideGitRepo:         true,
 			runExitCode:           None[int](),
 			runExitCodeChecked:    false,
diff --git a/internal/test/handlebars/expand.go b/internal/test/handlebars/expand.go
index 5343e3519..745f2e2e5 100644
--- a/internal/test/handlebars/expand.go
+++ b/internal/test/handlebars/expand.go
@@ -2,7 +2,6 @@ package handlebars
 
 import (
 	"fmt"
-	"maps"
 	"regexp"
 	"strings"
 	"sync"
@@ -12,7 +11,7 @@ import (
 )
 
 type Runner interface {
-	SHAsForCommit(name string) gitdomain.SHAs
+	SHAsForCommit(gitdomain.CommitMessage) gitdomain.SHAs
 }
 
 var (
@@ -26,73 +25,73 @@ func Expand(text string, args ExpandArgs) string {
 		match := templateRE.FindString(text)
 		switch {
 		case strings.HasPrefix(match, "{{ sha "):
-			commitName := match[8 : len(match)-4]
-			shas := args.LocalRepo.SHAsForCommit(commitName)
+			commitMessage := gitdomain.CommitMessage(match[8 : len(match)-4])
+			shas := args.LocalRepo.SHAsForCommit(commitMessage)
 			if len(shas) == 0 {
-				panic(fmt.Sprintf("test workspace has no commit %q", commitName))
+				panic(fmt.Sprintf("test workspace has no commit %q", commitMessage))
 			}
 			sha := shas.First()
 			text = strings.Replace(text, match, sha.String(), 1)
 		case strings.HasPrefix(match, "{{ sha-short "):
-			commitName := match[14 : len(match)-4]
-			shas := args.LocalRepo.SHAsForCommit(commitName)
+			commitMessage := gitdomain.CommitMessage(match[14 : len(match)-4])
+			shas := args.LocalRepo.SHAsForCommit(commitMessage)
 			if len(shas) == 0 {
-				panic(fmt.Sprintf("test workspace has no commit %q", commitName))
+				panic(fmt.Sprintf("test workspace has no commit %q", commitMessage))
 			}
 			sha := shas.First().Truncate(7)
 			text = strings.Replace(text, match, sha.String(), 1)
 		case strings.HasPrefix(match, "{{ sha-in-origin "):
-			commitName := match[18 : len(match)-4]
-			shas := args.RemoteRepo.SHAsForCommit(commitName)
+			commitMessage := gitdomain.CommitMessage(match[18 : len(match)-4])
+			shas := args.RemoteRepo.SHAsForCommit(commitMessage)
 			sha := shas.First()
 			text = strings.Replace(text, match, sha.String(), 1)
 		case strings.HasPrefix(match, "{{ sha-initial "):
-			commitName := match[16 : len(match)-4]
-			sha, found := args.InitialDevSHAs[commitName]
-			if !found {
-				fmt.Printf("I cannot find the initial dev commit %q.\n", commitName)
-				fmt.Printf("I have records about %d commits:\n", len(args.InitialDevSHAs))
-				for key := range maps.Keys(args.InitialDevSHAs) {
-					fmt.Println("  -", key)
+			commitMessage := gitdomain.CommitMessage(match[16 : len(match)-4])
+			commit, hasCommit := args.InitialDevCommits.FindByCommitMessage(commitMessage).Get()
+			if !hasCommit {
+				fmt.Printf("I cannot find the initial dev commit %q.\n", commitMessage)
+				fmt.Printf("I have records about %d commits:\n", len(args.InitialDevCommits))
+				for _, commit := range args.InitialDevCommits {
+					fmt.Printf("  - %q (%s)\n", commit.Message, commit.SHA)
 				}
 				panic("see error above")
 			}
-			text = strings.Replace(text, match, sha.String(), 1)
+			text = strings.Replace(text, match, commit.SHA.String(), 1)
 		case strings.HasPrefix(match, "{{ sha-in-origin-initial "):
-			initialOriginSHAs, has := args.InitialOriginSHAsOpt.Get()
+			initialOriginCommits, has := args.InitialOriginCommits.Get()
 			if !has {
 				panic("no origin SHAs recorded")
 			}
-			commitName := match[26 : len(match)-4]
-			sha, found := initialOriginSHAs[commitName]
-			if !found {
-				fmt.Printf("I cannot find the initial origin commit %q.\n", commitName)
-				fmt.Printf("I have records about %d commits:\n", len(initialOriginSHAs))
-				for key := range maps.Keys(initialOriginSHAs) {
-					fmt.Println("  -", key)
+			commitMessage := gitdomain.CommitMessage(match[26 : len(match)-4])
+			commit, hasCommit := initialOriginCommits.FindByCommitMessage(commitMessage).Get()
+			if !hasCommit {
+				fmt.Printf("I cannot find the initial origin commit %q.\n", commitMessage)
+				fmt.Printf("I have records about %d commits:\n", len(initialOriginCommits))
+				for _, commit := range initialOriginCommits {
+					fmt.Printf("  - %q (%s)\n", commit.Message, commit.SHA)
 				}
 			}
-			text = strings.Replace(text, match, sha.String(), 1)
+			text = strings.Replace(text, match, commit.SHA.String(), 1)
 		case strings.HasPrefix(match, "{{ sha-in-worktree "):
-			commitName := match[20 : len(match)-4]
-			shas := args.WorktreeRepo.SHAsForCommit(commitName)
+			commitMessage := gitdomain.CommitMessage(match[20 : len(match)-4])
+			shas := args.WorktreeRepo.SHAsForCommit(commitMessage)
 			sha := shas.First()
 			text = strings.Replace(text, match, sha.String(), 1)
 		case strings.HasPrefix(match, "{{ sha-in-worktree-initial "):
-			commitName := match[28 : len(match)-4]
-			initialWorktreeSHAs, has := args.InitialWorktreeSHAsOpt.Get()
+			commitMessage := gitdomain.CommitMessage(match[28 : len(match)-4])
+			initialWorktreeSHAs, has := args.InitialWorktreeCommits.Get()
 			if !has {
 				panic("no initial worktree SHAs recorded")
 			}
-			sha, found := initialWorktreeSHAs[commitName]
-			if !found {
-				fmt.Printf("I cannot find the initial worktree commit %q.\n", commitName)
+			commit, hasCommit := initialWorktreeSHAs.FindByCommitMessage(commitMessage).Get()
+			if !hasCommit {
+				fmt.Printf("I cannot find the initial worktree commit %q.\n", commitMessage)
 				fmt.Printf("I have records about %d commits:\n", len(initialWorktreeSHAs))
-				for key := range maps.Keys(initialWorktreeSHAs) {
-					fmt.Println("  -", key)
+				for _, commit := range initialWorktreeSHAs {
+					fmt.Printf("  - %q (%s)\n", commit.Message, commit.SHA)
 				}
 			}
-			text = strings.Replace(text, match, sha.String(), 1)
+			text = strings.Replace(text, match, commit.SHA.String(), 1)
 		default:
 			panic(fmt.Sprintf("DataTable.Expand: unknown template expression %q", match))
 		}
@@ -101,9 +100,9 @@ func Expand(text string, args ExpandArgs) string {
 }
 
 type ExpandArgs struct {
-	InitialDevSHAs         map[string]gitdomain.SHA
-	InitialOriginSHAsOpt   Option[map[string]gitdomain.SHA]
-	InitialWorktreeSHAsOpt Option[map[string]gitdomain.SHA]
+	InitialDevCommits      gitdomain.Commits
+	InitialOriginCommits   Option[gitdomain.Commits]
+	InitialWorktreeCommits Option[gitdomain.Commits]
 	LocalRepo              Runner
 	RemoteRepo             Runner
 	WorktreeRepo           Runner