1use std::path::{Path, PathBuf};
22
23use anyhow::Result;
24
25use crate::git;
26use crate::run::{Liveness, RunState, RunStatus};
27
28#[derive(Debug, Clone)]
30pub struct Takeover {
31 pub earlier: Vec<String>,
34 pub home: PathBuf,
36 pub choice: Option<crate::reconcile::Choice>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Refused(pub String);
48
49impl std::fmt::Display for Refused {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 f.write_str(&self.0)
52 }
53}
54
55impl std::error::Error for Refused {}
56
57#[derive(Debug, Clone)]
60pub struct Released {
61 pub old_id: String,
63 index: usize,
64 path: PathBuf,
65 home: PathBuf,
66 tip: String,
69}
70
71impl Released {
72 pub async fn restore(&self, repo: &Path, branch: &str) {
76 let path = self.path.to_string_lossy().to_string();
77 let refname = format!("refs/heads/{branch}");
80 if git::rev_parse(repo, &refname).await.ok().as_deref() != Some(self.tip.as_str())
81 && let Err(e) = git::git(repo, &["branch", "-f", branch, &self.tip]).await
82 {
83 tracing::warn!("could not put `{branch}` back at {}: {e:#}", self.tip);
84 return;
85 }
86 if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
87 tracing::warn!(
88 "could not put run {}'s worktree back at {path}: {e:#}",
89 self.old_id
90 );
91 return;
92 }
93 let put_back = RunState::load_under(&self.old_id, &self.home).and_then(|mut s| {
94 if let Some(c) = s.candidates.get_mut(self.index) {
95 c.folded = false;
96 }
97 s.released_to = None;
98 s.released_branches.retain(|b| b != branch);
99 s.events.pop();
100 s.save_under(&self.home)
101 });
102 if let Err(e) = put_back {
103 tracing::warn!("could not unmark run {}: {e:#}", self.old_id);
104 }
105 }
106}
107
108#[derive(Debug, Clone)]
110pub struct Holder {
111 pub run: String,
113 pub status: RunStatus,
115 pub liveness: Liveness,
117 pub driver_unproven: bool,
120 pub dirty: bool,
122 pub head: String,
124 pub tip: String,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum Decision {
131 Release,
133 Refuse(String),
136 NotOurs,
139}
140
141fn short(sha: &str) -> String {
142 sha.chars().take(7).collect()
143}
144
145pub fn decide(superseded: bool, holder: &Holder) -> Decision {
151 if !superseded {
152 return Decision::NotOurs;
153 }
154 let mut why = Vec::new();
155 if holder.liveness == Liveness::Live {
156 why.push("that run is being worked on right now".to_owned());
157 } else if holder.driver_unproven {
158 why.push("its driver process could not be shown to be gone".to_owned());
159 }
160 if holder.dirty {
161 why.push("its worktree has uncommitted changes".to_owned());
162 }
163 if holder.head != holder.tip {
164 why.push("its HEAD is not at the branch tip".to_owned());
165 }
166 if why.is_empty() {
167 return Decision::Release;
168 }
169 Decision::Refuse(format!(
170 "run {} (status `{}`, worktree {}, HEAD {}, branch tip {}) is an earlier attempt \
171 at this task and still has the branch checked out, so it was not released \
172 automatically: {}",
173 crate::run::short_of(&holder.run),
174 holder.status.as_str(),
175 if holder.dirty { "dirty" } else { "clean" },
176 short(&holder.head),
177 short(&holder.tip),
178 why.join("; ")
179 ))
180}
181
182async fn inspect(
184 repo: &Path,
185 branch: &str,
186 path: &Path,
187 state: &RunState,
188 home: &Path,
189) -> Result<Holder> {
190 let claimed = crate::daemon::is_working_on(home, &state.id, jiff::Timestamp::now());
191 let liveness = state.liveness(claimed);
192 Ok(Holder {
193 run: state.id.clone(),
194 status: state.status,
195 liveness,
196 driver_unproven: liveness == Liveness::Unknown && state.driver_pid.is_some(),
197 dirty: !git::git(path, &["status", "--porcelain", "--untracked-files=normal"])
200 .await?
201 .trim()
202 .is_empty(),
203 head: git::rev_parse(path, "HEAD").await?,
204 tip: git::rev_parse(repo, &format!("refs/heads/{branch}")).await?,
205 })
206}
207
208fn same_path(a: &Path, b: &Path) -> bool {
209 let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
210 canon(a) == canon(b)
211}
212
213pub async fn release(
221 repo: &Path,
222 branch: &str,
223 new_run: &str,
224 takeover: &Takeover,
225) -> Result<Option<Released>> {
226 let Some(path) = git::worktree_holding(repo, branch).await? else {
227 return Ok(None);
228 };
229 let mut owner = None;
232 for id in &takeover.earlier {
233 let Ok(state) = RunState::load_under(id, &takeover.home) else {
234 continue;
235 };
236 if let Some(i) = state
237 .candidates
238 .iter()
239 .position(|c| !c.folded && same_path(&c.worktree, &path))
240 {
241 owner = Some((state, i));
242 break;
243 }
244 }
245 let Some((mut state, index)) = owner else {
246 return Err(Refused(format!(
247 "branch `{branch}` is checked out in {}, which is not a worktree of an earlier \
248 attempt at this task (a run of another task, or one made by hand), so it was \
249 not touched. Remove that worktree (`git worktree remove`) if it is not needed, \
250 and try again.",
251 path.display()
252 ))
253 .into());
254 };
255
256 let holder = inspect(repo, branch, &path, &state, &takeover.home).await?;
257 match decide(true, &holder) {
258 Decision::NotOurs => return Ok(None),
259 Decision::Refuse(why) => {
260 return Err(Refused(format!(
261 "branch `{branch}` is checked out in {}: {why}. Commit or discard the work \
262 there and remove that worktree (`git worktree remove`), or say the run may be \
263 discarded, and try again.",
264 path.display()
265 ))
266 .into());
267 }
268 Decision::Release => {}
269 }
270
271 let old_id = state.id.clone();
272 state.candidates[index].folded = true;
273 state.released_to = Some(new_run.to_owned());
274 if !state.released_branches.iter().any(|b| b == branch) {
275 state.released_branches.push(branch.to_owned());
276 }
277 state.event(
278 "release",
279 format!(
280 "worktree of `{branch}` released to run {}; this run can no longer be resumed \
281 from here",
282 crate::run::short_of(new_run)
283 ),
284 );
285 state.save_under(&takeover.home)?;
286
287 let again = match RunState::load_under(&old_id, &takeover.home) {
295 Ok(fresh) => inspect(repo, branch, &path, &fresh, &takeover.home).await,
296 Err(e) => Err(e),
297 };
298 let safe = matches!(&again, Ok(h) if decide(true, h) == Decision::Release);
299 let removed = safe
300 && git::worktree_remove_clean(repo, &path)
301 .await
302 .unwrap_or(false);
303 if !removed || path.exists() {
304 state = RunState::load_under(&old_id, &takeover.home)?;
305 state.candidates[index].folded = false;
306 state.released_to = None;
307 state.released_branches.retain(|b| b != branch);
308 state.events.pop();
309 state.save_under(&takeover.home)?;
310 return Err(Refused(format!(
311 "branch `{branch}` is checked out in {} by run {}, and releasing that worktree \
312 failed or found it changed (git refuses to remove a worktree with uncommitted \
313 changes); it was left as it was",
314 path.display(),
315 crate::run::short_of(&old_id)
316 ))
317 .into());
318 }
319 Ok(Some(Released {
320 old_id,
321 index,
322 path,
323 home: takeover.home.clone(),
324 tip: holder.tip,
325 }))
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 fn holder() -> Holder {
333 Holder {
334 run: "20260901-000000-f82f".to_owned(),
335 status: RunStatus::Gating,
336 liveness: Liveness::Unknown,
337 driver_unproven: false,
338 dirty: false,
339 head: "a".repeat(40),
340 tip: "a".repeat(40),
341 }
342 }
343
344 #[test]
345 fn a_clean_superseded_stale_run_is_released() {
346 assert_eq!(decide(true, &holder()), Decision::Release);
347 let dead = Holder {
348 liveness: Liveness::Dead,
349 ..holder()
350 };
351 assert_eq!(decide(true, &dead), Decision::Release);
352 }
353
354 #[test]
355 fn a_run_that_is_not_superseded_is_not_ours() {
356 assert_eq!(decide(false, &holder()), Decision::NotOurs);
357 }
358
359 #[test]
360 fn a_dirty_worktree_is_refused_and_says_why() {
361 let dirty = Holder {
362 dirty: true,
363 ..holder()
364 };
365 let Decision::Refuse(why) = decide(true, &dirty) else {
366 panic!("dirty must be refused");
367 };
368 assert!(why.contains("uncommitted"), "{why}");
369 assert!(why.contains("f82f") && why.contains("gating") && why.contains("dirty"));
370 }
371
372 #[test]
373 fn a_live_run_is_refused() {
374 let live = Holder {
375 liveness: Liveness::Live,
376 ..holder()
377 };
378 let Decision::Refuse(why) = decide(true, &live) else {
379 panic!("live must be refused");
380 };
381 assert!(why.contains("right now"), "{why}");
382 }
383
384 #[test]
385 fn a_driver_that_could_not_be_shown_dead_is_refused() {
386 let unproven = Holder {
387 driver_unproven: true,
388 ..holder()
389 };
390 let Decision::Refuse(why) = decide(true, &unproven) else {
391 panic!("an unproven driver must be refused");
392 };
393 assert!(why.contains("driver"), "{why}");
394 }
395
396 #[test]
397 fn a_head_off_the_tip_is_refused() {
398 let off = Holder {
399 head: "b".repeat(40),
400 ..holder()
401 };
402 assert!(matches!(decide(true, &off), Decision::Refuse(_)));
403 }
404}