krz/gitbay

A CLI-first git forge.

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

08b325bb1d221743ebfd2d99502f63b59ff6f346

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T23:11:09Z

M7: merge requests

- repo fork (objects copied via local fetch, fork_of recorded); mr
  create/list/show/diff/comment/review/merge/close; same-repo and
  cross-fork sources (source must be the target or a fork of it)
- refs/merge-requests/N/head lives in the TARGET repo, populated by
  fetching from the source: fork deletion leaves the MR diff intact
  and still mergeable (marked source_gone; branch reappearing reopens)
- post-receive cross-repo effect: source-branch pushes refresh MR
  heads and mark reviews at other heads stale, in one transaction
- merge strategies ff + merge-commit with update-ref CAS against
  concurrent pushes; merge-commit identity is the merging user's
  verified primary email; no server signing key
- require_signed_commits: push-time enforcement via two-phase hook
  (daemon requests raw commit objects from the quarantine-side hook);
  merge-time policy allows only ff of all-verified commits, refusing
  merge commits with exit 4 and rebase instructions
- web read views: /mrs list and /mrs/{n} detail with diff and stale
  markers; diff classification shared with the commit page
 cmd/forged/hook.go              |  33 +++
 cmd/forged/main.go              |   2 +-
 e2e/mr_test.go                  | 260 ++++++++++++++++++
 internal/control/mr.go          | 595 ++++++++++++++++++++++++++++++++++++++++
 internal/control/repo.go        |   5 +
 internal/control/sig.go         |   3 +
 internal/gitutil/merge.go       | 117 ++++++++
 internal/hookd/hookd.go         | 182 ++++++++++--
 internal/httpd/routes.go        |   2 +
 internal/httpd/web.go           | 103 +++++--
 internal/store/mrs.go           | 233 ++++++++++++++++
 internal/store/repos.go         |  14 +-
 internal/web/templates/mr.html  |  15 +
 internal/web/templates/mrs.html |  16 ++
 14 files changed, 1528 insertions(+), 52 deletions(-)

diff --git a/cmd/forged/hook.go b/cmd/forged/hook.go
index e76e79d..f242219 100644
--- a/cmd/forged/hook.go
+++ b/cmd/forged/hook.go
@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"fmt"
 	"os"
+	"os/exec"
 	"strconv"
 	"strings"
 
@@ -14,6 +15,36 @@ import (
 	"github.com/krazywarez/forge/internal/policy"
 )
 
+// collectIncomingCommits lists the commits this push introduces and reads
+// their raw objects. It runs in the hook process, which inherits git's
+// quarantine environment — the daemon cannot see these objects yet.
+func collectIncomingCommits(updates []policy.RefUpdate) (hookd.CommitsPayload, error) {
+	seen := map[string]bool{}
+	var payload hookd.CommitsPayload
+	for _, u := range updates {
+		if u.IsDelete {
+			continue
+		}
+		// Everything reachable from the new tip that no existing ref has.
+		out, err := exec.Command("git", "rev-list", u.New, "--not", "--all").Output()
+		if err != nil {
+			return payload, fmt.Errorf("rev-list %s: %w", u.New, err)
+		}
+		for _, sha := range strings.Fields(string(out)) {
+			if seen[sha] {
+				continue
+			}
+			seen[sha] = true
+			raw, err := exec.Command("git", "cat-file", "commit", sha).Output()
+			if err != nil {
+				return payload, fmt.Errorf("cat-file %s: %w", sha, err)
+			}
+			payload.Commits = append(payload.Commits, hookd.RawCommit{SHA: sha, Raw: raw})
+		}
+	}
+	return payload, nil
+}
+
 // hookCmd runs inside a git hook. It computes git facts here — the hook
 // process inherits git's quarantine environment, so incoming objects are
 // visible — and asks the daemon for a policy decision over the unix socket.
