git_stk/commands/
repair.rs1use 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#[derive(Debug, clap::Args)]
11pub struct Repair {
12 #[arg(long, short = 'n', action = ArgAction::SetTrue)]
14 dry_run: bool,
15 #[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
31fn 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
45pub fn repair(dry_run: bool) -> Result<()> {
50 let branches = git::local_branches()?;
51 let trunk = stack::trunk_branch(&branches);
52
53 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 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 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
230fn 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 (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 (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
294fn 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 if other_tip != tip && git::is_ancestor(other, branch)? {
308 candidates.push((other.clone(), other_tip));
309 }
310 }
311
312 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}