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