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