@@ -57,6 +88,8 @@ func hookCmd() *cobra.Command {
 				RepoID:  repoID,
 				UserID:  userID,
 				Updates: updates,
+			}, func() (hookd.CommitsPayload, error) {
+				return collectIncomingCommits(updates)
 			})
 			if err != nil {
 				return fmt.Errorf("forge daemon unreachable: %w", err)
diff --git a/cmd/forged/main.go b/cmd/forged/main.go
index 86c3aa4..a7dbbe9 100644
--- a/cmd/forged/main.go
+++ b/cmd/forged/main.go
@@ -112,7 +112,7 @@ func serveCmd() *cobra.Command {
 			if err := hookd.WriteHookScripts(control.HooksDir(cfg.Server.Root), self); err != nil {
 				return err
 			}
-			stopHookd, err := hookd.Serve(cfg.Server.Root, st)
+			stopHookd, err := hookd.Serve(cfg, st)
 			if err != nil {
 				return err
 			}
diff --git a/e2e/mr_test.go b/e2e/mr_test.go
new file mode 100644
index 0000000..c8b4438
--- /dev/null
+++ b/e2e/mr_test.go
@@ -0,0 +1,260 @@
+package e2e
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"golang.org/x/crypto/ssh"
+
+	"github.com/krazywarez/forge/internal/sig"
+)
+
+type mrShow struct {
+	Number  int64  `json:"number"`
+	State   string `json:"state"`
+	Source  string `json:"source"`
+	HeadSHA string `json:"head_sha"`
+	Reviews []struct {
+		Reviewer string `json:"reviewer"`
+		Verdict  string `json:"verdict"`
+		Stale    bool   `json:"stale"`
+	} `json:"reviews"`
+}
+
+func (i *instance) mrShow(t *testing.T, key, repo, n string) mrShow {
+	t.Helper()
+	out, errOut, code := i.ssh(t, key, "", "mr", "show", repo, n, "--json")
+	if code != 0 {
+		t.Fatalf("mr show: exit %d, %s", code, errOut)
+	}
+	var env struct {
+		Data mrShow `json:"data"`
+	}
+	if err := json.Unmarshal([]byte(out), &env); err != nil {
+		t.Fatalf("mr show JSON: %v\n%s", err, out)
+	}
+	return env.Data
+}
+
+func TestMergeRequests(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")
+
+	// Alice's upstream repo with an initial commit.
+	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
+		t.Fatalf("repo create: %s", errOut)
+	}
+	aliceEnv := inst.gitEnv(aliceKey)
+	aliceWork := t.TempDir()
+	mustGit(t, aliceWork, aliceEnv, "clone", inst.sshURL("alice/lib"), "w")
+	aliceDir := filepath.Join(aliceWork, "w")
+	os.WriteFile(filepath.Join(aliceDir, "lib.txt"), []byte("v1\n"), 0o644)
+	mustGit(t, aliceDir, aliceEnv, "checkout", "-q", "-b", "main")
+	mustGit(t, aliceDir, aliceEnv, "add", ".")
+	mustGit(t, aliceDir, aliceEnv, "commit", "-q", "-m", "base")
+	mustGit(t, aliceDir, aliceEnv, "push", "-q", "origin", "main")
+
+	// Bob forks and pushes a feature branch to his fork.
+	if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/lib"); code != 0 {
+		t.Fatalf("fork: %s", errOut)
+	}
+	bobEnv := inst.gitEnv(bobKey)
+	bobWork := t.TempDir()
+	mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("bob/lib"), "w")
+	bobDir := filepath.Join(bobWork, "w")
+	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feature", "origin/main")
+	os.WriteFile(filepath.Join(bobDir, "feature.txt"), []byte("bob's work\n"), 0o644)
+	mustGit(t, bobDir, bobEnv, "add", ".")
+	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "add feature")
+	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feature")
+
+	// MR from the fork into alice/lib.
+	out, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
+		"--source", "bob/lib:feature", "--target", "main", "--title", "'add feature'", "--json")
+	if code != 0 {
+		t.Fatalf("mr create: %s", errOut)
+	}
+	if !strings.Contains(out, `"number":1`) {
+		t.Fatalf("mr create output: %s", out)
+	}
+
+	// The MR head ref is fetchable from the TARGET repo by a reader.
+	fetchDir := t.TempDir()
+	mustGit(t, fetchDir, aliceEnv, "clone", "-q", inst.sshURL("alice/lib"), "c")
+	mustGit(t, filepath.Join(fetchDir, "c"), aliceEnv, "fetch", "-q", "origin", "refs/merge-requests/1/head")
+
+	// Alice approves.
+	if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "review", "alice/lib", "1", "--approve"); code != 0 {
+		t.Fatalf("review: %s", errOut)
+	}
+	show := inst.mrShow(t, aliceKey, "alice/lib", "1")
+	if len(show.Reviews) != 1 || show.Reviews[0].Stale {
+		t.Fatalf("fresh review wrong: %+v", show.Reviews)
+	}
+	firstHead := show.HeadSHA
+
+	// Bob force-pushes the source branch: the MR head updates and the
+	// review goes stale.
+	mustGit(t, bobDir, bobEnv, "commit", "-q", "--amend", "-m", "add feature (amended)")
+	mustGit(t, bobDir, bobEnv, "push", "-q", "--force", "origin", "feature")
+	show = inst.mrShow(t, aliceKey, "alice/lib", "1")
+	if show.HeadSHA == firstHead {
+		t.Fatal("MR head not updated after force-push")
+	}
+	if len(show.Reviews) != 1 || !show.Reviews[0].Stale {
+		t.Fatalf("review not marked stale: %+v", show.Reviews)
+	}
+
+	// Target advances, so fast-forward is impossible: default merge makes a
+	// merge commit authored by the merging user.
+	mustGit(t, aliceDir, aliceEnv, "commit", "-q", "--allow-empty", "-m", "mainline moves on")
+	mustGit(t, aliceDir, aliceEnv, "push", "-q", "origin", "main")
+	out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "1", "--json")
+	if code != 0 {
+		t.Fatalf("merge: exit %d, %s", code, errOut)
+	}
+	if !strings.Contains(out, `"strategy":"merge"`) {
+		t.Fatalf("expected merge-commit strategy: %s", out)
+	}
+	if inst.mrShow(t, aliceKey, "alice/lib", "1").State != "merged" {
+		t.Fatal("MR not marked merged")
+	}
+	mustGit(t, aliceDir, aliceEnv, "pull", "-q", "origin", "main")
+	if _, err := os.Stat(filepath.Join(aliceDir, "feature.txt")); err != nil {
+		t.Fatal("merged content missing from main")
+	}
+	// The merge commit carries the merging user's identity and is unsigned.
+	tip := strings.TrimSpace(mustGit(t, aliceDir, aliceEnv, "log", "-1", "--format=%an <%ae>"))
+	if tip != "alice <alice@example.test>" {
+		t.Fatalf("merge commit identity: %q", tip)
+	}
+	logOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/lib", "--limit", "1")
+	if !strings.Contains(logOut, "unsigned") {
+		t.Fatalf("merge commit should display unsigned:\n%s", logOut)
+	}
+
+	// --- require_signed_commits: push-time and merge-time policy ---
+
+	if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "create", "alice/sec"); code != 0 {
+		t.Fatalf("create sec: %s", errOut)
+	}
+	if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/sec", "on"); code != 0 {
+		t.Fatalf("require-signed: %s", errOut)
+	}
+	secWork := t.TempDir()
+	mustGit(t, secWork, aliceEnv, "clone", inst.sshURL("alice/sec"), "w")
+	secDir := filepath.Join(secWork, "w")
+
+	// Unsigned push is rejected at pre-receive.
+	os.WriteFile(filepath.Join(secDir, "a.txt"), []byte("a\n"), 0o644)
+	mustGit(t, secDir, aliceEnv, "checkout", "-q", "-b", "main")
+	mustGit(t, secDir, aliceEnv, "add", ".")
+	mustGit(t, secDir, aliceEnv, "commit", "-q", "-m", "unsigned attempt")
+	pushOut, pushCode := gitRun(t, secDir, aliceEnv, "push", "origin", "main")
+	if pushCode == 0 {
+		t.Fatal("unsigned push accepted into require-signed repo")
+	}
+	if !strings.Contains(pushOut, "requires signed commits") {
+		t.Fatalf("unsigned push message:\n%s", pushOut)
+	}
+
+	// SSHSIG-signed commits go through.
+	raw, _ := os.ReadFile(aliceKey)
+	signer, err := ssh.ParsePrivateKey(raw)
+	if err != nil {
+		t.Fatal(err)
+	}
+	signAlice := func(p []byte) string {
+		s, err := sig.MarshalSSHSig(signer, p)
+		if err != nil {
+			t.Fatal(err)
+		}
+		return string(s)
+	}
+	buildCommits(t, secDir, aliceEnv, []commitSpec{
+		{authorEmail: "alice@example.test", subject: "signed base", sign: signAlice},
+	})
+	mustGit(t, secDir, aliceEnv, "push", "-q", "origin", "main")
+
+	// A signed feature branch and a same-repo MR.
+	base := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main"))
+	tree := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main^{tree}"))
+	buildChain(t, secDir, aliceEnv, tree, base, []commitSpec{
+		{authorEmail: "alice@example.test", subject: "signed feature", sign: signAlice},
+	})
+	// buildChain moved refs/heads/main; restore and use a feature branch.
+	feat := strings.TrimSpace(mustGit(t, secDir, aliceEnv, "rev-parse", "main"))
+	mustGit(t, secDir, aliceEnv, "update-ref", "refs/heads/main", base)
+	mustGit(t, secDir, aliceEnv, "update-ref", "refs/heads/feat", feat)
+	mustGit(t, secDir, aliceEnv, "push", "-q", "origin", "feat")
+
+	if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "create", "alice/sec",
+		"--source", "feat", "--target", "main", "--title", "'signed work'"); code != 0 {
+		t.Fatalf("sec mr create: %s", errOut)
+	}
+
+	// An explicit merge-commit strategy is refused with exit 4 and rebase
+	// instructions.
+	_, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/sec", "1", "--strategy", "merge")
+	if code != 4 {
+		t.Fatalf("merge-commit on require-signed: exit %d (want 4), %s", code, errOut)
+	}
+	if !strings.Contains(errOut, "only fast-forward") || !strings.Contains(errOut, "rebase") {
+		t.Fatalf("refusal message: %s", errOut)
+	}
+
+	// Fast-forward merge of verified commits succeeds.
+	out, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/sec", "1", "--json")
+	if code != 0 {
+		t.Fatalf("ff merge: %s", errOut)
+	}
+	if !strings.Contains(out, `"strategy":"ff"`) {
+		t.Fatalf("expected ff: %s", out)
+	}
+
+	// --- fork deletion leaves the MR diff intact ---
+
+	mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "second", "origin/main")
+	os.WriteFile(filepath.Join(bobDir, "second.txt"), []byte("more\n"), 0o644)
+	mustGit(t, bobDir, bobEnv, "add", ".")
+	mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "second feature")
+	mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "second")
+	if _, errOut, code = inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
+		"--source", "bob/lib:second", "--target", "main", "--title", "'second'"); code != 0 {
+		t.Fatalf("mr 2 create: %s", errOut)
+	}
+	if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "delete", "bob/lib", "--yes"); code != 0 {
+		t.Fatalf("fork delete: %s", errOut)
+	}
+	show = inst.mrShow(t, aliceKey, "alice/lib", "2")
+	if show.State != "source_gone" {
+		t.Fatalf("MR 2 state after fork deletion: %s", show.State)
+	}
+	diffOut, errOut, code := inst.ssh(t, aliceKey, "", "mr", "diff", "alice/lib", "2")
+	if code != 0 || !strings.Contains(diffOut, "second.txt") {
+		t.Fatalf("diff after fork deletion: exit %d\n%s%s", code, diffOut, errOut)
+	}
+	// And it can still be merged: the target owns the objects.
+	if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "2"); code != 0 {
+		t.Fatalf("merge after fork deletion: %s", errOut)
+	}
+
+	// Web read views.
+	status, body := inst.get(t, "/alice/lib/mrs?state=all")
+	if status != 200 || !strings.Contains(body, "add feature") || !strings.Contains(body, "second") {
+		t.Fatalf("mrs page: %d\n%s", status, body)
+	}
+	status, body = inst.get(t, "/alice/lib/mrs/1")
+	if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") {
+		t.Fatalf("mr detail: %d\n%s", status, body)
+	}
+}
diff --git a/internal/control/mr.go b/internal/control/mr.go
new file mode 100644
index 0000000..ce92ea7
--- /dev/null
+++ b/internal/control/mr.go
@@ -0,0 +1,595 @@
+package control
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+
+	"github.com/krazywarez/forge/internal/gitutil"
+	"github.com/krazywarez/forge/internal/policy"
+	"github.com/krazywarez/forge/internal/protocol"
+	"github.com/krazywarez/forge/internal/store"
+)
+
+func init() {
+	register(Command{Path: []string{"repo", "fork"},
+		Summary: "fork a repository under your account: repo fork <owner/name> [--name <n>]", Run: runRepoFork})
+	register(Command{Path: []string{"repo", "settings", "require-signed"},
+		Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
+	register(Command{Path: []string{"mr", "create"},
+		Summary:    "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]",
+		ReadsStdin: true, Run: runMRCreate})
+	register(Command{Path: []string{"mr", "list"},
+		Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", Run: runMRList})
+	register(Command{Path: []string{"mr", "show"},
+		Summary: "show a merge request: mr show <owner/name> <n>", Run: runMRShow})
+	register(Command{Path: []string{"mr", "diff"},
+		Summary: "show the diff: mr diff <owner/name> <n>", Run: runMRDiff})
+	register(Command{Path: []string{"mr", "comment"},
+		Summary:    "comment: mr comment <owner/name> <n> [--message <m> | --file -]",
+		ReadsStdin: true, Run: runMRComment})
+	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})
+	register(Command{Path: []string{"mr", "close"},
+		Summary: "close without merging: mr close <owner/name> <n>", Run: runMRClose})
+}
+
+func runRepoFork(c *Ctx, args []string) int {
+	var path, name string
+	for i := 0; i < len(args); i++ {
+		switch args[i] {
+		case "--name":
+			if i+1 >= len(args) {
+				return c.fail(protocol.ExitUsage, "--name requires a value")
+			}
+			name = args[i+1]
+			i++
+		default:
+			if path != "" {
+				return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
+			}
+			path = args[i]
+		}
+	}
+	if path == "" {
+		return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
+	}
+	src, code := resolveRepo(c, path, policy.CanRead)
+	if code >= 0 {
+		return code
+	}
+	if name == "" {
+		name = src.Name
+	}
+	if err := policy.ValidateName(name); err != nil {
+		return c.fail(protocol.ExitUsage, "%v", err)
+	}
+	id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	if err := c.Store.SetForkOf(id, src.ID); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name)
+	srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
+	if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
+		c.Store.DeleteRepo(id)
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil {
+		// Empty source repos have nothing to fetch; that is fine.
+		if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil {
+			c.Store.DeleteRepo(id)
+			return c.fail(protocol.ExitFailure, "copying refs: %v", err)
+		}
+	}
+	forkPath := c.User.Username + "/" + name
+	return c.emit(map[string]string{"path": forkPath, "fork_of": src.Path()}, func(w io.Writer) {
+		fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
+	})
+}
+
+func runRequireSigned(c *Ctx, args []string) int {
+	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
+		return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off")
+	}
+	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
+	if code >= 0 {
+		return code
+	}
+	s := repo.Settings
+	s.RequireSignedCommits = args[1] == "on"
+	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return c.emit(s, func(w io.Writer) {
+		fmt.Fprintf(w, "require_signed_commits %s on %s\n", args[1], repo.Path())
+	})
+}
+
+// mrRef parses "<owner/name> <n>" and loads the MR.
+func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.MR, int) {
+	if len(args) < 2 {
+		return store.Repo{}, store.MR{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
+	}
+	repo, code := resolveRepo(c, args[0], perm)
+	if code >= 0 {
+		return repo, store.MR{}, code
+	}
+	n, err := strconv.ParseInt(args[1], 10, 64)
+	if err != nil {
+		return repo, store.MR{}, c.fail(protocol.ExitUsage, "bad MR number %q", args[1])
+	}
+	mr, err := c.Store.MRByNumber(repo.ID, n)
+	if errors.Is(err, store.ErrNotFound) {
+		return repo, mr, c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path())
+	}
+	if err != nil {
+		return repo, mr, c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return repo, mr, -1
+}
+
+func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
+
+func runMRCreate(c *Ctx, args []string) int {
+	var path, source, target, title, body, file string
+	for i := 0; i < len(args); i++ {
+		switch args[i] {
+		case "--source", "--target", "--title", "--body", "--file":
+			if i+1 >= len(args) {
+				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
+			}
+			v := args[i+1]
+			switch args[i] {
+			case "--source":
+				source = v
+			case "--target":
+				target = v
+			case "--title":
+				title = v
+			case "--body":
+				body = v
+			case "--file":
+				file = v
+			}
+			i++
+		default:
+			if path != "" {
+				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
+			}
+			path = args[i]
+		}
+	}
+	if path == "" || source == "" || title == "" {
+		return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>")
+	}
+	repo, code := resolveRepo(c, path, policy.CanRead)
+	if code >= 0 {
+		return code
+	}
+	if target == "" {
+		target = repo.DefaultBranch
+	}
+
+	// Source is "branch" (same repo) or "owner/name:branch" (a fork).
+	srcRepo := repo
+	srcBranch := source
+	if sp, br, ok := strings.Cut(source, ":"); ok {
+		srcBranch = br
+		var scode int
+		srcRepo, scode = resolveRepo(c, sp, policy.CanRead)
+		if scode >= 0 {
+			return scode
+		}
+		if srcRepo.ForkOf != repo.ID && srcRepo.ID != repo.ID {
+			return c.fail(protocol.ExitUsage, "%s is not a fork of %s", srcRepo.Path(), repo.Path())
+		}
+	}
+	srcDir := RepoDir(c.Cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
+	headSHA, err := gitutil.ResolveRef(srcDir, "refs/heads/"+srcBranch)
+	if err != nil {
+		return c.fail(protocol.ExitNotFound, "branch %s not found in %s", srcBranch, srcRepo.Path())
+	}
+	b, err := bodyFrom(c, body, file)
+	if err != nil {
+		return c.fail(protocol.ExitUsage, "%v", err)
+	}
+	n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	// Fetch the head into the target so the target owns the objects.
+	dstDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
+	if err := gitutil.FetchInto(dstDir, srcDir, headSHA, mrHeadRef(n)); err != nil {
+		return c.fail(protocol.ExitFailure, "recording MR head: %v", err)
+	}
+	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
+	return c.emit(map[string]any{"number": n, "head_sha": headSHA}, func(w io.Writer) {
+		fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
+	})
+}
+
+type mrOut struct {
+	Number    int64  `json:"number"`
+	Title     string `json:"title"`
+	State     string `json:"state"`
+	Author    string `json:"author"`
+	Source    string `json:"source"` // owner/name:branch, or branch, "" if gone
+	TargetRef string `json:"target_ref"`
+	HeadSHA   string `json:"head_sha"`
+	Body      string `json:"body,omitempty"`
+	CreatedAt string `json:"created_at"`
+}
+
+func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
+	src := ""
+	if m.SourcePath != "" {
+		if m.SourceRepoID == repo.ID {
+			src = m.SourceRef
+		} else {
+			src = m.SourcePath + ":" + m.SourceRef
+		}
+	}
+	o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author,
+		Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, CreatedAt: m.CreatedAt}
+	if withBody {
+		o.Body = m.Body
+	}
+	return o
+}
+
+func runMRList(c *Ctx, args []string) int {
+	state := "open"
+	var path string
+	for i := 0; i < len(args); i++ {
+		switch args[i] {
+		case "--state":
+			if i+1 >= len(args) {
+				return c.fail(protocol.ExitUsage, "--state requires a value")
+			}
+			state = args[i+1]
+			i++
+		default:
+			if path != "" {
+				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
+			}
+			path = args[i]
+		}
+	}
+	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
+	if path == "" || !valid[state] {
+		return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all]")
+	}
+	repo, code := resolveRepo(c, path, policy.CanRead)
+	if code >= 0 {
+		return code
+	}
+	mrs, err := c.Store.ListMRs(repo.ID, state)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	var ds []mrOut
+	for _, m := range mrs {
+		ds = append(ds, mrToOut(repo, m, false))
+	}
+	return c.emit(ds, func(w io.Writer) {
+		for _, d := range ds {
+			fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef)
+		}
+	})
+}
+
+func runMRShow(c *Ctx, args []string) int {
+	repo, mr, code := mrRef(c, args, policy.CanRead)
+	if code >= 0 {
+		return code
+	}
+	if len(args) != 2 {
+		return c.fail(protocol.ExitUsage, "usage: mr show <owner/name> <n>")
+	}
+	comments, err := c.Store.ListMRComments(mr.ID)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	reviews, err := c.Store.ListMRReviews(mr.ID)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	type commentOut struct {
+		Author    string `json:"author"`
+		Body      string `json:"body"`
+		CreatedAt string `json:"created_at"`
+	}
+	type reviewOut struct {
+		Reviewer string `json:"reviewer"`
+		Verdict  string `json:"verdict"`
+		Stale    bool   `json:"stale"`
+	}
+	var cs []commentOut
+	for _, cm := range comments {
+		cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt})
+	}
+	var rs []reviewOut
+	for _, r := range reviews {
+		rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale})
+	}
+	d := struct {
+		mrOut
+		Comments []commentOut `json:"comments,omitempty"`
+		Reviews  []reviewOut  `json:"reviews,omitempty"`
+	}{mrToOut(repo, mr, true), cs, rs}
+	return c.emit(d, func(w io.Writer) {
+		fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA)
+		if d.Body != "" {
+			fmt.Fprintf(w, "\n%s\n", d.Body)
+		}
+		for _, r := range rs {
+			stale := ""
+			if r.Stale {
+				stale = " (stale)"
+			}
+			fmt.Fprintf(w, "review: %s %s%s\n", r.Reviewer, r.Verdict, stale)
+		}
+		for _, cm := range cs {
+			fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
+		}
+	})
+}
+
+func runMRDiff(c *Ctx, args []string) int {
+	repo, mr, code := mrRef(c, args, policy.CanRead)
+	if code >= 0 {
+		return code
+	}
+	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
+	head := mrHeadRef(mr.Number)
+	base, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	patch, err := gitutil.Diff(dir, base, head, 4<<20)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	fmt.Fprint(c.Stdout, patch)
+	return protocol.ExitOK
+}
+
+func runMRComment(c *Ctx, args []string) int {
+	var rest []string
+	var message, file string
+	for i := 0; i < len(args); i++ {
+		switch args[i] {
+		case "--message", "--file":
+			if i+1 >= len(args) {
+				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
+			}
+			if args[i] == "--message" {
+				message = args[i+1]
+			} else {
+				file = args[i+1]
+			}
+			i++
+		default:
+			rest = append(rest, args[i])
+		}
+	}
+	repo, mr, code := mrRef(c, rest, policy.CanRead)
+	if code >= 0 {
+		return code
+	}
+	body, err := bodyFrom(c, message, file)
+	if err != nil {
+		return c.fail(protocol.ExitUsage, "%v", err)
+	}
+	if strings.TrimSpace(body) == "" {
+		return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
+	}
+	if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
+		fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number)
+	})
+}
+
+func runMRReview(c *Ctx, args []string) int {
+	verdict := ""
+	var rest []string
+	for _, a := range args {
+		switch a {
+		case "--approve":
+			verdict = "approve"
+		case "--request-changes":
+			verdict = "request_changes"
+		case "--comment":
+			verdict = "comment"
+		default:
+			rest = append(rest, a)
+		}
+	}
+	if verdict == "" {
+		return c.fail(protocol.ExitUsage, "usage: mr review <owner/name> <n> --approve|--request-changes|--comment")
+	}
+	repo, mr, code := mrRef(c, rest, policy.CanRead)
+	if code >= 0 {
+		return code
+	}
+	if mr.State != "open" {
+		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
+	}
+	if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) {
+		fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict)
+	})
+}
+
+func runMRMerge(c *Ctx, args []string) int {
+	strategy := ""
+	var rest []string
+	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")
+			}
+			strategy = args[i+1]
+			i++
+			continue
+		}
+		rest = append(rest, args[i])
+	}
+	if strategy != "" && strategy != "ff" && strategy != "merge" {
+		return c.fail(protocol.ExitUsage, "--strategy must be ff or merge")
+	}
+	repo, mr, code := mrRef(c, rest, policy.CanWrite)
+	if code >= 0 {
+		return code
+	}
+	if mr.State != "open" && mr.State != "source_gone" {
+		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
+	}
+
+	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
+	targetRef := "refs/heads/" + mr.TargetRef
+	targetSHA, err := gitutil.ResolveRef(dir, targetRef)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
+	}
+	headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
+	}
+
+	upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	if upToDate {
+		return c.fail(protocol.ExitUsage, "target already contains the MR head")
+	}
+	ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+
+	// 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.
+	if repo.Settings.RequireSignedCommits {
+		if strategy == "merge" || !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)
+		}
+		strategy = "ff"
+		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
+		if err != nil {
+			return c.fail(protocol.ExitFailure, "%v", err)
+		}
+		for _, sha := range commits {
+			raw, err := gitutil.ReadCommit(dir, sha)
+			if err != nil {
+				return c.fail(protocol.ExitFailure, "%v", err)
+			}
+			parsed, err := sigParse(raw)
+			if err != nil {
+				return c.fail(protocol.ExitFailure, "%v", err)
+			}
+			res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
+			if err != nil {
+				return c.fail(protocol.ExitFailure, "%v", err)
+			}
+			if res.State != "verified" {
+				return c.fail(protocol.ExitDenied,
+					"%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
+			}
+		}
+	}
+	if strategy == "" {
+		if ffPossible {
+			strategy = "ff"
+		} else {
+			strategy = "merge"
+		}
+	}
+
+	var newSHA string
+	switch strategy {
+	case "ff":
+		if !ffPossible {
+			return c.fail(protocol.ExitUsage,
+				"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)
+		}
+		if conflict {
+			return c.fail(protocol.ExitUsage,
+				"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)
+		if err != nil {
+			return c.fail(protocol.ExitFailure, "%v", err)
+		}
+	}
+
+	// CAS so a concurrent push between our read and this write fails the
+	// merge instead of silently discarding the push.
+	if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
+		return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
+	}
+	if err := c.Store.SetMRState(mr.ID, "merged"); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
+	return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
+		fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
+	})
+}
+
+func runMRClose(c *Ctx, args []string) int {
+	repo, mr, code := mrRef(c, args, policy.CanRead)
+	if code >= 0 {
+		return code
+	}
+	if len(args) != 2 {
+		return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
+	}
+	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
+		return c.fail(protocol.ExitDenied, "only the author or users with write access can close this MR")
+	}
+	if mr.State == "merged" || mr.State == "closed" {
+		return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
+	}
+	if err := c.Store.SetMRState(mr.ID, "closed"); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
+		fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
+	})
+}
diff --git a/internal/control/repo.go b/internal/control/repo.go
index f779144..ab5d02e 100644
--- a/internal/control/repo.go
+++ b/internal/control/repo.go
@@ -188,6 +188,11 @@ func runRepoDelete(c *Ctx, args []string) int {
 	if !yes {
 		return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
 	}
+	// Open MRs sourced from this repo keep working (targets own the
+	// objects) but must show that the source is gone.
+	if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
 	if err := c.Store.DeleteRepo(repo.ID); err != nil {
 		return c.fail(protocol.ExitFailure, "%v", err)
 	}
diff --git a/internal/control/sig.go b/internal/control/sig.go
index d295fc6..11102b7 100644
--- a/internal/control/sig.go
+++ b/internal/control/sig.go
@@ -92,6 +92,9 @@ func runPGPRemove(c *Ctx, args []string) int {
 	})
 }
 
