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}
31
32impl Run for Merge {
33 fn run(self) -> Result<()> {
34 if self.all {
35 merge_all(self.dry_run, self.yes)
36 } else {
37 merge(self.dry_run, self.yes, self.auto)
38 }
39 }
40}
41
42fn merge(dry_run: bool, yes: bool, auto: bool) -> Result<()> {
43 let Some(bottom) = bottom_branch()? else {
44 bail!("no stacked branches to merge");
45 };
46
47 let provider = detect_provider()?;
48 let review_provider = review_provider(provider.kind);
49 let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;
50
51 let strategy = settings::merge_strategy()?;
52 let mode = if auto {
53 format!("{strategy}, auto")
54 } else {
55 strategy.clone()
56 };
57 let label = review.label();
58
59 if dry_run {
60 println!("would merge {label} into {} ({mode})", review.base);
61 println!("would sync afterwards");
62 return Ok(());
63 }
64
65 if !yes
66 && !confirm(&format!(
67 "merge {label} into {} ({mode})? [y/N] ",
68 review.base
69 ))?
70 {
71 println!("merge cancelled");
72 return Ok(());
73 }
74
75 match merge_and_check(review_provider.as_ref(), &review, &strategy, auto)? {
76 MergeOutcome::Merged => sync(false, PushMode::Config),
79 MergeOutcome::Scheduled => Ok(()),
80 }
81}
82
83fn merge_all(dry_run: bool, yes: bool) -> Result<()> {
87 let Some(bottom) = bottom_branch()? else {
88 bail!("no stacked branches to merge");
89 };
90
91 let provider = detect_provider()?;
92 let review_provider = review_provider(provider.kind);
93 let strategy = settings::merge_strategy()?;
94
95 let current = crate::git::current_branch()?;
97 let root = stack::stack_root(¤t)?;
98 let trunk = stack::trunk_branch(&crate::git::local_branches()?);
99 let branches: Vec<String> = stack::branch_and_descendants(&root)?
100 .into_iter()
101 .filter(|branch| Some(branch) != trunk.as_ref())
102 .collect();
103 let count = branches.len();
104
105 if dry_run {
106 for branch in &branches {
107 let review = open_review_for(review_provider.as_ref(), provider.kind, branch)?;
108 println!(
109 "would merge {} into {} ({strategy})",
110 review.label(),
111 review.base
112 );
113 }
114 println!("would sync after each merge");
115 return Ok(());
116 }
117
118 let base = stack::parent_for_branch(&bottom)?.unwrap_or_else(|| "its base".to_owned());
119 if !yes
120 && !confirm(&format!(
121 "merge {count} review{} into {base}, bottom-up ({strategy})? [y/N] ",
122 if count == 1 { "" } else { "s" }
123 ))?
124 {
125 println!("merge cancelled");
126 return Ok(());
127 }
128
129 let mut landed = 0;
132 for _ in 0..count {
133 let Some(bottom) = bottom_branch()? else {
134 break;
135 };
136 let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;
137 match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
138 MergeOutcome::Merged => {
139 sync(false, PushMode::Config)?;
140 landed += 1;
141 }
142 MergeOutcome::Scheduled => break,
143 }
144 }
145
146 anstream::println!(
147 "{}",
148 style::success(&format!(
149 "merge complete: {landed} of {count} review{} merged",
150 if count == 1 { "" } else { "s" }
151 ))
152 );
153 Ok(())
154}
155
156fn bottom_branch() -> Result<Option<String>> {
159 let current = crate::git::current_branch()?;
160 let root = stack::stack_root(¤t)?;
161 let trunk = stack::trunk_branch(&crate::git::local_branches()?);
162
163 Ok(stack::branch_and_descendants(&root)?
164 .into_iter()
165 .find(|branch| Some(branch) != trunk.as_ref()))
166}
167
168fn open_review_for(
171 review_provider: &dyn ReviewProvider,
172 kind: ProviderKind,
173 branch: &str,
174) -> Result<ReviewRequest> {
175 let Some(review) = review_provider.review_for_branch(branch)? else {
176 bail!("no {kind} review found for {branch}; submit the stack first");
177 };
178 if review.state != ReviewState::Open {
179 bail!(
180 "review {} for {branch} is {}, not open",
181 review.id,
182 review.state
183 );
184 }
185
186 let expected_base = stack::parent_for_branch(branch)?;
187 if let Some(expected) = &expected_base
188 && *expected != review.base
189 {
190 bail!(
191 "review {} targets {}, but {branch}'s stack parent is {expected}; \
192 run `git stk submit` first",
193 review.id,
194 review.base
195 );
196 }
197
198 Ok(review)
199}
200
201enum MergeOutcome {
202 Merged,
203 Scheduled,
204}
205
206fn merge_and_check(
210 review_provider: &dyn ReviewProvider,
211 review: &ReviewRequest,
212 strategy: &str,
213 auto: bool,
214) -> Result<MergeOutcome> {
215 let label = review.label();
216
217 let output = match review_provider.merge_review(review, strategy, auto) {
218 Ok(output) => output,
219 Err(error) => {
220 let text = error.to_string().to_lowercase();
222 if text.contains("status check") || text.contains("not mergeable") {
223 anstream::eprintln!(
224 "{} required checks may not be green yet - rerun `git stk merge` \
225 when they pass, or schedule with `git stk merge --auto`",
226 style::hint_prefix()
227 );
228 }
229 return Err(error);
230 }
231 };
232 if !output.is_empty() {
233 println!("{output}");
234 }
235
236 match review_provider.review_for_branch(&review.branch)? {
237 Some(after) if after.state == ReviewState::Merged => {
238 anstream::println!("{}", style::success(&format!("merged {label}")));
239 Ok(MergeOutcome::Merged)
240 }
241 _ => {
242 anstream::println!(
243 "{}",
244 style::warn(&format!(
245 "merge scheduled for {label}; rerun `git stk sync` once checks pass"
246 ))
247 );
248 Ok(MergeOutcome::Scheduled)
249 }
250 }
251}