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!("no stacked branches to merge");
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 match merge_and_check(review_provider.as_ref(), &review, &strategy, auto)? {
91 MergeOutcome::Merged => sync(false, PushMode::Config),
94 MergeOutcome::Scheduled => Ok(()),
95 }
96}
97
98fn merge_all(dry_run: bool, yes: bool, wait: bool) -> Result<()> {
103 let Some(bottom) = bottom_branch()? else {
104 bail!("no stacked branches to merge");
105 };
106
107 let provider = detect_provider()?;
108 let review_provider = review_provider(provider.kind);
109 let strategy = settings::merge_strategy()?;
110
111 let current = crate::git::current_branch()?;
113 let root = stack::stack_root(¤t)?;
114 let trunk = stack::trunk_branch(&crate::git::local_branches()?);
115 let branches: Vec<String> = stack::branch_and_descendants(&root)?
116 .into_iter()
117 .filter(|branch| Some(branch) != trunk.as_ref())
118 .collect();
119 let count = branches.len();
120
121 if dry_run {
122 for branch in &branches {
123 let review = open_review_for(review_provider.as_ref(), provider.kind, branch)?;
124 if wait {
125 println!("would wait for checks on {}", review.id);
126 }
127 println!(
128 "would merge {} into {} ({strategy})",
129 review.label(),
130 review.base
131 );
132 }
133 println!("would sync after each merge");
134 return Ok(());
135 }
136
137 let base = stack::parent_for_branch(&bottom)?.unwrap_or_else(|| "its base".to_owned());
138 if !yes
139 && !confirm(&format!(
140 "merge {count} review{} into {base}, bottom-up ({strategy})? [y/N] ",
141 if count == 1 { "" } else { "s" }
142 ))?
143 {
144 println!("merge cancelled");
145 return Ok(());
146 }
147
148 let mut landed = 0;
151 for _ in 0..count {
152 let Some(bottom) = bottom_branch()? else {
153 break;
154 };
155 let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;
156
157 if wait {
160 anstream::println!(
161 "waiting for checks on {} {}",
162 review.id,
163 style::dim("(ctrl-c is safe; rerun `git stk merge --all` to resume)")
164 );
165 if !review_provider.wait_for_checks(&review)? {
166 bail!(
167 "checks failed for {}; fix them and rerun `git stk merge --all`",
168 review.id
169 );
170 }
171 }
172
173 match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
174 MergeOutcome::Merged => {
175 sync(false, PushMode::Config)?;
176 landed += 1;
177 }
178 MergeOutcome::Scheduled => break,
179 }
180 }
181
182 anstream::println!(
183 "{}",
184 style::success(&format!(
185 "merge complete: {landed} of {count} review{} merged",
186 if count == 1 { "" } else { "s" }
187 ))
188 );
189 Ok(())
190}
191
192fn bottom_branch() -> Result<Option<String>> {
195 let current = crate::git::current_branch()?;
196 let root = stack::stack_root(¤t)?;
197 let trunk = stack::trunk_branch(&crate::git::local_branches()?);
198
199 Ok(stack::branch_and_descendants(&root)?
200 .into_iter()
201 .find(|branch| Some(branch) != trunk.as_ref()))
202}
203
204fn open_review_for(
207 review_provider: &dyn ReviewProvider,
208 kind: ProviderKind,
209 branch: &str,
210) -> Result<ReviewRequest> {
211 let Some(review) = review_provider.review_for_branch(branch)? else {
212 bail!("no {kind} review found for {branch}; submit the stack first");
213 };
214 if review.state != ReviewState::Open {
215 bail!(
216 "review {} for {branch} is {}, not open",
217 review.id,
218 review.state
219 );
220 }
221
222 let expected_base = stack::parent_for_branch(branch)?;
223 if let Some(expected) = &expected_base
224 && *expected != review.base
225 {
226 bail!(
227 "review {} targets {}, but {branch}'s stack parent is {expected}; \
228 run `git stk submit` first",
229 review.id,
230 review.base
231 );
232 }
233
234 Ok(review)
235}
236
237enum MergeOutcome {
238 Merged,
239 Scheduled,
240}
241
242fn merge_and_check(
246 review_provider: &dyn ReviewProvider,
247 review: &ReviewRequest,
248 strategy: &str,
249 auto: bool,
250) -> Result<MergeOutcome> {
251 let label = review.label();
252
253 let output = match review_provider.merge_review(review, strategy, auto) {
254 Ok(output) => output,
255 Err(error) => {
256 let text = error.to_string().to_lowercase();
258 if text.contains("status check") || text.contains("not mergeable") {
259 anstream::eprintln!(
260 "{} required checks may not be green yet - rerun `git stk merge` \
261 when they pass, or schedule with `git stk merge --auto`",
262 style::hint_prefix()
263 );
264 }
265 return Err(error);
266 }
267 };
268 if !output.is_empty() {
269 println!("{output}");
270 }
271
272 match review_provider.review_for_branch(&review.branch)? {
273 Some(after) if after.state == ReviewState::Merged => {
274 anstream::println!("{}", style::success(&format!("merged {label}")));
275 Ok(MergeOutcome::Merged)
276 }
277 _ => {
278 anstream::println!(
279 "{}",
280 style::warn(&format!(
281 "merge scheduled for {label}; rerun `git stk sync` once checks pass"
282 ))
283 );
284 Ok(MergeOutcome::Scheduled)
285 }
286 }
287}