+// sigParse is a package-local alias so callers avoid importing sig directly.
+func sigParse(raw []byte) (*sig.Commit, error) { return sig.ParseCommit(raw) }
+
 // VerifyCommitCached verifies one commit with the epoch cache. Shared with
 // the web UI.
 func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) {
diff --git a/internal/gitutil/merge.go b/internal/gitutil/merge.go
new file mode 100644
index 0000000..8fb7a6e
--- /dev/null
+++ b/internal/gitutil/merge.go
@@ -0,0 +1,117 @@
+package gitutil
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+)
+
+// FetchInto copies srcRef from srcDir into dstDir as dstRef, forcing the
+// update. Objects are copied, not shared — the destination owns everything
+// afterward, which is what keeps MRs alive when their fork is deleted.
+func FetchInto(dstDir, srcDir, srcRef, dstRef string) error {
+	cmd := exec.Command("git", "-C", dstDir, "fetch", "--quiet", "--no-write-fetch-head",
+		srcDir, "+"+srcRef+":"+dstRef)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("fetch %s from %s: %v\n%s", srcRef, srcDir, err, out)
+	}
+	return nil
+}
+
+// UpdateRefCAS points ref at newSHA only if it currently points at oldSHA
+// (empty oldSHA = must not exist). This is the compare-and-swap that makes
+// merges safe against concurrent pushes.
+func UpdateRefCAS(dir, ref, newSHA, oldSHA string) error {
+	args := []string{"-C", dir, "update-ref", ref, newSHA}
+	if oldSHA != "" {
+		args = append(args, oldSHA)
+	}
+	cmd := exec.Command("git", args...)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("update-ref %s: %v\n%s", ref, err, out)
+	}
+	return nil
+}
+
+func DeleteRef(dir, ref string) error {
+	cmd := exec.Command("git", "-C", dir, "update-ref", "-d", ref)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("delete-ref %s: %v\n%s", ref, err, out)
+	}
+	return nil
+}
+
+// RevListRange returns commits in old..new, newest first.
+func RevListRange(dir, old, new string) ([]string, error) {
+	cmd := exec.Command("git", "-C", dir, "rev-list", new, "^"+old)
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("rev-list %s..%s: %w", old, new, err)
+	}
+	var shas []string
+	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+		if l != "" {
+			shas = append(shas, l)
+		}
+	}
+	return shas, nil
+}
+
+// MergeTree performs a real merge of ours and theirs, returning the merged
+// tree id. conflict=true means the merge cannot be done automatically.
+func MergeTree(dir, ours, theirs string) (tree string, conflict bool, err error) {
+	cmd := exec.Command("git", "-C", dir, "merge-tree", "--write-tree", ours, theirs)
+	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 // conflicted merge
+		}
+		return "", false, fmt.Errorf("merge-tree: %w", runErr)
+	}
+	return tree, false, nil
+}
+
+// CommitTree creates a merge commit with the given parents, authored and
+// committed by the merging user. There is no server signing key by design.
+func CommitTree(dir, tree string, parents []string, name, email, 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...)
+	cmd.Env = append(os.Environ(),
+		"GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email,
+		"GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email,
+	)
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("commit-tree: %w", err)
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// Diff returns the patch for old..new (three-dot semantics are the caller's
+// job: pass the merge base as old).
+func Diff(dir, old, new string, limit int64) (string, error) {
+	cmd := exec.Command("git", "-C", dir, "diff", "--stat", "--patch", old, new)
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("diff: %w", err)
+	}
+	if int64(len(out)) > limit {
+		out = out[:limit]
+	}
+	return string(out), nil
+}
+
+// MergeBase returns the best common ancestor, or an error if none exists.
+func MergeBase(dir, a, b string) (string, error) {
+	cmd := exec.Command("git", "-C", dir, "merge-base", a, b)
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("no common history between %s and %s", a, b)
+	}
+	return strings.TrimSpace(string(out)), nil
+}
diff --git a/internal/hookd/hookd.go b/internal/hookd/hookd.go
index 1efb95d..47026eb 100644
--- a/internal/hookd/hookd.go
+++ b/internal/hookd/hookd.go
@@ -1,18 +1,27 @@
 // Package hookd is the unix-socket bridge between git hooks and the daemon.
 // The hook process (forged in hook mode) computes git facts — it inherits
 // git's quarantine environment, which the daemon does not see — and sends
