| @@ -0,0 +1,130 @@ |
| |
1 | #+title: Stacked merge requests |
| |
2 | |
| |
3 | Two or more merge requests where each targets the source branch of the |
| |
4 | one below it, down to =main=. Review and merge them one layer at a time; |
| |
5 | the forge moves what is above onto =main= as each layer lands. |
| |
6 | |
| |
7 | * What a stack is |
| |
8 | |
| |
9 | #+begin_example |
| |
10 | main ─── A (feat-a) !159 feat-a → main |
| |
11 | └── B (feat-b) !160 feat-b → feat-a stacked on !159 |
| |
12 | └── C (feat-c) !161 feat-c → feat-b stacked on !160 |
| |
13 | #+end_example |
| |
14 | |
| |
15 | The rule is one sentence: =B= is stacked on =A= when =B='s target branch |
| |
16 | is =A='s source branch, both are open, and both are in the same |
| |
17 | repository. Nothing is stored and no flag exists. The stack is a fact |
| |
18 | about branches that the forge reads whenever it shows a merge request, |
| |
19 | so it is never out of date and cannot be forgotten to be set. |
| |
20 | |
| |
21 | * Why |
| |
22 | |
| |
23 | - Keep working on a change that depends on one still in review, instead |
| |
24 | of waiting for it to merge or carrying both in one branch. |
| |
25 | - Each merge request holds one reviewable change. The diff of =B= is |
| |
26 | =B='s commits only, not =A='s underneath it. |
| |
27 | - Reviews and checks sit on the layer they were made on and stay there |
| |
28 | when the layer below merges. |
| |
29 | |
| |
30 | * The workflow is branches |
| |
31 | |
| |
32 | There is no stack command. Branch from the branch below, push, and open |
| |
33 | the merge request against it: |
| |
34 | |
| |
35 | #+begin_src sh |
| |
36 | git checkout -b feat-a main && ...commit... && git push -u origin feat-a |
| |
37 | gitbay mr create --source feat-a --target main --title "A" |
| |
38 | git checkout -b feat-b feat-a && ...commit... && git push -u origin feat-b |
| |
39 | gitbay mr create --source feat-b --target feat-a --title "B" |
| |
40 | #+end_src |
| |
41 | |
| |
42 | The second =mr create= answers with a line the first did not: |
| |
43 | |
| |
44 | #+begin_example |
| |
45 | created krz/gitbay!160 (feat-b -> feat-a) |
| |
46 | stacked on !159 A |
| |
47 | #+end_example |
| |
48 | |
| |
49 | =mr show= carries =stacked_on= (the merge request below) and =stacked= |
| |
50 | (the ones above); =mr list= rows carry =stacked_on=; the merge request |
| |
51 | page says "Stacked on !159" in the header and "Builds on this: !161" |
| |
52 | below it. |
| |
53 | |
| |
54 | Changing a lower layer is a rebase you do yourself. Amend =feat-a=, |
| |
55 | then =git rebase feat-a= on =feat-b= and each layer above, and |
| |
56 | force-push them. A force-push stales the reviews on that layer, the |
| |
57 | same as on any merge request. The server never rewrites your commits: |
| |
58 | it holds no signing key, and a rebase it performed would land commits |
| |
59 | nobody signed. |
| |
60 | |
| |
61 | * Merging |
| |
62 | |
| |
63 | Merge from the bottom. When =A= merges, every merge request stacked on |
| |
64 | it is retargeted onto what =A= merged into, with a system comment: |
| |
65 | |
| |
66 | #+begin_example |
| |
67 | retargeted from feat-a to main: !159 merged |
| |
68 | #+end_example |
| |
69 | |
| |
70 | Reviews on the retargeted merge request are kept. After a fast-forward |
| |
71 | or a merge commit, =A='s commits are on =main=, so =B='s diff against |
| |
72 | =main= is the diff its reviewers approved; there is nothing to stale. |
| |
73 | |
| |
74 | That is also why a stack constrains the strategy. A squash or rebase |
| |
75 | merge of =A= puts different commits on =main= than the ones =B= builds |
| |
76 | on, and =B='s diff would carry =A='s changes a second time. So while |
| |
77 | anything is stacked on a merge request, =--strategy squash= and |
| |
78 | =--strategy rebase= are refused: |
| |
79 | |
| |
80 | #+begin_example |
| |
81 | !159 is stacked on by !160; a squash merge rewrites the commits they |
| |
82 | build on. Merge with --strategy ff or merge, or merge the stack into |
| |
83 | feat-a first |
| |
84 | #+end_example |
| |
85 | |
| |
86 | The second option is real: merging =B= into =feat-a= while =A= is open |
| |
87 | is allowed and collapses =B= into =A=, whose head moves as on any push |
| |
88 | to its branch. Closing =A= without merging leaves the stack alone; its |
| |
89 | branch still exists and =B= still targets it. |
| |
90 | |
| |
91 | Merge gates apply per layer as on any merge request: required |
| |
92 | approvals, resolved threads, green checks, and =require_signed_commits=, |
| |
93 | which under a stack already forces fast-forward. |
| |
94 | |
| |
95 | * Where it works |
| |
96 | |
| |
97 | - CLI and bare SSH: everything above. |
| |
98 | - Web: the header shows the stack both ways. Creating a merge request |
| |
99 | against a branch that is another's source works from the form; the |
| |
100 | stack appears once it exists. |
| |
101 | - iOS: renders the retarget comment; no stack view yet. |
| |
102 | - Same repository only. A fork's branch is not something another merge |
| |
103 | request can target, so a stack cannot cross a fork. |
| |
104 | |
| |
105 | See [[Parity][Parity]] for the row. |
| |
106 | |
| |
107 | * A stack that merged |
| |
108 | |
| |
109 | The six merge requests that shipped v1.6.0 were the first stack merged |
| |
110 | on gitbay.org, one commit each, on 2026-09-02: |
| |
111 | |
| |
112 | | MR | source | target | |
| |
113 | |------+-----------------+-----------------| |
| |
114 | | !159 | stack-1-reaper | main | |
| |
115 | | !160 | stack-2-runners | stack-1-reaper | |
| |
116 | | !161 | stack-3-healthz | stack-2-runners | |
| |
117 | | !162 | stack-4-monitor | stack-3-healthz | |
| |
118 | | !163 | stack-5-lfs | stack-4-monitor | |
| |
119 | | !164 | stack-6-verify | stack-5-lfs | |
| |
120 | |
| |
121 | Each was merged with =mr merge --strategy ff= in that order. After each |
| |
122 | one, the next reported =target_ref: main= and no =stacked_on=, with the |
| |
123 | comment naming the merge — =retargeted from stack-1-reaper to main: |
| |
124 | !159 merged= on !160, and so on up to !164. No =mr retarget= was typed. |
| |
125 | =main= ended with the six commits in order on top of the commit that |
| |
126 | had added stacking. |
| |
127 | |
| |
128 | One thing the run exposed: each fast-forward queued the commit's CI jobs |
| |
129 | again on =main=, although the same commit had just passed them on its |
| |
130 | branch. That is [[https://gitbay.org/krz/gitbay/issues/90][#90]]. |