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, 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 anstream::println!("would merge {label} into {} ({mode})", review.base);
77 anstream::println!("would sync afterwards");
78 return Ok(());
79 }
80
81 if !yes
82 && !confirm(&format!(
83 "merge {label} into {} ({mode})? [y/N] ",
84 review.base
85 ))?
86 {
87 anstream::println!("merge cancelled");
88 return Ok(());
89 }
90
91 stack::snapshot("merge");
92 match merge_and_check(review_provider.as_ref(), &review, &strategy, auto)? {
93 MergeOutcome::Merged => sync(false, PushMode::Config),
96 MergeOutcome::Scheduled => Ok(()),
97 }
98}
99
100fn merge_all(dry_run: bool, yes: bool, wait: bool) -> Result<()> {
105 let Some(bottom) = bottom_branch()? else {
106 bail!(nothing_to_merge_hint()?);
107 };
108
109 let (provider, review_provider) = detect_review_provider()?;
110 let strategy = settings::merge_strategy()?;
111
112 let current = crate::git::current_branch()?;
115 let branches = stack::stack_line(¤t)?;
116 let count = branches.len();
117
118 if dry_run {
119 for branch in &branches {
120 let review = open_review_for(review_provider.as_ref(), provider.kind, branch)?;
121 if wait {
122 anstream::println!("would wait for checks on {}", review.id);
123 }
124 anstream::println!(
125 "would merge {} into {} ({strategy})",
126 review.label(),
127 review.base
128 );
129 }
130 anstream::println!("would sync after each merge");
131 return Ok(());
132 }
133
134 let base = stack::parent_of(&bottom)?.unwrap_or_else(|| "its base".to_owned());
135 if !yes
136 && !confirm(&format!(
137 "merge {count} review{} into {base}, bottom-up ({strategy})? [y/N] ",
138 if count == 1 { "" } else { "s" }
139 ))?
140 {
141 anstream::println!("merge cancelled");
142 return Ok(());
143 }
144
145 stack::snapshot("merge --all");
146
147 let mut landed = 0;
150 for _ in 0..count {
151 let Some(bottom) = bottom_branch()? else {
152 break;
153 };
154 let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;
155
156 if wait {
159 anstream::println!(
160 "waiting for checks on {} {}",
161 review.id,
162 style::dim("(ctrl-c is safe; rerun `git stk merge --all` to resume)")
163 );
164 match review_provider.wait_for_checks(&review)? {
165 WaitOutcome::Passed => {}
166 WaitOutcome::Failed => bail!(
167 "checks failed for {}; fix them and rerun `git stk merge --all`",
168 review.id
169 ),
170 WaitOutcome::Landed => {
173 anstream::println!(
174 "{}",
175 style::warn(&format!(
176 "{} was merged outside git-stk; syncing instead",
177 review.id
178 ))
179 );
180 sync(false, PushMode::Config)?;
181 landed += 1;
182 continue;
183 }
184 }
185 }
186
187 match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
188 MergeOutcome::Merged => {
189 sync(false, PushMode::Config)?;
190 landed += 1;
191 }
192 MergeOutcome::Scheduled => break,
193 }
194 }
195
196 anstream::println!(
197 "{}",
198 style::success(&format!(
199 "merge complete: {landed} of {count} review{} merged",
200 if count == 1 { "" } else { "s" }
201 ))
202 );
203 Ok(())
204}
205
206fn bottom_branch() -> Result<Option<String>> {
209 let current = crate::git::current_branch()?;
210 Ok(stack::stack_line(¤t)?.into_iter().next())
211}
212
213fn nothing_to_merge_hint() -> Result<String> {
217 let current = crate::git::current_branch()?;
218 let trunk = stack::trunk_branch(&crate::git::local_branches()?);
219 let on_trunk_with_stacks =
222 Some(¤t) == trunk.as_ref() && !stack::children_of(¤t)?.is_empty();
223 Ok(if on_trunk_with_stacks {
224 format!("you are on the trunk ({current}); check out a stacked branch first")
225 } else {
226 "no stacked branches to merge".to_owned()
227 })
228}
229
230fn open_review_for(
233 review_provider: &dyn ReviewProvider,
234 kind: ProviderKind,
235 branch: &str,
236) -> Result<ReviewRequest> {
237 let Some(review) = review_provider.review_for_branch(branch)? else {
238 bail!("no {kind} review found for {branch}; submit the stack first");
239 };
240 if review.state != ReviewState::Open {
241 bail!(
242 "review {} for {branch} is {}, not open",
243 review.id,
244 review.state
245 );
246 }
247
248 let expected_base = stack::parent_of(branch)?;
249 if let Some(expected) = &expected_base
250 && *expected != review.base
251 {
252 bail!(
253 "review {} targets {}, but {branch}'s stack parent is {expected}; \
254 run `git stk submit` first",
255 review.id,
256 review.base
257 );
258 }
259
260 Ok(review)
261}
262
263enum MergeOutcome {
264 Merged,
265 Scheduled,
266}
267
268fn merge_and_check(
272 review_provider: &dyn ReviewProvider,
273 review: &ReviewRequest,
274 strategy: &str,
275 auto: bool,
276) -> Result<MergeOutcome> {
277 let label = review.label();
278
279 let output = match review_provider.merge_review(review, strategy, auto) {
280 Ok(output) => output,
281 Err(error) => return Err(explain_merge_failure(review_provider, review, error)),
282 };
283 if !output.is_empty() {
284 println!("{output}");
285 }
286
287 match review_provider.review_for_branch(&review.branch)? {
288 Some(after) if after.state == ReviewState::Merged => {
289 anstream::println!("{}", style::success(&format!("merged {label}")));
290 Ok(MergeOutcome::Merged)
291 }
292 _ => {
293 anstream::println!(
294 "{}",
295 style::warn(&format!(
296 "merge scheduled for {label}; rerun `git stk sync` once checks pass"
297 ))
298 );
299 Ok(MergeOutcome::Scheduled)
300 }
301 }
302}
303
304fn explain_merge_failure(
309 review_provider: &dyn ReviewProvider,
310 review: &ReviewRequest,
311 error: anyhow::Error,
312) -> anyhow::Error {
313 match review_provider
314 .merge_blocker(review)
315 .unwrap_or(MergeBlocker::None)
316 {
317 MergeBlocker::ChecksPending => checks_not_green_error(review),
318 MergeBlocker::Conflicts => anyhow::anyhow!(
319 "{} conflicts with {} - resolve the conflicts, push, and rerun `git stk merge`",
320 review.id,
321 review.base
322 ),
323 MergeBlocker::None => {
326 let text = error.to_string().to_lowercase();
327 if text.contains("status check") || text.contains("not mergeable") {
328 checks_not_green_error(review)
329 } else {
330 error
331 }
332 }
333 }
334}
335
336fn checks_not_green_error(review: &ReviewRequest) -> anyhow::Error {
337 anyhow::anyhow!(
338 "{}'s required checks are not green yet - wait and rerun `git stk merge`, \
339 or schedule with `git stk merge --auto`",
340 review.id
341 )
342}