1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
use anyhow::{Result, bail};
use clap::ArgAction;
use crate::cli::PushMode;
use crate::commands::Run;
use crate::commands::sync::sync;
use crate::prompt::confirm;
use crate::providers::{
BaseGap, MergeBlocker, ProviderKind, ReviewProvider, ReviewRequest, ReviewState, WaitOutcome,
detect_review_provider,
};
use crate::settings;
use crate::stack;
use crate::style;
/// Merge the review at the bottom of the stack, then sync.
#[derive(Debug, clap::Args)]
pub struct Merge {
/// Print what would happen without merging anything.
#[arg(long, short = 'n', action = ArgAction::SetTrue)]
dry_run: bool,
/// Skip the confirmation prompt.
#[arg(long, short = 'y', action = ArgAction::SetTrue)]
yes: bool,
/// Schedule the merge for when required checks pass instead of merging
/// now.
#[arg(long, action = ArgAction::SetTrue, conflicts_with = "all")]
auto: bool,
/// Repeat merge-and-sync bottom-up until the whole stack has landed.
#[arg(long, action = ArgAction::SetTrue)]
all: bool,
/// With --all: wait for each review's checks before merging it.
#[arg(long, action = ArgAction::SetTrue, requires = "all", conflicts_with = "no_wait")]
wait: bool,
/// With --all: do not wait for checks, overriding stk.mergeWait.
#[arg(long, action = ArgAction::SetTrue, requires = "all")]
no_wait: bool,
}
impl Run for Merge {
fn run(self) -> Result<()> {
if self.all {
// Waiting: --wait forces it on, --no-wait off; otherwise
// stk.mergeWait decides.
let wait = if self.wait {
true
} else if self.no_wait {
false
} else {
settings::bool_setting(settings::MERGE_WAIT_KEY)?
};
merge_all(self.dry_run, self.yes, wait)
} else {
merge(self.dry_run, self.yes, self.auto)
}
}
}
fn merge(dry_run: bool, yes: bool, auto: bool) -> Result<()> {
let Some(bottom) = bottom_branch()? else {
bail!(nothing_to_merge_hint()?);
};
let (provider, review_provider) = detect_review_provider()?;
let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;
let strategy = settings::merge_strategy()?;
let mode = if auto {
format!("{strategy}, auto")
} else {
strategy.clone()
};
let label = review.label();
if dry_run {
// Same refusal the real run would raise, rather than advertising a
// mode that is about to be declined - including in the mode string
// itself, which is the last surface that would still say `auto`. Best
// effort: a provider that cannot answer leaves the dry run as it was.
let mut mode = mode;
if auto
&& review_provider
.native_stack_for(&review.branch)
.is_ok_and(|found| found.is_some())
{
anstream::println!(
"{}",
style::warn(&format!(
"{} is in a stack the platform owns, so --auto would be refused; \
it merges when you run `git stk merge` with checks green",
review.id
))
);
mode = mode.replace(", auto", "");
}
anstream::println!("would merge {label} into {} ({mode})", review.base);
anstream::println!("would sync afterwards");
return Ok(());
}
if !yes
&& !confirm(&format!(
"merge {label} into {} ({mode})? [y/N] ",
review.base
))?
{
anstream::println!("merge cancelled");
return Ok(());
}
stack::snapshot("merge");
match merge_and_check(review_provider.as_ref(), &review, &strategy, auto)? {
// Reconcile everything the merge changed: fetch, clean up, restack,
// push.
MergeOutcome::Merged => sync(false, PushMode::Config),
MergeOutcome::Scheduled => Ok(()),
}
}
/// Land the whole stack: merge the bottom review and sync, bottom-up, until
/// the stack is complete. One confirmation up front; a merge that only gets
/// scheduled stops the loop, and with `wait` each review's checks settle
/// before its merge.
fn merge_all(dry_run: bool, yes: bool, wait: bool) -> Result<()> {
let Some(bottom) = bottom_branch()? else {
bail!(nothing_to_merge_hint()?);
};
let (provider, review_provider) = detect_review_provider()?;
let strategy = settings::merge_strategy()?;
// What is about to land, bottom-up, for the dry run and the prompt: the
// current branch's own line, not sibling stacks sharing the trunk.
let current = crate::git::current_branch()?;
let line = stack::stack_line(¤t)?;
let branches = stack::stacked_layers(&line)?;
let count = branches.len();
// An off-trunk line's base is not part of this landing, and it has to stay
// that way for the whole loop rather than be re-derived each iteration:
// the `sync` between merges re-records the base's parent from its own
// review (#308), which would otherwise make it the lowest stacked branch
// next time round and land it - unprompted, since the confirmation below
// names it as the destination, not as something being merged.
let pinned_base = stack::unanchored_base(&line)?;
if dry_run {
for branch in &branches {
let review = open_review_for(review_provider.as_ref(), provider.kind, branch)?;
if wait {
anstream::println!("would wait for checks on {}", review.id);
}
anstream::println!(
"would merge {} into {} ({strategy})",
review.label(),
review.base
);
}
anstream::println!("would sync after each merge");
return Ok(());
}
let base = stack::parent_of(&bottom)?.unwrap_or_else(|| "its base".to_owned());
if !yes
&& !confirm(&format!(
"merge {count} review{} into {base}, bottom-up ({strategy})? [y/N] ",
if count == 1 { "" } else { "s" }
))?
{
anstream::println!("merge cancelled");
return Ok(());
}
stack::snapshot("merge --all");
// Each sync removes the merged bottom, so the loop is bounded by the
// number of branches it started with.
let mut landed = 0;
for _ in 0..count {
let Some(bottom) = bottom_branch_excluding(pinned_base.as_deref())? else {
break;
};
let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;
// Each sync force-pushes the next branch and restarts its checks;
// waiting here is what turns the landing into one command.
if wait {
anstream::println!(
"waiting for checks on {} {}",
review.id,
style::dim("(ctrl-c is safe; rerun `git stk merge --all` to resume)")
);
match review_provider.wait_for_checks(&review)? {
WaitOutcome::Passed => {}
WaitOutcome::Failed => bail!(
"checks failed for {}; fix them and rerun `git stk merge --all`",
review.id
),
// Merged out-of-band while we waited: skip the redundant merge,
// let sync reconcile it, and carry on with the next review.
WaitOutcome::Landed => {
anstream::println!(
"{}",
style::warn(&format!(
"{} was merged outside git-stk; syncing instead",
review.id
))
);
sync(false, PushMode::Config)?;
landed += 1;
continue;
}
}
}
match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
MergeOutcome::Merged => {
sync(false, PushMode::Config)?;
landed += 1;
}
MergeOutcome::Scheduled => break,
}
}
anstream::println!(
"{}",
style::success(&format!(
"merge complete: {landed} of {count} review{} merged",
if count == 1 { "" } else { "s" }
))
);
Ok(())
}
/// The bottom of the stack containing the current branch: the lowest branch on
/// its line that actually stacks on something. A line rooted off the trunk
/// keeps its parentless root - the base the branch above targets - and that
/// base is never merged: with no parent recorded there is nothing to check its
/// review against, and it is typically not ours to land (a release line, say).
fn bottom_branch() -> Result<Option<String>> {
bottom_branch_excluding(None)
}
/// [`bottom_branch`], with `exclude` held out of the search by name. `merge
/// --all` pins the line's base this way: metadata written mid-run must not be
/// able to promote it into the landing.
fn bottom_branch_excluding(exclude: Option<&str>) -> Result<Option<String>> {
let current = crate::git::current_branch()?;
let line = stack::stack_line(¤t)?;
Ok(stack::stacked_layers(&line)?
.into_iter()
.find(|branch| Some(branch.as_str()) != exclude))
}
/// "Nothing to merge" message, tailored to call out the trunk - a natural
/// place to be standing, but never part of a stack - rather than implying the
/// repo has no stacks at all.
fn nothing_to_merge_hint() -> Result<String> {
let current = crate::git::current_branch()?;
let trunk = stack::trunk_branch(&crate::git::local_branches()?);
// Only blame the trunk when the repo actually has a stack: then standing on
// it is the footgun. An empty repo on the trunk just has nothing to merge.
// "Has a stack" is not "the trunk has children" - a stack rooted off the
// trunk leaves the trunk childless while plainly being one.
let on_trunk_with_stacks = Some(¤t) == trunk.as_ref() && stack::has_stacked_branches()?;
if on_trunk_with_stacks {
return Ok(format!(
"you are on the trunk ({current}); check out a stacked branch first"
));
}
// Standing on a branch with no stack parent: there is a branch here, just
// no base recorded to merge it into. Say which, rather than implying the
// repo has no stacks.
// A recorded base standing alone is not missing metadata - it is the
// branch a stack sat on. Suggesting `adopt` here would re-root it: `adopt`
// defaults to the branch you are on.
if stack::is_floor(¤t)? {
return Ok(format!(
"{current} is a stack's base, and nothing is stacked on it - \
there is nothing to merge"
));
}
if Some(¤t) != trunk.as_ref() && stack::parent_of(¤t)?.is_none() {
return Ok(format!(
"{current} has no stack parent, so there is no base to merge it into; \
attach it with `git stk adopt --parent <parent>`, or rebuild its metadata \
with `git stk repair`"
));
}
Ok("no stacked branches to merge".to_owned())
}
/// The branch's review, validated as mergeable: it exists, is open, and
/// still targets the branch's stack parent.
fn open_review_for(
review_provider: &dyn ReviewProvider,
kind: ProviderKind,
branch: &str,
) -> Result<ReviewRequest> {
let Some(review) = review_provider.review_for_branch(branch)? else {
bail!("no {kind} review found for {branch}; submit the stack first");
};
if review.state != ReviewState::Open {
bail!(
"review {} for {branch} is {}, not open",
review.id,
review.state
);
}
// A base and a local parent that disagree normally mean the review needs
// resubmitting, and the merge would otherwise land into the wrong branch.
//
// There is one state where the disagreement is expected instead: a layer
// that GitHub still owes a retarget. `cleanup` moves the local parent as
// the layer below lands and deliberately leaves the review to GitHub,
// which retargets it on its own clock - so between those two moments the
// two differ, and bailing would stop `merge --all` halfway and name
// `submit`, which refuses outright for a review in a stack.
//
// The question is narrower than "is it in a stack": can the stack still
// bring this base to the parent we have? It can reach the layer recorded
// below and the stack's own base, and nowhere else - so a re-rooted or
// reordered line, and the stack's bottom, get the ordinary refusal this
// guard exists for.
let expected_base = stack::parent_of(branch)?;
if let Some(expected) = &expected_base
&& *expected != review.base
{
match review_provider.base_gap(&review, expected).unwrap_or(None) {
// The platform is going to close this itself, as the layer below
// lands. Carrying on is right: `merge --all` would otherwise stop
// halfway and name `submit`, which refuses a review in a stack.
Some(BaseGap::Platform) => {}
Some(BaseGap::Sync) => bail!(
"review {} already targets {} - the platform moved it when {expected} \
landed, and {branch}'s stack parent has not caught up; run \
`git stk sync` first",
review.id,
review.base
),
Some(BaseGap::Neither) => bail!(
"review {} targets {}, but {branch}'s stack parent is {expected} - \
its stack will not move it there, and the platform refuses a \
change by hand; run `git stk unstack`, then \
`git stk submit`",
review.id,
review.base
),
None => bail!(
"review {} targets {}, but {branch}'s stack parent is {expected}; \
run `git stk submit` first",
review.id,
review.base
),
}
}
Ok(review)
}
enum MergeOutcome {
Merged,
Scheduled,
}
/// Merge the review and report what actually happened: gh --auto and glab's
/// default auto-merge schedule the merge instead of performing it, and only
/// a review that reads merged afterwards should start a sync.
fn merge_and_check(
review_provider: &dyn ReviewProvider,
review: &ReviewRequest,
strategy: &str,
auto: bool,
) -> Result<MergeOutcome> {
let label = review.label();
let output = match review_provider.merge_review(review, strategy, auto) {
Ok(output) => output,
Err(error) => return Err(explain_merge_failure(review_provider, review, error)),
};
if !output.is_empty() {
println!("{output}");
}
match review_provider.review_for_branch(&review.branch)? {
Some(after) if after.state == ReviewState::Merged => {
anstream::println!("{}", style::success(&format!("merged {label}")));
Ok(MergeOutcome::Merged)
}
_ => {
anstream::println!(
"{}",
style::warn(&format!(
"merge scheduled for {label}; rerun `git stk sync` once checks pass"
))
);
Ok(MergeOutcome::Scheduled)
}
}
}
/// Turn a rejected merge into an actionable error. Ask the platform why from
/// its structured status first; only if that is inconclusive (or the query
/// itself fails) fall back to matching the CLI's error text, then surface the
/// raw error.
fn explain_merge_failure(
review_provider: &dyn ReviewProvider,
review: &ReviewRequest,
error: anyhow::Error,
) -> anyhow::Error {
// Our own refusal is already exact - re-diagnosing it against the merge
// blocker can answer "--auto is not available here" with "rerun with
// --auto", which is the reverse of what was said.
if error
.downcast_ref::<crate::providers::MergeRefused>()
.is_some()
{
return error;
}
// Whether scheduling is even on the table here - the same question the dry
// run asks before printing the mode.
let can_schedule = !review_provider
.native_stack_for(&review.branch)
.is_ok_and(|found| found.is_some());
match review_provider
.merge_blocker(review)
.unwrap_or(MergeBlocker::None)
{
MergeBlocker::ChecksPending => checks_not_green_error(review, can_schedule),
MergeBlocker::Conflicts => anyhow::anyhow!(
"{} conflicts with {} - resolve the conflicts, push, and rerun `git stk merge`",
review.id,
review.base
),
// The platform did not say (or the status query failed): fall back to
// the CLI's error wording before surfacing it raw.
MergeBlocker::None => {
let text = error.to_string().to_lowercase();
if text.contains("status check") || text.contains("not mergeable") {
checks_not_green_error(review, can_schedule)
} else {
error
}
}
}
}
fn checks_not_green_error(review: &ReviewRequest, can_schedule: bool) -> anyhow::Error {
// `--auto` is refused for a review in a platform stack, so recommending it
// there answers one refusal with another.
if can_schedule {
anyhow::anyhow!(
"{}'s required checks are not green yet - wait and rerun `git stk merge`, \
or schedule with `git stk merge --auto`",
review.id
)
} else {
anyhow::anyhow!(
"{}'s required checks are not green yet - wait and rerun `git stk merge`; \
`--auto` is not available for a review in a stack",
review.id
)
}
}