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