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        // A recorded base is not missing a parent - it is the branch the stack
69        // sits on. Inferring one from its own review would write metadata that
70        // contradicts the marker, and that every rewrite path then ignores.
71        if stack::is_floor(branch)? {
72            anstream::println!(
73                "{}",
74                style::dim(&format!(
75                    "{branch}: stack base, left alone \
76                     (`git stk detach {branch}` if it is not)"
77                ))
78            );
79            verified += 1;
80            continue;
81        }
82
83        if let Some(parent) = stack::parent_of(branch)? {
84            if !branches.contains(&parent) {
85                anstream::println!(
86                    "{}",
87                    style::warn(&format!(
88                        "{branch}: parent {parent} does not exist locally; \
89                         fix with `git stk adopt {branch} --parent <parent>` \
90                         or `git stk detach {branch}`"
91                    ))
92                );
93                unresolved += 1;
94                continue;
95            }
96
97            if stack::base_is_current(branch, &parent)? {
98                verified += 1;
99            } else {
100                anstream::println!(
101                    "{}: {} fork point from {}",
102                    style::branch(branch),
103                    if dry_run {
104                        "would re-record"
105                    } else {
106                        "re-recorded"
107                    },
108                    style::branch(&parent)
109                );
110                if !dry_run {
111                    stack::record_base(branch, &parent);
112                }
113                repaired += 1;
114            }
115            continue;
116        }
117
118        let mut found: Option<(String, String)> = None;
119
120        // The platform's own stack first, when it keeps one and its answer is
121        // still current: it is an ordering someone stated rather than one
122        // inferred, and it survives a wiped `.git/config`.
123        //
124        // Current is the load-bearing word. The platform keeps a landed layer
125        // listed, so the ordering goes on naming a merged branch as the parent
126        // long after the platform itself retargeted this review away from it -
127        // and the platform is the only thing that ever retargets a stacked
128        // review, since it refuses a change by hand. So once the layer below
129        // has landed, the review base is the fresher answer and this falls
130        // through to it. Taking the stale one would write a `stkParent`
131        // pointing at a merged branch, which `restack` then rebases and
132        // force-pushes against.
133        //
134        // Best effort - `native_stack_for` answers `None` for every provider
135        // but GitHub, and for a repo without stacks.
136        if let Some((_, review_provider)) = &provider
137            && let Ok(Some(stack)) = review_provider.native_stack_for(branch)
138            && stack.parent_is_current(branch)
139            && let Some(parent) = stack.parent_of(branch)
140            && parent != branch
141        {
142            if branches.contains(&parent.to_owned()) {
143                let id = stack.review_id_for(branch).unwrap_or("");
144                found = Some((parent.to_owned(), format!("stack {} ({id})", stack.number)));
145            } else {
146                anstream::println!(
147                    "{}",
148                    style::warn(&format!(
149                        "{branch}: stack {} puts it on {parent}, which is not a local branch",
150                        stack.number
151                    ))
152                );
153            }
154        }
155
156        if found.is_none()
157            && let Some((kind, review_provider)) = &provider
158            && let Ok(Some(review)) = owned_review_for_branch(&**review_provider, branch)
159            && review.base != *branch
160        {
161            if branches.contains(&review.base) {
162                found = Some((review.base.clone(), format!("{kind} review {}", review.id)));
163            } else {
164                anstream::println!(
165                    "{}",
166                    style::warn(&format!(
167                        "{branch}: review {} targets {}, which is not a local branch",
168                        review.id, review.base
169                    ))
170                );
171            }
172        }
173
174        if found.is_none() {
175            match nearest_ancestor_branch(branch, &branches)? {
176                Ancestry::One(parent) => found = Some((parent, "ancestry".to_owned())),
177                Ancestry::None => {
178                    anstream::println!(
179                        "{}",
180                        style::warn(&format!(
181                            "{branch}: no parent found; attach manually with \
182                             `git stk adopt {branch} --parent <parent>`"
183                        ))
184                    );
185                }
186                Ancestry::Ambiguous(candidates) => {
187                    anstream::println!(
188                        "{}",
189                        style::warn(&format!(
190                            "{branch}: ambiguous parent candidates ({}); attach manually with \
191                             `git stk adopt {branch} --parent <parent>`",
192                            candidates.join(", ")
193                        ))
194                    );
195                }
196            }
197        }
198
199        match found {
200            Some((parent, source)) => {
201                anstream::println!(
202                    "{}: {} parent {} {}",
203                    style::branch(branch),
204                    if dry_run { "would set" } else { "set" },
205                    style::branch(&parent),
206                    style::dim(&format!("(from {source})"))
207                );
208                if !dry_run {
209                    stack::set_parent(branch, &parent)?;
210                    stack::record_base(branch, &parent);
211                }
212                repaired += 1;
213            }
214            None => unresolved += 1,
215        }
216    }
217
218    repaired += repair_worktree_ownership(&branches, dry_run)?;
219
220    anstream::println!(
221        "{}",
222        style::success(&format!(
223            "repair complete: {repaired} {}repaired, {verified} verified, {unresolved} unresolved",
224            if dry_run { "would be " } else { "" }
225        ))
226    );
227    Ok(())
228}
229
230/// Reconcile `branch.<name>.stkWorktree` - the marker saying git-stk created a
231/// branch's worktree, and so may remove it when the branch lands.
232///
233/// It drifts in both directions: the recorded worktree can be removed by hand,
234/// leaving a marker pointing at nothing; and the marker can be lost (a fresh
235/// clone, a wiped config) while the worktree is still there, which would strand
236/// it forever since cleanup only removes what it owns.
237fn repair_worktree_ownership(branches: &[String], dry_run: bool) -> Result<usize> {
238    let mut repaired = 0;
239
240    for branch in branches {
241        let recorded = stack::recorded_worktree(branch);
242        let actual = git::worktree_holding(branch)?;
243
244        match (&recorded, &actual) {
245            // Marker with no worktree behind it, or pointing somewhere the
246            // branch no longer lives.
247            (Some(path), actual) if Some(path) != actual.as_ref() => {
248                anstream::println!(
249                    "{}: {} stale worktree marker {}",
250                    style::branch(branch),
251                    if dry_run { "would clear" } else { "cleared" },
252                    style::dim(&git::display_path(path))
253                );
254                if !dry_run {
255                    stack::unset_owned_worktree(branch)?;
256                }
257                repaired += 1;
258            }
259            // An unmarked worktree sitting exactly where `new --worktree` would
260            // have put it: that directory is git-stk's, so claim it back rather
261            // than leaving it unownable.
262            (None, Some(path))
263                if settings::worktree_path_for(branch)
264                    .is_ok_and(|expected| git::same_path(&expected, path)) =>
265            {
266                anstream::println!(
267                    "{}: {} worktree {}",
268                    style::branch(branch),
269                    if dry_run {
270                        "would re-adopt"
271                    } else {
272                        "re-adopted"
273                    },
274                    style::dim(&git::display_path(path))
275                );
276                if !dry_run {
277                    stack::set_owned_worktree(branch, path)?;
278                }
279                repaired += 1;
280            }
281            _ => {}
282        }
283    }
284
285    Ok(repaired)
286}
287
288enum Ancestry {
289    One(String),
290    None,
291    Ambiguous(Vec<String>),
292}
293
294/// Find the nearest other local branch whose tip is a strict ancestor of
295/// `branch` - the best guess at its stack parent.
296fn nearest_ancestor_branch(branch: &str, branches: &[String]) -> Result<Ancestry> {
297    let tip = git::rev_parse(branch)?;
298
299    let mut candidates: Vec<(String, String)> = Vec::new();
300    for other in branches {
301        if other == branch {
302            continue;
303        }
304        let other_tip = git::rev_parse(other)?;
305        // Equal tips (e.g. a just-created branch) leave the direction
306        // ambiguous, so they are not usable candidates.
307        if other_tip != tip && git::is_ancestor(other, branch)? {
308            candidates.push((other.clone(), other_tip));
309        }
310    }
311
312    // Keep only the nearest candidates: drop any that are ancestors of
313    // another candidate (i.e. further from the branch).
314    let nearest: Vec<String> = candidates
315        .iter()
316        .filter(|(candidate, candidate_tip)| {
317            !candidates.iter().any(|(other, other_tip)| {
318                other != candidate
319                    && other_tip != candidate_tip
320                    && git::is_ancestor(candidate, other).unwrap_or(false)
321            })
322        })
323        .map(|(candidate, _)| candidate.clone())
324        .collect();
325
326    Ok(match nearest.len() {
327        0 => Ancestry::None,
328        1 => Ancestry::One(nearest.into_iter().next().expect("one candidate")),
329        _ => Ancestry::Ambiguous(nearest),
330    })
331}