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::{git, stack};
10
11/// Sync the stack with remote state: fetch the trunk, refresh metadata from
12/// reviews, clean up merged branches, then restack and push.
13#[derive(Debug, clap::Args)]
14pub struct Sync {
15    /// Print what would change without changing anything.
16    #[arg(long, action = ArgAction::SetTrue)]
17    dry_run: bool,
18    /// Force-push (with lease) rebased branches after the restack.
19    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
20    push: bool,
21    /// Do not push rebased branches, overriding stk.pushOnRestack.
22    #[arg(long, action = ArgAction::SetTrue)]
23    no_push: bool,
24}
25
26impl Run for Sync {
27    fn run(self) -> Result<()> {
28        sync(self.dry_run, PushMode::from_flags(self.push, self.no_push))
29    }
30}
31
32pub(crate) fn sync(dry_run: bool, push_mode: PushMode) -> Result<()> {
33    let current = git::current_branch()?;
34    let local_branches = git::local_branches()?;
35    let trunk = stack::trunk_branch(&local_branches);
36
37    // 1. Fetch the trunk so merged work is visible locally.
38    let remote = settings::remote()?;
39    if let Some(trunk) = &trunk {
40        if git::remote_url(&remote)?.is_none() {
41            println!("no remote {remote}; skipped fetch");
42        } else if dry_run {
43            println!("would fetch {trunk} from {remote}");
44        } else if current == *trunk {
45            git::pull_ff_only()?;
46        } else {
47            git::fetch_branch(&remote, trunk)?;
48        }
49    }
50
51    // 2. The stack containing the current branch (the trunk itself has no
52    //    review and is never synced).
53    let root = stack::stack_root(&current)?;
54    let branches: Vec<String> = stack::branch_and_descendants(&root)?
55        .into_iter()
56        .filter(|branch| Some(branch) != trunk.as_ref())
57        .collect();
58
59    let provider = detect_provider()?;
60    let review_provider = review_provider(provider.kind);
61
62    // 3. Classify every branch: refresh metadata from open reviews, collect
63    //    merged ones for cleanup.
64    let mut merged = Vec::new();
65    let mut synced = 0;
66    let mut skipped = 0;
67
68    for branch in &branches {
69        // Closed-inclusive so a review closed without merging gets a
70        // truthful skip instead of "no review found".
71        let Some(review) = review_provider.review_for_branch_including_closed(branch)? else {
72            println!("skipped {branch}: no {} review found", provider.kind);
73            skipped += 1;
74            continue;
75        };
76
77        if review.branch != *branch {
78            println!(
79                "skipped {branch}: {} review belongs to {}",
80                provider.kind, review.branch
81            );
82            skipped += 1;
83            continue;
84        }
85
86        if review.state == ReviewState::Merged {
87            println!("{branch}: review {} is merged", review.id);
88            merged.push(branch.clone());
89            continue;
90        }
91
92        // A closed review's base is dead state: surface it, but never let
93        // it drive the stack metadata.
94        if review.state == ReviewState::Closed {
95            println!(
96                "skipped {branch}: review {} was closed without merging",
97                review.id
98            );
99            skipped += 1;
100            continue;
101        }
102
103        if review.branch == review.base {
104            bail!("refusing to set {branch} as its own stack parent");
105        }
106
107        if !dry_run {
108            stack::set_parent_for_branch(branch, &review.base)?;
109            stack::record_base(branch, &review.base);
110        }
111        println!(
112            "{} {} -> {} ({})",
113            if dry_run { "would sync" } else { "synced" },
114            review.branch,
115            review.base,
116            review.id
117        );
118        synced += 1;
119    }
120
121    println!(
122        "sync complete: {synced} {}synced, {skipped} skipped",
123        if dry_run { "would be " } else { "" }
124    );
125
126    // 4. Refresh the stack overview ledger in every review body while the
127    //    merged branches and their reviews are still resolvable, so their
128    //    entries get restyled rather than dropped.
129    let mut branch_parents = Vec::new();
130    for branch in &branches {
131        if let Some(parent) = stack::parent_for_branch(branch)? {
132            branch_parents.push((branch.clone(), parent));
133        }
134    }
135    crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run)?;
136
137    let survivors: Vec<String> = branches
138        .iter()
139        .filter(|branch| !merged.contains(branch))
140        .cloned()
141        .collect();
142
143    // 5. Move off any branch that is about to be deleted, onto the first
144    //    survivor (the new stack bottom) or the trunk.
145    let mut position = current.clone();
146    if merged.contains(&current) {
147        let target = survivors
148            .first()
149            .cloned()
150            .or_else(|| trunk.clone())
151            .unwrap_or(root.clone());
152        if dry_run {
153            println!("would switch to {target}");
154        } else {
155            git::checkout(&target)?;
156        }
157        position = target;
158    }
159
160    // 6. Clean up the merged branches: retarget children, then delete.
161    for branch in &merged {
162        cleanup_merged_branch(review_provider.as_ref(), branch, dry_run)?;
163        cleanup_branch_deletion(branch, &position, dry_run, true)?;
164    }
165
166    // 7. Restack the remainder (and push, per flags/config).
167    if dry_run {
168        println!("would restack the remaining stack");
169    } else if !survivors.is_empty() {
170        stack::restack(UpdateRefsMode::Config, push_mode)?;
171    }
172
173    // 8. Where to look next.
174    match survivors.first() {
175        Some(bottom) => match review_provider.review_for_branch(bottom)? {
176            Some(review) => println!("next up: {bottom} -> {} {}", review.id, review.url),
177            None => println!("next up: {bottom} (no review yet)"),
178        },
179        None => {
180            let base = trunk.unwrap_or(root);
181            println!("stack complete: everything merged into {base}");
182        }
183    }
184
185    Ok(())
186}