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::{ProviderKind, ReviewProvider, ReviewRequest, ReviewState};
9use crate::providers::{detect_provider, review_provider};
10use crate::settings;
11use crate::stack;
12use crate::style;
13
14/// Merge the review at the bottom of the stack, then sync.
15#[derive(Debug, clap::Args)]
16pub struct Merge {
17    /// Print what would happen without merging anything.
18    #[arg(long, action = ArgAction::SetTrue)]
19    dry_run: bool,
20    /// Skip the confirmation prompt.
21    #[arg(long, short = 'y', action = ArgAction::SetTrue)]
22    yes: bool,
23    /// Schedule the merge for when required checks pass instead of merging
24    /// now.
25    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "all")]
26    auto: bool,
27    /// Repeat merge-and-sync bottom-up until the whole stack has landed.
28    #[arg(long, action = ArgAction::SetTrue)]
29    all: bool,
30    /// With --all: wait for each review's checks before merging it.
31    #[arg(long, action = ArgAction::SetTrue, requires = "all", conflicts_with = "no_wait")]
32    wait: bool,
33    /// With --all: do not wait for checks, overriding stk.mergeWait.
34    #[arg(long, action = ArgAction::SetTrue, requires = "all")]
35    no_wait: bool,
36}
37
38impl Run for Merge {
39    fn run(self) -> Result<()> {
40        if self.all {
41            // Waiting: --wait forces it on, --no-wait off; otherwise
42            // stk.mergeWait decides.
43            let wait = if self.wait {
44                true
45            } else if self.no_wait {
46                false
47            } else {
48                settings::bool_setting(settings::MERGE_WAIT_KEY)?
49            };
50            merge_all(self.dry_run, self.yes, wait)
51        } else {
52            merge(self.dry_run, self.yes, self.auto)
53        }
54    }
55}
56
57fn merge(dry_run: bool, yes: bool, auto: bool) -> Result<()> {
58    let Some(bottom) = bottom_branch()? else {
59        bail!(nothing_to_merge_hint()?);
60    };
61
62    let provider = detect_provider()?;
63    let review_provider = review_provider(provider.kind);
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        println!("would merge {label} into {} ({mode})", review.base);
76        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        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 = detect_provider()?;
109    let review_provider = review_provider(provider.kind);
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                println!("would wait for checks on {}", review.id);
123            }
124            println!(
125                "would merge {} into {} ({strategy})",
126                review.label(),
127                review.base
128            );
129        }
130        println!("would sync after each merge");
131        return Ok(());
132    }
133
134    let base = stack::parent_for_branch(&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        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            if !review_provider.wait_for_checks(&review)? {
165                bail!(
166                    "checks failed for {}; fix them and rerun `git stk merge --all`",
167                    review.id
168                );
169            }
170        }
171
172        match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
173            MergeOutcome::Merged => {
174                sync(false, PushMode::Config)?;
175                landed += 1;
176            }
177            MergeOutcome::Scheduled => break,
178        }
179    }
180
181    anstream::println!(
182        "{}",
183        style::success(&format!(
184            "merge complete: {landed} of {count} review{} merged",
185            if count == 1 { "" } else { "s" }
186        ))
187    );
188    Ok(())
189}
190
191/// The bottom of the stack containing the current branch: the first branch on
192/// its line above the trunk. (A rootless fragment's own root is its bottom.)
193fn bottom_branch() -> Result<Option<String>> {
194    let current = crate::git::current_branch()?;
195    Ok(stack::stack_line(&current)?.into_iter().next())
196}
197
198/// "Nothing to merge" message, tailored to call out the trunk - a natural
199/// place to be standing, but never part of a stack - rather than implying the
200/// repo has no stacks at all.
201fn nothing_to_merge_hint() -> Result<String> {
202    let current = crate::git::current_branch()?;
203    let trunk = stack::trunk_branch(&crate::git::local_branches()?);
204    // Only blame the trunk when it actually carries stacks: then standing on it
205    // is the footgun. An empty repo on the trunk just has nothing to merge.
206    let on_trunk_with_stacks =
207        Some(&current) == trunk.as_ref() && !stack::children_for_branch(&current)?.is_empty();
208    Ok(if on_trunk_with_stacks {
209        format!("you are on the trunk ({current}); check out a stacked branch first")
210    } else {
211        "no stacked branches to merge".to_owned()
212    })
213}
214
215/// The branch's review, validated as mergeable: it exists, is open, and
216/// still targets the branch's stack parent.
217fn open_review_for(
218    review_provider: &dyn ReviewProvider,
219    kind: ProviderKind,
220    branch: &str,
221) -> Result<ReviewRequest> {
222    let Some(review) = review_provider.review_for_branch(branch)? else {
223        bail!("no {kind} review found for {branch}; submit the stack first");
224    };
225    if review.state != ReviewState::Open {
226        bail!(
227            "review {} for {branch} is {}, not open",
228            review.id,
229            review.state
230        );
231    }
232
233    let expected_base = stack::parent_for_branch(branch)?;
234    if let Some(expected) = &expected_base
235        && *expected != review.base
236    {
237        bail!(
238            "review {} targets {}, but {branch}'s stack parent is {expected}; \
239             run `git stk submit` first",
240            review.id,
241            review.base
242        );
243    }
244
245    Ok(review)
246}
247
248enum MergeOutcome {
249    Merged,
250    Scheduled,
251}
252
253/// Merge the review and report what actually happened: gh --auto and glab's
254/// default auto-merge schedule the merge instead of performing it, and only
255/// a review that reads merged afterwards should start a sync.
256fn merge_and_check(
257    review_provider: &dyn ReviewProvider,
258    review: &ReviewRequest,
259    strategy: &str,
260    auto: bool,
261) -> Result<MergeOutcome> {
262    let label = review.label();
263
264    let output = match review_provider.merge_review(review, strategy, auto) {
265        Ok(output) => output,
266        Err(error) => {
267            // gh refuses outright when required checks are not green. Surface
268            // a clean, actionable message instead of the raw gh error.
269            let text = error.to_string().to_lowercase();
270            if text.contains("status check") || text.contains("not mergeable") {
271                bail!(
272                    "{}'s required checks are not green yet - wait and rerun \
273                     `git stk merge`, or schedule with `git stk merge --auto`",
274                    review.id
275                );
276            }
277            return Err(error);
278        }
279    };
280    if !output.is_empty() {
281        println!("{output}");
282    }
283
284    match review_provider.review_for_branch(&review.branch)? {
285        Some(after) if after.state == ReviewState::Merged => {
286            anstream::println!("{}", style::success(&format!("merged {label}")));
287            Ok(MergeOutcome::Merged)
288        }
289        _ => {
290            anstream::println!(
291                "{}",
292                style::warn(&format!(
293                    "merge scheduled for {label}; rerun `git stk sync` once checks pass"
294                ))
295            );
296            Ok(MergeOutcome::Scheduled)
297        }
298    }
299}