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 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 repaired += repair_worktree_ownership(&branches, dry_run)?;
165
166 anstream::println!(
167 "{}",
168 style::success(&format!(
169 "repair complete: {repaired} {}repaired, {verified} verified, {unresolved} unresolved",
170 if dry_run { "would be " } else { "" }
171 ))
172 );
173 Ok(())
174}
175
176fn repair_worktree_ownership(branches: &[String], dry_run: bool) -> Result<usize> {
184 let mut repaired = 0;
185
186 for branch in branches {
187 let recorded = stack::recorded_worktree(branch);
188 let actual = git::worktree_holding(branch)?;
189
190 match (&recorded, &actual) {
191 (Some(path), actual) if Some(path) != actual.as_ref() => {
194 anstream::println!(
195 "{}: {} stale worktree marker {}",
196 style::branch(branch),
197 if dry_run { "would clear" } else { "cleared" },
198 style::dim(&git::display_path(path))
199 );
200 if !dry_run {
201 stack::unset_owned_worktree(branch)?;
202 }
203 repaired += 1;
204 }
205 (None, Some(path))
209 if settings::worktree_path_for(branch)
210 .is_ok_and(|expected| git::same_path(&expected, path)) =>
211 {
212 anstream::println!(
213 "{}: {} worktree {}",
214 style::branch(branch),
215 if dry_run {
216 "would re-adopt"
217 } else {
218 "re-adopted"
219 },
220 style::dim(&git::display_path(path))
221 );
222 if !dry_run {
223 stack::set_owned_worktree(branch, path)?;
224 }
225 repaired += 1;
226 }
227 _ => {}
228 }
229 }
230
231 Ok(repaired)
232}
233
234enum Ancestry {
235 One(String),
236 None,
237 Ambiguous(Vec<String>),
238}
239
240fn nearest_ancestor_branch(branch: &str, branches: &[String]) -> Result<Ancestry> {
243 let tip = git::rev_parse(branch)?;
244
245 let mut candidates: Vec<(String, String)> = Vec::new();
246 for other in branches {
247 if other == branch {
248 continue;
249 }
250 let other_tip = git::rev_parse(other)?;
251 if other_tip != tip && git::is_ancestor(other, branch)? {
254 candidates.push((other.clone(), other_tip));
255 }
256 }
257
258 let nearest: Vec<String> = candidates
261 .iter()
262 .filter(|(candidate, candidate_tip)| {
263 !candidates.iter().any(|(other, other_tip)| {
264 other != candidate
265 && other_tip != candidate_tip
266 && git::is_ancestor(candidate, other).unwrap_or(false)
267 })
268 })
269 .map(|(candidate, _)| candidate.clone())
270 .collect();
271
272 Ok(match nearest.len() {
273 0 => Ancestry::None,
274 1 => Ancestry::One(nearest.into_iter().next().expect("one candidate")),
275 _ => Ancestry::Ambiguous(nearest),
276 })
277}