Skip to main content

git_stk/commands/
merge.rs

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