1use std::collections::BTreeSet;
2
3use anyhow::{Result, bail};
4use clap::ArgAction;
5
6use crate::cli::{FetchMode, PushMode, UpdateRefsMode};
7use crate::commands::Run;
8use crate::commands::cleanup::{
9 Landing, cleanup_branch_deletion, cleanup_finished_branch, deletion_blocker, landing_for,
10 report_kept,
11};
12use crate::providers::{ReviewState, detect_review_provider};
13use crate::settings;
14use crate::style;
15use crate::{git, stack};
16
17#[derive(Debug, clap::Args)]
20pub struct Sync {
21 #[arg(long, short = 'n', action = ArgAction::SetTrue)]
23 dry_run: bool,
24 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
26 push: bool,
27 #[arg(long, action = ArgAction::SetTrue)]
29 no_push: bool,
30}
31
32impl Run for Sync {
33 fn run(self) -> Result<()> {
34 sync(self.dry_run, PushMode::from_flags(self.push, self.no_push))
35 }
36}
37
38pub(crate) fn sync(dry_run: bool, push_mode: PushMode) -> Result<()> {
39 let current = git::current_branch()?;
40 let local_branches = git::local_branches()?;
41 let trunk = stack::trunk_branch(&local_branches);
42
43 if !dry_run {
46 stack::snapshot("sync");
47 }
48
49 let remote = settings::remote()?;
51 let has_remote = git::remote_url(&remote)?.is_some();
52 if let Some(trunk) = &trunk {
53 if !has_remote {
54 anstream::println!("no remote {remote}; skipped fetch");
55 } else if stack::trunk_held_elsewhere(trunk)? {
56 } else if dry_run {
59 anstream::println!("would fetch {trunk} from {remote}");
60 } else if current == *trunk {
61 git::pull_ff_only()?;
62 } else {
63 git::fetch_branch(&remote, trunk)?;
64 }
65 }
66
67 let root = stack::stack_root(¤t)?;
70 let branches = stack::current_stack_branches(¤t)?;
71
72 let base = stack::unanchored_base(&branches)?;
80
81 let (provider, review_provider) = match detect_review_provider() {
82 Ok(pair) => pair,
83 Err(_) if !has_remote => {
89 if branches.is_empty() {
90 anstream::println!("no stacked branches to sync");
91 } else {
92 anstream::println!("no remote configured - nothing to sync");
93 anstream::println!(
94 "{}",
95 style::dim("run `git stk restack` to refresh local branches")
96 );
97 }
98 return Ok(());
99 }
100 Err(error) => return Err(error),
101 };
102
103 let clean_closed = settings::bool_setting(settings::CLEAN_CLOSED_KEY)?;
108 let mut finished = Vec::new();
109 let mut closed = BTreeSet::new();
110 let mut synced = 0;
111 let mut skipped = 0;
112
113 for branch in &branches {
114 if Some(branch) == base.as_ref() {
115 let note = if stack::is_floor(branch)? {
119 format!("skipped {branch}: this stack's base")
120 } else {
121 format!(
122 "skipped {branch}: nothing below it in this stack, so it reads as the base; \
123 `git stk repair` if it is a stacked branch"
124 )
125 };
126 anstream::println!("{}", style::dim(¬e));
127 skipped += 1;
128 continue;
129 }
130
131 let Some(review) = review_provider.review_for_branch_including_closed(branch)? else {
134 anstream::println!(
135 "{}",
136 style::dim(&format!(
137 "skipped {branch}: no {} review found",
138 provider.kind
139 ))
140 );
141 skipped += 1;
142 continue;
143 };
144
145 if review.branch != *branch {
146 anstream::println!(
147 "{}",
148 style::dim(&format!(
149 "skipped {branch}: {} review belongs to {}",
150 provider.kind, review.branch
151 ))
152 );
153 skipped += 1;
154 continue;
155 }
156
157 if let Some(landing) = landing_for(&review.state, clean_closed) {
158 anstream::println!(
159 "{}: review {} is {}",
160 style::branch(branch),
161 review.id,
162 style::state(&review.state)
163 );
164 finished.push(branch.clone());
165 if landing == Landing::Closed {
166 closed.insert(branch.clone());
167 }
168 continue;
169 }
170
171 if review.state == ReviewState::Closed {
174 anstream::println!(
175 "{}",
176 style::dim(&format!(
177 "skipped {branch}: review {} was closed without merging",
178 review.id
179 ))
180 );
181 skipped += 1;
182 continue;
183 }
184
185 if let Some(parent) = stack::parent_of(branch)?
192 && finished.contains(&parent)
193 {
194 continue;
195 }
196
197 if review.branch == review.base {
198 bail!("refusing to set {branch} as its own stack parent");
199 }
200
201 if !dry_run {
202 stack::set_parent(branch, &review.base)?;
203 stack::record_base(branch, &review.base);
204 }
205 anstream::println!(
206 "{} {} -> {} {}",
207 if dry_run { "would sync" } else { "synced" },
208 style::branch(&review.branch),
209 style::branch(&review.base),
210 style::dim(&format!("({})", review.id))
211 );
212 synced += 1;
213 }
214
215 anstream::println!(
216 "{}",
217 style::success(&format!(
218 "sync complete: {synced} {}synced, {skipped} skipped",
219 if dry_run { "would be " } else { "" }
220 ))
221 );
222
223 let branch_parents = stack::branch_parents(&branches)?;
227 crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
228
229 let survivors: Vec<String> = branches
230 .iter()
231 .filter(|branch| !finished.contains(branch))
232 .cloned()
233 .collect();
234
235 let mut position = current.clone();
238 if finished.contains(¤t) {
239 let target = survivors
240 .first()
241 .cloned()
242 .or_else(|| trunk.clone())
243 .unwrap_or(root.clone());
244 let held = git::worktree_holding(&target)?;
245 if let Some(path) = held {
246 anstream::println!(
251 "{}",
252 style::warn(&format!(
253 "stayed on {current}: {target} is checked out in the worktree at {}",
254 git::display_path(&path)
255 ))
256 );
257 } else if git::in_linked_worktree() {
258 anstream::println!(
265 "{}",
266 style::warn(&format!(
267 "stayed on {current}: this is its own worktree, not the main checkout"
268 ))
269 );
270 } else if dry_run {
271 anstream::println!("would switch to {}", style::branch(&target));
272 position = target;
273 } else {
274 git::checkout(&target)?;
275 position = target;
276 }
277 }
278
279 for branch in &finished {
283 let landing = if closed.contains(branch) {
284 Landing::Closed
285 } else {
286 Landing::Merged
287 };
288 if let Some(reason) = deletion_blocker(branch, &position)? {
289 report_kept(branch, &reason);
290 continue;
291 }
292 cleanup_finished_branch(review_provider.as_ref(), branch, landing, dry_run)?;
293 cleanup_branch_deletion(branch, landing, dry_run)?;
294 }
295
296 if dry_run {
298 anstream::println!("would restack the remaining stack");
299 } else if !survivors.is_empty() {
300 stack::restack(
302 FetchMode::Disabled,
303 UpdateRefsMode::Config,
304 push_mode,
305 false,
306 )?;
307 }
308
309 match survivors
312 .iter()
313 .find(|branch| Some(*branch) != base.as_ref())
314 {
315 Some(bottom) => match review_provider.review_for_branch(bottom)? {
316 Some(review) => anstream::println!(
317 "next up: {} -> {} {}",
318 style::branch(bottom),
319 review.id,
320 style::dim(&review.url)
321 ),
322 None => anstream::println!(
323 "next up: {} {}",
324 style::branch(bottom),
325 style::dim("(no review yet)")
326 ),
327 },
328 None => {
329 let landed_into = base.clone().or(trunk).unwrap_or(root);
332 let ending = if closed.is_empty() {
335 format!("stack complete: everything merged into {landed_into}")
336 } else {
337 format!("stack complete: nothing left above {landed_into} - merged or closed")
338 };
339 anstream::println!("{}", style::success(&ending));
340 }
341 }
342
343 Ok(())
344}