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::{git, stack};
10
11#[derive(Debug, clap::Args)]
14pub struct Sync {
15 #[arg(long, action = ArgAction::SetTrue)]
17 dry_run: bool,
18 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
20 push: bool,
21 #[arg(long, action = ArgAction::SetTrue)]
23 no_push: bool,
24}
25
26impl Run for Sync {
27 fn run(self) -> Result<()> {
28 sync(self.dry_run, PushMode::from_flags(self.push, self.no_push))
29 }
30}
31
32pub(crate) fn sync(dry_run: bool, push_mode: PushMode) -> Result<()> {
33 let current = git::current_branch()?;
34 let local_branches = git::local_branches()?;
35 let trunk = stack::trunk_branch(&local_branches);
36
37 let remote = settings::remote()?;
39 if let Some(trunk) = &trunk {
40 if git::remote_url(&remote)?.is_none() {
41 println!("no remote {remote}; skipped fetch");
42 } else if dry_run {
43 println!("would fetch {trunk} from {remote}");
44 } else if current == *trunk {
45 git::pull_ff_only()?;
46 } else {
47 git::fetch_branch(&remote, trunk)?;
48 }
49 }
50
51 let root = stack::stack_root(¤t)?;
54 let branches: Vec<String> = stack::branch_and_descendants(&root)?
55 .into_iter()
56 .filter(|branch| Some(branch) != trunk.as_ref())
57 .collect();
58
59 let provider = detect_provider()?;
60 let review_provider = review_provider(provider.kind);
61
62 let mut merged = Vec::new();
65 let mut synced = 0;
66 let mut skipped = 0;
67
68 for branch in &branches {
69 let Some(review) = review_provider.review_for_branch_including_closed(branch)? else {
72 println!("skipped {branch}: no {} review found", provider.kind);
73 skipped += 1;
74 continue;
75 };
76
77 if review.branch != *branch {
78 println!(
79 "skipped {branch}: {} review belongs to {}",
80 provider.kind, review.branch
81 );
82 skipped += 1;
83 continue;
84 }
85
86 if review.state == ReviewState::Merged {
87 println!("{branch}: review {} is merged", review.id);
88 merged.push(branch.clone());
89 continue;
90 }
91
92 if review.state == ReviewState::Closed {
95 println!(
96 "skipped {branch}: review {} was closed without merging",
97 review.id
98 );
99 skipped += 1;
100 continue;
101 }
102
103 if review.branch == review.base {
104 bail!("refusing to set {branch} as its own stack parent");
105 }
106
107 if !dry_run {
108 stack::set_parent_for_branch(branch, &review.base)?;
109 stack::record_base(branch, &review.base);
110 }
111 println!(
112 "{} {} -> {} ({})",
113 if dry_run { "would sync" } else { "synced" },
114 review.branch,
115 review.base,
116 review.id
117 );
118 synced += 1;
119 }
120
121 println!(
122 "sync complete: {synced} {}synced, {skipped} skipped",
123 if dry_run { "would be " } else { "" }
124 );
125
126 let mut branch_parents = Vec::new();
130 for branch in &branches {
131 if let Some(parent) = stack::parent_for_branch(branch)? {
132 branch_parents.push((branch.clone(), parent));
133 }
134 }
135 crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run)?;
136
137 let survivors: Vec<String> = branches
138 .iter()
139 .filter(|branch| !merged.contains(branch))
140 .cloned()
141 .collect();
142
143 let mut position = current.clone();
146 if merged.contains(¤t) {
147 let target = survivors
148 .first()
149 .cloned()
150 .or_else(|| trunk.clone())
151 .unwrap_or(root.clone());
152 if dry_run {
153 println!("would switch to {target}");
154 } else {
155 git::checkout(&target)?;
156 }
157 position = target;
158 }
159
160 for branch in &merged {
162 cleanup_merged_branch(review_provider.as_ref(), branch, dry_run)?;
163 cleanup_branch_deletion(branch, &position, dry_run, true)?;
164 }
165
166 if dry_run {
168 println!("would restack the remaining stack");
169 } else if !survivors.is_empty() {
170 stack::restack(UpdateRefsMode::Config, push_mode)?;
171 }
172
173 match survivors.first() {
175 Some(bottom) => match review_provider.review_for_branch(bottom)? {
176 Some(review) => println!("next up: {bottom} -> {} {}", review.id, review.url),
177 None => println!("next up: {bottom} (no review yet)"),
178 },
179 None => {
180 let base = trunk.unwrap_or(root);
181 println!("stack complete: everything merged into {base}");
182 }
183 }
184
185 Ok(())
186}