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