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_provider, review_provider};
8use crate::settings;
9use crate::style;
10use crate::{git, stack};
11
12#[derive(Debug, clap::Args)]
15pub struct Sync {
16 #[arg(long, 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 if let Some(trunk) = &trunk {
47 if git::remote_url(&remote)?.is_none() {
48 println!("no remote {remote}; skipped fetch");
49 } else if dry_run {
50 println!("would fetch {trunk} from {remote}");
51 } else if current == *trunk {
52 git::pull_ff_only()?;
53 } else {
54 git::fetch_branch(&remote, trunk)?;
55 }
56 }
57
58 let root = stack::stack_root(¤t)?;
61 let branches: Vec<String> = stack::branch_and_descendants(&root)?
62 .into_iter()
63 .filter(|branch| Some(branch) != trunk.as_ref())
64 .collect();
65
66 let provider = detect_provider()?;
67 let review_provider = review_provider(provider.kind);
68
69 let mut merged = Vec::new();
72 let mut synced = 0;
73 let mut skipped = 0;
74
75 for branch in &branches {
76 let Some(review) = review_provider.review_for_branch_including_closed(branch)? else {
79 anstream::println!(
80 "{}",
81 style::dim(&format!(
82 "skipped {branch}: no {} review found",
83 provider.kind
84 ))
85 );
86 skipped += 1;
87 continue;
88 };
89
90 if review.branch != *branch {
91 anstream::println!(
92 "{}",
93 style::dim(&format!(
94 "skipped {branch}: {} review belongs to {}",
95 provider.kind, review.branch
96 ))
97 );
98 skipped += 1;
99 continue;
100 }
101
102 if review.state == ReviewState::Merged {
103 anstream::println!(
104 "{}: review {} is {}",
105 style::branch(branch),
106 review.id,
107 style::state(&review.state)
108 );
109 merged.push(branch.clone());
110 continue;
111 }
112
113 if review.state == ReviewState::Closed {
116 anstream::println!(
117 "{}",
118 style::dim(&format!(
119 "skipped {branch}: review {} was closed without merging",
120 review.id
121 ))
122 );
123 skipped += 1;
124 continue;
125 }
126
127 if review.branch == review.base {
128 bail!("refusing to set {branch} as its own stack parent");
129 }
130
131 if !dry_run {
132 stack::set_parent_for_branch(branch, &review.base)?;
133 stack::record_base(branch, &review.base);
134 }
135 anstream::println!(
136 "{} {} -> {} {}",
137 if dry_run { "would sync" } else { "synced" },
138 style::branch(&review.branch),
139 style::branch(&review.base),
140 style::dim(&format!("({})", review.id))
141 );
142 synced += 1;
143 }
144
145 anstream::println!(
146 "{}",
147 style::success(&format!(
148 "sync complete: {synced} {}synced, {skipped} skipped",
149 if dry_run { "would be " } else { "" }
150 ))
151 );
152
153 let branch_parents = stack::branch_parents(&branches)?;
157 crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run)?;
158
159 let survivors: Vec<String> = branches
160 .iter()
161 .filter(|branch| !merged.contains(branch))
162 .cloned()
163 .collect();
164
165 let mut position = current.clone();
168 if merged.contains(¤t) {
169 let target = survivors
170 .first()
171 .cloned()
172 .or_else(|| trunk.clone())
173 .unwrap_or(root.clone());
174 if dry_run {
175 anstream::println!("would switch to {}", style::branch(&target));
176 } else {
177 git::checkout(&target)?;
178 }
179 position = target;
180 }
181
182 for branch in &merged {
184 cleanup_merged_branch(review_provider.as_ref(), branch, dry_run)?;
185 cleanup_branch_deletion(branch, &position, dry_run, true)?;
186 }
187
188 if dry_run {
190 println!("would restack the remaining stack");
191 } else if !survivors.is_empty() {
192 stack::restack(UpdateRefsMode::Config, push_mode, false)?;
193 }
194
195 match survivors.first() {
197 Some(bottom) => match review_provider.review_for_branch(bottom)? {
198 Some(review) => anstream::println!(
199 "next up: {} -> {} {}",
200 style::branch(bottom),
201 review.id,
202 style::dim(&review.url)
203 ),
204 None => anstream::println!(
205 "next up: {} {}",
206 style::branch(bottom),
207 style::dim("(no review yet)")
208 ),
209 },
210 None => {
211 let base = trunk.unwrap_or(root);
212 anstream::println!(
213 "{}",
214 style::success(&format!("stack complete: everything merged into {base}"))
215 );
216 }
217 }
218
219 Ok(())
220}