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_provider, 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, 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    if let Some(trunk) = &trunk {
47        if git::remote_url(&remote)?.is_none() {
48            println!("no remote {remote}; skipped fetch");
49        } else if dry_run {
50            println!("would fetch {trunk} from {remote}");
51        } else if current == *trunk {
52            git::pull_ff_only()?;
53        } else {
54            git::fetch_branch(&remote, trunk)?;
55        }
56    }
57
58    // 2. The stack containing the current branch (the trunk itself has no
59    //    review and is never synced).
60    let root = stack::stack_root(&current)?;
61    let branches: Vec<String> = stack::branch_and_descendants(&root)?
62        .into_iter()
63        .filter(|branch| Some(branch) != trunk.as_ref())
64        .collect();
65
66    let provider = detect_provider()?;
67    let review_provider = review_provider(provider.kind);
68
69    // 3. Classify every branch: refresh metadata from open reviews, collect
70    //    merged ones for cleanup.
71    let mut merged = Vec::new();
72    let mut synced = 0;
73    let mut skipped = 0;
74
75    for branch in &branches {
76        // Closed-inclusive so a review closed without merging gets a
77        // truthful skip instead of "no review found".
78        let Some(review) = review_provider.review_for_branch_including_closed(branch)? else {
79            anstream::println!(
80                "{}",
81                style::dim(&format!(
82                    "skipped {branch}: no {} review found",
83                    provider.kind
84                ))
85            );
86            skipped += 1;
87            continue;
88        };
89
90        if review.branch != *branch {
91            anstream::println!(
92                "{}",
93                style::dim(&format!(
94                    "skipped {branch}: {} review belongs to {}",
95                    provider.kind, review.branch
96                ))
97            );
98            skipped += 1;
99            continue;
100        }
101
102        if review.state == ReviewState::Merged {
103            anstream::println!(
104                "{}: review {} is {}",
105                style::branch(branch),
106                review.id,
107                style::state(&review.state)
108            );
109            merged.push(branch.clone());
110            continue;
111        }
112
113        // A closed review's base is dead state: surface it, but never let
114        // it drive the stack metadata.
115        if review.state == ReviewState::Closed {
116            anstream::println!(
117                "{}",
118                style::dim(&format!(
119                    "skipped {branch}: review {} was closed without merging",
120                    review.id
121                ))
122            );
123            skipped += 1;
124            continue;
125        }
126
127        if review.branch == review.base {
128            bail!("refusing to set {branch} as its own stack parent");
129        }
130
131        if !dry_run {
132            stack::set_parent_for_branch(branch, &review.base)?;
133            stack::record_base(branch, &review.base);
134        }
135        anstream::println!(
136            "{} {} -> {} {}",
137            if dry_run { "would sync" } else { "synced" },
138            style::branch(&review.branch),
139            style::branch(&review.base),
140            style::dim(&format!("({})", review.id))
141        );
142        synced += 1;
143    }
144
145    anstream::println!(
146        "{}",
147        style::success(&format!(
148            "sync complete: {synced} {}synced, {skipped} skipped",
149            if dry_run { "would be " } else { "" }
150        ))
151    );
152
153    // 4. Refresh the stack overview ledger in every review body while the
154    //    merged branches and their reviews are still resolvable, so their
155    //    entries get restyled rather than dropped.
156    let branch_parents = stack::branch_parents(&branches)?;
157    crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run)?;
158
159    let survivors: Vec<String> = branches
160        .iter()
161        .filter(|branch| !merged.contains(branch))
162        .cloned()
163        .collect();
164
165    // 5. Move off any branch that is about to be deleted, onto the first
166    //    survivor (the new stack bottom) or the trunk.
167    let mut position = current.clone();
168    if merged.contains(&current) {
169        let target = survivors
170            .first()
171            .cloned()
172            .or_else(|| trunk.clone())
173            .unwrap_or(root.clone());
174        if dry_run {
175            anstream::println!("would switch to {}", style::branch(&target));
176        } else {
177            git::checkout(&target)?;
178        }
179        position = target;
180    }
181
182    // 6. Clean up the merged branches: retarget children, then delete.
183    for branch in &merged {
184        cleanup_merged_branch(review_provider.as_ref(), branch, dry_run)?;
185        cleanup_branch_deletion(branch, &position, dry_run, true)?;
186    }
187
188    // 7. Restack the remainder (and push, per flags/config).
189    if dry_run {
190        println!("would restack the remaining stack");
191    } else if !survivors.is_empty() {
192        stack::restack(UpdateRefsMode::Config, push_mode, false)?;
193    }
194
195    // 8. Where to look next.
196    match survivors.first() {
197        Some(bottom) => match review_provider.review_for_branch(bottom)? {
198            Some(review) => anstream::println!(
199                "next up: {} -> {} {}",
200                style::branch(bottom),
201                review.id,
202                style::dim(&review.url)
203            ),
204            None => anstream::println!(
205                "next up: {} {}",
206                style::branch(bottom),
207                style::dim("(no review yet)")
208            ),
209        },
210        None => {
211            let base = trunk.unwrap_or(root);
212            anstream::println!(
213                "{}",
214                style::success(&format!("stack complete: everything merged into {base}"))
215            );
216        }
217    }
218
219    Ok(())
220}