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, 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, 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_provider()
56        .ok()
57        .map(|provider| (provider.kind, review_provider(provider.kind)));
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_for_branch(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            let base_valid = matches!(
82                stack::base_for_branch(branch)?,
83                Some(base) if git::is_ancestor(&base, branch).unwrap_or(false)
84            );
85            if base_valid {
86                verified += 1;
87            } else {
88                anstream::println!(
89                    "{}: {} fork point from {}",
90                    style::branch(branch),
91                    if dry_run {
92                        "would re-record"
93                    } else {
94                        "re-recorded"
95                    },
96                    style::branch(&parent)
97                );
98                if !dry_run {
99                    stack::record_base(branch, &parent);
100                }
101                repaired += 1;
102            }
103            continue;
104        }
105
106        let mut found: Option<(String, String)> = None;
107        if let Some((kind, review_provider)) = &provider
108            && let Ok(Some(review)) = review_provider.review_for_branch(branch)
109            && review.branch == *branch
110            && review.base != *branch
111        {
112            if branches.contains(&review.base) {
113                found = Some((review.base.clone(), format!("{kind} review {}", review.id)));
114            } else {
115                anstream::println!(
116                    "{}",
117                    style::warn(&format!(
118                        "{branch}: review {} targets {}, which is not a local branch",
119                        review.id, review.base
120                    ))
121                );
122            }
123        }
124
125        if found.is_none() {
126            match nearest_ancestor_branch(branch, &branches)? {
127                Ancestry::One(parent) => found = Some((parent, "ancestry".to_owned())),
128                Ancestry::None => {
129                    anstream::println!(
130                        "{}",
131                        style::warn(&format!(
132                            "{branch}: no parent found; attach manually with \
133                             `git stk adopt {branch} --parent <parent>`"
134                        ))
135                    );
136                }
137                Ancestry::Ambiguous(candidates) => {
138                    anstream::println!(
139                        "{}",
140                        style::warn(&format!(
141                            "{branch}: ambiguous parent candidates ({}); attach manually with \
142                             `git stk adopt`",
143                            candidates.join(", ")
144                        ))
145                    );
146                }
147            }
148        }
149
150        match found {
151            Some((parent, source)) => {
152                anstream::println!(
153                    "{}: {} parent {} {}",
154                    style::branch(branch),
155                    if dry_run { "would set" } else { "set" },
156                    style::branch(&parent),
157                    style::dim(&format!("(from {source})"))
158                );
159                if !dry_run {
160                    stack::set_parent_for_branch(branch, &parent)?;
161                    stack::record_base(branch, &parent);
162                }
163                repaired += 1;
164            }
165            None => unresolved += 1,
166        }
167    }
168
169    anstream::println!(
170        "{}",
171        style::success(&format!(
172            "repair complete: {repaired} {}repaired, {verified} verified, {unresolved} unresolved",
173            if dry_run { "would be " } else { "" }
174        ))
175    );
176    Ok(())
177}
178
179enum Ancestry {
180    One(String),
181    None,
182    Ambiguous(Vec<String>),
183}
184
185/// Find the nearest other local branch whose tip is a strict ancestor of
186/// `branch` - the best guess at its stack parent.
187fn nearest_ancestor_branch(branch: &str, branches: &[String]) -> Result<Ancestry> {
188    let tip = git::rev_parse(branch)?;
189
190    let mut candidates: Vec<(String, String)> = Vec::new();
191    for other in branches {
192        if other == branch {
193            continue;
194        }
195        let other_tip = git::rev_parse(other)?;
196        // Equal tips (e.g. a just-created branch) leave the direction
197        // ambiguous, so they are not usable candidates.
198        if other_tip != tip && git::is_ancestor(other, branch)? {
199            candidates.push((other.clone(), other_tip));
200        }
201    }
202
203    // Keep only the nearest candidates: drop any that are ancestors of
204    // another candidate (i.e. further from the branch).
205    let nearest: Vec<String> = candidates
206        .iter()
207        .filter(|(candidate, candidate_tip)| {
208            !candidates.iter().any(|(other, other_tip)| {
209                other != candidate
210                    && other_tip != candidate_tip
211                    && git::is_ancestor(candidate, other).unwrap_or(false)
212            })
213        })
214        .map(|(candidate, _)| candidate.clone())
215        .collect();
216
217    Ok(match nearest.len() {
218        0 => Ancestry::None,
219        1 => Ancestry::One(nearest.into_iter().next().expect("one candidate")),
220        _ => Ancestry::Ambiguous(nearest),
221    })
222}