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 BaseGap, MergeBlocker, ProviderKind, ReviewProvider, ReviewRequest, ReviewState, WaitOutcome,
10 detect_review_provider,
11};
12use crate::settings;
13use crate::stack;
14use crate::style;
15
16#[derive(Debug, clap::Args)]
18pub struct Merge {
19 #[arg(long, short = 'n', action = ArgAction::SetTrue)]
21 dry_run: bool,
22 #[arg(long, short = 'y', action = ArgAction::SetTrue)]
24 yes: bool,
25 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "all")]
28 auto: bool,
29 #[arg(long, action = ArgAction::SetTrue)]
31 all: bool,
32 #[arg(long, action = ArgAction::SetTrue, requires = "all", conflicts_with = "no_wait")]
34 wait: bool,
35 #[arg(long, action = ArgAction::SetTrue, requires = "all")]
37 no_wait: bool,
38}
39
40impl Run for Merge {
41 fn run(self) -> Result<()> {
42 if self.all {
43 let wait = if self.wait {
46 true
47 } else if self.no_wait {
48 false
49 } else {
50 settings::bool_setting(settings::MERGE_WAIT_KEY)?
51 };
52 merge_all(self.dry_run, self.yes, wait)
53 } else {
54 merge(self.dry_run, self.yes, self.auto)
55 }
56 }
57}
58
59fn merge(dry_run: bool, yes: bool, auto: bool) -> Result<()> {
60 let Some(bottom) = bottom_branch()? else {
61 bail!(nothing_to_merge_hint()?);
62 };
63
64 let (provider, review_provider) = detect_review_provider()?;
65 let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;
66
67 let strategy = settings::merge_strategy()?;
68 let mode = if auto {
69 format!("{strategy}, auto")
70 } else {
71 strategy.clone()
72 };
73 let label = review.label();
74
75 if dry_run {
76 let mut mode = mode;
81 if auto
82 && review_provider
83 .native_stack_for(&review.branch)
84 .is_ok_and(|found| found.is_some())
85 {
86 anstream::println!(
87 "{}",
88 style::warn(&format!(
89 "{} is in a stack the platform owns, so --auto would be refused; \
90 it merges when you run `git stk merge` with checks green",
91 review.id
92 ))
93 );
94 mode = mode.replace(", auto", "");
95 }
96 anstream::println!("would merge {label} into {} ({mode})", review.base);
97 anstream::println!("would sync afterwards");
98 return Ok(());
99 }
100
101 if !yes
102 && !confirm(&format!(
103 "merge {label} into {} ({mode})? [y/N] ",
104 review.base
105 ))?
106 {
107 anstream::println!("merge cancelled");
108 return Ok(());
109 }
110
111 stack::snapshot("merge");
112 match merge_and_check(review_provider.as_ref(), &review, &strategy, auto)? {
113 MergeOutcome::Merged => sync(false, PushMode::Config),
116 MergeOutcome::Scheduled => Ok(()),
117 }
118}
119
120fn merge_all(dry_run: bool, yes: bool, wait: bool) -> Result<()> {
125 let Some(bottom) = bottom_branch()? else {
126 bail!(nothing_to_merge_hint()?);
127 };
128
129 let (provider, review_provider) = detect_review_provider()?;
130 let strategy = settings::merge_strategy()?;
131
132 let current = crate::git::current_branch()?;
135 let line = stack::stack_line(¤t)?;
136 let branches = stack::stacked_layers(&line)?;
137 let count = branches.len();
138
139 let pinned_base = stack::unanchored_base(&line)?;
146
147 if dry_run {
148 for branch in &branches {
149 let review = open_review_for(review_provider.as_ref(), provider.kind, branch)?;
150 if wait {
151 anstream::println!("would wait for checks on {}", review.id);
152 }
153 anstream::println!(
154 "would merge {} into {} ({strategy})",
155 review.label(),
156 review.base
157 );
158 }
159 anstream::println!("would sync after each merge");
160 return Ok(());
161 }
162
163 let base = stack::parent_of(&bottom)?.unwrap_or_else(|| "its base".to_owned());
164 if !yes
165 && !confirm(&format!(
166 "merge {count} review{} into {base}, bottom-up ({strategy})? [y/N] ",
167 if count == 1 { "" } else { "s" }
168 ))?
169 {
170 anstream::println!("merge cancelled");
171 return Ok(());
172 }
173
174 stack::snapshot("merge --all");
175
176 let mut landed = 0;
179 for _ in 0..count {
180 let Some(bottom) = bottom_branch_excluding(pinned_base.as_deref())? else {
181 break;
182 };
183 let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;
184
185 if wait {
188 anstream::println!(
189 "waiting for checks on {} {}",
190 review.id,
191 style::dim("(ctrl-c is safe; rerun `git stk merge --all` to resume)")
192 );
193 match review_provider.wait_for_checks(&review)? {
194 WaitOutcome::Passed => {}
195 WaitOutcome::Failed => bail!(
196 "checks failed for {}; fix them and rerun `git stk merge --all`",
197 review.id
198 ),
199 WaitOutcome::Inconclusive => bail!(
200 "checks for {} stopped without a verdict - a cancelled run, or one \
201 waiting on a person; resolve it and rerun `git stk merge --all`",
202 review.id
203 ),
204 WaitOutcome::Landed => {
207 anstream::println!(
208 "{}",
209 style::warn(&format!(
210 "{} was merged outside git-stk; syncing instead",
211 review.id
212 ))
213 );
214 sync(false, PushMode::Config)?;
215 landed += 1;
216 continue;
217 }
218 }
219 }
220
221 match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
222 MergeOutcome::Merged => {
223 sync(false, PushMode::Config)?;
224 landed += 1;
225 }
226 MergeOutcome::Scheduled => break,
227 }
228 }
229
230 anstream::println!(
231 "{}",
232 style::success(&format!(
233 "merge complete: {landed} of {count} review{} merged",
234 if count == 1 { "" } else { "s" }
235 ))
236 );
237 Ok(())
238}
239
240fn bottom_branch() -> Result<Option<String>> {
246 bottom_branch_excluding(None)
247}
248
249fn bottom_branch_excluding(exclude: Option<&str>) -> Result<Option<String>> {
253 let current = crate::git::current_branch()?;
254 let line = stack::stack_line(¤t)?;
255 Ok(stack::stacked_layers(&line)?
256 .into_iter()
257 .find(|branch| Some(branch.as_str()) != exclude))
258}
259
260fn nothing_to_merge_hint() -> Result<String> {
264 let current = crate::git::current_branch()?;
265 let trunk = stack::trunk_branch(&crate::git::local_branches()?);
266 let on_trunk_with_stacks = Some(¤t) == trunk.as_ref() && stack::has_stacked_branches()?;
271 if on_trunk_with_stacks {
272 return Ok(format!(
273 "you are on the trunk ({current}); check out a stacked branch first"
274 ));
275 }
276 if stack::is_floor(¤t)? {
283 return Ok(format!(
284 "{current} is a stack's base, and nothing is stacked on it - \
285 there is nothing to merge"
286 ));
287 }
288 if Some(¤t) != trunk.as_ref() && stack::parent_of(¤t)?.is_none() {
289 return Ok(format!(
290 "{current} has no stack parent, so there is no base to merge it into; \
291 attach it with `git stk adopt --parent <parent>`, or rebuild its metadata \
292 with `git stk repair`"
293 ));
294 }
295 Ok("no stacked branches to merge".to_owned())
296}
297
298fn open_review_for(
301 review_provider: &dyn ReviewProvider,
302 kind: ProviderKind,
303 branch: &str,
304) -> Result<ReviewRequest> {
305 let Some(review) = review_provider.review_for_branch(branch)? else {
306 bail!("no {kind} review found for {branch}; submit the stack first");
307 };
308 if review.state != ReviewState::Open {
309 bail!(
310 "review {} for {branch} is {}, not open",
311 review.id,
312 review.state
313 );
314 }
315
316 let expected_base = stack::parent_of(branch)?;
332 if let Some(expected) = &expected_base
333 && *expected != review.base
334 {
335 match review_provider.base_gap(&review, expected).unwrap_or(None) {
336 Some(BaseGap::Platform) => {}
340 Some(BaseGap::Sync) => bail!(
341 "review {} already targets {} - the platform moved it when {expected} \
342 landed, and {branch}'s stack parent has not caught up; run \
343 `git stk sync` first",
344 review.id,
345 review.base
346 ),
347 Some(BaseGap::Neither) => bail!(
348 "review {} targets {}, but {branch}'s stack parent is {expected} - \
349 its stack will not move it there, and the platform refuses a \
350 change by hand; run `git stk unstack`, then \
351 `git stk submit`",
352 review.id,
353 review.base
354 ),
355 None => bail!(
356 "review {} targets {}, but {branch}'s stack parent is {expected}; \
357 run `git stk submit` first",
358 review.id,
359 review.base
360 ),
361 }
362 }
363
364 Ok(review)
365}
366
367enum MergeOutcome {
368 Merged,
369 Scheduled,
370}
371
372fn merge_and_check(
376 review_provider: &dyn ReviewProvider,
377 review: &ReviewRequest,
378 strategy: &str,
379 auto: bool,
380) -> Result<MergeOutcome> {
381 let label = review.label();
382
383 let output = match review_provider.merge_review(review, strategy, auto) {
384 Ok(output) => output,
385 Err(error) => return Err(explain_merge_failure(review_provider, review, error)),
386 };
387 if !output.is_empty() {
388 println!("{output}");
389 }
390
391 match review_provider.review_for_branch(&review.branch)? {
392 Some(after) if after.state == ReviewState::Merged => {
393 anstream::println!("{}", style::success(&format!("merged {label}")));
394 Ok(MergeOutcome::Merged)
395 }
396 _ => {
397 anstream::println!(
398 "{}",
399 style::warn(&format!(
400 "merge scheduled for {label}; rerun `git stk sync` once checks pass"
401 ))
402 );
403 Ok(MergeOutcome::Scheduled)
404 }
405 }
406}
407
408fn explain_merge_failure(
413 review_provider: &dyn ReviewProvider,
414 review: &ReviewRequest,
415 error: anyhow::Error,
416) -> anyhow::Error {
417 if error
421 .downcast_ref::<crate::providers::MergeRefused>()
422 .is_some()
423 {
424 return error;
425 }
426 let can_schedule = !review_provider
429 .native_stack_for(&review.branch)
430 .is_ok_and(|found| found.is_some());
431 match review_provider
432 .merge_blocker(review)
433 .unwrap_or(MergeBlocker::None)
434 {
435 MergeBlocker::ChecksPending => checks_not_green_error(review, can_schedule),
436 MergeBlocker::Conflicts => anyhow::anyhow!(
437 "{} conflicts with {} - resolve the conflicts, push, and rerun `git stk merge`",
438 review.id,
439 review.base
440 ),
441 MergeBlocker::None => {
444 let text = error.to_string().to_lowercase();
445 if text.contains("status check") || text.contains("not mergeable") {
446 checks_not_green_error(review, can_schedule)
447 } else {
448 error
449 }
450 }
451 }
452}
453
454fn checks_not_green_error(review: &ReviewRequest, can_schedule: bool) -> anyhow::Error {
455 if can_schedule {
458 anyhow::anyhow!(
459 "{}'s required checks are not green yet - wait and rerun `git stk merge`, \
460 or schedule with `git stk merge --auto`",
461 review.id
462 )
463 } else {
464 anyhow::anyhow!(
465 "{}'s required checks are not green yet - wait and rerun `git stk merge`; \
466 `--auto` is not available for a review in a stack",
467 review.id
468 )
469 }
470}