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