krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

ea05aa6c8cd4d033d0eefd739160d29d4ead008a

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T00:40:49Z

merge strategies: squash and rebase

- squash: one single-parent commit with the merged tree; author = MR
  author (verified identity when available, else the merger), committer
  = merger; subject '<title> (!N)' with the MR body appended
- rebase: replays the linear range via merge-tree --merge-base, one
  commit at a time, preserving each original author name/email/date and
  message; committer = merger; refuses ranges containing merge commits
  and stops at the first commit that does not apply cleanly
- rebase onto an ancestor short-circuits to a true fast-forward, so
  original commits and signatures land untouched — which also keeps it
  legal under require_signed_commits; squash and merge-commit remain
  refused there (server-created, unsigned)
- shared verified-identity requirement for every server-created commit
- e2e: squash identity/parent/subject/content checks, rebase
  author-preservation and linearity, merge-commit-in-source refusal,
  require-signed squash refusal, ff-able rebase lands the exact
  original tip sha
 cmd/gitbay/main.go           |   2 +-
 e2e/merge_strategies_test.go | 197 +++++++++++++++++++++++++++++++++++++++++++
 internal/control/mr.go       | 135 +++++++++++++++++++++++++----
 internal/gitutil/merge.go    |  83 ++++++++++++++++++
 4 files changed, 399 insertions(+), 18 deletions(-)

diff --git a/cmd/gitbay/main.go b/cmd/gitbay/main.go
index 874a7d5..7c0d18b 100644
--- a/cmd/gitbay/main.go
+++ b/cmd/gitbay/main.go
@@ -242,7 +242,7 @@ func mrCmd() *cobra.Command {
 		local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout),
 		pass("comment", "comment on a merge request", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
 		pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}),
-		pass("merge", "merge (fast-forward or merge-commit): [--strategy ff|merge]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
+		pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
 		pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}),
 	)
 }
