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