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}
31
32impl Run for Merge {
33    fn run(self) -> Result<()> {
34        if self.all {
35            merge_all(self.dry_run, self.yes)
36        } else {
37            merge(self.dry_run, self.yes, self.auto)
38        }
39    }
40}
41
42fn merge(dry_run: bool, yes: bool, auto: bool) -> Result<()> {
43    let Some(bottom) = bottom_branch()? else {
44        bail!("no stacked branches to merge");
45    };
46
47    let provider = detect_provider()?;
48    let review_provider = review_provider(provider.kind);
49    let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;
50
51    let strategy = settings::merge_strategy()?;
52    let mode = if auto {
53        format!("{strategy}, auto")
54    } else {
55        strategy.clone()
56    };
57    let label = review.label();
58
59    if dry_run {
60        println!("would merge {label} into {} ({mode})", review.base);
61        println!("would sync afterwards");
62        return Ok(());
63    }
64
65    if !yes
66        && !confirm(&format!(
67            "merge {label} into {} ({mode})? [y/N] ",
68            review.base
69        ))?
70    {
71        println!("merge cancelled");
72        return Ok(());
73    }
74
75    match merge_and_check(review_provider.as_ref(), &review, &strategy, auto)? {
76        // Reconcile everything the merge changed: fetch, clean up, restack,
77        // push.
78        MergeOutcome::Merged => sync(false, PushMode::Config),
79        MergeOutcome::Scheduled => Ok(()),
80    }
81}
82
83/// Land the whole stack: merge the bottom review and sync, bottom-up, until
84/// the stack is complete. One confirmation up front; a merge that only gets
85/// scheduled stops the loop.
86fn merge_all(dry_run: bool, yes: bool) -> Result<()> {
87    let Some(bottom) = bottom_branch()? else {
88        bail!("no stacked branches to merge");
89    };
90
91    let provider = detect_provider()?;
92    let review_provider = review_provider(provider.kind);
93    let strategy = settings::merge_strategy()?;
94
95    // What is about to land, bottom-up, for the dry run and the prompt.
96    let current = crate::git::current_branch()?;
97    let root = stack::stack_root(&current)?;
98    let trunk = stack::trunk_branch(&crate::git::local_branches()?);
99    let branches: Vec<String> = stack::branch_and_descendants(&root)?
100        .into_iter()
101        .filter(|branch| Some(branch) != trunk.as_ref())
102        .collect();
103    let count = branches.len();
104
105    if dry_run {
106        for branch in &branches {
107            let review = open_review_for(review_provider.as_ref(), provider.kind, branch)?;
108            println!(
109                "would merge {} into {} ({strategy})",
110                review.label(),
111                review.base
112            );
113        }
114        println!("would sync after each merge");
115        return Ok(());
116    }
117
118    let base = stack::parent_for_branch(&bottom)?.unwrap_or_else(|| "its base".to_owned());
119    if !yes
120        && !confirm(&format!(
121            "merge {count} review{} into {base}, bottom-up ({strategy})? [y/N] ",
122            if count == 1 { "" } else { "s" }
123        ))?
124    {
125        println!("merge cancelled");
126        return Ok(());
127    }
128
129    // Each sync removes the merged bottom, so the loop is bounded by the
130    // number of branches it started with.
131    let mut landed = 0;
132    for _ in 0..count {
133        let Some(bottom) = bottom_branch()? else {
134            break;
135        };
136        let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;
137        match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
138            MergeOutcome::Merged => {
139                sync(false, PushMode::Config)?;
140                landed += 1;
141            }
142            MergeOutcome::Scheduled => break,
143        }
144    }
145
146    anstream::println!(
147        "{}",
148        style::success(&format!(
149            "merge complete: {landed} of {count} review{} merged",
150            if count == 1 { "" } else { "s" }
151        ))
152    );
153    Ok(())
154}
155
156/// The bottom of the stack containing the current branch: the first branch
157/// that is not the trunk. (A rootless fragment's own root is its bottom.)
158fn bottom_branch() -> Result<Option<String>> {
159    let current = crate::git::current_branch()?;
160    let root = stack::stack_root(&current)?;
161    let trunk = stack::trunk_branch(&crate::git::local_branches()?);
162
163    Ok(stack::branch_and_descendants(&root)?
164        .into_iter()
165        .find(|branch| Some(branch) != trunk.as_ref()))
166}
167
168/// The branch's review, validated as mergeable: it exists, is open, and
169/// still targets the branch's stack parent.
170fn open_review_for(
171    review_provider: &dyn ReviewProvider,
172    kind: ProviderKind,
173    branch: &str,
174) -> Result<ReviewRequest> {
175    let Some(review) = review_provider.review_for_branch(branch)? else {
176        bail!("no {kind} review found for {branch}; submit the stack first");
177    };
178    if review.state != ReviewState::Open {
179        bail!(
180            "review {} for {branch} is {}, not open",
181            review.id,
182            review.state
183        );
184    }
185
186    let expected_base = stack::parent_for_branch(branch)?;
187    if let Some(expected) = &expected_base
188        && *expected != review.base
189    {
190        bail!(
191            "review {} targets {}, but {branch}'s stack parent is {expected}; \
192             run `git stk submit` first",
193            review.id,
194            review.base
195        );
196    }
197
198    Ok(review)
199}
200
201enum MergeOutcome {
202    Merged,
203    Scheduled,
204}
205
206/// Merge the review and report what actually happened: gh --auto and glab's
207/// default auto-merge schedule the merge instead of performing it, and only
208/// a review that reads merged afterwards should start a sync.
209fn merge_and_check(
210    review_provider: &dyn ReviewProvider,
211    review: &ReviewRequest,
212    strategy: &str,
213    auto: bool,
214) -> Result<MergeOutcome> {
215    let label = review.label();
216
217    let output = match review_provider.merge_review(review, strategy, auto) {
218        Ok(output) => output,
219        Err(error) => {
220            // gh refuses outright when required checks are not green.
221            let text = error.to_string().to_lowercase();
222            if text.contains("status check") || text.contains("not mergeable") {
223                anstream::eprintln!(
224                    "{} required checks may not be green yet - rerun `git stk merge` \
225                     when they pass, or schedule with `git stk merge --auto`",
226                    style::hint_prefix()
227                );
228            }
229            return Err(error);
230        }
231    };
232    if !output.is_empty() {
233        println!("{output}");
234    }
235
236    match review_provider.review_for_branch(&review.branch)? {
237        Some(after) if after.state == ReviewState::Merged => {
238            anstream::println!("{}", style::success(&format!("merged {label}")));
239            Ok(MergeOutcome::Merged)
240        }
241        _ => {
242            anstream::println!(
243                "{}",
244                style::warn(&format!(
245                    "merge scheduled for {label}; rerun `git stk sync` once checks pass"
246                ))
247            );
248            Ok(MergeOutcome::Scheduled)
249        }
250    }
251}