git_stk/commands/
merge.rs1use 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#[derive(Debug, clap::Args)]
15pub struct Merge {
16 #[arg(long, action = ArgAction::SetTrue)]
18 dry_run: bool,
19 #[arg(long, short = 'y', action = ArgAction::SetTrue)]
21 yes: bool,
22 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "all")]
25 auto: bool,
26 #[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();
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 MergeOutcome::Merged => sync(false, PushMode::Config),
78 MergeOutcome::Scheduled => Ok(()),
79 }
80}
81
82fn 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 let current = crate::git::current_branch()?;
96 let root = stack::stack_root(¤t)?;
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(),
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 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
152fn bottom_branch() -> Result<Option<String>> {
155 let current = crate::git::current_branch()?;
156 let root = stack::stack_root(¤t)?;
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
164fn 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
197enum MergeOutcome {
198 Merged,
199 Scheduled,
200}
201
202fn merge_and_check(
206 review_provider: &dyn ReviewProvider,
207 review: &ReviewRequest,
208 strategy: &str,
209 auto: bool,
210) -> Result<MergeOutcome> {
211 let label = review.label();
212
213 let output = match review_provider.merge_review(review, strategy, auto) {
214 Ok(output) => output,
215 Err(error) => {
216 let text = error.to_string().to_lowercase();
218 if text.contains("status check") || text.contains("not mergeable") {
219 eprintln!(
220 "hint: required checks may not be green yet - rerun `git stk merge` \
221 when they pass, or schedule with `git stk merge --auto`"
222 );
223 }
224 return Err(error);
225 }
226 };
227 if !output.is_empty() {
228 println!("{output}");
229 }
230
231 match review_provider.review_for_branch(&review.branch)? {
232 Some(after) if after.state == ReviewState::Merged => {
233 println!("merged {label}");
234 Ok(MergeOutcome::Merged)
235 }
236 _ => {
237 println!("merge scheduled for {label}; rerun `git stk sync` once checks pass");
238 Ok(MergeOutcome::Scheduled)
239 }
240 }
241}