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 (provider, review_provider) = match detect_review_provider() {
73 Ok(pair) => pair,
74 Err(_) if !has_remote => {
80 if branches.is_empty() {
81 anstream::println!("no stacked branches to sync");
82 } else {
83 anstream::println!("no remote configured - nothing to sync");
84 anstream::println!(
85 "{}",
86 style::dim("run `git stk restack` to refresh local branches")
87 );
88 }
89 return Ok(());
90 }
91 Err(error) => return Err(error),
92 };
93
94 let clean_closed = settings::bool_setting(settings::CLEAN_CLOSED_KEY)?;
99 let mut finished = Vec::new();
100 let mut closed = BTreeSet::new();
101 let mut synced = 0;
102 let mut skipped = 0;
103
104 for branch in &branches {
105 let Some(review) = review_provider.review_for_branch_including_closed(branch)? else {
108 anstream::println!(
109 "{}",
110 style::dim(&format!(
111 "skipped {branch}: no {} review found",
112 provider.kind
113 ))
114 );
115 skipped += 1;
116 continue;
117 };
118
119 if review.branch != *branch {
120 anstream::println!(
121 "{}",
122 style::dim(&format!(
123 "skipped {branch}: {} review belongs to {}",
124 provider.kind, review.branch
125 ))
126 );
127 skipped += 1;
128 continue;
129 }
130
131 if let Some(landing) = landing_for(&review.state, clean_closed) {
132 anstream::println!(
133 "{}: review {} is {}",
134 style::branch(branch),
135 review.id,
136 style::state(&review.state)
137 );
138 finished.push(branch.clone());
139 if landing == Landing::Closed {
140 closed.insert(branch.clone());
141 }
142 continue;
143 }
144
145 if review.state == ReviewState::Closed {
148 anstream::println!(
149 "{}",
150 style::dim(&format!(
151 "skipped {branch}: review {} was closed without merging",
152 review.id
153 ))
154 );
155 skipped += 1;
156 continue;
157 }
158
159 if let Some(parent) = stack::parent_of(branch)?
166 && finished.contains(&parent)
167 {
168 continue;
169 }
170
171 if review.branch == review.base {
172 bail!("refusing to set {branch} as its own stack parent");
173 }
174
175 if !dry_run {
176 stack::set_parent(branch, &review.base)?;
177 stack::record_base(branch, &review.base);
178 }
179 anstream::println!(
180 "{} {} -> {} {}",
181 if dry_run { "would sync" } else { "synced" },
182 style::branch(&review.branch),
183 style::branch(&review.base),
184 style::dim(&format!("({})", review.id))
185 );
186 synced += 1;
187 }
188
189 anstream::println!(
190 "{}",
191 style::success(&format!(
192 "sync complete: {synced} {}synced, {skipped} skipped",
193 if dry_run { "would be " } else { "" }
194 ))
195 );
196
197 let branch_parents = stack::branch_parents(&branches)?;
201 crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
202
203 let survivors: Vec<String> = branches
204 .iter()
205 .filter(|branch| !finished.contains(branch))
206 .cloned()
207 .collect();
208
209 let mut position = current.clone();
212 if finished.contains(¤t) {
213 let target = survivors
214 .first()
215 .cloned()
216 .or_else(|| trunk.clone())
217 .unwrap_or(root.clone());
218 let held = git::worktree_holding(&target)?;
219 if let Some(path) = held {
220 anstream::println!(
225 "{}",
226 style::warn(&format!(
227 "stayed on {current}: {target} is checked out in the worktree at {}",
228 git::display_path(&path)
229 ))
230 );
231 } else if git::in_linked_worktree() {
232 anstream::println!(
239 "{}",
240 style::warn(&format!(
241 "stayed on {current}: this is its own worktree, not the main checkout"
242 ))
243 );
244 } else if dry_run {
245 anstream::println!("would switch to {}", style::branch(&target));
246 position = target;
247 } else {
248 git::checkout(&target)?;
249 position = target;
250 }
251 }
252
253 for branch in &finished {
257 let landing = if closed.contains(branch) {
258 Landing::Closed
259 } else {
260 Landing::Merged
261 };
262 if let Some(reason) = deletion_blocker(branch, &position)? {
263 report_kept(branch, &reason);
264 continue;
265 }
266 cleanup_finished_branch(review_provider.as_ref(), branch, landing, dry_run)?;
267 cleanup_branch_deletion(branch, landing, dry_run)?;
268 }
269
270 if dry_run {
272 anstream::println!("would restack the remaining stack");
273 } else if !survivors.is_empty() {
274 stack::restack(
276 FetchMode::Disabled,
277 UpdateRefsMode::Config,
278 push_mode,
279 false,
280 )?;
281 }
282
283 match survivors.first() {
285 Some(bottom) => match review_provider.review_for_branch(bottom)? {
286 Some(review) => anstream::println!(
287 "next up: {} -> {} {}",
288 style::branch(bottom),
289 review.id,
290 style::dim(&review.url)
291 ),
292 None => anstream::println!(
293 "next up: {} {}",
294 style::branch(bottom),
295 style::dim("(no review yet)")
296 ),
297 },
298 None => {
299 let base = trunk.unwrap_or(root);
300 let ending = if closed.is_empty() {
303 format!("stack complete: everything merged into {base}")
304 } else {
305 format!("stack complete: nothing left above {base} - merged or closed")
306 };
307 anstream::println!("{}", style::success(&ending));
308 }
309 }
310
311 Ok(())
312}