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                // Merged out-of-band while we waited: skip the redundant merge,
200                // let sync reconcile it, and carry on with the next review.
201                WaitOutcome::Landed => {
202                    anstream::println!(
203                        "{}",
204                        style::warn(&format!(
205                            "{} was merged outside git-stk; syncing instead",
206                            review.id
207                        ))
208                    );
209                    sync(false, PushMode::Config)?;
210                    landed += 1;
211                    continue;
212                }
213            }
214        }
215
216        match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
217            MergeOutcome::Merged => {
218                sync(false, PushMode::Config)?;
219                landed += 1;
220            }
221            MergeOutcome::Scheduled => break,
222        }
223    }
224
225    anstream::println!(
226        "{}",
227        style::success(&format!(
228            "merge complete: {landed} of {count} review{} merged",
229            if count == 1 { "" } else { "s" }
230        ))
231    );
232    Ok(())
233}
234
235/// The bottom of the stack containing the current branch: the lowest branch on
236/// its line that actually stacks on something. A line rooted off the trunk
237/// keeps its parentless root - the base the branch above targets - and that
238/// base is never merged: with no parent recorded there is nothing to check its
239/// review against, and it is typically not ours to land (a release line, say).
240fn bottom_branch() -> Result<Option<String>> {
241    bottom_branch_excluding(None)
242}
243
244/// [`bottom_branch`], with `exclude` held out of the search by name. `merge
245/// --all` pins the line's base this way: metadata written mid-run must not be
246/// able to promote it into the landing.
247fn bottom_branch_excluding(exclude: Option<&str>) -> Result<Option<String>> {
248    let current = crate::git::current_branch()?;
249    let line = stack::stack_line(&current)?;
250    Ok(stack::stacked_layers(&line)?
251        .into_iter()
252        .find(|branch| Some(branch.as_str()) != exclude))
253}
254
255/// "Nothing to merge" message, tailored to call out the trunk - a natural
256/// place to be standing, but never part of a stack - rather than implying the
257/// repo has no stacks at all.
258fn nothing_to_merge_hint() -> Result<String> {
259    let current = crate::git::current_branch()?;
260    let trunk = stack::trunk_branch(&crate::git::local_branches()?);
261    // Only blame the trunk when the repo actually has a stack: then standing on
262    // it is the footgun. An empty repo on the trunk just has nothing to merge.
263    // "Has a stack" is not "the trunk has children" - a stack rooted off the
264    // trunk leaves the trunk childless while plainly being one.
265    let on_trunk_with_stacks = Some(&current) == trunk.as_ref() && stack::has_stacked_branches()?;
266    if on_trunk_with_stacks {
267        return Ok(format!(
268            "you are on the trunk ({current}); check out a stacked branch first"
269        ));
270    }
271    // Standing on a branch with no stack parent: there is a branch here, just
272    // no base recorded to merge it into. Say which, rather than implying the
273    // repo has no stacks.
274    // A recorded base standing alone is not missing metadata - it is the
275    // branch a stack sat on. Suggesting `adopt` here would re-root it: `adopt`
276    // defaults to the branch you are on.
277    if stack::is_floor(&current)? {
278        return Ok(format!(
279            "{current} is a stack's base, and nothing is stacked on it - \
280             there is nothing to merge"
281        ));
282    }
283    if Some(&current) != trunk.as_ref() && stack::parent_of(&current)?.is_none() {
284        return Ok(format!(
285            "{current} has no stack parent, so there is no base to merge it into; \
286             attach it with `git stk adopt --parent <parent>`, or rebuild its metadata \
287             with `git stk repair`"
288        ));
289    }
290    Ok("no stacked branches to merge".to_owned())
291}
292
293/// The branch's review, validated as mergeable: it exists, is open, and
294/// still targets the branch's stack parent.
295fn open_review_for(
296    review_provider: &dyn ReviewProvider,
297    kind: ProviderKind,
298    branch: &str,
299) -> Result<ReviewRequest> {
300    let Some(review) = review_provider.review_for_branch(branch)? else {
301        bail!("no {kind} review found for {branch}; submit the stack first");
302    };
303    if review.state != ReviewState::Open {
304        bail!(
305            "review {} for {branch} is {}, not open",
306            review.id,
307            review.state
308        );
309    }
310
311    // A base and a local parent that disagree normally mean the review needs
312    // resubmitting, and the merge would otherwise land into the wrong branch.
313    //
314    // There is one state where the disagreement is expected instead: a layer
315    // that GitHub still owes a retarget. `cleanup` moves the local parent as
316    // the layer below lands and deliberately leaves the review to GitHub,
317    // which retargets it on its own clock - so between those two moments the
318    // two differ, and bailing would stop `merge --all` halfway and name
319    // `submit`, which refuses outright for a review in a stack.
320    //
321    // The question is narrower than "is it in a stack": can the stack still
322    // bring this base to the parent we have? It can reach the layer recorded
323    // below and the stack's own base, and nowhere else - so a re-rooted or
324    // reordered line, and the stack's bottom, get the ordinary refusal this
325    // guard exists for.
326    let expected_base = stack::parent_of(branch)?;
327    if let Some(expected) = &expected_base
328        && *expected != review.base
329    {
330        match review_provider.base_gap(&review, expected).unwrap_or(None) {
331            // The platform is going to close this itself, as the layer below
332            // lands. Carrying on is right: `merge --all` would otherwise stop
333            // halfway and name `submit`, which refuses a review in a stack.
334            Some(BaseGap::Platform) => {}
335            Some(BaseGap::Sync) => bail!(
336                "review {} already targets {} - the platform moved it when {expected} \
337                 landed, and {branch}'s stack parent has not caught up; run \
338                 `git stk sync` first",
339                review.id,
340                review.base
341            ),
342            Some(BaseGap::Neither) => bail!(
343                "review {} targets {}, but {branch}'s stack parent is {expected} - \
344                 its stack will not move it there, and the platform refuses a \
345                 change by hand; run `git stk unstack`, then \
346                 `git stk submit`",
347                review.id,
348                review.base
349            ),
350            None => bail!(
351                "review {} targets {}, but {branch}'s stack parent is {expected}; \
352                 run `git stk submit` first",
353                review.id,
354                review.base
355            ),
356        }
357    }
358
359    Ok(review)
360}
361
362enum MergeOutcome {
363    Merged,
364    Scheduled,
365}
366
367/// Merge the review and report what actually happened: gh --auto and glab's
368/// default auto-merge schedule the merge instead of performing it, and only
369/// a review that reads merged afterwards should start a sync.
370fn merge_and_check(
371    review_provider: &dyn ReviewProvider,
372    review: &ReviewRequest,
373    strategy: &str,
374    auto: bool,
375) -> Result<MergeOutcome> {
376    let label = review.label();
377
378    let output = match review_provider.merge_review(review, strategy, auto) {
379        Ok(output) => output,
380        Err(error) => return Err(explain_merge_failure(review_provider, review, error)),
381    };
382    if !output.is_empty() {
383        println!("{output}");
384    }
385
386    match review_provider.review_for_branch(&review.branch)? {
387        Some(after) if after.state == ReviewState::Merged => {
388            anstream::println!("{}", style::success(&format!("merged {label}")));
389            Ok(MergeOutcome::Merged)
390        }
391        _ => {
392            anstream::println!(
393                "{}",
394                style::warn(&format!(
395                    "merge scheduled for {label}; rerun `git stk sync` once checks pass"
396                ))
397            );
398            Ok(MergeOutcome::Scheduled)
399        }
400    }
401}
402
403/// Turn a rejected merge into an actionable error. Ask the platform why from
404/// its structured status first; only if that is inconclusive (or the query
405/// itself fails) fall back to matching the CLI's error text, then surface the
406/// raw error.
407fn explain_merge_failure(
408    review_provider: &dyn ReviewProvider,
409    review: &ReviewRequest,
410    error: anyhow::Error,
411) -> anyhow::Error {
412    // Our own refusal is already exact - re-diagnosing it against the merge
413    // blocker can answer "--auto is not available here" with "rerun with
414    // --auto", which is the reverse of what was said.
415    if error
416        .downcast_ref::<crate::providers::MergeRefused>()
417        .is_some()
418    {
419        return error;
420    }
421    // Whether scheduling is even on the table here - the same question the dry
422    // run asks before printing the mode.
423    let can_schedule = !review_provider
424        .native_stack_for(&review.branch)
425        .is_ok_and(|found| found.is_some());
426    match review_provider
427        .merge_blocker(review)
428        .unwrap_or(MergeBlocker::None)
429    {
430        MergeBlocker::ChecksPending => checks_not_green_error(review, can_schedule),
431        MergeBlocker::Conflicts => anyhow::anyhow!(
432            "{} conflicts with {} - resolve the conflicts, push, and rerun `git stk merge`",
433            review.id,
434            review.base
435        ),
436        // The platform did not say (or the status query failed): fall back to
437        // the CLI's error wording before surfacing it raw.
438        MergeBlocker::None => {
439            let text = error.to_string().to_lowercase();
440            if text.contains("status check") || text.contains("not mergeable") {
441                checks_not_green_error(review, can_schedule)
442            } else {
443                error
444            }
445        }
446    }
447}
448
449fn checks_not_green_error(review: &ReviewRequest, can_schedule: bool) -> anyhow::Error {
450    // `--auto` is refused for a review in a platform stack, so recommending it
451    // there answers one refusal with another.
452    if can_schedule {
453        anyhow::anyhow!(
454            "{}'s required checks are not green yet - wait and rerun `git stk merge`, \
455             or schedule with `git stk merge --auto`",
456            review.id
457        )
458    } else {
459        anyhow::anyhow!(
460            "{}'s required checks are not green yet - wait and rerun `git stk merge`; \
461             `--auto` is not available for a review in a stack",
462            review.id
463        )
464    }
465}