Skip to main content

git_stk/commands/
repair.rs

1use anyhow::Result;
2use clap::ArgAction;
3
4use crate::commands::Run;
5use crate::providers::{detect_review_provider, owned_review_for_branch};
6use crate::style;
7use crate::{git, settings, stack};
8
9/// Rebuild or verify local stack metadata from reviews and ancestry.
10#[derive(Debug, clap::Args)]
11pub struct Repair {
12    /// Print what would change without updating local metadata.
13    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
14    dry_run: bool,
15    /// Rebuild the stack from the metadata another machine pushed, fetching
16    /// any of its branches that are missing locally.
17    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "dry_run")]
18    from_remote: bool,
19}
20
21impl Run for Repair {
22    fn run(self) -> Result<()> {
23        if self.from_remote {
24            repair_from_remote()
25        } else {
26            repair(self.dry_run)
27        }
28    }
29}
30
31/// Rehydrate a stack on this machine from the metadata ref pushed elsewhere.
32fn repair_from_remote() -> Result<()> {
33    let remote = settings::remote()?;
34    let attached = stack::apply_remote_metadata(&remote)?;
35    anstream::println!(
36        "{}",
37        style::success(&format!(
38            "rebuilt {attached} branch{} from {remote}",
39            if attached == 1 { "" } else { "es" }
40        ))
41    );
42    Ok(())
43}
44
45/// Rebuild or verify local stack metadata. For branches missing a parent,
46/// try the provider's review base first, then nearest-ancestor inference.
47/// For branches with a parent, verify it exists and the recorded fork point
48/// is still valid, re-deriving it when stale.
49pub fn repair(dry_run: bool) -> Result<()> {
50    let branches = git::local_branches()?;
51    let trunk = stack::trunk_branch(&branches);
52
53    // Provider lookup is best effort: repair must work without a remote or
54    // an authenticated gh/glab.
55    let provider = detect_review_provider()
56        .ok()
57        .map(|(detected, client)| (detected.kind, client));
58
59    let mut repaired = 0;
60    let mut verified = 0;
61    let mut unresolved = 0;
62
63    for branch in &branches {
64        if Some(branch.as_str()) == trunk.as_deref() {
65            continue;
66        }
67
68        if let Some(parent) = stack::parent_of(branch)? {
69            if !branches.contains(&parent) {
70                anstream::println!(
71                    "{}",
72                    style::warn(&format!(
73                        "{branch}: parent {parent} does not exist locally; \
74                         fix with `git stk adopt` or `git stk detach {branch}`"
75                    ))
76                );
77                unresolved += 1;
78                continue;
79            }
80
81            if stack::base_is_current(branch, &parent)? {
82                verified += 1;
83            } else {
84                anstream::println!(
85                    "{}: {} fork point from {}",
86                    style::branch(branch),
87                    if dry_run {
88                        "would re-record"
89                    } else {
90                        "re-recorded"
91                    },
92                    style::branch(&parent)
93                );
94                if !dry_run {
95                    stack::record_base(branch, &parent);
96                }
97                repaired += 1;
98            }
99            continue;
100        }
101
102        let mut found: Option<(String, String)> = None;
103        if let Some((kind, review_provider)) = &provider
104            && let Ok(Some(review)) = owned_review_for_branch(&**review_provider, branch)
105            && review.base != *branch
106        {
107            if branches.contains(&review.base) {
108                found = Some((review.base.clone(), format!("{kind} review {}", review.id)));
109            } else {
110                anstream::println!(
111                    "{}",
112                    style::warn(&format!(
113                        "{branch}: review {} targets {}, which is not a local branch",
114                        review.id, review.base
115                    ))
116                );
117            }
118        }
119
120        if found.is_none() {
121            match nearest_ancestor_branch(branch, &branches)? {
122                Ancestry::One(parent) => found = Some((parent, "ancestry".to_owned())),
123                Ancestry::None => {
124                    anstream::println!(
125                        "{}",
126                        style::warn(&format!(
127                            "{branch}: no parent found; attach manually with \
128                             `git stk adopt {branch} --parent <parent>`"
129                        ))
130                    );
131                }
132                Ancestry::Ambiguous(candidates) => {
133                    anstream::println!(
134                        "{}",
135                        style::warn(&format!(
136                            "{branch}: ambiguous parent candidates ({}); attach manually with \
137                             `git stk adopt`",
138                            candidates.join(", ")
139                        ))
140                    );
141                }
142            }
143        }
144
145        match found {
146            Some((parent, source)) => {
147                anstream::println!(
148                    "{}: {} parent {} {}",
149                    style::branch(branch),
150                    if dry_run { "would set" } else { "set" },
151                    style::branch(&parent),
152                    style::dim(&format!("(from {source})"))
153                );
154                if !dry_run {
155                    stack::set_parent(branch, &parent)?;
156                    stack::record_base(branch, &parent);
157                }
158                repaired += 1;
159            }
160            None => unresolved += 1,
161        }
162    }
163
164    repaired += repair_worktree_ownership(&branches, dry_run)?;
165
166    anstream::println!(
167        "{}",
168        style::success(&format!(
169            "repair complete: {repaired} {}repaired, {verified} verified, {unresolved} unresolved",
170            if dry_run { "would be " } else { "" }
171        ))
172    );
173    Ok(())
174}
175
176/// Reconcile `branch.<name>.stkWorktree` - the marker saying git-stk created a
177/// branch's worktree, and so may remove it when the branch lands.
178///
179/// It drifts in both directions: the recorded worktree can be removed by hand,
180/// leaving a marker pointing at nothing; and the marker can be lost (a fresh
181/// clone, a wiped config) while the worktree is still there, which would strand
182/// it forever since cleanup only removes what it owns.
183fn repair_worktree_ownership(branches: &[String], dry_run: bool) -> Result<usize> {
184    let mut repaired = 0;
185
186    for branch in branches {
187        let recorded = stack::recorded_worktree(branch);
188        let actual = git::worktree_holding(branch)?;
189
190        match (&recorded, &actual) {
191            // Marker with no worktree behind it, or pointing somewhere the
192            // branch no longer lives.
193            (Some(path), actual) if Some(path) != actual.as_ref() => {
194                anstream::println!(
195                    "{}: {} stale worktree marker {}",
196                    style::branch(branch),
197                    if dry_run { "would clear" } else { "cleared" },
198                    style::dim(&git::display_path(path))
199                );
200                if !dry_run {
201                    stack::unset_owned_worktree(branch)?;
202                }
203                repaired += 1;
204            }
205            // An unmarked worktree sitting exactly where `new --worktree` would
206            // have put it: that directory is git-stk's, so claim it back rather
207            // than leaving it unownable.
208            (None, Some(path))
209                if settings::worktree_path_for(branch)
210                    .is_ok_and(|expected| git::same_path(&expected, path)) =>
211            {
212                anstream::println!(
213                    "{}: {} worktree {}",
214                    style::branch(branch),
215                    if dry_run {
216                        "would re-adopt"
217                    } else {
218                        "re-adopted"
219                    },
220                    style::dim(&git::display_path(path))
221                );
222                if !dry_run {
223                    stack::set_owned_worktree(branch, path)?;
224                }
225                repaired += 1;
226            }
227            _ => {}
228        }
229    }
230
231    Ok(repaired)
232}
233
234enum Ancestry {
235    One(String),
236    None,
237    Ambiguous(Vec<String>),
238}
239
240/// Find the nearest other local branch whose tip is a strict ancestor of
241/// `branch` - the best guess at its stack parent.
242fn nearest_ancestor_branch(branch: &str, branches: &[String]) -> Result<Ancestry> {
243    let tip = git::rev_parse(branch)?;
244
245    let mut candidates: Vec<(String, String)> = Vec::new();
246    for other in branches {
247        if other == branch {
248            continue;
249        }
250        let other_tip = git::rev_parse(other)?;
251        // Equal tips (e.g. a just-created branch) leave the direction
252        // ambiguous, so they are not usable candidates.
253        if other_tip != tip && git::is_ancestor(other, branch)? {
254            candidates.push((other.clone(), other_tip));
255        }
256    }
257
258    // Keep only the nearest candidates: drop any that are ancestors of
259    // another candidate (i.e. further from the branch).
260    let nearest: Vec<String> = candidates
261        .iter()
262        .filter(|(candidate, candidate_tip)| {
263            !candidates.iter().any(|(other, other_tip)| {
264                other != candidate
265                    && other_tip != candidate_tip
266                    && git::is_ancestor(candidate, other).unwrap_or(false)
267            })
268        })
269        .map(|(candidate, _)| candidate.clone())
270        .collect();
271
272    Ok(match nearest.len() {
273        0 => Ancestry::None,
274        1 => Ancestry::One(nearest.into_iter().next().expect("one candidate")),
275        _ => Ancestry::Ambiguous(nearest),
276    })
277}