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    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        anstream::println!("would merge {label} into {} ({mode})", review.base);
77        anstream::println!("would sync afterwards");
78        return Ok(());
79    }
80
81    if !yes
82        && !confirm(&format!(
83            "merge {label} into {} ({mode})? [y/N] ",
84            review.base
85        ))?
86    {
87        anstream::println!("merge cancelled");
88        return Ok(());
89    }
90
91    stack::snapshot("merge");
92    match merge_and_check(review_provider.as_ref(), &review, &strategy, auto)? {
93        // Reconcile everything the merge changed: fetch, clean up, restack,
94        // push.
95        MergeOutcome::Merged => sync(false, PushMode::Config),
96        MergeOutcome::Scheduled => Ok(()),
97    }
98}
99
100/// Land the whole stack: merge the bottom review and sync, bottom-up, until
101/// the stack is complete. One confirmation up front; a merge that only gets
102/// scheduled stops the loop, and with `wait` each review's checks settle
103/// before its merge.
104fn merge_all(dry_run: bool, yes: bool, wait: bool) -> Result<()> {
105    let Some(bottom) = bottom_branch()? else {
106        bail!(nothing_to_merge_hint()?);
107    };
108
109    let (provider, review_provider) = detect_review_provider()?;
110    let strategy = settings::merge_strategy()?;
111
112    // What is about to land, bottom-up, for the dry run and the prompt: the
113    // current branch's own line, not sibling stacks sharing the trunk.
114    let current = crate::git::current_branch()?;
115    let branches = stack::stack_line(&current)?;
116    let count = branches.len();
117
118    if dry_run {
119        for branch in &branches {
120            let review = open_review_for(review_provider.as_ref(), provider.kind, branch)?;
121            if wait {
122                anstream::println!("would wait for checks on {}", review.id);
123            }
124            anstream::println!(
125                "would merge {} into {} ({strategy})",
126                review.label(),
127                review.base
128            );
129        }
130        anstream::println!("would sync after each merge");
131        return Ok(());
132    }
133
134    let base = stack::parent_of(&bottom)?.unwrap_or_else(|| "its base".to_owned());
135    if !yes
136        && !confirm(&format!(
137            "merge {count} review{} into {base}, bottom-up ({strategy})? [y/N] ",
138            if count == 1 { "" } else { "s" }
139        ))?
140    {
141        anstream::println!("merge cancelled");
142        return Ok(());
143    }
144
145    stack::snapshot("merge --all");
146
147    // Each sync removes the merged bottom, so the loop is bounded by the
148    // number of branches it started with.
149    let mut landed = 0;
150    for _ in 0..count {
151        let Some(bottom) = bottom_branch()? else {
152            break;
153        };
154        let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;
155
156        // Each sync force-pushes the next branch and restarts its checks;
157        // waiting here is what turns the landing into one command.
158        if wait {
159            anstream::println!(
160                "waiting for checks on {} {}",
161                review.id,
162                style::dim("(ctrl-c is safe; rerun `git stk merge --all` to resume)")
163            );
164            match review_provider.wait_for_checks(&review)? {
165                WaitOutcome::Passed => {}
166                WaitOutcome::Failed => bail!(
167                    "checks failed for {}; fix them and rerun `git stk merge --all`",
168                    review.id
169                ),
170                // Merged out-of-band while we waited: skip the redundant merge,
171                // let sync reconcile it, and carry on with the next review.
172                WaitOutcome::Landed => {
173                    anstream::println!(
174                        "{}",
175                        style::warn(&format!(
176                            "{} was merged outside git-stk; syncing instead",
177                            review.id
178                        ))
179                    );
180                    sync(false, PushMode::Config)?;
181                    landed += 1;
182                    continue;
183                }
184            }
185        }
186
187        match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
188            MergeOutcome::Merged => {
189                sync(false, PushMode::Config)?;
190                landed += 1;
191            }
192            MergeOutcome::Scheduled => break,
193        }
194    }
195
196    anstream::println!(
197        "{}",
198        style::success(&format!(
199            "merge complete: {landed} of {count} review{} merged",
200            if count == 1 { "" } else { "s" }
201        ))
202    );
203    Ok(())
204}
205
206/// The bottom of the stack containing the current branch: the first branch on
207/// its line above the trunk. (A rootless fragment's own root is its bottom.)
208fn bottom_branch() -> Result<Option<String>> {
209    let current = crate::git::current_branch()?;
210    Ok(stack::stack_line(&current)?.into_iter().next())
211}
212
213/// "Nothing to merge" message, tailored to call out the trunk - a natural
214/// place to be standing, but never part of a stack - rather than implying the
215/// repo has no stacks at all.
216fn nothing_to_merge_hint() -> Result<String> {
217    let current = crate::git::current_branch()?;
218    let trunk = stack::trunk_branch(&crate::git::local_branches()?);
219    // Only blame the trunk when it actually carries stacks: then standing on it
220    // is the footgun. An empty repo on the trunk just has nothing to merge.
221    let on_trunk_with_stacks =
222        Some(&current) == trunk.as_ref() && !stack::children_of(&current)?.is_empty();
223    Ok(if on_trunk_with_stacks {
224        format!("you are on the trunk ({current}); check out a stacked branch first")
225    } else {
226        "no stacked branches to merge".to_owned()
227    })
228}
229
230/// The branch's review, validated as mergeable: it exists, is open, and
231/// still targets the branch's stack parent.
232fn open_review_for(
233    review_provider: &dyn ReviewProvider,
234    kind: ProviderKind,
235    branch: &str,
236) -> Result<ReviewRequest> {
237    let Some(review) = review_provider.review_for_branch(branch)? else {
238        bail!("no {kind} review found for {branch}; submit the stack first");
239    };
240    if review.state != ReviewState::Open {
241        bail!(
242            "review {} for {branch} is {}, not open",
243            review.id,
244            review.state
245        );
246    }
247
248    let expected_base = stack::parent_of(branch)?;
249    if let Some(expected) = &expected_base
250        && *expected != review.base
251    {
252        bail!(
253            "review {} targets {}, but {branch}'s stack parent is {expected}; \
254             run `git stk submit` first",
255            review.id,
256            review.base
257        );
258    }
259
260    Ok(review)
261}
262
263enum MergeOutcome {
264    Merged,
265    Scheduled,
266}
267
268/// Merge the review and report what actually happened: gh --auto and glab's
269/// default auto-merge schedule the merge instead of performing it, and only
270/// a review that reads merged afterwards should start a sync.
271fn merge_and_check(
272    review_provider: &dyn ReviewProvider,
273    review: &ReviewRequest,
274    strategy: &str,
275    auto: bool,
276) -> Result<MergeOutcome> {
277    let label = review.label();
278
279    let output = match review_provider.merge_review(review, strategy, auto) {
280        Ok(output) => output,
281        Err(error) => return Err(explain_merge_failure(review_provider, review, error)),
282    };
283    if !output.is_empty() {
284        println!("{output}");
285    }
286
287    match review_provider.review_for_branch(&review.branch)? {
288        Some(after) if after.state == ReviewState::Merged => {
289            anstream::println!("{}", style::success(&format!("merged {label}")));
290            Ok(MergeOutcome::Merged)
291        }
292        _ => {
293            anstream::println!(
294                "{}",
295                style::warn(&format!(
296                    "merge scheduled for {label}; rerun `git stk sync` once checks pass"
297                ))
298            );
299            Ok(MergeOutcome::Scheduled)
300        }
301    }
302}
303
304/// Turn a rejected merge into an actionable error. Ask the platform why from
305/// its structured status first; only if that is inconclusive (or the query
306/// itself fails) fall back to matching the CLI's error text, then surface the
307/// raw error.
308fn explain_merge_failure(
309    review_provider: &dyn ReviewProvider,
310    review: &ReviewRequest,
311    error: anyhow::Error,
312) -> anyhow::Error {
313    match review_provider
314        .merge_blocker(review)
315        .unwrap_or(MergeBlocker::None)
316    {
317        MergeBlocker::ChecksPending => checks_not_green_error(review),
318        MergeBlocker::Conflicts => anyhow::anyhow!(
319            "{} conflicts with {} - resolve the conflicts, push, and rerun `git stk merge`",
320            review.id,
321            review.base
322        ),
323        // The platform did not say (or the status query failed): fall back to
324        // the CLI's error wording before surfacing it raw.
325        MergeBlocker::None => {
326            let text = error.to_string().to_lowercase();
327            if text.contains("status check") || text.contains("not mergeable") {
328                checks_not_green_error(review)
329            } else {
330                error
331            }
332        }
333    }
334}
335
336fn checks_not_green_error(review: &ReviewRequest) -> anyhow::Error {
337    anyhow::anyhow!(
338        "{}'s required checks are not green yet - wait and rerun `git stk merge`, \
339         or schedule with `git stk merge --auto`",
340        review.id
341    )
342}