Commit ea05aa6c8c

ea05aa6c8cd4d033d0eefd739160d29d4ead008a

parent: 08edfb55e9

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 00:40 UTC

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

Layout: unified · split

cmd/gitbay/main.go +1 −1
@@ -242,7 +242,7 @@ func mrCmd() *cobra.Command {
242 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout), 242 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout),
243 pass("comment", "comment on a merge request", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), 243 pass("comment", "comment on a merge request", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
244 pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}), 244 pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}),
245 pass("merge", "merge (fast-forward or merge-commit): [--strategy ff|merge]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), 245 pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}),
246 pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}), 246 pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}),
247 ) 247 )
248} 248}
e2e/merge_strategies_test.go added +197
@@ -0,0 +1,197 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func TestSquashAndRebaseMerges(t *testing.T) {
11 inst := startInstance(t)
12 aliceKey := inst.newKey(t, "alice")
13 bobKey := inst.newKey(t, "bob")
14 inst.admin(t, "admin", "user", "create", "alice",
15 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
16 inst.admin(t, "admin", "user", "create", "bob",
17 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
18
19 // Repo with bob granted write; bob authors branches, alice merges.
20 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
21 t.Fatalf("repo create: %s", errOut)
22 }
23 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/lib", "bob", "write"); code != 0 {
24 t.Fatal("grant failed")
25 }
26
27 aliceEnv := inst.gitEnv(aliceKey)
28 bobEnv := inst.gitEnv(bobKey)
29 work := t.TempDir()
30 mustGit(t, work, aliceEnv, "clone", inst.sshURL("alice/lib"), "w")
31 dir := filepath.Join(work, "w")
32 os.WriteFile(filepath.Join(dir, "base.txt"), []byte("base\n"), 0o644)
33 mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main")
34 mustGit(t, dir, aliceEnv, "add", ".")
35 mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "base")
36 mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
37
38 // --- squash: two bob commits, diverged target -> one new commit ---
39 bobWork := t.TempDir()
40 mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/lib"), "w")
41 bobDir := filepath.Join(bobWork, "w")
42 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat1", "origin/main")
43 os.WriteFile(filepath.Join(bobDir, "a.txt"), []byte("a\n"), 0o644)
44 mustGit(t, bobDir, bobEnv, "add", ".")
45 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "wip 1")
46 os.WriteFile(filepath.Join(bobDir, "b.txt"), []byte("b\n"), 0o644)
47 mustGit(t, bobDir, bobEnv, "add", ".")
48 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "wip 2")
49 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat1")
50 if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
51 "--source", "feat1", "--target", "main", "--title", "'squash me'", "--body", "'two wips'"); code != 0 {
52 t.Fatalf("mr create: %s", errOut)
53 }
54 // Target advances so ff is impossible.
55 mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "mainline")
56 mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
57
58 before := strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-parse", "origin/main"))
59 out, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "1", "--strategy", "squash", "--json")
60 if code != 0 {
61 t.Fatalf("squash merge: %s", errOut)
62 }
63 if !strings.Contains(out, `"strategy":"squash"`) {
64 t.Fatalf("squash output: %s", out)
65 }
66 mustGit(t, dir, aliceEnv, "fetch", "-q", "origin")
67 // Exactly one commit landed on top of the old tip.
68 count := strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-list", "--count", before+"..origin/main"))
69 if count != "1" {
70 t.Fatalf("squash added %s commits, want 1", count)
71 }
72 // Single parent, author = MR author (bob), committer = merger (alice).
73 ident := strings.TrimSpace(mustGit(t, dir, aliceEnv, "log", "-1",
74 "--format=%an <%ae>|%cn <%ce>|%p|%s", "origin/main"))
75 parts := strings.Split(ident, "|")
76 if parts[0] != "bob <bob@example.test>" || parts[1] != "alice <alice@example.test>" {
77 t.Fatalf("squash identities: %s", ident)
78 }
79 if strings.Contains(parts[2], " ") {
80 t.Fatalf("squash commit has multiple parents: %s", ident)
81 }
82 if parts[3] != "squash me (!1)" {
83 t.Fatalf("squash subject: %s", ident)
84 }
85 // Both files present.
86 mustGit(t, dir, aliceEnv, "checkout", "-q", "main")
87 mustGit(t, dir, aliceEnv, "pull", "-q", "origin", "main")
88 for _, f := range []string{"a.txt", "b.txt"} {
89 if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
90 t.Fatalf("squashed content missing %s", f)
91 }
92 }
93
94 // --- rebase: two commits replayed onto a diverged target ---
95 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat2", "origin/main")
96 mustGit(t, bobDir, bobEnv, "fetch", "-q", "origin")
97 mustGit(t, bobDir, bobEnv, "reset", "-q", "--hard", "origin/main")
98 os.WriteFile(filepath.Join(bobDir, "c.txt"), []byte("c\n"), 0o644)
99 mustGit(t, bobDir, bobEnv, "add", ".")
100 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "step one")
101 os.WriteFile(filepath.Join(bobDir, "d.txt"), []byte("d\n"), 0o644)
102 mustGit(t, bobDir, bobEnv, "add", ".")
103 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "step two")
104 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat2")
105 if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
106 "--source", "feat2", "--target", "main", "--title", "'rebase me'"); code != 0 {
107 t.Fatalf("mr2 create: %s", errOut)
108 }
109 mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "mainline again")
110 mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
111
112 before = strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-parse", "origin/main"))
113 out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "2", "--strategy", "rebase", "--json")
114 if code != 0 {
115 t.Fatalf("rebase merge: %s", errOut)
116 }
117 mustGit(t, dir, aliceEnv, "fetch", "-q", "origin")
118 // Two commits, linear (no merges), authors preserved, committer alice.
119 count = strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-list", "--count", before+"..origin/main"))
120 if count != "2" {
121 t.Fatalf("rebase added %s commits, want 2", count)
122 }
123 merges := strings.TrimSpace(mustGit(t, dir, aliceEnv, "rev-list", "--merges", "--count", before+"..origin/main"))
124 if merges != "0" {
125 t.Fatal("rebase produced a merge commit")
126 }
127 logOut := mustGit(t, dir, aliceEnv, "log", "--format=%ae|%ce|%s", before+"..origin/main")
128 for _, line := range strings.Split(strings.TrimSpace(logOut), "\n") {
129 p := strings.Split(line, "|")
130 if p[0] != "t@example.test" || p[1] != "alice@example.test" {
131 t.Fatalf("rebase identities: %s", line)
132 }
133 }
134 if !strings.Contains(logOut, "step one") || !strings.Contains(logOut, "step two") {
135 t.Fatalf("rebase messages: %s", logOut)
136 }
137
138 // --- rebase refuses merge commits in the source ---
139 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat3")
140 mustGit(t, bobDir, bobEnv, "fetch", "-q", "origin")
141 mustGit(t, bobDir, bobEnv, "reset", "-q", "--hard", "origin/main")
142 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "side")
143 os.WriteFile(filepath.Join(bobDir, "e.txt"), []byte("e\n"), 0o644)
144 mustGit(t, bobDir, bobEnv, "add", ".")
145 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "side work")
146 mustGit(t, bobDir, bobEnv, "checkout", "-q", "feat3")
147 os.WriteFile(filepath.Join(bobDir, "f.txt"), []byte("f\n"), 0o644)
148 mustGit(t, bobDir, bobEnv, "add", ".")
149 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "main work")
150 mustGit(t, bobDir, bobEnv, "merge", "-q", "--no-ff", "-m", "internal merge", "side")
151 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat3")
152 if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
153 "--source", "feat3", "--target", "main", "--title", "'has a merge'"); code != 0 {
154 t.Fatalf("mr3 create: %s", errOut)
155 }
156 mustGit(t, dir, aliceEnv, "fetch", "-q", "origin")
157 mustGit(t, dir, aliceEnv, "reset", "-q", "--hard", "origin/main")
158 mustGit(t, dir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "diverge again")
159 mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
160 _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "3", "--strategy", "rebase")
161 if code != 2 || !strings.Contains(errOut, "linear history") {
162 t.Fatalf("rebase with merge commit: exit %d, %s", code, errOut)
163 }
164
165 // --- require_signed_commits refuses squash outright ---
166 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/lib", "on"); code != 0 {
167 t.Fatal("require-signed failed")
168 }
169 _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "3", "--strategy", "squash")
170 if code != 4 || !strings.Contains(errOut, "only fast-forward") {
171 t.Fatalf("squash on require-signed: exit %d, %s", code, errOut)
172 }
173 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/lib", "off"); code != 0 {
174 t.Fatal("require-signed off failed")
175 }
176
177 // --- rebase when ff is possible IS a fast-forward: shas preserved ---
178 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feat4")
179 mustGit(t, bobDir, bobEnv, "fetch", "-q", "origin")
180 mustGit(t, bobDir, bobEnv, "reset", "-q", "--hard", "origin/main")
181 os.WriteFile(filepath.Join(bobDir, "g.txt"), []byte("g\n"), 0o644)
182 mustGit(t, bobDir, bobEnv, "add", ".")
183 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "clean on top")
184 tip := strings.TrimSpace(mustGit(t, bobDir, bobEnv, "rev-parse", "HEAD"))
185 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feat4")
186 if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
187 "--source", "feat4", "--target", "main", "--title", "'ff-able'"); code != 0 {
188 t.Fatalf("mr4 create: %s", errOut)
189 }
190 out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "4", "--strategy", "rebase", "--json")
191 if code != 0 {
192 t.Fatalf("ff-able rebase: %s", errOut)
193 }
194 if !strings.Contains(out, `"strategy":"ff"`) || !strings.Contains(out, tip) {
195 t.Fatalf("ff-able rebase should fast-forward to %s: %s", tip, out)
196 }
197}
internal/control/mr.go +118 −17
@@ -33,7 +33,7 @@ func init() {
33 register(Command{Path: []string{"mr", "review"}, 33 register(Command{Path: []string{"mr", "review"},
34 Summary: "review: mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview}) 34 Summary: "review: mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview})
35 register(Command{Path: []string{"mr", "merge"}, 35 register(Command{Path: []string{"mr", "merge"},
36 Summary: "merge: mr merge <owner/name> <n> [--strategy ff|merge]", Run: runMRMerge}) 36 Summary: "merge: mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge})
37 register(Command{Path: []string{"mr", "close"}, 37 register(Command{Path: []string{"mr", "close"},
38 Summary: "close without merging: mr close <owner/name> <n>", Run: runMRClose}) 38 Summary: "close without merging: mr close <owner/name> <n>", Run: runMRClose})
39} 39}
@@ -438,7 +438,7 @@ func runMRMerge(c *Ctx, args []string) int {
438 for i := 0; i < len(args); i++ { 438 for i := 0; i < len(args); i++ {
439 if args[i] == "--strategy" { 439 if args[i] == "--strategy" {
440 if i+1 >= len(args) { 440 if i+1 >= len(args) {
441 return c.fail(protocol.ExitUsage, "--strategy requires ff|merge") 441 return c.fail(protocol.ExitUsage, "--strategy requires ff|merge|squash|rebase")
442 } 442 }
443 strategy = args[i+1] 443 strategy = args[i+1]
444 i++ 444 i++
@@ -446,8 +446,9 @@ func runMRMerge(c *Ctx, args []string) int {
446 } 446 }
447 rest = append(rest, args[i]) 447 rest = append(rest, args[i])
448 } 448 }
449 if strategy != "" && strategy != "ff" && strategy != "merge" { 449 valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
450 return c.fail(protocol.ExitUsage, "--strategy must be ff or merge") 450 if !valid[strategy] {
451 return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
451 } 452 }
452 repo, mr, code := mrRef(c, rest, policy.CanWrite) 453 repo, mr, code := mrRef(c, rest, policy.CanWrite)
453 if code >= 0 { 454 if code >= 0 {
@@ -481,11 +482,13 @@ func runMRMerge(c *Ctx, args []string) int {
481 } 482 }
482 483
483 // Signature policy matrix: with require_signed_commits, only 484 // Signature policy matrix: with require_signed_commits, only
484 // fast-forward is allowed — a server-created merge commit would be 485 // fast-forward is allowed — squash, rebase-replay, and merge commits
485 // unsigned, violating the branch's own policy — and every landed 486 // are all server-created and unsigned, violating the branch's own
486 // commit must be verified. 487 // policy — and every landed commit must be verified. An explicit
488 // rebase when fast-forward is already possible IS a fast-forward
489 // (nothing is rewritten), so it stays legal.
487 if repo.Settings.RequireSignedCommits { 490 if repo.Settings.RequireSignedCommits {
488 if strategy == "merge" || !ffPossible { 491 if strategy == "merge" || strategy == "squash" || !ffPossible {
489 return c.fail(protocol.ExitDenied, 492 return c.fail(protocol.ExitDenied,
490 "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again", 493 "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
491 repo.Path(), mr.SourceRef, mr.TargetRef) 494 repo.Path(), mr.SourceRef, mr.TargetRef)
@@ -521,6 +524,25 @@ func runMRMerge(c *Ctx, args []string) int {
521 strategy = "merge" 524 strategy = "merge"
522 } 525 }
523 } 526 }
527 if strategy == "rebase" && ffPossible {
528 // Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
529 // and taking it keeps the original commits and their signatures.
530 strategy = "ff"
531 }
532
533 // Every server-created commit needs the merger's verified identity.
534 mergerEmail := ""
535 if strategy != "ff" {
536 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
537 if err != nil {
538 return c.fail(protocol.ExitFailure, "%v", err)
539 }
540 if email == "" {
541 return c.fail(protocol.ExitDenied,
542 "%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
543 }
544 mergerEmail = email
545 }
524 546
525 var newSHA string 547 var newSHA string
526 switch strategy { 548 switch strategy {
@@ -530,15 +552,8 @@ func runMRMerge(c *Ctx, args []string) int {
530 "fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef) 552 "fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
531 } 553 }
532 newSHA = headSHA 554 newSHA = headSHA
555
533 case "merge": 556 case "merge":
534 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
535 if err != nil {
536 return c.fail(protocol.ExitFailure, "%v", err)
537 }
538 if email == "" {
539 return c.fail(protocol.ExitDenied,
540 "merge commits carry your identity: verify a primary email first (ask an admin, or use a fast-forward merge)")
541 }
542 tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA) 557 tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
543 if err != nil { 558 if err != nil {
544 return c.fail(protocol.ExitFailure, "%v", err) 559 return c.fail(protocol.ExitFailure, "%v", err)
@@ -548,10 +563,96 @@ func runMRMerge(c *Ctx, args []string) int {
548 "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef) 563 "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
549 } 564 }
550 msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef) 565 msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
551 newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, email, msg) 566 newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
567 if err != nil {
568 return c.fail(protocol.ExitFailure, "%v", err)
569 }
570
571 case "squash":
572 // One new commit with the merged tree. Authorship credit goes to
573 // the MR author (their verified identity when they have one); the
574 // committer is the merger.
575 tree := ""
576 if ffPossible {
577 t, err := gitutil.ResolveTree(dir, headSHA)
578 if err != nil {
579 return c.fail(protocol.ExitFailure, "%v", err)
580 }
581 tree = t
582 } else {
583 t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
584 if err != nil {
585 return c.fail(protocol.ExitFailure, "%v", err)
586 }
587 if conflict {
588 return c.fail(protocol.ExitUsage,
589 "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
590 }
591 tree = t
592 }
593 authorName, authorEmail := c.User.Username, mergerEmail
594 if author, err := c.Store.UserByUsername(mr.Author); err == nil {
595 if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
596 authorName, authorEmail = author.Username, ae
597 }
598 }
599 msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
600 if mr.Body != "" {
601 msg += "\n\n" + mr.Body
602 }
603 var err error
604 newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
605 authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
606 if err != nil {
607 return c.fail(protocol.ExitFailure, "%v", err)
608 }
609
610 case "rebase":
611 commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
552 if err != nil { 612 if err != nil {
553 return c.fail(protocol.ExitFailure, "%v", err) 613 return c.fail(protocol.ExitFailure, "%v", err)
554 } 614 }
615 // Oldest first.
616 for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
617 commits[i], commits[j] = commits[j], commits[i]
618 }
619 onto := targetSHA
620 for _, sha := range commits {
621 parents, err := gitutil.CommitParents(dir, sha)
622 if err != nil {
623 return c.fail(protocol.ExitFailure, "%v", err)
624 }
625 if len(parents) > 1 {
626 return c.fail(protocol.ExitUsage,
627 "the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha)
628 }
629 base := onto // root commit: replay against the new tip itself
630 if len(parents) == 1 {
631 base = parents[0]
632 }
633 tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
634 if err != nil {
635 return c.fail(protocol.ExitFailure, "%v", err)
636 }
637 if conflict {
638 return c.fail(protocol.ExitUsage,
639 "commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
640 }
641 aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
642 if err != nil {
643 return c.fail(protocol.ExitFailure, "%v", err)
644 }
645 msg, err := gitutil.CommitMessage(dir, sha)
646 if err != nil {
647 return c.fail(protocol.ExitFailure, "%v", err)
648 }
649 onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
650 aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
651 if err != nil {
652 return c.fail(protocol.ExitFailure, "%v", err)
653 }
654 }
655 newSHA = onto
555 } 656 }
556 657
557 // CAS so a concurrent push between our read and this write fails the 658 // CAS so a concurrent push between our read and this write fails the
internal/gitutil/merge.go +83
@@ -172,3 +172,86 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
172 } 172 }
173 return sha, nil 173 return sha, nil
174} 174}
175
176// CommitParents returns the parent SHAs of a commit.
177func CommitParents(dir, sha string) ([]string, error) {
178 out, err := exec.Command("git", "-C", dir, "rev-list", "--parents", "-n1", sha).Output()
179 if err != nil {
180 return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err)
181 }
182 fields := strings.Fields(string(out))
183 if len(fields) < 1 {
184 return nil, fmt.Errorf("no output for %s", sha)
185 }
186 return fields[1:], nil
187}
188
189// AuthorIdent returns a commit's author name, email, and ISO date.
190func AuthorIdent(dir, sha string) (name, email, date string, err error) {
191 out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%an%x1f%ae%x1f%aI", sha).Output()
192 if err != nil {
193 return "", "", "", fmt.Errorf("log %s: %w", sha, err)
194 }
195 parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3)
196 if len(parts) != 3 {
197 return "", "", "", fmt.Errorf("bad ident for %s", sha)
198 }
199 return parts[0], parts[1], parts[2], nil
200}
201
202// CommitMessage returns a commit's full message.
203func CommitMessage(dir, sha string) (string, error) {
204 out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%B", sha).Output()
205 if err != nil {
206 return "", fmt.Errorf("log %s: %w", sha, err)
207 }
208 return strings.TrimRight(string(out), "\n"), nil
209}
210
211// MergeTreeOnto replays commit's changes (relative to base) onto onto,
212// returning the resulting tree. conflict=true when it cannot apply cleanly.
213func MergeTreeOnto(dir, base, onto, commit string) (tree string, conflict bool, err error) {
214 cmd := exec.Command("git", "-C", dir, "merge-tree", "--write-tree", "--merge-base="+base, onto, commit)
215 out, runErr := cmd.Output()
216 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
217 if runErr != nil {
218 if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
219 return "", true, nil
220 }
221 return "", false, fmt.Errorf("merge-tree: %w", runErr)
222 }
223 return tree, false, nil
224}
225
226// CommitTreeIdent creates a commit with distinct author and committer
227// identities. Empty authorDate means now.
228func CommitTreeIdent(dir, tree string, parents []string,
229 authorName, authorEmail, authorDate, committerName, committerEmail, message string) (string, error) {
230 args := []string{"-C", dir, "commit-tree", tree, "-m", message}
231 for _, p := range parents {
232 args = append(args, "-p", p)
233 }
234 cmd := exec.Command("git", args...)
235 env := append(os.Environ(),
236 "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail,
237 "GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail,
238 )
239 if authorDate != "" {
240 env = append(env, "GIT_AUTHOR_DATE="+authorDate)
241 }
242 cmd.Env = env
243 out, err := cmd.Output()
244 if err != nil {
245 return "", fmt.Errorf("commit-tree: %w", err)
246 }
247 return strings.TrimSpace(string(out)), nil
248}
249
250// ResolveTree returns the tree id of a commit.
251func ResolveTree(dir, sha string) (string, error) {
252 out, err := exec.Command("git", "-C", dir, "rev-parse", sha+"^{tree}").Output()
253 if err != nil {
254 return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err)
255 }
256 return strings.TrimSpace(string(out)), nil
257}