diff --git a/e2e/merge_strategies_test.go b/e2e/merge_strategies_test.go
new file mode 100644
index 0000000..02030db
--- /dev/null
+++ b/e2e/merge_strategies_test.go
@@ -0,0 +1,197 @@
+package e2e
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestSquashAndRebaseMerges(t *testing.T) {
+	inst := startInstance(t)
+	aliceKey := inst.newKey(t, "alice")
+	bobKey := inst.newKey(t, "bob")
+	inst.admin(t, "admin", "user", "create", "alice",
+		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
+	inst.admin(t, "admin", "user", "create", "bob",
+		"--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
+
+	// Repo with bob granted write; bob authors branches, alice merges.
+	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
+		t.Fatalf("repo create: %s", errOut)
+	}
+	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/lib", "bob", "write"); code != 0 {
+		t.Fatal("grant failed")
+	}
+
+	aliceEnv := inst.gitEnv(aliceKey)
+	bobEnv := inst.gitEnv(bobKey)
+	work := t.TempDir()
+	mustGit(t, work, aliceEnv, "clone", inst.sshURL("alice/lib"), "w")
+	dir := filepath.Join(work, "w")
+	os.WriteFile(filepath.Join(dir, "base.txt"), []byte("base\n"), 0o644)
+	mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main")
+	mustGit(t, dir, aliceEnv, "add", ".")
+	mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "base")
+	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
+
+	// --- squash: two bob commits, diverged target -> one new commit ---
+	bobWork := t.TempDir()
+	mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/lib"), "w")
+	bobDir := filepath.Join(bobWork, "w")
+	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat1", "origin/main")
+	os.WriteFile(filepath.Join(bobDir, "a.txt"), []byte("a\n"), 0o644)
+	mustGit(t, bobDir, bobEnv, "add", ".")
+	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "wip 1")
+	os.WriteFile(filepath.Join(bobDir, "b.txt"), []byte("b\n"), 0o644)
+	mustGit(t, bobDir, bobEnv, "add", ".")
+	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "wip 2")
+	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat1")
+	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
+		"--source", "feat1", "--target", "main", "--title", "'squash me'", "--body", "'two wips'"); code != 0 {
+		t.Fatalf("mr create: %s", errOut)
+	}
+	// Target advances so ff is impossible.
+	mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "mainline")
+	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
+
+	before := strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-parse", "origin/main"))
+	out, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "1", "--strategy", "squash", "--json")
+	if code != 0 {
+		t.Fatalf("squash merge: %s", errOut)
+	}
+	if !strings.Contains(out, `"strategy":"squash"`) {
+		t.Fatalf("squash output: %s", out)
+	}
+	mustGit(t, dir, aliceEnv, "fetch", "-q", "origin")
+	// Exactly one commit landed on top of the old tip.
+	count := strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-list", "--count", before+"..origin/main"))
+	if count != "1" {
+		t.Fatalf("squash added %s commits, want 1", count)
+	}
+	// Single parent, author = MR author (bob), committer = merger (alice).
+	ident := strings.TrimSpace(mustGit(t, dir, aliceEnv, "log", "-1",
+		"--format=%an <%ae>|%cn <%ce>|%p|%s", "origin/main"))
+	parts := strings.Split(ident, "|")
+	if parts[0] != "bob <bob@example.test>" || parts[1] != "alice <alice@example.test>" {
+		t.Fatalf("squash identities: %s", ident)
+	}
+	if strings.Contains(parts[2], " ") {
+		t.Fatalf("squash commit has multiple parents: %s", ident)
+	}
+	if parts[3] != "squash me (!1)" {
+		t.Fatalf("squash subject: %s", ident)
+	}
+	// Both files present.
+	mustGit(t, dir, aliceEnv, "checkout", "-q", "main")
+	mustGit(t, dir, aliceEnv, "pull", "-q", "origin", "main")
+	for _, f := range []string{"a.txt", "b.txt"} {
+		if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
+			t.Fatalf("squashed content missing %s", f)
+		}
+	}
+
+	// --- rebase: two commits replayed onto a diverged target ---
+	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat2", "origin/main")
+	mustGit(t, bobDir, bobEnv, "fetch", "-q", "origin")
+	mustGit(t, bobDir, bobEnv, "reset", "-q", "--hard", "origin/main")
+	os.WriteFile(filepath.Join(bobDir, "c.txt"), []byte("c\n"), 0o644)
+	mustGit(t, bobDir, bobEnv, "add", ".")
+	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "step one")
+	os.WriteFile(filepath.Join(bobDir, "d.txt"), []byte("d\n"), 0o644)
+	mustGit(t, bobDir, bobEnv, "add", ".")
+	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "step two")
+	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat2")
+	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
+		"--source", "feat2", "--target", "main", "--title", "'rebase me'"); code != 0 {
+		t.Fatalf("mr2 create: %s", errOut)
+	}
+	mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "mainline again")
+	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
+
+	before = strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-parse", "origin/main"))
+	out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "2", "--strategy", "rebase", "--json")
+	if code != 0 {
+		t.Fatalf("rebase merge: %s", errOut)
+	}
+	mustGit(t, dir, aliceEnv, "fetch", "-q", "origin")
+	// Two commits, linear (no merges), authors preserved, committer alice.
+	count = strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-list", "--count", before+"..origin/main"))
+	if count != "2" {
+		t.Fatalf("rebase added %s commits, want 2", count)
+	}
+	merges := strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-list", "--merges", "--count", before+"..origin/main"))
+	if merges != "0" {
+		t.Fatal("rebase produced a merge commit")
+	}
+	logOut := mustGit(t, dir, aliceEnv, "log", "--format=%ae|%ce|%s", before+"..origin/main")
+	for _, line := range strings.Split(strings.TrimSpace(logOut), "\n") {
+		p := strings.Split(line, "|")
+		if p[0] != "t@example.test" || p[1] != "alice@example.test" {
+			t.Fatalf("rebase identities: %s", line)
+		}
+	}
+	if !strings.Contains(logOut, "step one") || !strings.Contains(logOut, "step two") {
+		t.Fatalf("rebase messages: %s", logOut)
+	}
+
+	// --- rebase refuses merge commits in the source ---
+	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat3")
+	mustGit(t, bobDir, bobEnv, "fetch", "-q", "origin")
+	mustGit(t, bobDir, bobEnv, "reset", "-q", "--hard", "origin/main")
+	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "side")
+	os.WriteFile(filepath.Join(bobDir, "e.txt"), []byte("e\n"), 0o644)
+	mustGit(t, bobDir, bobEnv, "add", ".")
+	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "side work")
+	mustGit(t, bobDir, bobEnv, "checkout", "-q", "feat3")
+	os.WriteFile(filepath.Join(bobDir, "f.txt"), []byte("f\n"), 0o644)
+	mustGit(t, bobDir, bobEnv, "add", ".")
+	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "main work")
+	mustGit(t, bobDir, bobEnv, "merge", "-q", "--no-ff", "-m", "internal merge", "side")
+	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat3")
+	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
+		"--source", "feat3", "--target", "main", "--title", "'has a merge'"); code != 0 {
+		t.Fatalf("mr3 create: %s", errOut)
+	}
+	mustGit(t, dir, aliceEnv, "fetch", "-q", "origin")
+	mustGit(t, dir, aliceEnv, "reset", "-q", "--hard", "origin/main")
+	mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "diverge again")
+	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
+	_, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "3", "--strategy", "rebase")
+	if code != 2 || !strings.Contains(errOut, "linear history") {
+		t.Fatalf("rebase with merge commit: exit %d, %s", code, errOut)
+	}
+
+	// --- require_signed_commits refuses squash outright ---
+	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/lib", "on"); code != 0 {
+		t.Fatal("require-signed failed")
+	}
+	_, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "3", "--strategy", "squash")
+	if code != 4 || !strings.Contains(errOut, "only fast-forward") {
+		t.Fatalf("squash on require-signed: exit %d, %s", code, errOut)
+	}
+	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/lib", "off"); code != 0 {
+		t.Fatal("require-signed off failed")
+	}
+
+	// --- rebase when ff is possible IS a fast-forward: shas preserved ---
+	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat4")
+	mustGit(t, bobDir, bobEnv, "fetch", "-q", "origin")
+	mustGit(t, bobDir, bobEnv, "reset", "-q", "--hard", "origin/main")
+	os.WriteFile(filepath.Join(bobDir, "g.txt"), []byte("g\n"), 0o644)
+	mustGit(t, bobDir, bobEnv, "add", ".")
+	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "clean on top")
+	tip := strings.TrimSpace(mustGit(t, bobDir, bobEnv, "rev-parse", "HEAD"))
+	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat4")
+	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
+		"--source", "feat4", "--target", "main", "--title", "'ff-able'"); code != 0 {
+		t.Fatalf("mr4 create: %s", errOut)
+	}
+	out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "4", "--strategy", "rebase", "--json")
+	if code != 0 {
+		t.Fatalf("ff-able rebase: %s", errOut)
+	}
+	if !strings.Contains(out, `"strategy":"ff"`) || !strings.Contains(out, tip) {
+		t.Fatalf("ff-able rebase should fast-forward to %s: %s", tip, out)
+	}
+}
diff --git a/internal/control/mr.go b/internal/control/mr.go
index b27a3d7..93fe3f4 100644
--- a/internal/control/mr.go
+++ b/internal/control/mr.go
@@ -33,7 +33,7 @@ func init() {
 	register(Command{Path: []string{"mr", "review"},
 		Summary: "review: mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview})
 	register(Command{Path: []string{"mr", "merge"},
-		Summary: "merge: mr merge <owner/name> <n> [--strategy ff|merge]", Run: runMRMerge})
+		Summary: "merge: mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge})
 	register(Command{Path: []string{"mr", "close"},
 		Summary: "close without merging: mr close <owner/name> <n>", Run: runMRClose})
 }
@@ -438,7 +438,7 @@ func runMRMerge(c *Ctx, args []string) int {
 	for i := 0; i < len(args); i++ {
 		if args[i] == "--strategy" {
 			if i+1 >= len(args) {
-				return c.fail(protocol.ExitUsage, "--strategy requires ff|merge")
+				return c.fail(protocol.ExitUsage, "--strategy requires ff|merge|squash|rebase")
 			}
 			strategy = args[i+1]
 			i++
@@ -446,8 +446,9 @@ func runMRMerge(c *Ctx, args []string) int {
 		}
 		rest = append(rest, args[i])
 	}
-	if strategy != "" && strategy != "ff" && strategy != "merge" {
-		return c.fail(protocol.ExitUsage, "--strategy must be ff or merge")
+	valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
+	if !valid[strategy] {
+		return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
 	}
 	repo, mr, code := mrRef(c, rest, policy.CanWrite)
 	if code >= 0 {
@@ -481,11 +482,13 @@ func runMRMerge(c *Ctx, args []string) int {
 	}
 
 	// Signature policy matrix: with require_signed_commits, only
-	// fast-forward is allowed — a server-created merge commit would be
-	// unsigned, violating the branch's own policy — and every landed
-	// commit must be verified.
+	// fast-forward is allowed — squash, rebase-replay, and merge commits
+	// are all server-created and unsigned, violating the branch's own
+	// policy — and every landed commit must be verified. An explicit
+	// rebase when fast-forward is already possible IS a fast-forward
+	// (nothing is rewritten), so it stays legal.
 	if repo.Settings.RequireSignedCommits {
-		if strategy == "merge" || !ffPossible {
+		if strategy == "merge" || strategy == "squash" || !ffPossible {
 			return c.fail(protocol.ExitDenied,
 				"%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
 				repo.Path(), mr.SourceRef, mr.TargetRef)
@@ -521,6 +524,25 @@ func runMRMerge(c *Ctx, args []string) int {
 			strategy = "merge"
 		}
 	}
+	if strategy == "rebase" && ffPossible {
+		// Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
+		// and taking it keeps the original commits and their signatures.
+		strategy = "ff"
+	}
+
+	// Every server-created commit needs the merger's verified identity.
+	mergerEmail := ""
+	if strategy != "ff" {
+		email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
+		if err != nil {
+			return c.fail(protocol.ExitFailure, "%v", err)
+		}
+		if email == "" {
+			return c.fail(protocol.ExitDenied,
+				"%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
+		}
+		mergerEmail = email
+	}
 
 	var newSHA string
 	switch strategy {
@@ -530,15 +552,8 @@ func runMRMerge(c *Ctx, args []string) int {
 				"fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
 		}
 		newSHA = headSHA
+
 	case "merge":
-		email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
-		if err != nil {
-			return c.fail(protocol.ExitFailure, "%v", err)
-		}
-		if email == "" {
-			return c.fail(protocol.ExitDenied,
-				"merge commits carry your identity: verify a primary email first (ask an admin, or use a fast-forward merge)")
-		}
 		tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
 		if err != nil {
 			return c.fail(protocol.ExitFailure, "%v", err)
@@ -548,10 +563,96 @@ func runMRMerge(c *Ctx, args []string) int {
 				"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
 		}
 		msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
-		newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, email, msg)
+		newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
+		if err != nil {
+			return c.fail(protocol.ExitFailure, "%v", err)
+		}
+
+	case "squash":
+		// One new commit with the merged tree. Authorship credit goes to
+		// the MR author (their verified identity when they have one); the
+		// committer is the merger.
+		tree := ""
+		if ffPossible {
+			t, err := gitutil.ResolveTree(dir, headSHA)
+			if err != nil {
+				return c.fail(protocol.ExitFailure, "%v", err)
+			}
+			tree = t
+		} else {
+			t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
+			if err != nil {
+				return c.fail(protocol.ExitFailure, "%v", err)
+			}
+			if conflict {
+				return c.fail(protocol.ExitUsage,
+					"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
+			}
+			tree = t
+		}
+		authorName, authorEmail := c.User.Username, mergerEmail
+		if author, err := c.Store.UserByUsername(mr.Author); err == nil {
+			if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
+				authorName, authorEmail = author.Username, ae
+			}
+		}
+		msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
+		if mr.Body != "" {
+			msg += "\n\n" + mr.Body
+		}
+		var err error
+		newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
+			authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
+		if err != nil {
+			return c.fail(protocol.ExitFailure, "%v", err)
+		}
+
+	case "rebase":
+		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
 		if err != nil {
 			return c.fail(protocol.ExitFailure, "%v", err)
 		}
+		// Oldest first.
+		for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
+			commits[i], commits[j] = commits[j], commits[i]
+		}
+		onto := targetSHA
+		for _, sha := range commits {
+			parents, err := gitutil.CommitParents(dir, sha)
+			if err != nil {
+				return c.fail(protocol.ExitFailure, "%v", err)
+			}
+			if len(parents) > 1 {
+				return c.fail(protocol.ExitUsage,
+					"the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha)
+			}
+			base := onto // root commit: replay against the new tip itself
+			if len(parents) == 1 {
+				base = parents[0]
+			}
+			tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
+			if err != nil {
+				return c.fail(protocol.ExitFailure, "%v", err)
+			}
+			if conflict {
+				return c.fail(protocol.ExitUsage,
+					"commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
+			}
+			aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
+			if err != nil {
+				return c.fail(protocol.ExitFailure, "%v", err)
+			}
+			msg, err := gitutil.CommitMessage(dir, sha)
+			if err != nil {
+				return c.fail(protocol.ExitFailure, "%v", err)
+			}
+			onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
+				aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
+			if err != nil {
+				return c.fail(protocol.ExitFailure, "%v", err)
+			}
+		}
+		newSHA = onto
 	}
 
 	// CAS so a concurrent push between our read and this write fails the
diff --git a/internal/gitutil/merge.go b/internal/gitutil/merge.go
index 87bc9a9..990997d 100644
--- a/internal/gitutil/merge.go
+++ b/internal/gitutil/merge.go
@@ -172,3 +172,86 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
 	}
 	return sha, nil
 }
+
+// CommitParents returns the parent SHAs of a commit.
+func CommitParents(dir, sha string) ([]string, error) {
+	out, err := exec.Command("git", "-C", dir, "rev-list", "--parents", "-n1", sha).Output()
+	if err != nil {
+		return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err)
+	}
+	fields := strings.Fields(string(out))
+	if len(fields) < 1 {
+		return nil, fmt.Errorf("no output for %s", sha)
+	}
+	return fields[1:], nil
+}
+
+// AuthorIdent returns a commit's author name, email, and ISO date.
+func AuthorIdent(dir, sha string) (name, email, date string, err error) {
+	out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%an%x1f%ae%x1f%aI", sha).Output()
+	if err != nil {
+		return "", "", "", fmt.Errorf("log %s: %w", sha, err)
+	}
+	parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3)
+	if len(parts) != 3 {
+		return "", "", "", fmt.Errorf("bad ident for %s", sha)
+	}
+	return parts[0], parts[1], parts[2], nil
+}
+
+// CommitMessage returns a commit's full message.
+func CommitMessage(dir, sha string) (string, error) {
+	out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%B", sha).Output()
+	if err != nil {
+		return "", fmt.Errorf("log %s: %w", sha, err)
+	}
+	return strings.TrimRight(string(out), "\n"), nil
+}
+
+// MergeTreeOnto replays commit's changes (relative to base) onto onto,
+// returning the resulting tree. conflict=true when it cannot apply cleanly.
+func MergeTreeOnto(dir, base, onto, commit string) (tree string, conflict bool, err error) {
+	cmd := exec.Command("git", "-C", dir, "merge-tree", "--write-tree", "--merge-base="+base, onto, commit)
+	out, runErr := cmd.Output()
+	tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
+	if runErr != nil {
+		if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
+			return "", true, nil
+		}
+		return "", false, fmt.Errorf("merge-tree: %w", runErr)
+	}
+	return tree, false, nil
+}
+
+// CommitTreeIdent creates a commit with distinct author and committer
+// identities. Empty authorDate means now.
+func CommitTreeIdent(dir, tree string, parents []string,
+	authorName, authorEmail, authorDate, committerName, committerEmail, message string) (string, error) {
+	args := []string{"-C", dir, "commit-tree", tree, "-m", message}
+	for _, p := range parents {
+		args = append(args, "-p", p)
+	}
+	cmd := exec.Command("git", args...)
+	env := append(os.Environ(),
+		"GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail,
+		"GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail,
+	)
+	if authorDate != "" {
+		env = append(env, "GIT_AUTHOR_DATE="+authorDate)
+	}
+	cmd.Env = env
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("commit-tree: %w", err)
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// ResolveTree returns the tree id of a commit.
+func ResolveTree(dir, sha string) (string, error) {
+	out, err := exec.Command("git", "-C", dir, "rev-parse", sha+"^{tree}").Output()
+	if err != nil {
+		return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err)
+	}
+	return strings.TrimSpace(string(out)), nil
+}