Commit ea05aa6c8c
Verified · cmc
Layout: unified · split
cmd/gitbay/main.go +1 −1
| @@ -242,7 +242,7 @@ func mrCmd() *cobra.Command { | ||
| 242 | 242 | local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout), |
| 243 | 243 | pass("comment", "comment on a merge request", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), |
| 244 | 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 | 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | func 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 | 33 | register(Command{Path: []string{"mr", "review"}, |
| 34 | 34 | Summary: "review: mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview}) |
| 35 | 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 | 37 | register(Command{Path: []string{"mr", "close"}, |
| 38 | 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 | 438 | for i := 0; i < len(args); i++ { |
| 439 | 439 | if args[i] == "--strategy" { |
| 440 | 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 | 443 | strategy = args[i+1] |
| 444 | 444 | i++ |
| @@ -446,8 +446,9 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 446 | 446 | } |
| 447 | 447 | rest = append(rest, args[i]) |
| 448 | 448 | } |
| 449 | if strategy != "" && strategy != "ff" && strategy != "merge" { | |
| 450 | return c.fail(protocol.ExitUsage, "--strategy must be ff or merge") | |
| 449 | valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true} | |
| 450 | if !valid[strategy] { | |
| 451 | return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase") | |
| 451 | 452 | } |
| 452 | 453 | repo, mr, code := mrRef(c, rest, policy.CanWrite) |
| 453 | 454 | if code >= 0 { |
| @@ -481,11 +482,13 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 481 | 482 | } |
| 482 | 483 | |
| 483 | 484 | // Signature policy matrix: with require_signed_commits, only |
| 484 | // fast-forward is allowed — a server-created merge commit would be | |
| 485 | // unsigned, violating the branch's own policy — and every landed | |
| 486 | // commit must be verified. | |
| 485 | // fast-forward is allowed — squash, rebase-replay, and merge commits | |
| 486 | // are all server-created and unsigned, violating the branch's own | |
| 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 | 490 | if repo.Settings.RequireSignedCommits { |
| 488 | if strategy == "merge" || !ffPossible { | |
| 491 | if strategy == "merge" || strategy == "squash" || !ffPossible { | |
| 489 | 492 | return c.fail(protocol.ExitDenied, |
| 490 | 493 | "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again", |
| 491 | 494 | repo.Path(), mr.SourceRef, mr.TargetRef) |
| @@ -521,6 +524,25 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 521 | 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 | 547 | var newSHA string |
| 526 | 548 | switch strategy { |
| @@ -530,15 +552,8 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 530 | 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 | 554 | newSHA = headSHA |
| 555 | ||
| 533 | 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 | 557 | tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA) |
| 543 | 558 | if err != nil { |
| 544 | 559 | return c.fail(protocol.ExitFailure, "%v", err) |
| @@ -548,10 +563,96 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 548 | 563 | "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef) |
| 549 | 564 | } |
| 550 | 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 | 612 | if err != nil { |
| 553 | 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 | 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 | 173 | return sha, nil |
| 174 | 174 | } |
| 175 | ||
| 176 | // CommitParents returns the parent SHAs of a commit. | |
| 177 | func 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. | |
| 190 | func 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. | |
| 203 | func 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. | |
| 213 | func 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. | |
| 228 | func 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. | |
| 251 | func 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 | } | |