-// them here; the daemon answers with a pure policy decision.
+// them here; the daemon answers with a policy decision.
+//
+// pre-receive is two-phase when the repo requires signed commits: the first
+// response sets NeedCommits, and the hook answers with the raw commit
+// objects (only the hook can read them out of quarantine) for verification.
 package hookd
 
 import (
 	"crypto/sha256"
 	"encoding/json"
 	"fmt"
+	"log/slog"
 	"net"
 	"os"
 	"path/filepath"
 
+	"github.com/krazywarez/forge/internal/config"
+	"github.com/krazywarez/forge/internal/control"
+	"github.com/krazywarez/forge/internal/gitutil"
 	"github.com/krazywarez/forge/internal/policy"
+	"github.com/krazywarez/forge/internal/sig"
 	"github.com/krazywarez/forge/internal/store"
 )
 
@@ -31,9 +40,20 @@ type Request struct {
 	Updates []policy.RefUpdate `json:"updates"`
 }
 
+type RawCommit struct {
+	SHA string `json:"sha"`
+	Raw []byte `json:"raw"`
+}
+
+// CommitsPayload is the hook's second message when NeedCommits was set.
+type CommitsPayload struct {
+	Commits []RawCommit `json:"commits"`
+}
+
 type Response struct {
-	Allow   bool   `json:"allow"`
-	Message string `json:"message,omitempty"`
+	Allow       bool   `json:"allow"`
+	Message     string `json:"message,omitempty"`
+	NeedCommits bool   `json:"need_commits,omitempty"`
 }
 
 // SocketPath returns the hook socket location. It prefers the server root,
