Skip to main content

git_stk/commands/
sync.rs

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/// Sync the stack with remote state: fetch the trunk, refresh metadata from
13/// reviews, clean up merged branches, then restack and push.
14#[derive(Debug, clap::Args)]
15pub struct Sync {
16    /// Print what would change without changing anything.
17    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
18    dry_run: bool,
19    /// Force-push (with lease) rebased branches after the restack.
20    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
21    push: bool,
22    /// Do not push rebased branches, overriding stk.pushOnRestack.
23    #[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    // Snapshot before the fetch/cleanup/restack rewrites anything. (When
39    // `merge` calls sync, merge has already snapshotted; this no-ops.)
40    if !dry_run {
41        stack::snapshot("sync");
42    }
43
44    // 1. Fetch the trunk so merged work is visible locally.
45    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    // 2. The stack containing the current branch (the trunk itself has no
60    //    review and is never synced).
61    let root = stack::stack_root(&current)?;
62    let branches = stack::current_stack_branches(&current)?;
63
64    let (provider, review_provider) = match detect_review_provider() {
65        Ok(pair) => pair,
66        // A bare local repo - no remote and no provider configured (the demo
67        // provider sets one, so it isn't this case) - has no review state to
68        // sync against, so there is nothing to do rather than an error. A
69        // remote that exists but isn't recognized is a real config error and
70        // still surfaces.
71        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    // 3. Classify every branch: refresh metadata from open reviews, collect
87    //    merged ones for cleanup.
88    let mut merged = Vec::new();
89    let mut synced = 0;
90    let mut skipped = 0;
91
92    for branch in &branches {
93        // Closed-inclusive so a review closed without merging gets a
94        // truthful skip instead of "no review found".
95        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        // A closed review's base is dead state: surface it, but never let
131        // it drive the stack metadata.
132        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 review.branch == review.base {
145            bail!("refusing to set {branch} as its own stack parent");
146        }
147
148        if !dry_run {
149            stack::set_parent(branch, &review.base)?;
150            stack::record_base(branch, &review.base);
151        }
152        anstream::println!(
153            "{} {} -> {} {}",
154            if dry_run { "would sync" } else { "synced" },
155            style::branch(&review.branch),
156            style::branch(&review.base),
157            style::dim(&format!("({})", review.id))
158        );
159        synced += 1;
160    }
161
162    anstream::println!(
163        "{}",
164        style::success(&format!(
165            "sync complete: {synced} {}synced, {skipped} skipped",
166            if dry_run { "would be " } else { "" }
167        ))
168    );
169
170    // 4. Refresh the stack overview ledger in every review body while the
171    //    merged branches and their reviews are still resolvable, so their
172    //    entries get restyled rather than dropped.
173    let branch_parents = stack::branch_parents(&branches)?;
174    crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
175
176    let survivors: Vec<String> = branches
177        .iter()
178        .filter(|branch| !merged.contains(branch))
179        .cloned()
180        .collect();
181
182    // 5. Move off any branch that is about to be deleted, onto the first
183    //    survivor (the new stack bottom) or the trunk.
184    let mut position = current.clone();
185    if merged.contains(&current) {
186        let target = survivors
187            .first()
188            .cloned()
189            .or_else(|| trunk.clone())
190            .unwrap_or(root.clone());
191        if dry_run {
192            anstream::println!("would switch to {}", style::branch(&target));
193        } else {
194            git::checkout(&target)?;
195        }
196        position = target;
197    }
198
199    // 6. Clean up the merged branches: retarget children, then delete.
200    for branch in &merged {
201        cleanup_merged_branch(review_provider.as_ref(), branch, dry_run)?;
202        cleanup_branch_deletion(branch, &position, dry_run, true)?;
203    }
204
205    // 7. Restack the remainder (and push, per flags/config).
206    if dry_run {
207        anstream::println!("would restack the remaining stack");
208    } else if !survivors.is_empty() {
209        stack::restack(UpdateRefsMode::Config, push_mode, false)?;
210    }
211
212    // 8. Where to look next.
213    match survivors.first() {
214        Some(bottom) => match review_provider.review_for_branch(bottom)? {
215            Some(review) => anstream::println!(
216                "next up: {} -> {} {}",
217                style::branch(bottom),
218                review.id,
219                style::dim(&review.url)
220            ),
221            None => anstream::println!(
222                "next up: {} {}",
223                style::branch(bottom),
224                style::dim("(no review yet)")
225            ),
226        },
227        None => {
228            let base = trunk.unwrap_or(root);
229            anstream::println!(
230                "{}",
231                style::success(&format!("stack complete: everything merged into {base}"))
232            );
233        }
234    }
235
236    Ok(())
237}