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
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(-) @@ -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}), ) } new file mode 100644 @@ -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) + } +} @@ -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 @@ -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 +}