1use anyhow::{Result, bail};
2use clap::ArgAction;
3
4use crate::cli::{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 dry_run {
51 anstream::println!("would fetch {trunk} from {remote}");
52 } else if current == *trunk {
53 git::pull_ff_only()?;
54 } else {
55 git::fetch_branch(&remote, trunk)?;
56 }
57 }
58
59 let root = stack::stack_root(¤t)?;
62 let branches = stack::current_stack_branches(¤t)?;
63
64 let (provider, review_provider) = match detect_review_provider() {
65 Ok(pair) => pair,
66 Err(_) if !has_remote => {
72 if branches.is_empty() {
73 anstream::println!("no stacked branches to sync");
74 } else {
75 anstream::println!("no remote configured - nothing to sync");
76 anstream::println!(
77 "{}",
78 style::dim("run `git stk restack` to refresh local branches")
79 );
80 }
81 return Ok(());
82 }
83 Err(error) => return Err(error),
84 };
85
86 let mut merged = Vec::new();
89 let mut synced = 0;
90 let mut skipped = 0;
91
92 for branch in &branches {
93 let Some(review) = review_provider.review_for_branch_including_closed(branch)? else {
96 anstream::println!(
97 "{}",
98 style::dim(&format!(
99 "skipped {branch}: no {} review found",
100 provider.kind
101 ))
102 );
103 skipped += 1;
104 continue;
105 };
106
107 if review.branch != *branch {
108 anstream::println!(
109 "{}",
110 style::dim(&format!(
111 "skipped {branch}: {} review belongs to {}",
112 provider.kind, review.branch
113 ))
114 );
115 skipped += 1;
116 continue;
117 }
118
119 if review.state == ReviewState::Merged {
120 anstream::println!(
121 "{}: review {} is {}",
122 style::branch(branch),
123 review.id,
124 style::state(&review.state)
125 );
126 merged.push(branch.clone());
127 continue;
128 }
129
130 if review.state == ReviewState::Closed {
133 anstream::println!(
134 "{}",
135 style::dim(&format!(
136 "skipped {branch}: review {} was closed without merging",
137 review.id
138 ))
139 );
140 skipped += 1;
141 continue;
142 }
143
144 if let Some(parent) = stack::parent_of(branch)?
151 && merged.contains(&parent)
152 {
153 continue;
154 }
155
156 if review.branch == review.base {
157 bail!("refusing to set {branch} as its own stack parent");
158 }
159
160 if !dry_run {
161 stack::set_parent(branch, &review.base)?;
162 stack::record_base(branch, &review.base);
163 }
164 anstream::println!(
165 "{} {} -> {} {}",
166 if dry_run { "would sync" } else { "synced" },
167 style::branch(&review.branch),
168 style::branch(&review.base),
169 style::dim(&format!("({})", review.id))
170 );
171 synced += 1;
172 }
173
174 anstream::println!(
175 "{}",
176 style::success(&format!(
177 "sync complete: {synced} {}synced, {skipped} skipped",
178 if dry_run { "would be " } else { "" }
179 ))
180 );
181
182 let branch_parents = stack::branch_parents(&branches)?;
186 crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
187
188 let survivors: Vec<String> = branches
189 .iter()
190 .filter(|branch| !merged.contains(branch))
191 .cloned()
192 .collect();
193
194 let mut position = current.clone();
197 if merged.contains(¤t) {
198 let target = survivors
199 .first()
200 .cloned()
201 .or_else(|| trunk.clone())
202 .unwrap_or(root.clone());
203 if dry_run {
204 anstream::println!("would switch to {}", style::branch(&target));
205 } else {
206 git::checkout(&target)?;
207 }
208 position = target;
209 }
210
211 for branch in &merged {
213 cleanup_merged_branch(review_provider.as_ref(), branch, dry_run)?;
214 cleanup_branch_deletion(branch, &position, dry_run, true)?;
215 }
216
217 if dry_run {
219 anstream::println!("would restack the remaining stack");
220 } else if !survivors.is_empty() {
221 stack::restack(UpdateRefsMode::Config, push_mode, false)?;
222 }
223
224 match survivors.first() {
226 Some(bottom) => match review_provider.review_for_branch(bottom)? {
227 Some(review) => anstream::println!(
228 "next up: {} -> {} {}",
229 style::branch(bottom),
230 review.id,
231 style::dim(&review.url)
232 ),
233 None => anstream::println!(
234 "next up: {} {}",
235 style::branch(bottom),
236 style::dim("(no review yet)")
237 ),
238 },
239 None => {
240 let base = trunk.unwrap_or(root);
241 anstream::println!(
242 "{}",
243 style::success(&format!("stack complete: everything merged into {base}"))
244 );
245 }
246 }
247
248 Ok(())
249}