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    // A stack rooted off the trunk sits on a branch that is not part of it: no
73    // stack parent of its own, with layers stacked on top. Adopting it from
74    // its own review - a release PR into the trunk, say - would hand a shared
75    // branch to restack, which rebases and force-pushes it; letting it count
76    // as finished would delete it locally. It is the stack's base, so sync
77    // leaves its metadata and its ref alone. `repair` remains the explicit
78    // path for rebuilding a parent when that is genuinely what is wanted.
79    let base = stack::unanchored_base(&branches)?;
80
81    let (provider, review_provider) = match detect_review_provider() {
82        Ok(pair) => pair,
83        // A bare local repo - no remote and no provider configured (the demo
84        // provider sets one, so it isn't this case) - has no review state to
85        // sync against, so there is nothing to do rather than an error. A
86        // remote that exists but isn't recognized is a real config error and
87        // still surfaces.
88        Err(_) if !has_remote => {
89            if branches.is_empty() {
90                anstream::println!("no stacked branches to sync");
91            } else {
92                anstream::println!("no remote configured - nothing to sync");
93                anstream::println!(
94                    "{}",
95                    style::dim("run `git stk restack` to refresh local branches")
96                );
97            }
98            return Ok(());
99        }
100        Err(error) => return Err(error),
101    };
102
103    // 3. Classify every branch: refresh metadata from open reviews, collect
104    //    the finished ones for cleanup - merged, plus closed under
105    //    `stk.cleanClosed`. `closed` tracks which of them never reached the
106    //    trunk, since that changes both the cleanup and the final report.
107    let clean_closed = settings::bool_setting(settings::CLEAN_CLOSED_KEY)?;
108    let mut finished = Vec::new();
109    let mut closed = BTreeSet::new();
110    let mut synced = 0;
111    let mut skipped = 0;
112
113    for branch in &branches {
114        if Some(branch) == base.as_ref() {
115            // A recorded base is a fact; one read off the shape is a guess -
116            // and skipping it means its own metadata never gets rebuilt here.
117            // Say which, and name the command that does rebuild it.
118            let note = if stack::is_floor(branch)? {
119                format!("skipped {branch}: this stack's base")
120            } else {
121                format!(
122                    "skipped {branch}: nothing below it in this stack, so it reads as the base; \
123                     `git stk repair` if it is a stacked branch"
124                )
125            };
126            anstream::println!("{}", style::dim(&note));
127            skipped += 1;
128            continue;
129        }
130
131        // Closed-inclusive so a review closed without merging gets a
132        // truthful skip instead of "no review found".
133        let Some(review) = review_provider.review_for_branch_including_closed(branch)? else {
134            anstream::println!(
135                "{}",
136                style::dim(&format!(
137                    "skipped {branch}: no {} review found",
138                    provider.kind
139                ))
140            );
141            skipped += 1;
142            continue;
143        };
144
145        if review.branch != *branch {
146            anstream::println!(
147                "{}",
148                style::dim(&format!(
149                    "skipped {branch}: {} review belongs to {}",
150                    provider.kind, review.branch
151                ))
152            );
153            skipped += 1;
154            continue;
155        }
156
157        if let Some(landing) = landing_for(&review.state, clean_closed) {
158            anstream::println!(
159                "{}: review {} is {}",
160                style::branch(branch),
161                review.id,
162                style::state(&review.state)
163            );
164            finished.push(branch.clone());
165            if landing == Landing::Closed {
166                closed.insert(branch.clone());
167            }
168            continue;
169        }
170
171        // A closed review's base is dead state: surface it, but never let
172        // it drive the stack metadata.
173        if review.state == ReviewState::Closed {
174            anstream::println!(
175                "{}",
176                style::dim(&format!(
177                    "skipped {branch}: review {} was closed without merging",
178                    review.id
179                ))
180            );
181            skipped += 1;
182            continue;
183        }
184
185        // If this branch's parent finished in this same sync, leave its retarget
186        // to cleanup_finished_branch (step 6): it decides the fork point from
187        // how the parent ended - pinned past a squash merge, dropped for a
188        // closed branch. Recording a base off the new parent here would lose
189        // that - the provider may have already retargeted the review to the
190        // trunk (GitLab does this when the parent branch is deleted).
191        if let Some(parent) = stack::parent_of(branch)?
192            && finished.contains(&parent)
193        {
194            continue;
195        }
196
197        if review.branch == review.base {
198            bail!("refusing to set {branch} as its own stack parent");
199        }
200
201        if !dry_run {
202            stack::set_parent(branch, &review.base)?;
203            stack::record_base(branch, &review.base);
204        }
205        anstream::println!(
206            "{} {} -> {} {}",
207            if dry_run { "would sync" } else { "synced" },
208            style::branch(&review.branch),
209            style::branch(&review.base),
210            style::dim(&format!("({})", review.id))
211        );
212        synced += 1;
213    }
214
215    anstream::println!(
216        "{}",
217        style::success(&format!(
218            "sync complete: {synced} {}synced, {skipped} skipped",
219            if dry_run { "would be " } else { "" }
220        ))
221    );
222
223    // 4. Refresh the stack overview ledger in every review body while the
224    //    finished branches and their reviews are still resolvable, so their
225    //    entries get restyled rather than dropped.
226    let branch_parents = stack::branch_parents(&branches)?;
227    crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
228
229    let survivors: Vec<String> = branches
230        .iter()
231        .filter(|branch| !finished.contains(branch))
232        .cloned()
233        .collect();
234
235    // 5. Move off any branch that is about to be deleted, onto the first
236    //    survivor (the new stack bottom) or the trunk.
237    let mut position = current.clone();
238    if finished.contains(&current) {
239        let target = survivors
240            .first()
241            .cloned()
242            .or_else(|| trunk.clone())
243            .unwrap_or(root.clone());
244        let held = git::worktree_holding(&target)?;
245        if let Some(path) = held {
246            // The place to land lives in another worktree. Staying put is not a
247            // failure - the review is already finished - and it leaves
248            // `position` on that branch, so the deletion below keeps it, which
249            // is right: it is still checked out right here.
250            anstream::println!(
251                "{}",
252                style::warn(&format!(
253                    "stayed on {current}: {target} is checked out in the worktree at {}",
254                    git::display_path(&path)
255                ))
256            );
257        } else if git::in_linked_worktree() {
258            // This worktree exists for the branch we are standing on. Checking
259            // the trunk out here would repoint someone's dedicated checkout,
260            // and - because the branch would no longer be held - let the
261            // deletion below take it while leaving the worktree behind with
262            // nothing pointing at it. Stay; the branch is kept below, and a
263            // cleanup from the main checkout can finish it.
264            anstream::println!(
265                "{}",
266                style::warn(&format!(
267                    "stayed on {current}: this is its own worktree, not the main checkout"
268                ))
269            );
270        } else if dry_run {
271            anstream::println!("would switch to {}", style::branch(&target));
272            position = target;
273        } else {
274            git::checkout(&target)?;
275            position = target;
276        }
277    }
278
279    // 6. Clean up the finished branches: retarget children, then delete. One
280    //    whose ref cannot go yet keeps its metadata, so it stays in the stack
281    //    for a later cleanup instead of quietly dropping out of it.
282    for branch in &finished {
283        let landing = if closed.contains(branch) {
284            Landing::Closed
285        } else {
286            Landing::Merged
287        };
288        if let Some(reason) = deletion_blocker(branch, &position)? {
289            report_kept(branch, &reason);
290            continue;
291        }
292        cleanup_finished_branch(review_provider.as_ref(), branch, landing, dry_run)?;
293        cleanup_branch_deletion(branch, landing, dry_run)?;
294    }
295
296    // 7. Restack the remainder (and push, per flags/config).
297    if dry_run {
298        anstream::println!("would restack the remaining stack");
299    } else if !survivors.is_empty() {
300        // sync already fetched the trunk in step 1, so the restack must not.
301        stack::restack(
302            FetchMode::Disabled,
303            UpdateRefsMode::Config,
304            push_mode,
305            false,
306        )?;
307    }
308
309    // 8. Where to look next: the lowest surviving layer. The base is not one -
310    //    there is nothing of ours to review or land on it.
311    match survivors
312        .iter()
313        .find(|branch| Some(*branch) != base.as_ref())
314    {
315        Some(bottom) => match review_provider.review_for_branch(bottom)? {
316            Some(review) => anstream::println!(
317                "next up: {} -> {} {}",
318                style::branch(bottom),
319                review.id,
320                style::dim(&review.url)
321            ),
322            None => anstream::println!(
323                "next up: {} {}",
324                style::branch(bottom),
325                style::dim("(no review yet)")
326            ),
327        },
328        None => {
329            // The layers landed in whatever the stack sits on: its own base
330            // when it is rooted off the trunk, the trunk otherwise.
331            let landed_into = base.clone().or(trunk).unwrap_or(root);
332            // Only claim a merge when there was one: a stack cleaned up under
333            // `stk.cleanClosed` may have been closed rather than landed.
334            let ending = if closed.is_empty() {
335                format!("stack complete: everything merged into {landed_into}")
336            } else {
337                format!("stack complete: nothing left above {landed_into} - merged or closed")
338            };
339            anstream::println!("{}", style::success(&ending));
340        }
341    }
342
343    Ok(())
344}