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#[derive(Debug, clap::Args)]
17pub struct Merge {
18 #[arg(long, short = 'n', action = ArgAction::SetTrue)]
20 dry_run: bool,
21 #[arg(long, short = 'y', action = ArgAction::SetTrue)]
23 yes: bool,
24 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "all")]
27 auto: bool,
28 #[arg(long, action = ArgAction::SetTrue)]
30 all: bool,
31 #[arg(long, action = ArgAction::SetTrue, requires = "all", conflicts_with = "no_wait")]
33 wait: bool,
34 #[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 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 MergeOutcome::Merged => sync(false, PushMode::Config),
95 MergeOutcome::Scheduled => Ok(()),
96 }
97}
98
99fn 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 let current = crate::git::current_branch()?;
114 let branches = stack::stack_line(¤t)?;
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 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 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
190fn bottom_branch() -> Result<Option<String>> {
193 let current = crate::git::current_branch()?;
194 Ok(stack::stack_line(¤t)?.into_iter().next())
195}
196
197fn nothing_to_merge_hint() -> Result<String> {
201 let current = crate::git::current_branch()?;
202 let trunk = stack::trunk_branch(&crate::git::local_branches()?);
203 let on_trunk_with_stacks =
206 Some(¤t) == trunk.as_ref() && !stack::children_of(¤t)?.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
214fn 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
252fn 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
288fn 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 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}