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 branch_parents = stack::branch_parents(&branches)?;
130    crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run)?;
131
132    let survivors: Vec<String> = branches
133        .iter()
134        .filter(|branch| !merged.contains(branch))
135        .cloned()
136        .collect();
137
138    // 5. Move off any branch that is about to be deleted, onto the first
139    //    survivor (the new stack bottom) or the trunk.
140    let mut position = current.clone();
141    if merged.contains(&current) {
142        let target = survivors
143            .first()
144            .cloned()
145            .or_else(|| trunk.clone())
146            .unwrap_or(root.clone());
147        if dry_run {
148            println!("would switch to {target}");
149        } else {
150            git::checkout(&target)?;
151        }
152        position = target;
153    }
154
155    // 6. Clean up the merged branches: retarget children, then delete.
156    for branch in &merged {
157        cleanup_merged_branch(review_provider.as_ref(), branch, dry_run)?;
158        cleanup_branch_deletion(branch, &position, dry_run, true)?;
159    }
160
161    // 7. Restack the remainder (and push, per flags/config).
162    if dry_run {
163        println!("would restack the remaining stack");
164    } else if !survivors.is_empty() {
165        stack::restack(UpdateRefsMode::Config, push_mode)?;
166    }
167
168    // 8. Where to look next.
169    match survivors.first() {
170        Some(bottom) => match review_provider.review_for_branch(bottom)? {
171            Some(review) => println!("next up: {bottom} -> {} {}", review.id, review.url),
172            None => println!("next up: {bottom} (no review yet)"),
173        },
174        None => {
175            let base = trunk.unwrap_or(root);
176            println!("stack complete: everything merged into {base}");
177        }
178    }
179
180    Ok(())
181}