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    anstream::println!(
165        "{}",
166        style::success(&format!(
167            "repair complete: {repaired} {}repaired, {verified} verified, {unresolved} unresolved",
168            if dry_run { "would be " } else { "" }
169        ))
170    );
171    Ok(())
172}
173
174enum Ancestry {
175    One(String),
176    None,
177    Ambiguous(Vec<String>),
178}
179
180/// Find the nearest other local branch whose tip is a strict ancestor of
181/// `branch` - the best guess at its stack parent.
182fn nearest_ancestor_branch(branch: &str, branches: &[String]) -> Result<Ancestry> {
183    let tip = git::rev_parse(branch)?;
184
185    let mut candidates: Vec<(String, String)> = Vec::new();
186    for other in branches {
187        if other == branch {
188            continue;
189        }
190        let other_tip = git::rev_parse(other)?;
191        // Equal tips (e.g. a just-created branch) leave the direction
192        // ambiguous, so they are not usable candidates.
193        if other_tip != tip && git::is_ancestor(other, branch)? {
194            candidates.push((other.clone(), other_tip));
195        }
196    }
197
198    // Keep only the nearest candidates: drop any that are ancestors of
199    // another candidate (i.e. further from the branch).
200    let nearest: Vec<String> = candidates
201        .iter()
202        .filter(|(candidate, candidate_tip)| {
203            !candidates.iter().any(|(other, other_tip)| {
204                other != candidate
205                    && other_tip != candidate_tip
206                    && git::is_ancestor(candidate, other).unwrap_or(false)
207            })
208        })
209        .map(|(candidate, _)| candidate.clone())
210        .collect();
211
212    Ok(match nearest.len() {
213        0 => Ancestry::None,
214        1 => Ancestry::One(nearest.into_iter().next().expect("one candidate")),
215        _ => Ancestry::Ambiguous(nearest),
216    })
217}