Skip to main content

git_stk/commands/
sync.rs

1use std::collections::BTreeSet;
2
3use anyhow::{Result, bail};
4use clap::ArgAction;
5
6use crate::cli::{FetchMode, PushMode, UpdateRefsMode};
7use crate::commands::Run;
8use crate::commands::cleanup::{
9    Landing, cleanup_branch_deletion, cleanup_finished_branch, deletion_blocker, landing_for,
10    report_kept,
11};
12use crate::providers::{ReviewState, detect_review_provider};
13use crate::settings;
14use crate::style;
15use crate::{git, stack};
16
17/// Sync the stack with remote state: fetch the trunk, refresh metadata from
18/// reviews, clean up finished branches, then restack and push.
19#[derive(Debug, clap::Args)]
20pub struct Sync {
21    /// Print what would change without changing anything.
22    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
23    dry_run: bool,
24    /// Force-push (with lease) rebased branches after the restack.
25    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
26    push: bool,
27    /// Do not push rebased branches, overriding stk.pushOnRestack.
28    #[arg(long, action = ArgAction::SetTrue)]
29    no_push: bool,
30}
31
32impl Run for Sync {
33    fn run(self) -> Result<()> {
34        sync(self.dry_run, PushMode::from_flags(self.push, self.no_push))
35    }
36}
37
38pub(crate) fn sync(dry_run: bool, push_mode: PushMode) -> Result<()> {
39    let current = git::current_branch()?;
40    let local_branches = git::local_branches()?;
41    let trunk = stack::trunk_branch(&local_branches);
42
43    // Snapshot before the fetch/cleanup/restack rewrites anything. (When
44    // `merge` calls sync, merge has already snapshotted; this no-ops.)
45    if !dry_run {
46        stack::snapshot("sync");
47    }
48
49    // 1. Fetch the trunk so merged work is visible locally.
50    let remote = settings::remote()?;
51    let has_remote = git::remote_url(&remote)?.is_some();
52    if let Some(trunk) = &trunk {
53        if !has_remote {
54            anstream::println!("no remote {remote}; skipped fetch");
55        } else if stack::trunk_held_elsewhere(trunk)? {
56            // Nothing to do: git will not fetch into a trunk another worktree
57            // holds, and the sync runs against the local one.
58        } else if dry_run {
59            anstream::println!("would fetch {trunk} from {remote}");
60        } else if current == *trunk {
61            git::pull_ff_only()?;
62        } else {
63            git::fetch_branch(&remote, trunk)?;
64        }
65    }
66
67    // 2. The stack containing the current branch (the trunk itself has no
68    //    review and is never synced).
69    let root = stack::stack_root(&current)?;
70    let branches = stack::current_stack_branches(&current)?;
71
72    let (provider, review_provider) = match detect_review_provider() {
73        Ok(pair) => pair,
74        // A bare local repo - no remote and no provider configured (the demo
75        // provider sets one, so it isn't this case) - has no review state to
76        // sync against, so there is nothing to do rather than an error. A
77        // remote that exists but isn't recognized is a real config error and
78        // still surfaces.
79        Err(_) if !has_remote => {
80            if branches.is_empty() {
81                anstream::println!("no stacked branches to sync");
82            } else {
83                anstream::println!("no remote configured - nothing to sync");
84                anstream::println!(
85                    "{}",
86                    style::dim("run `git stk restack` to refresh local branches")
87                );
88            }
89            return Ok(());
90        }
91        Err(error) => return Err(error),
92    };
93
94    // 3. Classify every branch: refresh metadata from open reviews, collect
95    //    the finished ones for cleanup - merged, plus closed under
96    //    `stk.cleanClosed`. `closed` tracks which of them never reached the
97    //    trunk, since that changes both the cleanup and the final report.
98    let clean_closed = settings::bool_setting(settings::CLEAN_CLOSED_KEY)?;
99    let mut finished = Vec::new();
100    let mut closed = BTreeSet::new();
101    let mut synced = 0;
102    let mut skipped = 0;
103
104    for branch in &branches {
105        // Closed-inclusive so a review closed without merging gets a
106        // truthful skip instead of "no review found".
107        let Some(review) = review_provider.review_for_branch_including_closed(branch)? else {
108            anstream::println!(
109                "{}",
110                style::dim(&format!(
111                    "skipped {branch}: no {} review found",
112                    provider.kind
113                ))
114            );
115            skipped += 1;
116            continue;
117        };
118
119        if review.branch != *branch {
120            anstream::println!(
121                "{}",
122                style::dim(&format!(
123                    "skipped {branch}: {} review belongs to {}",
124                    provider.kind, review.branch
125                ))
126            );
127            skipped += 1;
128            continue;
129        }
130
131        if let Some(landing) = landing_for(&review.state, clean_closed) {
132            anstream::println!(
133                "{}: review {} is {}",
134                style::branch(branch),
135                review.id,
136                style::state(&review.state)
137            );
138            finished.push(branch.clone());
139            if landing == Landing::Closed {
140                closed.insert(branch.clone());
141            }
142            continue;
143        }
144
145        // A closed review's base is dead state: surface it, but never let
146        // it drive the stack metadata.
147        if review.state == ReviewState::Closed {
148            anstream::println!(
149                "{}",
150                style::dim(&format!(
151                    "skipped {branch}: review {} was closed without merging",
152                    review.id
153                ))
154            );
155            skipped += 1;
156            continue;
157        }
158
159        // If this branch's parent finished in this same sync, leave its retarget
160        // to cleanup_finished_branch (step 6): it decides the fork point from
161        // how the parent ended - pinned past a squash merge, dropped for a
162        // closed branch. Recording a base off the new parent here would lose
163        // that - the provider may have already retargeted the review to the
164        // trunk (GitLab does this when the parent branch is deleted).
165        if let Some(parent) = stack::parent_of(branch)?
166            && finished.contains(&parent)
167        {
168            continue;
169        }
170
171        if review.branch == review.base {
172            bail!("refusing to set {branch} as its own stack parent");
173        }
174
175        if !dry_run {
176            stack::set_parent(branch, &review.base)?;
177            stack::record_base(branch, &review.base);
178        }
179        anstream::println!(
180            "{} {} -> {} {}",
181            if dry_run { "would sync" } else { "synced" },
182            style::branch(&review.branch),
183            style::branch(&review.base),
184            style::dim(&format!("({})", review.id))
185        );
186        synced += 1;
187    }
188
189    anstream::println!(
190        "{}",
191        style::success(&format!(
192            "sync complete: {synced} {}synced, {skipped} skipped",
193            if dry_run { "would be " } else { "" }
194        ))
195    );
196
197    // 4. Refresh the stack overview ledger in every review body while the
198    //    finished branches and their reviews are still resolvable, so their
199    //    entries get restyled rather than dropped.
200    let branch_parents = stack::branch_parents(&branches)?;
201    crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
202
203    let survivors: Vec<String> = branches
204        .iter()
205        .filter(|branch| !finished.contains(branch))
206        .cloned()
207        .collect();
208
209    // 5. Move off any branch that is about to be deleted, onto the first
210    //    survivor (the new stack bottom) or the trunk.
211    let mut position = current.clone();
212    if finished.contains(&current) {
213        let target = survivors
214            .first()
215            .cloned()
216            .or_else(|| trunk.clone())
217            .unwrap_or(root.clone());
218        let held = git::worktree_holding(&target)?;
219        if let Some(path) = held {
220            // The place to land lives in another worktree. Staying put is not a
221            // failure - the review is already finished - and it leaves
222            // `position` on that branch, so the deletion below keeps it, which
223            // is right: it is still checked out right here.
224            anstream::println!(
225                "{}",
226                style::warn(&format!(
227                    "stayed on {current}: {target} is checked out in the worktree at {}",
228                    git::display_path(&path)
229                ))
230            );
231        } else if git::in_linked_worktree() {
232            // This worktree exists for the branch we are standing on. Checking
233            // the trunk out here would repoint someone's dedicated checkout,
234            // and - because the branch would no longer be held - let the
235            // deletion below take it while leaving the worktree behind with
236            // nothing pointing at it. Stay; the branch is kept below, and a
237            // cleanup from the main checkout can finish it.
238            anstream::println!(
239                "{}",
240                style::warn(&format!(
241                    "stayed on {current}: this is its own worktree, not the main checkout"
242                ))
243            );
244        } else if dry_run {
245            anstream::println!("would switch to {}", style::branch(&target));
246            position = target;
247        } else {
248            git::checkout(&target)?;
249            position = target;
250        }
251    }
252
253    // 6. Clean up the finished branches: retarget children, then delete. One
254    //    whose ref cannot go yet keeps its metadata, so it stays in the stack
255    //    for a later cleanup instead of quietly dropping out of it.
256    for branch in &finished {
257        let landing = if closed.contains(branch) {
258            Landing::Closed
259        } else {
260            Landing::Merged
261        };
262        if let Some(reason) = deletion_blocker(branch, &position)? {
263            report_kept(branch, &reason);
264            continue;
265        }
266        cleanup_finished_branch(review_provider.as_ref(), branch, landing, dry_run)?;
267        cleanup_branch_deletion(branch, landing, dry_run)?;
268    }
269
270    // 7. Restack the remainder (and push, per flags/config).
271    if dry_run {
272        anstream::println!("would restack the remaining stack");
273    } else if !survivors.is_empty() {
274        // sync already fetched the trunk in step 1, so the restack must not.
275        stack::restack(
276            FetchMode::Disabled,
277            UpdateRefsMode::Config,
278            push_mode,
279            false,
280        )?;
281    }
282
283    // 8. Where to look next.
284    match survivors.first() {
285        Some(bottom) => match review_provider.review_for_branch(bottom)? {
286            Some(review) => anstream::println!(
287                "next up: {} -> {} {}",
288                style::branch(bottom),
289                review.id,
290                style::dim(&review.url)
291            ),
292            None => anstream::println!(
293                "next up: {} {}",
294                style::branch(bottom),
295                style::dim("(no review yet)")
296            ),
297        },
298        None => {
299            let base = trunk.unwrap_or(root);
300            // Only claim a merge when there was one: a stack cleaned up under
301            // `stk.cleanClosed` may have been closed rather than landed.
302            let ending = if closed.is_empty() {
303                format!("stack complete: everything merged into {base}")
304            } else {
305                format!("stack complete: nothing left above {base} - merged or closed")
306            };
307            anstream::println!("{}", style::success(&ending));
308        }
309    }
310
311    Ok(())
312}