krz/gitbay

A CLI-first git forge.

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

main: internal/control/mr.go · raw

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strconv"
  8	"strings"
  9
 10	"gitbay.org/gitbay/internal/gitutil"
 11	"gitbay.org/gitbay/internal/policy"
 12	"gitbay.org/gitbay/internal/protocol"
 13	"gitbay.org/gitbay/internal/store"
 14)
 15
 16func init() {
 17	register(Command{Path: []string{"repo", "fork"},
 18		Summary: "fork a repository under your account: repo fork <owner/name> [--name <n>]", Run: runRepoFork})
 19	register(Command{Path: []string{"repo", "settings", "require-signed"},
 20		Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
 21	register(Command{Path: []string{"mr", "create"},
 22		Summary:    "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]",
 23		ReadsStdin: true, Run: runMRCreate})
 24	register(Command{Path: []string{"mr", "list"},
 25		Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", ReadOnly: true, Run: runMRList})
 26	register(Command{Path: []string{"mr", "show"},
 27		Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
 28	register(Command{Path: []string{"mr", "diff"},
 29		Summary: "show the diff: mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff})
 30	register(Command{Path: []string{"mr", "comment"},
 31		Summary:    "comment: mr comment <owner/name> <n> [--message <m> | --file -]",
 32		ReadsStdin: true, Run: runMRComment})
 33	register(Command{Path: []string{"mr", "review"},
 34		Summary: "review: mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview})
 35	register(Command{Path: []string{"mr", "merge"},
 36		Summary: "merge: mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge})
 37	register(Command{Path: []string{"mr", "close"},
 38		Summary: "close without merging: mr close <owner/name> <n>", Run: runMRClose})
 39}
 40
 41func runRepoFork(c *Ctx, args []string) int {
 42	var path, name string
 43	for i := 0; i < len(args); i++ {
 44		switch args[i] {
 45		case "--name":
 46			if i+1 >= len(args) {
 47				return c.fail(protocol.ExitUsage, "--name requires a value")
 48			}
 49			name = args[i+1]
 50			i++
 51		default:
 52			if path != "" {
 53				return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
 54			}
 55			path = args[i]
 56		}
 57	}
 58	if path == "" {
 59		return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
 60	}
 61	src, code := resolveRepo(c, path, policy.CanRead)
 62	if code >= 0 {
 63		return code
 64	}
 65	if name == "" {
 66		name = src.Name
 67	}
 68	if err := policy.ValidateName(name); err != nil {
 69		return c.fail(protocol.ExitUsage, "%v", err)
 70	}
 71	id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility)
 72	if err != nil {
 73		return c.fail(protocol.ExitFailure, "%v", err)
 74	}
 75	if err := c.Store.SetForkOf(id, src.ID); err != nil {
 76		return c.fail(protocol.ExitFailure, "%v", err)
 77	}
 78	dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name)
 79	srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
 80	if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
 81		c.Store.DeleteRepo(id)
 82		return c.fail(protocol.ExitFailure, "%v", err)
 83	}
 84	if desc := gitutil.ReadDescription(srcDir); desc != "" {
 85		gitutil.WriteDescription(dstDir, desc)
 86	}
 87	if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil {
 88		// Empty source repos have nothing to fetch; that is fine.
 89		if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil {
 90			c.Store.DeleteRepo(id)
 91			return c.fail(protocol.ExitFailure, "copying refs: %v", err)
 92		}
 93	}
 94	forkPath := c.User.Username + "/" + name
 95	return c.emit(map[string]string{"path": forkPath, "fork_of": src.Path()}, func(w io.Writer) {
 96		fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
 97	})
 98}
 99
100func runRequireSigned(c *Ctx, args []string) int {
101	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
102		return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off")
103	}
104	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
105	if code >= 0 {
106		return code
107	}
108	s := repo.Settings
109	s.RequireSignedCommits = args[1] == "on"
110	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
111		return c.fail(protocol.ExitFailure, "%v", err)
112	}
113	return c.emit(s, func(w io.Writer) {
114		fmt.Fprintf(w, "require_signed_commits %s on %s\n", args[1], repo.Path())
115	})
116}
117
118// mrRef parses "<owner/name> <n>" and loads the MR.
119func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.MR, int) {
120	if len(args) < 2 {
121		return store.Repo{}, store.MR{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
122	}
123	repo, code := resolveRepo(c, args[0], perm)
124	if code >= 0 {
125		return repo, store.MR{}, code
126	}
127	n, err := strconv.ParseInt(args[1], 10, 64)
128	if err != nil {
129		return repo, store.MR{}, c.fail(protocol.ExitUsage, "bad MR number %q", args[1])
130	}
131	mr, err := c.Store.MRByNumber(repo.ID, n)
132	if errors.Is(err, store.ErrNotFound) {
133		return repo, mr, c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path())
134	}
135	if err != nil {
136		return repo, mr, c.fail(protocol.ExitFailure, "%v", err)
137	}
138	return repo, mr, -1
139}
140
141func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
142
143func runMRCreate(c *Ctx, args []string) int {
144	var path, source, target, title, body, file string
145	for i := 0; i < len(args); i++ {
146		switch args[i] {
147		case "--source", "--target", "--title", "--body", "--file":
148			if i+1 >= len(args) {
149				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
150			}
151			v := args[i+1]
152			switch args[i] {
153			case "--source":
154				source = v
155			case "--target":
156				target = v
157			case "--title":
158				title = v
159			case "--body":
160				body = v
161			case "--file":
162				file = v
163			}
164			i++
165		default:
166			if path != "" {
167				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
168			}
169			path = args[i]
170		}
171	}
172	if path == "" || source == "" || title == "" {
173		return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>")
174	}
175	repo, code := resolveRepo(c, path, policy.CanRead)
176	if code >= 0 {
177		return code
178	}
179	if target == "" {
180		target = repo.DefaultBranch
181	}
182
183	// Source is "branch" (same repo) or "owner/name:branch" (a fork).
184	srcRepo := repo
185	srcBranch := source
186	if sp, br, ok := strings.Cut(source, ":"); ok {
187		srcBranch = br
188		var scode int
189		srcRepo, scode = resolveRepo(c, sp, policy.CanRead)
190		if scode >= 0 {
191			return scode
192		}
193		if srcRepo.ForkOf != repo.ID && srcRepo.ID != repo.ID {
194			return c.fail(protocol.ExitUsage, "%s is not a fork of %s", srcRepo.Path(), repo.Path())
195		}
196	}
197	srcDir := RepoDir(c.Cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
198	headSHA, err := gitutil.ResolveRef(srcDir, "refs/heads/"+srcBranch)
199	if err != nil {
200		return c.fail(protocol.ExitNotFound, "branch %s not found in %s", srcBranch, srcRepo.Path())
201	}
202	b, err := bodyFrom(c, body, file)
203	if err != nil {
204		return c.fail(protocol.ExitUsage, "%v", err)
205	}
206	n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA)
207	if err != nil {
208		return c.fail(protocol.ExitFailure, "%v", err)
209	}
210	// Fetch the head into the target so the target owns the objects.
211	dstDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
212	if err := gitutil.FetchInto(dstDir, srcDir, headSHA, mrHeadRef(n)); err != nil {
213		return c.fail(protocol.ExitFailure, "recording MR head: %v", err)
214	}
215	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
216	return c.emit(map[string]any{"number": n, "head_sha": headSHA}, func(w io.Writer) {
217		fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
218	})
219}
220
221type mrOut struct {
222	Number    int64  `json:"number"`
223	Title     string `json:"title"`
224	State     string `json:"state"`
225	Author    string `json:"author"`
226	Source    string `json:"source"` // owner/name:branch, or branch, "" if gone
227	TargetRef string `json:"target_ref"`
228	HeadSHA   string `json:"head_sha"`
229	Body      string `json:"body,omitempty"`
230	CreatedAt string `json:"created_at"`
231}
232
233func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
234	src := ""
235	if m.SourcePath != "" {
236		if m.SourceRepoID == repo.ID {
237			src = m.SourceRef
238		} else {
239			src = m.SourcePath + ":" + m.SourceRef
240		}
241	}
242	o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author,
243		Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, CreatedAt: m.CreatedAt}
244	if withBody {
245		o.Body = m.Body
246	}
247	return o
248}
249
250func runMRList(c *Ctx, args []string) int {
251	state := "open"
252	var path string
253	for i := 0; i < len(args); i++ {
254		switch args[i] {
255		case "--state":
256			if i+1 >= len(args) {
257				return c.fail(protocol.ExitUsage, "--state requires a value")
258			}
259			state = args[i+1]
260			i++
261		default:
262			if path != "" {
263				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
264			}
265			path = args[i]
266		}
267	}
268	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
269	if path == "" || !valid[state] {
270		return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all]")
271	}
272	repo, code := resolveRepo(c, path, policy.CanRead)
273	if code >= 0 {
274		return code
275	}
276	mrs, err := c.Store.ListMRs(repo.ID, state)
277	if err != nil {
278		return c.fail(protocol.ExitFailure, "%v", err)
279	}
280	var ds []mrOut
281	for _, m := range mrs {
282		ds = append(ds, mrToOut(repo, m, false))
283	}
284	return c.emit(ds, func(w io.Writer) {
285		for _, d := range ds {
286			fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef)
287		}
288	})
289}
290
291func runMRShow(c *Ctx, args []string) int {
292	repo, mr, code := mrRef(c, args, policy.CanRead)
293	if code >= 0 {
294		return code
295	}
296	if len(args) != 2 {
297		return c.fail(protocol.ExitUsage, "usage: mr show <owner/name> <n>")
298	}
299	comments, err := c.Store.ListMRComments(mr.ID)
300	if err != nil {
301		return c.fail(protocol.ExitFailure, "%v", err)
302	}
303	reviews, err := c.Store.ListMRReviews(mr.ID)
304	if err != nil {
305		return c.fail(protocol.ExitFailure, "%v", err)
306	}
307	type commentOut struct {
308		Author    string `json:"author"`
309		Body      string `json:"body"`
310		CreatedAt string `json:"created_at"`
311	}
312	type reviewOut struct {
313		Reviewer string `json:"reviewer"`
314		Verdict  string `json:"verdict"`
315		Stale    bool   `json:"stale"`
316	}
317	var cs []commentOut
318	for _, cm := range comments {
319		cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt})
320	}
321	var rs []reviewOut
322	for _, r := range reviews {
323		rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale})
324	}
325	d := struct {
326		mrOut
327		Comments []commentOut `json:"comments,omitempty"`
328		Reviews  []reviewOut  `json:"reviews,omitempty"`
329	}{mrToOut(repo, mr, true), cs, rs}
330	return c.emit(d, func(w io.Writer) {
331		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)
332		if d.Body != "" {
333			fmt.Fprintf(w, "\n%s\n", d.Body)
334		}
335		for _, r := range rs {
336			stale := ""
337			if r.Stale {
338				stale = " (stale)"
339			}
340			fmt.Fprintf(w, "review: %s %s%s\n", r.Reviewer, r.Verdict, stale)
341		}
342		for _, cm := range cs {
343			fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
344		}
345	})
346}
347
348func runMRDiff(c *Ctx, args []string) int {
349	repo, mr, code := mrRef(c, args, policy.CanRead)
350	if code >= 0 {
351		return code
352	}
353	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
354	head := mrHeadRef(mr.Number)
355	// After a merge (especially fast-forward) the live merge-base equals
356	// the head and the diff would vanish; use the recorded base instead.
357	base := mr.MergedBase
358	if base == "" {
359		b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
360		if err != nil {
361			return c.fail(protocol.ExitFailure, "%v", err)
362		}
363		base = b
364	}
365	patch, err := gitutil.Diff(dir, base, head, 4<<20)
366	if err != nil {
367		return c.fail(protocol.ExitFailure, "%v", err)
368	}
369	fmt.Fprint(c.Stdout, patch)
370	return protocol.ExitOK
371}
372
373func runMRComment(c *Ctx, args []string) int {
374	var rest []string
375	var message, file string
376	for i := 0; i < len(args); i++ {
377		switch args[i] {
378		case "--message", "--file":
379			if i+1 >= len(args) {
380				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
381			}
382			if args[i] == "--message" {
383				message = args[i+1]
384			} else {
385				file = args[i+1]
386			}
387			i++
388		default:
389			rest = append(rest, args[i])
390		}
391	}
392	repo, mr, code := mrRef(c, rest, policy.CanRead)
393	if code >= 0 {
394		return code
395	}
396	body, err := bodyFrom(c, message, file)
397	if err != nil {
398		return c.fail(protocol.ExitUsage, "%v", err)
399	}
400	if strings.TrimSpace(body) == "" {
401		return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
402	}
403	if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil {
404		return c.fail(protocol.ExitFailure, "%v", err)
405	}
406	return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
407		fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number)
408	})
409}
410
411func runMRReview(c *Ctx, args []string) int {
412	verdict := ""
413	var rest []string
414	for _, a := range args {
415		switch a {
416		case "--approve":
417			verdict = "approve"
418		case "--request-changes":
419			verdict = "request_changes"
420		case "--comment":
421			verdict = "comment"
422		default:
423			rest = append(rest, a)
424		}
425	}
426	if verdict == "" {
427		return c.fail(protocol.ExitUsage, "usage: mr review <owner/name> <n> --approve|--request-changes|--comment")
428	}
429	repo, mr, code := mrRef(c, rest, policy.CanRead)
430	if code >= 0 {
431		return code
432	}
433	if mr.State != "open" {
434		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
435	}
436	if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
437		return c.fail(protocol.ExitFailure, "%v", err)
438	}
439	return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) {
440		fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict)
441	})
442}
443
444func runMRMerge(c *Ctx, args []string) int {
445	strategy := ""
446	var rest []string
447	for i := 0; i < len(args); i++ {
448		if args[i] == "--strategy" {
449			if i+1 >= len(args) {
450				return c.fail(protocol.ExitUsage, "--strategy requires ff|merge|squash|rebase")
451			}
452			strategy = args[i+1]
453			i++
454			continue
455		}
456		rest = append(rest, args[i])
457	}
458	valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
459	if !valid[strategy] {
460		return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
461	}
462	repo, mr, code := mrRef(c, rest, policy.CanWrite)
463	if code >= 0 {
464		return code
465	}
466	if mr.State != "open" && mr.State != "source_gone" {
467		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
468	}
469
470	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
471	targetRef := "refs/heads/" + mr.TargetRef
472	targetSHA, err := gitutil.ResolveRef(dir, targetRef)
473	if err != nil {
474		return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
475	}
476	headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
477	if err != nil {
478		return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
479	}
480
481	upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
482	if err != nil {
483		return c.fail(protocol.ExitFailure, "%v", err)
484	}
485	if upToDate {
486		return c.fail(protocol.ExitUsage, "target already contains the MR head")
487	}
488	ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
489	if err != nil {
490		return c.fail(protocol.ExitFailure, "%v", err)
491	}
492
493	// Signature policy matrix: with require_signed_commits, only
494	// fast-forward is allowed — squash, rebase-replay, and merge commits
495	// are all server-created and unsigned, violating the branch's own
496	// policy — and every landed commit must be verified. An explicit
497	// rebase when fast-forward is already possible IS a fast-forward
498	// (nothing is rewritten), so it stays legal.
499	if repo.Settings.RequireSignedCommits {
500		if strategy == "merge" || strategy == "squash" || !ffPossible {
501			return c.fail(protocol.ExitDenied,
502				"%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
503				repo.Path(), mr.SourceRef, mr.TargetRef)
504		}
505		strategy = "ff"
506		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
507		if err != nil {
508			return c.fail(protocol.ExitFailure, "%v", err)
509		}
510		for _, sha := range commits {
511			raw, err := gitutil.ReadCommit(dir, sha)
512			if err != nil {
513				return c.fail(protocol.ExitFailure, "%v", err)
514			}
515			parsed, err := sigParse(raw)
516			if err != nil {
517				return c.fail(protocol.ExitFailure, "%v", err)
518			}
519			res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
520			if err != nil {
521				return c.fail(protocol.ExitFailure, "%v", err)
522			}
523			if res.State != "verified" {
524				return c.fail(protocol.ExitDenied,
525					"%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
526			}
527		}
528	}
529	if strategy == "" {
530		if ffPossible {
531			strategy = "ff"
532		} else {
533			strategy = "merge"
534		}
535	}
536	if strategy == "rebase" && ffPossible {
537		// Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
538		// and taking it keeps the original commits and their signatures.
539		strategy = "ff"
540	}
541
542	// Every server-created commit needs the merger's verified identity.
543	mergerEmail := ""
544	if strategy != "ff" {
545		email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
546		if err != nil {
547			return c.fail(protocol.ExitFailure, "%v", err)
548		}
549		if email == "" {
550			return c.fail(protocol.ExitDenied,
551				"%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
552		}
553		mergerEmail = email
554	}
555
556	var newSHA string
557	switch strategy {
558	case "ff":
559		if !ffPossible {
560			return c.fail(protocol.ExitUsage,
561				"fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
562		}
563		newSHA = headSHA
564
565	case "merge":
566		tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
567		if err != nil {
568			return c.fail(protocol.ExitFailure, "%v", err)
569		}
570		if conflict {
571			return c.fail(protocol.ExitUsage,
572				"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
573		}
574		msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
575		newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
576		if err != nil {
577			return c.fail(protocol.ExitFailure, "%v", err)
578		}
579
580	case "squash":
581		// One new commit with the merged tree. Authorship credit goes to
582		// the MR author (their verified identity when they have one); the
583		// committer is the merger.
584		tree := ""
585		if ffPossible {
586			t, err := gitutil.ResolveTree(dir, headSHA)
587			if err != nil {
588				return c.fail(protocol.ExitFailure, "%v", err)
589			}
590			tree = t
591		} else {
592			t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
593			if err != nil {
594				return c.fail(protocol.ExitFailure, "%v", err)
595			}
596			if conflict {
597				return c.fail(protocol.ExitUsage,
598					"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
599			}
600			tree = t
601		}
602		authorName, authorEmail := c.User.Username, mergerEmail
603		if author, err := c.Store.UserByUsername(mr.Author); err == nil {
604			if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
605				authorName, authorEmail = author.Username, ae
606			}
607		}
608		msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
609		if mr.Body != "" {
610			msg += "\n\n" + mr.Body
611		}
612		var err error
613		newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
614			authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
615		if err != nil {
616			return c.fail(protocol.ExitFailure, "%v", err)
617		}
618
619	case "rebase":
620		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
621		if err != nil {
622			return c.fail(protocol.ExitFailure, "%v", err)
623		}
624		// Oldest first.
625		for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
626			commits[i], commits[j] = commits[j], commits[i]
627		}
628		onto := targetSHA
629		for _, sha := range commits {
630			parents, err := gitutil.CommitParents(dir, sha)
631			if err != nil {
632				return c.fail(protocol.ExitFailure, "%v", err)
633			}
634			if len(parents) > 1 {
635				return c.fail(protocol.ExitUsage,
636					"the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha)
637			}
638			base := onto // root commit: replay against the new tip itself
639			if len(parents) == 1 {
640				base = parents[0]
641			}
642			tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
643			if err != nil {
644				return c.fail(protocol.ExitFailure, "%v", err)
645			}
646			if conflict {
647				return c.fail(protocol.ExitUsage,
648					"commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
649			}
650			aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
651			if err != nil {
652				return c.fail(protocol.ExitFailure, "%v", err)
653			}
654			msg, err := gitutil.CommitMessage(dir, sha)
655			if err != nil {
656				return c.fail(protocol.ExitFailure, "%v", err)
657			}
658			onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
659				aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
660			if err != nil {
661				return c.fail(protocol.ExitFailure, "%v", err)
662			}
663		}
664		newSHA = onto
665	}
666
667	// CAS so a concurrent push between our read and this write fails the
668	// merge instead of silently discarding the push.
669	if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
670		return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
671	}
672	if err := c.Store.MarkMerged(mr.ID, targetSHA); err != nil {
673		return c.fail(protocol.ExitFailure, "%v", err)
674	}
675	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
676	return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
677		fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
678	})
679}
680
681func runMRClose(c *Ctx, args []string) int {
682	repo, mr, code := mrRef(c, args, policy.CanRead)
683	if code >= 0 {
684		return code
685	}
686	if len(args) != 2 {
687		return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
688	}
689	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
690	if err != nil {
691		return c.fail(protocol.ExitFailure, "%v", err)
692	}
693	if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
694		return c.fail(protocol.ExitDenied, "only the author or users with write access can close this MR")
695	}
696	if mr.State == "merged" || mr.State == "closed" {
697		return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
698	}
699	if err := c.Store.SetMRState(mr.ID, "closed"); err != nil {
700		return c.fail(protocol.ExitFailure, "%v", err)
701	}
702	return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
703		fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
704	})
705}