@@ -51,18 +71,19 @@ func SocketPath(root string) string {
 }
 
 type Server struct {
-	st *store.Store
+	cfg config.Config
+	st  *store.Store
 }
 
 // Serve listens on the unix socket until the listener is closed.
-func Serve(root string, st *store.Store) (func() error, error) {
-	path := SocketPath(root)
+func Serve(cfg config.Config, st *store.Store) (func() error, error) {
+	path := SocketPath(cfg.Server.Root)
 	os.Remove(path)
 	ln, err := net.Listen("unix", path)
 	if err != nil {
 		return nil, err
 	}
-	s := &Server{st: st}
+	s := &Server{cfg: cfg, st: st}
 	go func() {
 		for {
 			conn, err := ln.Accept()
@@ -77,48 +98,153 @@ func Serve(root string, st *store.Store) (func() error, error) {
 
 func (s *Server) handle(conn net.Conn) {
 	defer conn.Close()
+	dec := json.NewDecoder(conn)
+	enc := json.NewEncoder(conn)
 	var req Request
-	if err := json.NewDecoder(conn).Decode(&req); err != nil {
-		json.NewEncoder(conn).Encode(Response{Allow: false, Message: "bad hook request"})
+	if err := dec.Decode(&req); err != nil {
+		enc.Encode(Response{Allow: false, Message: "bad hook request"})
 		return
 	}
-	json.NewEncoder(conn).Encode(s.decide(req))
-}
-
-func (s *Server) decide(req Request) Response {
 	switch req.Hook {
 	case "pre-receive":
-		repo, err := s.st.RepoByID(req.RepoID)
+		s.preReceive(req, dec, enc)
+	case "post-receive":
+		s.postReceive(req)
+		enc.Encode(Response{Allow: true})
+	default:
+		enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)})
+	}
+}
+
+func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
+	repo, err := s.st.RepoByID(req.RepoID)
+	if err != nil {
+		enc.Encode(Response{Allow: false, Message: "unknown repository"})
+		return
+	}
+	if msg := policy.CheckPush(repo, req.Updates); msg != "" {
+		enc.Encode(Response{Allow: false, Message: msg})
+		return
+	}
+	if !repo.Settings.RequireSignedCommits {
+		enc.Encode(Response{Allow: true})
+		return
+	}
+
+	// Phase two: ask the hook for the incoming commit objects.
+	if err := enc.Encode(Response{Allow: true, NeedCommits: true}); err != nil {
+		return
+	}
+	var payload CommitsPayload
+	if err := dec.Decode(&payload); err != nil {
+		enc.Encode(Response{Allow: false, Message: "bad commits payload"})
+		return
+	}
+	db := store.SigDB{Store: s.st}
+	for _, rc := range payload.Commits {
+		parsed, err := sig.ParseCommit(rc.Raw)
 		if err != nil {
-			return Response{Allow: false, Message: "unknown repository"}
+			enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unparseable commit %s", rc.SHA)})
+			return
 		}
-		if msg := policy.CheckPush(repo, req.Updates); msg != "" {
-			return Response{Allow: false, Message: msg}
+		res, err := sig.VerifyCommit(db, parsed)
+		if err != nil || res.State != sig.Verified {
+			state := "error"
+			if err == nil {
+				state = string(res.State)
+			}
+			enc.Encode(Response{Allow: false, Message: fmt.Sprintf(
+				"this repository requires signed commits: %.10s is %s", rc.SHA, state)})
+			return
 		}
-		return Response{Allow: true}
-	case "post-receive":
-		// Event recording and signature verification enqueue land in M4.
-		return Response{Allow: true}
-	default:
-		return Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)}
 	}
+	enc.Encode(Response{Allow: true})
 }
 
-// Ask sends one request from the hook process to the daemon.
-func Ask(socketPath string, req Request) (Response, error) {
+// postReceive applies the cross-repo MR effect: a push to a source branch
+// refreshes refs/merge-requests/N/head in every target repo, by fetching —
+// the target owns the objects, so the MR outlives the fork. This is the only
+// place a hook writes outside its own repository.
+func (s *Server) postReceive(req Request) {
+	for _, u := range req.Updates {
+		branch, ok := cutHeads(u.Ref)
+		if !ok {
+			continue
+		}
+		mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
+		if err != nil {
+			slog.Error("post-receive: listing MRs", "err", err)
+			continue
+		}
+		srcRepo, err := s.st.RepoByID(req.RepoID)
+		if err != nil {
+			continue
+		}
+		srcDir := control.RepoDir(s.cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
+		for _, mr := range mrs {
+			target, err := s.st.RepoByID(mr.RepoID)
+			if err != nil {
+				continue
+			}
+			if u.IsDelete {
+				if mr.State == "open" {
+					s.st.SetMRState(mr.ID, "source_gone")
+				}
+				continue // head ref retained: the diff stays viewable
+			}
+			dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
+			headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
+			if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
+				slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
+				continue
+			}
+			if err := s.st.UpdateMRHead(mr.ID, u.New); err != nil {
+				slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
+			}
+			if mr.State == "source_gone" {
+				s.st.SetMRState(mr.ID, "open") // branch came back
+			}
+		}
+	}
+}
+
+func cutHeads(ref string) (string, bool) {
+	const p = "refs/heads/"
+	if len(ref) > len(p) && ref[:len(p)] == p {
+		return ref[len(p):], true
+	}
+	return "", false
+}
+
+// Ask sends one request from the hook process to the daemon. commits is
+// called if the daemon asks for the incoming commit objects.
+func Ask(socketPath string, req Request, commits func() (CommitsPayload, error)) (Response, error) {
 	conn, err := net.Dial("unix", socketPath)
 	if err != nil {
 		return Response{}, err
 	}
 	defer conn.Close()
-	if err := json.NewEncoder(conn).Encode(req); err != nil {
+	enc := json.NewEncoder(conn)
+	dec := json.NewDecoder(conn)
+	if err := enc.Encode(req); err != nil {
 		return Response{}, err
 	}
 	var resp Response
-	if err := json.NewDecoder(conn).Decode(&resp); err != nil {
+	if err := dec.Decode(&resp); err != nil {
+		return Response{}, err
+	}
+	if !resp.NeedCommits {
+		return resp, nil
+	}
+	payload, err := commits()
+	if err != nil {
+		return Response{}, err
+	}
+	if err := enc.Encode(payload); err != nil {
 		return Response{}, err
 	}
-	return resp, nil
+	err = dec.Decode(&resp)
+	return resp, err
 }
 
 // WriteHookScripts (re)generates the shared hooks directory. Called at
diff --git a/internal/httpd/routes.go b/internal/httpd/routes.go
index 952fe76..8377e1d 100644
--- a/internal/httpd/routes.go
+++ b/internal/httpd/routes.go
@@ -39,6 +39,8 @@ func (s *Server) Routes() []Route {
 		Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive},
 		Route{Method: "GET", Pattern: "/{owner}/{repo}/issues", Handler: s.issues},
 		Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/{n}", Handler: s.issue},
+		Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs", Handler: s.mrs},
+		Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr},
 	)
 
 	// Account-mode routes (login, web edits) are appended here in M8 —
diff --git a/internal/httpd/web.go b/internal/httpd/web.go
index ab16215..e68c642 100644
--- a/internal/httpd/web.go
+++ b/internal/httpd/web.go
@@ -244,6 +244,30 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 	w.Write(data)
 }
 
+type diffLine struct {
+	Class string
+	Text  string
+}
+
+func classifyDiff(patch string) []diffLine {
+	var lines []diffLine
+	for _, l := range strings.Split(patch, "\n") {
+		class := ""
+		switch {
+		case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
+			class = "meta"
+		case strings.HasPrefix(l, "@@"):
+			class = "hunk"
+		case strings.HasPrefix(l, "+"):
+			class = "add"
+		case strings.HasPrefix(l, "-"):
+			class = "del"
+		}
+		lines = append(lines, diffLine{class, l})
+	}
+	return lines
+}
+
 type sigView struct {
 	State       string
 	Signer      string
@@ -329,25 +353,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
-	type diffLine struct {
-		Class string
-		Text  string
-	}
-	var lines []diffLine
-	for _, l := range strings.Split(patch, "\n") {
-		class := ""
-		switch {
-		case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
-			class = "meta"
-		case strings.HasPrefix(l, "@@"):
-			class = "hunk"
-		case strings.HasPrefix(l, "+"):
-			class = "add"
-		case strings.HasPrefix(l, "-"):
-			class = "del"
-		}
-		lines = append(lines, diffLine{class, l})
-	}
+	lines := classifyDiff(patch)
 	committerEmail := ""
 	if parsed.CommitterEmail != parsed.AuthorEmail {
 		committerEmail = parsed.CommitterEmail
@@ -413,6 +419,65 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
 	}{p, iss, comments})
 }
 
