Skip to main content

git_stk/commands/
sync.rs

1use anyhow::{Result, bail};
2use clap::ArgAction;
3
4use crate::cli::{FetchMode, 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 stack::trunk_held_elsewhere(trunk)? {
51            // Nothing to do: git will not fetch into a trunk another worktree
52            // holds, and the sync runs against the local one.
53        } else if dry_run {
54            anstream::println!("would fetch {trunk} from {remote}");
55        } else if current == *trunk {
56            git::pull_ff_only()?;
57        } else {
58            git::fetch_branch(&remote, trunk)?;
59        }
60    }
61
62    // 2. The stack containing the current branch (the trunk itself has no
63    //    review and is never synced).
64    let root = stack::stack_root(&current)?;
65    let branches = stack::current_stack_branches(&current)?;
66
67    let (provider, review_provider) = match detect_review_provider() {
68        Ok(pair) => pair,
69        // A bare local repo - no remote and no provider configured (the demo
70        // provider sets one, so it isn't this case) - has no review state to
71        // sync against, so there is nothing to do rather than an error. A
72        // remote that exists but isn't recognized is a real config error and
73        // still surfaces.
74        Err(_) if !has_remote => {
75            if branches.is_empty() {
76                anstream::println!("no stacked branches to sync");
77            } else {
78                anstream::println!("no remote configured - nothing to sync");
79                anstream::println!(
80                    "{}",
81                    style::dim("run `git stk restack` to refresh local branches")
82                );
83            }
84            return Ok(());
85        }
86        Err(error) => return Err(error),
87    };
88
89    // 3. Classify every branch: refresh metadata from open reviews, collect
90    //    merged ones for cleanup.
91    let mut merged = Vec::new();
92    let mut synced = 0;
93    let mut skipped = 0;
94
95    for branch in &branches {
96        // Closed-inclusive so a review closed without merging gets a
97        // truthful skip instead of "no review found".
98        let Some(review) = review_provider.review_for_branch_including_closed(branch)? else {
99            anstream::println!(
100                "{}",
101                style::dim(&format!(
102                    "skipped {branch}: no {} review found",
103                    provider.kind
104                ))
105            );
106            skipped += 1;
107            continue;
108        };
109
110        if review.branch != *branch {
111            anstream::println!(
112                "{}",
113                style::dim(&format!(
114                    "skipped {branch}: {} review belongs to {}",
115                    provider.kind, review.branch
116                ))
117            );
118            skipped += 1;
119            continue;
120        }
121
122        if review.state == ReviewState::Merged {
123            anstream::println!(
124                "{}: review {} is {}",
125                style::branch(branch),
126                review.id,
127                style::state(&review.state)
128            );
129            merged.push(branch.clone());
130            continue;
131        }
132
133        // A closed review's base is dead state: surface it, but never let
134        // it drive the stack metadata.
135        if review.state == ReviewState::Closed {
136            anstream::println!(
137                "{}",
138                style::dim(&format!(
139                    "skipped {branch}: review {} was closed without merging",
140                    review.id
141                ))
142            );
143            skipped += 1;
144            continue;
145        }
146
147        // If this branch's parent merged in this same sync, leave its retarget
148        // to cleanup_merged_branch (step 6): it pins the fork point off the
149        // merged parent so the restack drops squash-merged commits instead of
150        // replaying them. Recording a base off the new parent here would lose
151        // that fork point - the provider may have already retargeted the review
152        // to the trunk (GitLab does this when the parent branch is deleted).
153        if let Some(parent) = stack::parent_of(branch)?
154            && merged.contains(&parent)
155        {
156            continue;
157        }
158
159        if review.branch == review.base {
160            bail!("refusing to set {branch} as its own stack parent");
161        }
162
163        if !dry_run {
164            stack::set_parent(branch, &review.base)?;
165            stack::record_base(branch, &review.base);
166        }
167        anstream::println!(
168            "{} {} -> {} {}",
169            if dry_run { "would sync" } else { "synced" },
170            style::branch(&review.branch),
171            style::branch(&review.base),
172            style::dim(&format!("({})", review.id))
173        );
174        synced += 1;
175    }
176
177    anstream::println!(
178        "{}",
179        style::success(&format!(
180            "sync complete: {synced} {}synced, {skipped} skipped",
181            if dry_run { "would be " } else { "" }
182        ))
183    );
184
185    // 4. Refresh the stack overview ledger in every review body while the
186    //    merged branches and their reviews are still resolvable, so their
187    //    entries get restyled rather than dropped.
188    let branch_parents = stack::branch_parents(&branches)?;
189    crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
190
191    let survivors: Vec<String> = branches
192        .iter()
193        .filter(|branch| !merged.contains(branch))
194        .cloned()
195        .collect();
196
197    // 5. Move off any branch that is about to be deleted, onto the first
198    //    survivor (the new stack bottom) or the trunk.
199    let mut position = current.clone();
200    if merged.contains(&current) {
201        let target = survivors
202            .first()
203            .cloned()
204            .or_else(|| trunk.clone())
205            .unwrap_or(root.clone());
206        let held = git::worktree_holding(&target)?;
207        if let Some(path) = held {
208            // The place to land lives in another worktree. Staying put is not a
209            // failure - the merge already happened - and it leaves `position` on
210            // the merged branch, so the deletion below keeps it, which is right:
211            // it is still checked out right here.
212            anstream::println!(
213                "{}",
214                style::warn(&format!(
215                    "stayed on {current}: {target} is checked out in the worktree at {}",
216                    git::display_path(&path)
217                ))
218            );
219        } else if dry_run {
220            anstream::println!("would switch to {}", style::branch(&target));
221            position = target;
222        } else {
223            git::checkout(&target)?;
224            position = target;
225        }
226    }
227
228    // 6. Clean up the merged branches: retarget children, then delete.
229    for branch in &merged {
230        cleanup_merged_branch(review_provider.as_ref(), branch, dry_run)?;
231        cleanup_branch_deletion(branch, &position, dry_run, true)?;
232    }
233
234    // 7. Restack the remainder (and push, per flags/config).
235    if dry_run {
236        anstream::println!("would restack the remaining stack");
237    } else if !survivors.is_empty() {
238        // sync already fetched the trunk in step 1, so the restack must not.
239        stack::restack(
240            FetchMode::Disabled,
241            UpdateRefsMode::Config,
242            push_mode,
243            false,
244        )?;
245    }
246
247    // 8. Where to look next.
248    match survivors.first() {
249        Some(bottom) => match review_provider.review_for_branch(bottom)? {
250            Some(review) => anstream::println!(
251                "next up: {} -> {} {}",
252                style::branch(bottom),
253                review.id,
254                style::dim(&review.url)
255            ),
256            None => anstream::println!(
257                "next up: {} {}",
258                style::branch(bottom),
259                style::dim("(no review yet)")
260            ),
261        },
262        None => {
263            let base = trunk.unwrap_or(root);
264            anstream::println!(
265                "{}",
266                style::success(&format!("stack complete: everything merged into {base}"))
267            );
268        }
269    }
270
271    Ok(())
272}