Skip to main content

git_stk/commands/
repair.rs

1use anyhow::Result;
2use clap::ArgAction;
3
4use crate::commands::Run;
5use crate::providers::{detect_provider, review_provider};
6use crate::{git, stack};
7
8/// Rebuild or verify local stack metadata from reviews and ancestry.
9#[derive(Debug, clap::Args)]
10pub struct Repair {
11    /// Print what would change without updating local metadata.
12    #[arg(long, action = ArgAction::SetTrue)]
13    dry_run: bool,
14}
15
16impl Run for Repair {
17    fn run(self) -> Result<()> {
18        repair(self.dry_run)
19    }
20}
21
22/// Rebuild or verify local stack metadata. For branches missing a parent,
23/// try the provider's review base first, then nearest-ancestor inference.
24/// For branches with a parent, verify it exists and the recorded fork point
25/// is still valid, re-deriving it when stale.
26pub fn repair(dry_run: bool) -> Result<()> {
27    let branches = git::local_branches()?;
28    let trunk = stack::trunk_branch(&branches);
29
30    // Provider lookup is best effort: repair must work without a remote or
31    // an authenticated gh/glab.
32    let provider = detect_provider()
33        .ok()
34        .map(|provider| (provider.kind, review_provider(provider.kind)));
35
36    let mut repaired = 0;
37    let mut verified = 0;
38    let mut unresolved = 0;
39
40    for branch in &branches {
41        if Some(branch.as_str()) == trunk.as_deref() {
42            continue;
43        }
44
45        if let Some(parent) = stack::parent_for_branch(branch)? {
46            if !branches.contains(&parent) {
47                println!(
48                    "{branch}: parent {parent} does not exist locally; \
49                     fix with `git stk adopt` or `git stk detach {branch}`"
50                );
51                unresolved += 1;
52                continue;
53            }
54
55            let base_valid = matches!(
56                stack::base_for_branch(branch)?,
57                Some(base) if git::is_ancestor(&base, branch).unwrap_or(false)
58            );
59            if base_valid {
60                verified += 1;
61            } else {
62                println!(
63                    "{branch}: {} fork point from {parent}",
64                    if dry_run {
65                        "would re-record"
66                    } else {
67                        "re-recorded"
68                    }
69                );
70                if !dry_run {
71                    stack::record_base(branch, &parent);
72                }
73                repaired += 1;
74            }
75            continue;
76        }
77
78        let mut found: Option<(String, String)> = None;
79        if let Some((kind, review_provider)) = &provider
80            && let Ok(Some(review)) = review_provider.review_for_branch(branch)
81            && review.branch == *branch
82            && review.base != *branch
83        {
84            if branches.contains(&review.base) {
85                found = Some((review.base.clone(), format!("{kind} review {}", review.id)));
86            } else {
87                println!(
88                    "{branch}: review {} targets {}, which is not a local branch",
89                    review.id, review.base
90                );
91            }
92        }
93
94        if found.is_none() {
95            match nearest_ancestor_branch(branch, &branches)? {
96                Ancestry::One(parent) => found = Some((parent, "ancestry".to_owned())),
97                Ancestry::None => {
98                    println!(
99                        "{branch}: no parent found; attach manually with \
100                         `git stk adopt {branch} --parent <parent>`"
101                    );
102                }
103                Ancestry::Ambiguous(candidates) => {
104                    println!(
105                        "{branch}: ambiguous parent candidates ({}); attach manually with \
106                         `git stk adopt`",
107                        candidates.join(", ")
108                    );
109                }
110            }
111        }
112
113        match found {
114            Some((parent, source)) => {
115                println!(
116                    "{branch}: {} parent {parent} (from {source})",
117                    if dry_run { "would set" } else { "set" }
118                );
119                if !dry_run {
120                    stack::set_parent_for_branch(branch, &parent)?;
121                    stack::record_base(branch, &parent);
122                }
123                repaired += 1;
124            }
125            None => unresolved += 1,
126        }
127    }
128
129    println!(
130        "repair complete: {repaired} {}repaired, {verified} verified, {unresolved} unresolved",
131        if dry_run { "would be " } else { "" }
132    );
133    Ok(())
134}
135
136enum Ancestry {
137    One(String),
138    None,
139    Ambiguous(Vec<String>),
140}
141
142/// Find the nearest other local branch whose tip is a strict ancestor of
143/// `branch` - the best guess at its stack parent.
144fn nearest_ancestor_branch(branch: &str, branches: &[String]) -> Result<Ancestry> {
145    let tip = git::rev_parse(branch)?;
146
147    let mut candidates: Vec<(String, String)> = Vec::new();
148    for other in branches {
149        if other == branch {
150            continue;
151        }
152        let other_tip = git::rev_parse(other)?;
153        // Equal tips (e.g. a just-created branch) leave the direction
154        // ambiguous, so they are not usable candidates.
155        if other_tip != tip && git::is_ancestor(other, branch)? {
156            candidates.push((other.clone(), other_tip));
157        }
158    }
159
160    // Keep only the nearest candidates: drop any that are ancestors of
161    // another candidate (i.e. further from the branch).
162    let nearest: Vec<String> = candidates
163        .iter()
164        .filter(|(candidate, candidate_tip)| {
165            !candidates.iter().any(|(other, other_tip)| {
166                other != candidate
167                    && other_tip != candidate_tip
168                    && git::is_ancestor(candidate, other).unwrap_or(false)
169            })
170        })
171        .map(|(candidate, _)| candidate.clone())
172        .collect();
173
174    Ok(match nearest.len() {
175        0 => Ancestry::None,
176        1 => Ancestry::One(nearest.into_iter().next().expect("one candidate")),
177        _ => Ancestry::Ambiguous(nearest),
178    })
179}