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::Landed => {
202 anstream::println!(
203 "{}",
204 style::warn(&format!(
205 "{} was merged outside git-stk; syncing instead",
206 review.id
207 ))
208 );
209 sync(false, PushMode::Config)?;
210 landed += 1;
211 continue;
212 }
213 }
214 }
215
216 match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
217 MergeOutcome::Merged => {
218 sync(false, PushMode::Config)?;
219 landed += 1;
220 }
221 MergeOutcome::Scheduled => break,
222 }
223 }
224
225 anstream::println!(
226 "{}",
227 style::success(&format!(
228 "merge complete: {landed} of {count} review{} merged",
229 if count == 1 { "" } else { "s" }
230 ))
231 );
232 Ok(())
233}
234
235fn bottom_branch() -> Result<Option<String>> {
241 bottom_branch_excluding(None)
242}
243
244fn bottom_branch_excluding(exclude: Option<&str>) -> Result<Option<String>> {
248 let current = crate::git::current_branch()?;
249 let line = stack::stack_line(¤t)?;
250 Ok(stack::stacked_layers(&line)?
251 .into_iter()
252 .find(|branch| Some(branch.as_str()) != exclude))
253}
254
255fn nothing_to_merge_hint() -> Result<String> {
259 let current = crate::git::current_branch()?;
260 let trunk = stack::trunk_branch(&crate::git::local_branches()?);
261 let on_trunk_with_stacks = Some(¤t) == trunk.as_ref() && stack::has_stacked_branches()?;
266 if on_trunk_with_stacks {
267 return Ok(format!(
268 "you are on the trunk ({current}); check out a stacked branch first"
269 ));
270 }
271 if stack::is_floor(¤t)? {
278 return Ok(format!(
279 "{current} is a stack's base, and nothing is stacked on it - \
280 there is nothing to merge"
281 ));
282 }
283 if Some(¤t) != trunk.as_ref() && stack::parent_of(¤t)?.is_none() {
284 return Ok(format!(
285 "{current} has no stack parent, so there is no base to merge it into; \
286 attach it with `git stk adopt --parent <parent>`, or rebuild its metadata \
287 with `git stk repair`"
288 ));
289 }
290 Ok("no stacked branches to merge".to_owned())
291}
292
293fn open_review_for(
296 review_provider: &dyn ReviewProvider,
297 kind: ProviderKind,
298 branch: &str,
299) -> Result<ReviewRequest> {
300 let Some(review) = review_provider.review_for_branch(branch)? else {
301 bail!("no {kind} review found for {branch}; submit the stack first");
302 };
303 if review.state != ReviewState::Open {
304 bail!(
305 "review {} for {branch} is {}, not open",
306 review.id,
307 review.state
308 );
309 }
310
311 let expected_base = stack::parent_of(branch)?;
327 if let Some(expected) = &expected_base
328 && *expected != review.base
329 {
330 match review_provider.base_gap(&review, expected).unwrap_or(None) {
331 Some(BaseGap::Platform) => {}
335 Some(BaseGap::Sync) => bail!(
336 "review {} already targets {} - the platform moved it when {expected} \
337 landed, and {branch}'s stack parent has not caught up; run \
338 `git stk sync` first",
339 review.id,
340 review.base
341 ),
342 Some(BaseGap::Neither) => bail!(
343 "review {} targets {}, but {branch}'s stack parent is {expected} - \
344 its stack will not move it there, and the platform refuses a \
345 change by hand; run `git stk unstack`, then \
346 `git stk submit`",
347 review.id,
348 review.base
349 ),
350 None => bail!(
351 "review {} targets {}, but {branch}'s stack parent is {expected}; \
352 run `git stk submit` first",
353 review.id,
354 review.base
355 ),
356 }
357 }
358
359 Ok(review)
360}
361
362enum MergeOutcome {
363 Merged,
364 Scheduled,
365}
366
367fn merge_and_check(
371 review_provider: &dyn ReviewProvider,
372 review: &ReviewRequest,
373 strategy: &str,
374 auto: bool,
375) -> Result<MergeOutcome> {
376 let label = review.label();
377
378 let output = match review_provider.merge_review(review, strategy, auto) {
379 Ok(output) => output,
380 Err(error) => return Err(explain_merge_failure(review_provider, review, error)),
381 };
382 if !output.is_empty() {
383 println!("{output}");
384 }
385
386 match review_provider.review_for_branch(&review.branch)? {
387 Some(after) if after.state == ReviewState::Merged => {
388 anstream::println!("{}", style::success(&format!("merged {label}")));
389 Ok(MergeOutcome::Merged)
390 }
391 _ => {
392 anstream::println!(
393 "{}",
394 style::warn(&format!(
395 "merge scheduled for {label}; rerun `git stk sync` once checks pass"
396 ))
397 );
398 Ok(MergeOutcome::Scheduled)
399 }
400 }
401}
402
403fn explain_merge_failure(
408 review_provider: &dyn ReviewProvider,
409 review: &ReviewRequest,
410 error: anyhow::Error,
411) -> anyhow::Error {
412 if error
416 .downcast_ref::<crate::providers::MergeRefused>()
417 .is_some()
418 {
419 return error;
420 }
421 let can_schedule = !review_provider
424 .native_stack_for(&review.branch)
425 .is_ok_and(|found| found.is_some());
426 match review_provider
427 .merge_blocker(review)
428 .unwrap_or(MergeBlocker::None)
429 {
430 MergeBlocker::ChecksPending => checks_not_green_error(review, can_schedule),
431 MergeBlocker::Conflicts => anyhow::anyhow!(
432 "{} conflicts with {} - resolve the conflicts, push, and rerun `git stk merge`",
433 review.id,
434 review.base
435 ),
436 MergeBlocker::None => {
439 let text = error.to_string().to_lowercase();
440 if text.contains("status check") || text.contains("not mergeable") {
441 checks_not_green_error(review, can_schedule)
442 } else {
443 error
444 }
445 }
446 }
447}
448
449fn checks_not_green_error(review: &ReviewRequest, can_schedule: bool) -> anyhow::Error {
450 if can_schedule {
453 anyhow::anyhow!(
454 "{}'s required checks are not green yet - wait and rerun `git stk merge`, \
455 or schedule with `git stk merge --auto`",
456 review.id
457 )
458 } else {
459 anyhow::anyhow!(
460 "{}'s required checks are not green yet - wait and rerun `git stk merge`; \
461 `--auto` is not available for a review in a stack",
462 review.id
463 )
464 }
465}