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}