+func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
+	p, ok := s.repoFor(w, r, "")
+	if !ok {
+		return
+	}
+	state := r.URL.Query().Get("state")
+	if state == "" {
+		state = "open"
+	}
+	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
+	if !valid[state] {
+		state = "open"
+	}
+	mrs, err := s.st.ListMRs(p.Repo.ID, state)
+	if err != nil {
+		http.Error(w, "internal error", http.StatusInternalServerError)
+		return
+	}
+	s.render(w, "mrs.html", struct {
+		repoPage
+		State string
+		MRs   []store.MR
+	}{p, state, mrs})
+}
+
+func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
+	p, ok := s.repoFor(w, r, "")
+	if !ok {
+		return
+	}
+	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	m, err := s.st.MRByNumber(p.Repo.ID, n)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	comments, _ := s.st.ListMRComments(m.ID)
+	reviews, _ := s.st.ListMRReviews(m.ID)
+
+	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
+	var lines []diffLine
+	if base, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
+		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
+			lines = classifyDiff(patch)
+		}
+	}
+	s.render(w, "mr.html", struct {
+		repoPage
+		MR        store.MR
+		Comments  []store.IssueComment
+		Reviews   []store.MRReview
+		DiffLines []diffLine
+	}{p, m, comments, reviews, lines})
+}
+
 func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
 	p, ok := s.repoFor(w, r, "")
 	if !ok {
diff --git a/internal/store/mrs.go b/internal/store/mrs.go
new file mode 100644
index 0000000..940f664
--- /dev/null
+++ b/internal/store/mrs.go
@@ -0,0 +1,233 @@
+package store
+
+import (
+	"database/sql"
+	"errors"
+)
+
+type MR struct {
+	ID           int64
+	RepoID       int64
+	Number       int64
+	Author       string
+	SourceRepoID int64 // 0 when the source repo is gone
+	SourcePath   string // owner/name of source repo, "" when gone
+	SourceRef    string
+	TargetRef    string
+	Title        string
+	Body         string
+	State        string // open | merged | closed | source_gone
+	HeadSHA      string
+	CreatedAt    string
+	UpdatedAt    string
+}
+
+type MRReview struct {
+	Reviewer  string
+	Verdict   string
+	HeadSHA   string
+	Stale     bool
+	CreatedAt string
+}
+
+func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA string) (int64, error) {
+	tx, err := s.DB.Begin()
+	if err != nil {
+		return 0, err
+	}
+	defer tx.Rollback()
+	if _, err := tx.Exec("UPDATE repos SET mr_counter = mr_counter + 1 WHERE id = ?", repoID); err != nil {
+		return 0, err
+	}
+	var n int64
+	if err := tx.QueryRow("SELECT mr_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil {
+		return 0, err
+	}
+	if _, err := tx.Exec(`
+		INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+		repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA); err != nil {
+		return 0, err
+	}
+	return n, tx.Commit()
+}
+
+const mrSelect = `
+	SELECT m.id, m.repo_id, m.number, u.username,
+	       COALESCE(m.source_repo_id, 0),
+	       COALESCE(su.username || '/' || sr.name, ''),
+	       m.source_ref, m.target_ref, m.title, m.body, m.state, m.head_sha,
+	       m.created_at, m.updated_at
+	FROM merge_requests m
+	JOIN users u ON u.id = m.author_id
+	LEFT JOIN repos sr ON sr.id = m.source_repo_id
+	LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id`
+
+func scanMR(row interface{ Scan(...any) error }) (MR, error) {
+	var m MR
+	err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
+		&m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.State, &m.HeadSHA, &m.CreatedAt, &m.UpdatedAt)
+	return m, err
+}
+
+func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
+	m, err := scanMR(s.DB.QueryRow(mrSelect+" WHERE m.repo_id = ? AND m.number = ?", repoID, number))
+	if errors.Is(err, sql.ErrNoRows) {
+		return m, ErrNotFound
+	}
+	return m, err
+}
+
+func (s *Store) ListMRs(repoID int64, state string) ([]MR, error) {
+	q := mrSelect + " WHERE m.repo_id = ?"
+	args := []any{repoID}
+	if state != "all" {
+		q += " AND m.state = ?"
+		args = append(args, state)
+	}
+	q += " ORDER BY m.number DESC"
+	rows, err := s.DB.Query(q, args...)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []MR
+	for rows.Next() {
+		m, err := scanMR(rows)
+		if err != nil {
+			return nil, err
+		}
+		out = append(out, m)
+	}
+	return out, rows.Err()
+}
+
+// OpenMRsBySource returns open (and source_gone) MRs fed by the given source
+// repo branch — the cross-repo hook effect consults this.
+func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, error) {
+	rows, err := s.DB.Query(
+		mrSelect+" WHERE m.source_repo_id = ? AND m.source_ref = ? AND m.state IN ('open','source_gone')",
+		sourceRepoID, sourceRef)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []MR
+	for rows.Next() {
+		m, err := scanMR(rows)
+		if err != nil {
+			return nil, err
+		}
+		out = append(out, m)
+	}
+	return out, rows.Err()
+}
+
+func (s *Store) SetMRState(mrID int64, state string) error {
+	res, err := s.DB.Exec(
+		"UPDATE merge_requests SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
+		state, mrID)
+	if err != nil {
+		return err
+	}
+	if n, _ := res.RowsAffected(); n == 0 {
+		return ErrNotFound
+	}
+	return nil
+}
+
+// UpdateMRHead records a new head and marks every review at another head
+// stale, in one transaction.
+func (s *Store) UpdateMRHead(mrID int64, headSHA string) error {
+	tx, err := s.DB.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+	if _, err := tx.Exec(
+		"UPDATE merge_requests SET head_sha = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
+		headSHA, mrID); err != nil {
+		return err
+	}
+	if _, err := tx.Exec(
+		"UPDATE mr_reviews SET stale = 1 WHERE mr_id = ? AND head_sha <> ?", mrID, headSHA); err != nil {
+		return err
+	}
+	return tx.Commit()
+}
+
+// MarkSourceGoneForRepo flags every open MR sourced from the repo; called
+// when a fork is deleted. Head refs in the target repos are retained.
+func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error {
+	_, err := s.DB.Exec(
+		"UPDATE merge_requests SET state = 'source_gone', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE source_repo_id = ? AND state = 'open'",
+		sourceRepoID)
+	return err
+}
+
+func (s *Store) AddMRComment(mrID, authorID int64, body string) error {
+	_, err := s.DB.Exec(
+		"INSERT INTO mr_comments (mr_id, author_id, body) VALUES (?, ?, ?)", mrID, authorID, body)
+	return err
+}
+
+func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
+	rows, err := s.DB.Query(`
+		SELECT u.username, c.body, c.created_at
+		FROM mr_comments c JOIN users u ON u.id = c.author_id
+		WHERE c.mr_id = ? ORDER BY c.id`, mrID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []IssueComment
+	for rows.Next() {
+		var c IssueComment
+		if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt); err != nil {
+			return nil, err
+		}
+		out = append(out, c)
+	}
+	return out, rows.Err()
+}
+
+func (s *Store) AddMRReview(mrID, reviewerID int64, verdict, headSHA string) error {
+	_, err := s.DB.Exec(
+		"INSERT INTO mr_reviews (mr_id, reviewer_id, verdict, head_sha) VALUES (?, ?, ?, ?)",
+		mrID, reviewerID, verdict, headSHA)
+	return err
+}
+
+func (s *Store) ListMRReviews(mrID int64) ([]MRReview, error) {
+	rows, err := s.DB.Query(`
+		SELECT u.username, r.verdict, r.head_sha, r.stale, r.created_at
+		FROM mr_reviews r JOIN users u ON u.id = r.reviewer_id
+		WHERE r.mr_id = ? ORDER BY r.id`, mrID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []MRReview
+	for rows.Next() {
+		var r MRReview
+		var stale int
+		if err := rows.Scan(&r.Reviewer, &r.Verdict, &r.HeadSHA, &stale, &r.CreatedAt); err != nil {
+			return nil, err
+		}
+		r.Stale = stale != 0
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+// PrimaryVerifiedEmail returns the user's primary email if verified, else "".
+func (s *Store) PrimaryVerifiedEmail(userID int64) (string, error) {
+	var addr string
+	err := s.DB.QueryRow(
+		"SELECT address FROM emails WHERE user_id = ? AND is_primary = 1 AND verified_at IS NOT NULL",
+		userID).Scan(&addr)
+	if errors.Is(err, sql.ErrNoRows) {
+		return "", nil
+	}
+	return addr, err
+}
diff --git a/internal/store/repos.go b/internal/store/repos.go
index 84e8809..b30f42c 100644
--- a/internal/store/repos.go
+++ b/internal/store/repos.go
@@ -16,6 +16,7 @@ type Repo struct {
 	Name          string
 	Visibility    string // public | private
 	DefaultBranch string
+	ForkOf        int64 // 0 when not a fork
 	Settings      RepoSettings
 }
 
@@ -50,10 +51,10 @@ func (s *Store) RepoByPath(path string) (Repo, error) {
 	var r Repo
 	var settingsJSON string
 	err := s.DB.QueryRow(`
-		SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
+		SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
 		FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
 		WHERE u.username = ? AND r.name = ?`, owner, name).
-		Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON)
+		Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
 	if errors.Is(err, sql.ErrNoRows) {
 		return Repo{}, ErrNotFound
 	}
@@ -75,6 +76,11 @@ func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error {
 	return err
 }
 
+func (s *Store) SetForkOf(repoID, parentID int64) error {
+	_, err := s.DB.Exec("UPDATE repos SET fork_of = ? WHERE id = ?", parentID, repoID)
+	return err
+}
+
 func (s *Store) DeleteRepo(repoID int64) error {
 	res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID)
 	if err != nil {
@@ -176,10 +182,10 @@ func (s *Store) RepoByID(id int64) (Repo, error) {
 	var r Repo
 	var settingsJSON string
 	err := s.DB.QueryRow(`
-		SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
+		SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
 		FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
 		WHERE r.id = ?`, id).
-		Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON)
+		Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
 	if errors.Is(err, sql.ErrNoRows) {
 		return Repo{}, ErrNotFound
 	}
diff --git a/internal/web/templates/mr.html b/internal/web/templates/mr.html
new file mode 100644
index 0000000..81fd089
--- /dev/null
+++ b/internal/web/templates/mr.html
@@ -0,0 +1,15 @@
+{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
+{{define "content"}}
+{{template "repoheader" .}}
+<h2>!{{.MR.Number}} {{.MR.Title}} <span class="badge badge-unsigned">{{.MR.State}}</span></h2>
+<p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}}
+ @ <code>{{.MR.HeadSHA}}</code></p>
+{{if .MR.Body}}<pre class="message">{{.MR.Body}}</pre>{{end}}
+{{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}}
+{{range .Comments}}
+<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div>
+{{end}}
+<h3>diff</h3>
+<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
+{{end}}</pre>
+{{end}}
diff --git a/internal/web/templates/mrs.html b/internal/web/templates/mrs.html
new file mode 100644
index 0000000..c258b11
--- /dev/null
+++ b/internal/web/templates/mrs.html
@@ -0,0 +1,16 @@
+{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
+{{define "content"}}
+{{template "repoheader" .}}
+<h2>merge requests ({{.State}})</h2>
+<p class="crumbs"><a href="?state=open">open</a> · <a href="?state=merged">merged</a> · <a href="?state=closed">closed</a> · <a href="?state=all">all</a></p>
+<table>
+{{range .MRs}}<tr>
+  <td>!{{.Number}}</td>
+  <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></td>
+  <td>{{.State}}</td>
+  <td>{{.Author}}</td>
+  <td>{{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</td>
+</tr>
+{{else}}<tr><td>no merge requests</td></tr>{{end}}
+</table>
+{{end}}