1use anyhow::{Result, bail};
2use clap::ArgAction;
3
4use crate::cli::{FetchMode, PushMode, UpdateRefsMode};
5use crate::commands::Run;
6use crate::commands::cleanup::{cleanup_branch_deletion, cleanup_merged_branch};
7use crate::providers::{ReviewState, detect_review_provider};
8use crate::settings;
9use crate::style;
10use crate::{git, stack};
11
12#[derive(Debug, clap::Args)]
15pub struct Sync {
16 #[arg(long, short = 'n', action = ArgAction::SetTrue)]
18 dry_run: bool,
19 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
21 push: bool,
22 #[arg(long, action = ArgAction::SetTrue)]
24 no_push: bool,
25}
26
27impl Run for Sync {
28 fn run(self) -> Result<()> {
29 sync(self.dry_run, PushMode::from_flags(self.push, self.no_push))
30 }
31}
32
33pub(crate) fn sync(dry_run: bool, push_mode: PushMode) -> Result<()> {
34 let current = git::current_branch()?;
35 let local_branches = git::local_branches()?;
36 let trunk = stack::trunk_branch(&local_branches);
37
38 if !dry_run {
41 stack::snapshot("sync");
42 }
43
44 let remote = settings::remote()?;
46 let has_remote = git::remote_url(&remote)?.is_some();
47 if let Some(trunk) = &trunk {
48 if !has_remote {
49 anstream::println!("no remote {remote}; skipped fetch");
50 } else if stack::trunk_held_elsewhere(trunk)? {
51 } else if dry_run {
54 anstream::println!("would fetch {trunk} from {remote}");
55 } else if current == *trunk {
56 git::pull_ff_only()?;
57 } else {
58 git::fetch_branch(&remote, trunk)?;
59 }
60 }
61
62 let root = stack::stack_root(¤t)?;
65 let branches = stack::current_stack_branches(¤t)?;
66
67 let (provider, review_provider) = match detect_review_provider() {
68 Ok(pair) => pair,
69 Err(_) if !has_remote => {
75 if branches.is_empty() {
76 anstream::println!("no stacked branches to sync");
77 } else {
78 anstream::println!("no remote configured - nothing to sync");
79 anstream::println!(
80 "{}",
81 style::dim("run `git stk restack` to refresh local branches")
82 );
83 }
84 return Ok(());
85 }
86 Err(error) => return Err(error),
87 };
88
89 let mut merged = Vec::new();
92 let mut synced = 0;
93 let mut skipped = 0;
94
95 for branch in &branches {
96 let Some(review) = review_provider.review_for_branch_including_closed(branch)? else {
99 anstream::println!(
100 "{}",
101 style::dim(&format!(
102 "skipped {branch}: no {} review found",
103 provider.kind
104 ))
105 );
106 skipped += 1;
107 continue;
108 };
109
110 if review.branch != *branch {
111 anstream::println!(
112 "{}",
113 style::dim(&format!(
114 "skipped {branch}: {} review belongs to {}",
115 provider.kind, review.branch
116 ))
117 );
118 skipped += 1;
119 continue;
120 }
121
122 if review.state == ReviewState::Merged {
123 anstream::println!(
124 "{}: review {} is {}",
125 style::branch(branch),
126 review.id,
127 style::state(&review.state)
128 );
129 merged.push(branch.clone());
130 continue;
131 }
132
133 if review.state == ReviewState::Closed {
136 anstream::println!(
137 "{}",
138 style::dim(&format!(
139 "skipped {branch}: review {} was closed without merging",
140 review.id
141 ))
142 );
143 skipped += 1;
144 continue;
145 }
146
147 if let Some(parent) = stack::parent_of(branch)?
154 && merged.contains(&parent)
155 {
156 continue;
157 }
158
159 if review.branch == review.base {
160 bail!("refusing to set {branch} as its own stack parent");
161 }
162
163 if !dry_run {
164 stack::set_parent(branch, &review.base)?;
165 stack::record_base(branch, &review.base);
166 }
167 anstream::println!(
168 "{} {} -> {} {}",
169 if dry_run { "would sync" } else { "synced" },
170 style::branch(&review.branch),
171 style::branch(&review.base),
172 style::dim(&format!("({})", review.id))
173 );
174 synced += 1;
175 }
176
177 anstream::println!(
178 "{}",
179 style::success(&format!(
180 "sync complete: {synced} {}synced, {skipped} skipped",
181 if dry_run { "would be " } else { "" }
182 ))
183 );
184
185 let branch_parents = stack::branch_parents(&branches)?;
189 crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
190
191 let survivors: Vec<String> = branches
192 .iter()
193 .filter(|branch| !merged.contains(branch))
194 .cloned()
195 .collect();
196
197 let mut position = current.clone();
200 if merged.contains(¤t) {
201 let target = survivors
202 .first()
203 .cloned()
204 .or_else(|| trunk.clone())
205 .unwrap_or(root.clone());
206 let held = git::worktree_holding(&target)?;
207 if let Some(path) = held {
208 anstream::println!(
213 "{}",
214 style::warn(&format!(
215 "stayed on {current}: {target} is checked out in the worktree at {}",
216 git::display_path(&path)
217 ))
218 );
219 } else if dry_run {
220 anstream::println!("would switch to {}", style::branch(&target));
221 position = target;
222 } else {
223 git::checkout(&target)?;
224 position = target;
225 }
226 }
227
228 for branch in &merged {
230 cleanup_merged_branch(review_provider.as_ref(), branch, dry_run)?;
231 cleanup_branch_deletion(branch, &position, dry_run, true)?;
232 }
233
234 if dry_run {
236 anstream::println!("would restack the remaining stack");
237 } else if !survivors.is_empty() {
238 stack::restack(
240 FetchMode::Disabled,
241 UpdateRefsMode::Config,
242 push_mode,
243 false,
244 )?;
245 }
246
247 match survivors.first() {
249 Some(bottom) => match review_provider.review_for_branch(bottom)? {
250 Some(review) => anstream::println!(
251 "next up: {} -> {} {}",
252 style::branch(bottom),
253 review.id,
254 style::dim(&review.url)
255 ),
256 None => anstream::println!(
257 "next up: {} {}",
258 style::branch(bottom),
259 style::dim("(no review yet)")
260 ),
261 },
262 None => {
263 let base = trunk.unwrap_or(root);
264 anstream::println!(
265 "{}",
266 style::success(&format!("stack complete: everything merged into {base}"))
267 );
268 }
269 }
270
271 Ok(())
272}