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;
12use crate::style;
13
14#[derive(Debug, clap::Args)]
16pub struct Merge {
17 #[arg(long, action = ArgAction::SetTrue)]
19 dry_run: bool,
20 #[arg(long, short = 'y', action = ArgAction::SetTrue)]
22 yes: bool,
23 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "all")]
26 auto: bool,
27 #[arg(long, action = ArgAction::SetTrue)]
29 all: bool,
30 #[arg(long, action = ArgAction::SetTrue, requires = "all", conflicts_with = "no_wait")]
32 wait: bool,
33 #[arg(long, action = ArgAction::SetTrue, requires = "all")]
35 no_wait: bool,
36}
37
38impl Run for Merge {
39 fn run(self) -> Result<()> {
40 if self.all {
41 let wait = if self.wait {
44 true
45 } else if self.no_wait {
46 false
47 } else {
48 settings::bool_setting(settings::MERGE_WAIT_KEY)?
49 };
50 merge_all(self.dry_run, self.yes, wait)
51 } else {
52 merge(self.dry_run, self.yes, self.auto)
53 }
54 }
55}
56
57fn merge(dry_run: bool, yes: bool, auto: bool) -> Result<()> {
58 let Some(bottom) = bottom_branch()? else {
59 bail!(nothing_to_merge_hint()?);
60 };
61
62 let provider = detect_provider()?;
63 let review_provider = review_provider(provider.kind);
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 println!("would merge {label} into {} ({mode})", review.base);
76 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 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 = detect_provider()?;
109 let review_provider = review_provider(provider.kind);
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 println!("would wait for checks on {}", review.id);
123 }
124 println!(
125 "would merge {} into {} ({strategy})",
126 review.label(),
127 review.base
128 );
129 }
130 println!("would sync after each merge");
131 return Ok(());
132 }
133
134 let base = stack::parent_for_branch(&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 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 if !review_provider.wait_for_checks(&review)? {
165 bail!(
166 "checks failed for {}; fix them and rerun `git stk merge --all`",
167 review.id
168 );
169 }
170 }
171
172 match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
173 MergeOutcome::Merged => {
174 sync(false, PushMode::Config)?;
175 landed += 1;
176 }
177 MergeOutcome::Scheduled => break,
178 }
179 }
180
181 anstream::println!(
182 "{}",
183 style::success(&format!(
184 "merge complete: {landed} of {count} review{} merged",
185 if count == 1 { "" } else { "s" }
186 ))
187 );
188 Ok(())
189}
190
191fn bottom_branch() -> Result<Option<String>> {
194 let current = crate::git::current_branch()?;
195 Ok(stack::stack_line(¤t)?.into_iter().next())
196}
197
198fn nothing_to_merge_hint() -> Result<String> {
202 let current = crate::git::current_branch()?;
203 let trunk = stack::trunk_branch(&crate::git::local_branches()?);
204 let on_trunk_with_stacks =
207 Some(¤t) == trunk.as_ref() && !stack::children_for_branch(¤t)?.is_empty();
208 Ok(if on_trunk_with_stacks {
209 format!("you are on the trunk ({current}); check out a stacked branch first")
210 } else {
211 "no stacked branches to merge".to_owned()
212 })
213}
214
215fn open_review_for(
218 review_provider: &dyn ReviewProvider,
219 kind: ProviderKind,
220 branch: &str,
221) -> Result<ReviewRequest> {
222 let Some(review) = review_provider.review_for_branch(branch)? else {
223 bail!("no {kind} review found for {branch}; submit the stack first");
224 };
225 if review.state != ReviewState::Open {
226 bail!(
227 "review {} for {branch} is {}, not open",
228 review.id,
229 review.state
230 );
231 }
232
233 let expected_base = stack::parent_for_branch(branch)?;
234 if let Some(expected) = &expected_base
235 && *expected != review.base
236 {
237 bail!(
238 "review {} targets {}, but {branch}'s stack parent is {expected}; \
239 run `git stk submit` first",
240 review.id,
241 review.base
242 );
243 }
244
245 Ok(review)
246}
247
248enum MergeOutcome {
249 Merged,
250 Scheduled,
251}
252
253fn merge_and_check(
257 review_provider: &dyn ReviewProvider,
258 review: &ReviewRequest,
259 strategy: &str,
260 auto: bool,
261) -> Result<MergeOutcome> {
262 let label = review.label();
263
264 let output = match review_provider.merge_review(review, strategy, auto) {
265 Ok(output) => output,
266 Err(error) => {
267 let text = error.to_string().to_lowercase();
270 if text.contains("status check") || text.contains("not mergeable") {
271 bail!(
272 "{}'s required checks are not green yet - wait and rerun \
273 `git stk merge`, or schedule with `git stk merge --auto`",
274 review.id
275 );
276 }
277 return Err(error);
278 }
279 };
280 if !output.is_empty() {
281 println!("{output}");
282 }
283
284 match review_provider.review_for_branch(&review.branch)? {
285 Some(after) if after.state == ReviewState::Merged => {
286 anstream::println!("{}", style::success(&format!("merged {label}")));
287 Ok(MergeOutcome::Merged)
288 }
289 _ => {
290 anstream::println!(
291 "{}",
292 style::warn(&format!(
293 "merge scheduled for {label}; rerun `git stk sync` once checks pass"
294 ))
295 );
296 Ok(MergeOutcome::Scheduled)
297 }
298 }
299}