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        let Some(review) = review_provider.review_for_branch(branch)? else {
70            println!("skipped {branch}: no {} review found", provider.kind);
71            skipped += 1;
72            continue;
73        };
74
75        if review.branch != *branch {
76            println!(
77                "skipped {branch}: {} review belongs to {}",
78                provider.kind, review.branch
79            );
80            skipped += 1;
81            continue;
82        }
83
84        if review.state == ReviewState::Merged {
85            println!("{branch}: review {} is merged", review.id);
86            merged.push(branch.clone());
87            continue;
88        }
89
90        if review.branch == review.base {
91            bail!("refusing to set {branch} as its own stack parent");
92        }
93
94        if !dry_run {
95            stack::set_parent_for_branch(branch, &review.base)?;
96            stack::record_base(branch, &review.base);
97        }
98        println!(
99            "{} {} -> {} ({})",
100            if dry_run { "would sync" } else { "synced" },
101            review.branch,
102            review.base,
103            review.id
104        );
105        synced += 1;
106    }
107
108    println!(
109        "sync complete: {synced} {}synced, {skipped} skipped",
110        if dry_run { "would be " } else { "" }
111    );
112
113    let survivors: Vec<String> = branches
114        .iter()
115        .filter(|branch| !merged.contains(branch))
116        .cloned()
117        .collect();
118
119    // 4. Move off any branch that is about to be deleted, onto the first
120    //    survivor (the new stack bottom) or the trunk.
121    let mut position = current.clone();
122    if merged.contains(&current) {
123        let target = survivors
124            .first()
125            .cloned()
126            .or_else(|| trunk.clone())
127            .unwrap_or(root.clone());
128        if dry_run {
129            println!("would switch to {target}");
130        } else {
131            git::checkout(&target)?;
132        }
133        position = target;
134    }
135
136    // 5. Clean up the merged branches: retarget children, then delete.
137    for branch in &merged {
138        cleanup_merged_branch(review_provider.as_ref(), branch, dry_run)?;
139        cleanup_branch_deletion(branch, &position, dry_run, true)?;
140    }
141
142    // 6. Restack the remainder (and push, per flags/config).
143    if dry_run {
144        println!("would restack the remaining stack");
145    } else if !survivors.is_empty() {
146        stack::restack(UpdateRefsMode::Config, push_mode)?;
147    }
148
149    // 7. Where to look next.
150    match survivors.first() {
151        Some(bottom) => match review_provider.review_for_branch(bottom)? {
152            Some(review) => println!("next up: {bottom} -> {} {}", review.id, review.url),
153            None => println!("next up: {bottom} (no review yet)"),
154        },
155        None => {
156            let base = trunk.unwrap_or(root);
157            println!("stack complete: everything merged into {base}");
158        }
159    }
160
161    Ok(())
162}