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