krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: 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 base, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
356 if err != nil {
357 return c.fail(protocol.ExitFailure, "%v", err)
358 }
359 patch, err := gitutil.Diff(dir, base, head, 4<<20)
360 if err != nil {
361 return c.fail(protocol.ExitFailure, "%v", err)
362 }
363 fmt.Fprint(c.Stdout, patch)
364 return protocol.ExitOK
365}
366
367func runMRComment(c *Ctx, args []string) int {
368 var rest []string
369 var message, file string
370 for i := 0; i < len(args); i++ {
371 switch args[i] {
372 case "--message", "--file":
373 if i+1 >= len(args) {
374 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
375 }
376 if args[i] == "--message" {
377 message = args[i+1]
378 } else {
379 file = args[i+1]
380 }
381 i++
382 default:
383 rest = append(rest, args[i])
384 }
385 }
386 repo, mr, code := mrRef(c, rest, policy.CanRead)
387 if code >= 0 {
388 return code
389 }
390 body, err := bodyFrom(c, message, file)
391 if err != nil {
392 return c.fail(protocol.ExitUsage, "%v", err)
393 }
394 if strings.TrimSpace(body) == "" {
395 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
396 }
397 if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil {
398 return c.fail(protocol.ExitFailure, "%v", err)
399 }
400 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
401 fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number)
402 })
403}
404
405func runMRReview(c *Ctx, args []string) int {
406 verdict := ""
407 var rest []string
408 for _, a := range args {
409 switch a {
410 case "--approve":
411 verdict = "approve"
412 case "--request-changes":
413 verdict = "request_changes"
414 case "--comment":
415 verdict = "comment"
416 default:
417 rest = append(rest, a)
418 }
419 }
420 if verdict == "" {
421 return c.fail(protocol.ExitUsage, "usage: mr review <owner/name> <n> --approve|--request-changes|--comment")
422 }
423 repo, mr, code := mrRef(c, rest, policy.CanRead)
424 if code >= 0 {
425 return code
426 }
427 if mr.State != "open" {
428 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
429 }
430 if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
431 return c.fail(protocol.ExitFailure, "%v", err)
432 }
433 return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) {
434 fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict)
435 })
436}
437
438func runMRMerge(c *Ctx, args []string) int {
439 strategy := ""
440 var rest []string
441 for i := 0; i < len(args); i++ {
442 if args[i] == "--strategy" {
443 if i+1 >= len(args) {
444 return c.fail(protocol.ExitUsage, "--strategy requires ff|merge|squash|rebase")
445 }
446 strategy = args[i+1]
447 i++
448 continue
449 }
450 rest = append(rest, args[i])
451 }
452 valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
453 if !valid[strategy] {
454 return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
455 }
456 repo, mr, code := mrRef(c, rest, policy.CanWrite)
457 if code >= 0 {
458 return code
459 }
460 if mr.State != "open" && mr.State != "source_gone" {
461 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
462 }
463
464 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
465 targetRef := "refs/heads/" + mr.TargetRef
466 targetSHA, err := gitutil.ResolveRef(dir, targetRef)
467 if err != nil {
468 return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
469 }
470 headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
471 if err != nil {
472 return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
473 }
474
475 upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
476 if err != nil {
477 return c.fail(protocol.ExitFailure, "%v", err)
478 }
479 if upToDate {
480 return c.fail(protocol.ExitUsage, "target already contains the MR head")
481 }
482 ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
483 if err != nil {
484 return c.fail(protocol.ExitFailure, "%v", err)
485 }
486
487 // Signature policy matrix: with require_signed_commits, only
488 // fast-forward is allowed — squash, rebase-replay, and merge commits
489 // are all server-created and unsigned, violating the branch's own
490 // policy — and every landed commit must be verified. An explicit
491 // rebase when fast-forward is already possible IS a fast-forward
492 // (nothing is rewritten), so it stays legal.
493 if repo.Settings.RequireSignedCommits {
494 if strategy == "merge" || strategy == "squash" || !ffPossible {
495 return c.fail(protocol.ExitDenied,
496 "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
497 repo.Path(), mr.SourceRef, mr.TargetRef)
498 }
499 strategy = "ff"
500 commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
501 if err != nil {
502 return c.fail(protocol.ExitFailure, "%v", err)
503 }
504 for _, sha := range commits {
505 raw, err := gitutil.ReadCommit(dir, sha)
506 if err != nil {
507 return c.fail(protocol.ExitFailure, "%v", err)
508 }
509 parsed, err := sigParse(raw)
510 if err != nil {
511 return c.fail(protocol.ExitFailure, "%v", err)
512 }
513 res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
514 if err != nil {
515 return c.fail(protocol.ExitFailure, "%v", err)
516 }
517 if res.State != "verified" {
518 return c.fail(protocol.ExitDenied,
519 "%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
520 }
521 }
522 }
523 if strategy == "" {
524 if ffPossible {
525 strategy = "ff"
526 } else {
527 strategy = "merge"
528 }
529 }
530 if strategy == "rebase" && ffPossible {
531 // Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
532 // and taking it keeps the original commits and their signatures.
533 strategy = "ff"
534 }
535
536 // Every server-created commit needs the merger's verified identity.
537 mergerEmail := ""
538 if strategy != "ff" {
539 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
540 if err != nil {
541 return c.fail(protocol.ExitFailure, "%v", err)
542 }
543 if email == "" {
544 return c.fail(protocol.ExitDenied,
545 "%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
546 }
547 mergerEmail = email
548 }
549
550 var newSHA string
551 switch strategy {
552 case "ff":
553 if !ffPossible {
554 return c.fail(protocol.ExitUsage,
555 "fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
556 }
557 newSHA = headSHA
558
559 case "merge":
560 tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
561 if err != nil {
562 return c.fail(protocol.ExitFailure, "%v", err)
563 }
564 if conflict {
565 return c.fail(protocol.ExitUsage,
566 "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
567 }
568 msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
569 newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
570 if err != nil {
571 return c.fail(protocol.ExitFailure, "%v", err)
572 }
573
574 case "squash":
575 // One new commit with the merged tree. Authorship credit goes to
576 // the MR author (their verified identity when they have one); the
577 // committer is the merger.
578 tree := ""
579 if ffPossible {
580 t, err := gitutil.ResolveTree(dir, headSHA)
581 if err != nil {
582 return c.fail(protocol.ExitFailure, "%v", err)
583 }
584 tree = t
585 } else {
586 t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
587 if err != nil {
588 return c.fail(protocol.ExitFailure, "%v", err)
589 }
590 if conflict {
591 return c.fail(protocol.ExitUsage,
592 "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
593 }
594 tree = t
595 }
596 authorName, authorEmail := c.User.Username, mergerEmail
597 if author, err := c.Store.UserByUsername(mr.Author); err == nil {
598 if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
599 authorName, authorEmail = author.Username, ae
600 }
601 }
602 msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
603 if mr.Body != "" {
604 msg += "\n\n" + mr.Body
605 }
606 var err error
607 newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
608 authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
609 if err != nil {
610 return c.fail(protocol.ExitFailure, "%v", err)
611 }
612
613 case "rebase":
614 commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
615 if err != nil {
616 return c.fail(protocol.ExitFailure, "%v", err)
617 }
618 // Oldest first.
619 for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
620 commits[i], commits[j] = commits[j], commits[i]
621 }
622 onto := targetSHA
623 for _, sha := range commits {
624 parents, err := gitutil.CommitParents(dir, sha)
625 if err != nil {
626 return c.fail(protocol.ExitFailure, "%v", err)
627 }
628 if len(parents) > 1 {
629 return c.fail(protocol.ExitUsage,
630 "the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha)
631 }
632 base := onto // root commit: replay against the new tip itself
633 if len(parents) == 1 {
634 base = parents[0]
635 }
636 tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
637 if err != nil {
638 return c.fail(protocol.ExitFailure, "%v", err)
639 }
640 if conflict {
641 return c.fail(protocol.ExitUsage,
642 "commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
643 }
644 aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
645 if err != nil {
646 return c.fail(protocol.ExitFailure, "%v", err)
647 }
648 msg, err := gitutil.CommitMessage(dir, sha)
649 if err != nil {
650 return c.fail(protocol.ExitFailure, "%v", err)
651 }
652 onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
653 aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
654 if err != nil {
655 return c.fail(protocol.ExitFailure, "%v", err)
656 }
657 }
658 newSHA = onto
659 }
660
661 // CAS so a concurrent push between our read and this write fails the
662 // merge instead of silently discarding the push.
663 if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
664 return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
665 }
666 if err := c.Store.SetMRState(mr.ID, "merged"); err != nil {
667 return c.fail(protocol.ExitFailure, "%v", err)
668 }
669 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
670 return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
671 fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
672 })
673}
674
675func runMRClose(c *Ctx, args []string) int {
676 repo, mr, code := mrRef(c, args, policy.CanRead)
677 if code >= 0 {
678 return code
679 }
680 if len(args) != 2 {
681 return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
682 }
683 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
684 if err != nil {
685 return c.fail(protocol.ExitFailure, "%v", err)
686 }
687 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
688 return c.fail(protocol.ExitDenied, "only the author or users with write access can close this MR")
689 }
690 if mr.State == "merged" || mr.State == "closed" {
691 return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
692 }
693 if err := c.Store.SetMRState(mr.ID, "closed"); err != nil {
694 return c.fail(protocol.ExitFailure, "%v", err)
695 }
696 return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
697 fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
698 })
699}