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