A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 93b38a063b

93b38a063b3b4b161a1baf976e92f53d02b1a221

parent: 70c014166e

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-30T06:16:04Z

mr: retarget an open merge request onto another branch

The Parity page claimed retarget existed over ssh/cli; nothing did. The
row is already gone, so this is the capability arriving rather than a
correction: mr retarget <owner/name> <n> <branch>, plus a branch picker
in the MR aside.

The diff, the commit list and the merge gates all derive their base from
the target on every read, so the command validates that the new branch
exists and shares history with the head, then moves target_ref. Reviews
do not derive: an approval was of the diff against the old branch, so
SetMRTarget stales them in the same transaction.

Closes #46
cmd/gitbay/main.go +1
@@ -395,6 +395,7 @@ func mrCmd() *cobra.Command {
395395 pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}),
396396 pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"mr", "edit"}, needsRepo: true, stdinOK: true}),
397397 pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}),
398 pass("retarget", "retarget onto another branch: <n> <branch>", passOpts{server: []string{"mr", "retarget"}, needsRepo: true}),
398399 )
399400 }
400401
e2e/mr_test.go +6 −5
@@ -13,11 +13,12 @@ import (
1313 )
1414
1515 type mrShow struct {
16 Number int64 `json:"number"`
17 State string `json:"state"`
18 Source string `json:"source"`
19 HeadSHA string `json:"head_sha"`
20 Reviews []struct {
16 Number int64 `json:"number"`
17 State string `json:"state"`
18 Source string `json:"source"`
19 TargetRef string `json:"target_ref"`
20 HeadSHA string `json:"head_sha"`
21 Reviews []struct {
2122 Reviewer string `json:"reviewer"`
2223 Verdict string `json:"verdict"`
2324 Stale bool `json:"stale"`
e2e/mrretarget_test.go added +127
@@ -0,0 +1,127 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// TestMRRetarget moves an open merge request onto another branch, over
12// SSH and from the browser. Retargeting changes which diff a review was
13// of, so the existing approvals have to go stale with it.
14func TestMRRetarget(t *testing.T) {
15 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
16 aliceKey := inst.newKey(t, "alice")
17 bobKey := inst.newKey(t, "bob")
18 inst.admin(t, "admin", "user", "create", "alice",
19 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
20 inst.admin(t, "admin", "user", "create", "bob",
21 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
22
23 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
24 t.Fatalf("repo create: %s", errOut)
25 }
26 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/lib", "bob", "write"); code != 0 {
27 t.Fatalf("grant: %s", errOut)
28 }
29
30 env := inst.gitEnv(aliceKey)
31 work := t.TempDir()
32 mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
33 dir := filepath.Join(work, "w")
34 os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("v1\n"), 0o644)
35 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
36 mustGit(t, dir, env, "add", ".")
37 mustGit(t, dir, env, "commit", "-q", "-m", "base")
38 mustGit(t, dir, env, "push", "-q", "origin", "main")
39 // A second long-lived branch to retarget onto.
40 mustGit(t, dir, env, "checkout", "-q", "-b", "release")
41 os.WriteFile(filepath.Join(dir, "release.txt"), []byte("1.0\n"), 0o644)
42 mustGit(t, dir, env, "add", ".")
43 mustGit(t, dir, env, "commit", "-q", "-m", "cut release")
44 mustGit(t, dir, env, "push", "-q", "origin", "release")
45 mustGit(t, dir, env, "checkout", "-q", "-b", "feat", "main")
46 os.WriteFile(filepath.Join(dir, "feat.txt"), []byte("work\n"), 0o644)
47 mustGit(t, dir, env, "add", ".")
48 mustGit(t, dir, env, "commit", "-q", "-m", "add feat")
49 mustGit(t, dir, env, "push", "-q", "origin", "feat")
50
51 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/lib",
52 "--source", "feat", "--target", "main", "--title", "'feature'"); code != 0 {
53 t.Fatalf("mr create: %s", errOut)
54 }
55 if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "review", "alice/lib", "1", "--approve"); code != 0 {
56 t.Fatalf("review: %s", errOut)
57 }
58
59 // Refusals first: an unknown branch, the branch it already targets,
60 // and its own source.
61 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "retarget", "alice/lib", "1", "nope"); code != 3 ||
62 !strings.Contains(errOut, "not found") {
63 t.Fatalf("unknown branch: exit %d, %s", code, errOut)
64 }
65 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "retarget", "alice/lib", "1", "main"); code != 2 ||
66 !strings.Contains(errOut, "already targets") {
67 t.Fatalf("same branch: exit %d, %s", code, errOut)
68 }
69 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "retarget", "alice/lib", "1", "feat"); code != 2 ||
70 !strings.Contains(errOut, "source branch") {
71 t.Fatalf("source branch: exit %d, %s", code, errOut)
72 }
73 // Nobody outside the repo can move it.
74 eveKey := inst.newKey(t, "eve")
75 inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub")
76 if _, _, code := inst.ssh(t, eveKey, "", "mr", "retarget", "alice/lib", "1", "release"); code == 0 {
77 t.Fatal("a stranger retargeted the merge request")
78 }
79
80 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "retarget", "alice/lib", "1", "release"); code != 0 {
81 t.Fatalf("retarget: %s", errOut)
82 }
83 show := inst.mrShow(t, aliceKey, "alice/lib", "1")
84 if show.TargetRef != "release" {
85 t.Fatalf("target not moved: %q", show.TargetRef)
86 }
87 if len(show.Reviews) != 1 || !show.Reviews[0].Stale {
88 t.Fatalf("approval survived the retarget: %+v", show.Reviews)
89 }
90 // The diff follows the new base: release.txt is on the target now, so
91 // it is no longer part of the change.
92 out, errOut, code := inst.ssh(t, aliceKey, "", "mr", "diff", "alice/lib", "1")
93 if code != 0 {
94 t.Fatalf("mr diff: %s", errOut)
95 }
96 if !strings.Contains(out, "feat.txt") || strings.Contains(out, "release.txt") {
97 t.Fatalf("diff not rebased on the new target:\n%s", out)
98 }
99
100 // The move is recorded on the conversation.
101 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1"); !strings.Contains(out, "retargeted from main to release") {
102 t.Fatalf("no system comment for the move:\n%s", out)
103 }
104
105 // And the browser can do it, through the same command.
106 mrURL := inst.base() + "/alice/lib/mrs/1"
107 alice := inst.login(t, aliceKey)
108 _, body := browserGet(t, alice, mrURL)
109 if !strings.Contains(body, `action="/alice/lib/mrs/1/retarget"`) {
110 t.Fatalf("no retarget control on the MR page:\n%s", body)
111 }
112 if status, _ := browserPost(t, alice, mrURL+"/retarget", url.Values{"target": {"main"}}); status != 200 {
113 t.Fatalf("retarget post: %d", status)
114 }
115 if got := inst.mrShow(t, aliceKey, "alice/lib", "1").TargetRef; got != "main" {
116 t.Fatalf("web retarget did not land: %q", got)
117 }
118
119 // A merged merge request is settled.
120 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/lib", "1"); code != 0 {
121 t.Fatalf("merge: %s", errOut)
122 }
123 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "retarget", "alice/lib", "1", "release"); code != 2 ||
124 !strings.Contains(errOut, "only an open merge request") {
125 t.Fatalf("merged MR retargeted: exit %d, %s", code, errOut)
126 }
127}
internal/control/mr.go +59
@@ -38,6 +38,8 @@ func init() {
3838 register(Command{Path: []string{"mr", "edit"},
3939 Summary: "edit title or body: mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]",
4040 ReadsStdin: true, Run: runMREdit})
41 register(Command{Path: []string{"mr", "retarget"},
42 Summary: "retarget onto another branch: mr retarget <owner/name> <n> <branch>", Run: runMRRetarget})
4143 register(Command{Path: []string{"mr", "comment"},
4244 Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
4345 ReadsStdin: true, Run: runMRComment})
@@ -550,6 +552,63 @@ func runMREdit(c *Ctx, args []string) int {
550552 })
551553 }
552554
555// runMRRetarget moves an open merge request onto another branch of the
556// same repository.
557func runMRRetarget(c *Ctx, args []string) int {
558 if len(args) != 3 {
559 return c.fail(protocol.ExitUsage, "usage: mr retarget <owner/name> <n> <branch>")
560 }
561 repo, mr, code := mrRef(c, args[:2], policy.CanRead)
562 if code >= 0 {
563 return code
564 }
565 if code := refuseArchived(c, repo); code >= 0 {
566 return code
567 }
568 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
569 if err != nil {
570 return c.fail(protocol.ExitFailure, "%v", err)
571 }
572 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
573 return c.fail(protocol.ExitDenied, "only the author or users with write access can retarget this merge request")
574 }
575 if mr.State == "merged" || mr.State == "closed" {
576 return c.fail(protocol.ExitUsage, "!%d is %s; only an open merge request can be retargeted", mr.Number, mr.State)
577 }
578 target := args[2]
579 if target == mr.TargetRef {
580 return c.fail(protocol.ExitUsage, "!%d already targets %s", mr.Number, target)
581 }
582 if mr.SourceRepoID == repo.ID && target == mr.SourceRef {
583 return c.fail(protocol.ExitUsage, "%s is the source branch of !%d", target, mr.Number)
584 }
585 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
586 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+target); err != nil {
587 return c.fail(protocol.ExitNotFound, "branch %s not found in %s", target, repo.Path())
588 }
589 // The diff, the commit list and the merge gates all derive their base
590 // from the target on every read, so the only thing to check here is
591 // that a base exists at all: without one there is nothing to show and
592 // nothing to merge.
593 base, err := gitutil.MergeBase(dir, "refs/heads/"+target, mrHeadRef(mr.Number))
594 if err != nil || base == "" {
595 return c.fail(protocol.ExitUsage, "%s shares no history with the head of !%d", target, mr.Number)
596 }
597 old := mr.TargetRef
598 if err := c.Store.SetMRTarget(mr.ID, target); err != nil {
599 return c.fail(protocol.ExitFailure, "%v", err)
600 }
601 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target))
602 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
603 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
604 notifyBody(c, fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target), "",
605 fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
606 }
607 return c.emit(map[string]any{"number": mr.Number, "target_ref": target, "merge_base": base}, func(w io.Writer) {
608 fmt.Fprintf(w, "retargeted %s!%d from %s to %s (base %.10s)\n", repo.Path(), mr.Number, old, target, base)
609 })
610}
611
553612 func runMRComment(c *Ctx, args []string) int {
554613 var rest []string
555614 var message, file, format string
internal/httpd/mractions.go +14
@@ -121,6 +121,20 @@ func (s *Server) mrDiffCommentSubmit(w http.ResponseWriter, r *http.Request, u s
121121 s.mrDiffRedirect(w, r, msg)
122122 }
123123
124// mrRetargetSubmit moves the merge request onto another branch.
125func (s *Server) mrRetargetSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
126 target := strings.TrimSpace(r.FormValue("target"))
127 if target == "" {
128 s.mrRedirect(w, r, "pick a branch to retarget onto")
129 return
130 }
131 _, msg, ok := s.runControl(u, mrArgs(r, "retarget", target))
132 if ok {
133 msg = ""
134 }
135 s.mrRedirect(w, r, msg)
136}
137
124138 // mrThreadSubmit resolves or reopens one review thread.
125139 func (s *Server) mrThreadSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
126140 verb := "resolve"
internal/httpd/routes.go +2
@@ -149,6 +149,8 @@ func (s *Server) Routes() []Route {
149149 Handler: s.checkOrigin(s.requireUser(s.mrMergeSubmit))},
150150 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/close", Mutating: true,
151151 Handler: s.checkOrigin(s.requireUser(s.mrCloseSubmit))},
152 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/retarget", Mutating: true,
153 Handler: s.checkOrigin(s.requireUser(s.mrRetargetSubmit))},
152154 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/thread", Mutating: true,
153155 Handler: s.checkOrigin(s.requireUser(s.mrThreadSubmit))},
154156 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/diff-comment", Mutating: true,
internal/httpd/web.go +3 −1
@@ -1656,6 +1656,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
16561656 // its own view rather than a fold at the foot of the conversation.
16571657 // A query parameter keeps this working without JavaScript.
16581658 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1659 branches, _ := gitutil.Refs(p.Dir, "heads")
16591660 view := r.URL.Query().Get("view")
16601661 if view != "commits" && view != "diff" {
16611662 view = "conversation"
@@ -1672,13 +1673,14 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
16721673 DiffFiles []diffFile
16731674 Stat diffStat
16741675 Commits []commitRow
1676 Branches []gitutil.Ref
16751677 CanEdit bool
16761678 CanWrite bool
16771679 Unresolved int
16781680 Notice string
16791681 DetachedThreads []diffThread
16801682 }{p, m, view, md(m.Body, m.BodyFormat), checks, store.CombinedStatus(checks), renderComments(comments, md),
1681 reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1683 reviews, files, stat, commits, branches, s.canEditItem(r, p.Repo, m.Author),
16821684 canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads})
16831685 }
16841686
internal/store/mrs.go +25
@@ -183,6 +183,31 @@ func (s *Store) UpdateMRHead(mrID int64, headSHA string) error {
183183 return tx.Commit()
184184 }
185185
186// SetMRTarget retargets a merge request and marks every existing review
187// stale, in one transaction. The base of the diff is derived from the
188// target on every read, so nothing else has to move; an approval,
189// though, was of the diff against the old branch.
190func (s *Store) SetMRTarget(mrID int64, targetRef string) error {
191 tx, err := s.DB.Begin()
192 if err != nil {
193 return err
194 }
195 defer tx.Rollback()
196 res, err := tx.Exec(
197 "UPDATE merge_requests SET target_ref = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
198 targetRef, mrID)
199 if err != nil {
200 return err
201 }
202 if n, _ := res.RowsAffected(); n == 0 {
203 return ErrNotFound
204 }
205 if _, err := tx.Exec("UPDATE mr_reviews SET stale = 1 WHERE mr_id = ?", mrID); err != nil {
206 return err
207 }
208 return tx.Commit()
209}
210
186211 // MarkSourceGoneForRepo flags every open MR sourced from the repo; called
187212 // when a fork is deleted. Head refs in the target repos are retained.
188213 func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error {
internal/web/templates/mr.html +13
@@ -94,6 +94,19 @@
9494 </form>
9595 </div>
9696 {{end}}
97 {{if and .CanEdit (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
98 <div class="grp">
99 <h2>Target</h2>
100 <form method="post" action="{{$base}}/retarget" class="actions">
101 <label class="none" for="target">Branch</label>
102 <select id="target" name="target">
103 {{range .Branches}}<option value="{{.Name}}"{{if eq .Name $.MR.TargetRef}} selected{{end}}>{{.Name}}</option>{{end}}
104 </select>
105 <button type="submit">Retarget</button>
106 </form>
107 <p class="row none">Retargeting stales existing reviews.</p>
108 </div>
109 {{end}}
97110 <div class="grp">
98111 <h2>Reviews</h2>
99112 {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p>