1use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22use serde::{Deserialize, Serialize};
23use speccoder_core::{CommandData, CommandResult, CommandStatus, InPlaceReport, RefusedPath};
24
25use crate::git::Git;
26use crate::integrity::{JudgeSnapshot, sha256_hex};
27use crate::tools::policy::Policy;
28use crate::worktree;
29
30pub const FILE: &str = "in-place.json";
32
33fn runtime_state(path: &str) -> bool {
35 path.contains("/workflows/runs/") || path.split('/').any(|c| c == "__pycache__")
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct InPlaceError {
41 pub code: &'static str,
43 pub message: String,
45 pub paths: Vec<(String, String)>,
47 pub fix: Vec<String>,
49}
50
51impl InPlaceError {
52 fn new(code: &'static str, message: impl Into<String>) -> Self {
53 Self {
54 code,
55 message: message.into(),
56 paths: Vec::new(),
57 fix: Vec::new(),
58 }
59 }
60
61 fn fix(mut self, lines: &[&str]) -> Self {
62 self.fix = lines.iter().map(|l| (*l).to_owned()).collect();
63 self
64 }
65
66 #[must_use]
68 pub fn is_stop(&self) -> bool {
69 matches!(
70 self.code,
71 "INTENT-CHANGED"
72 | "JUDGE-CHANGED"
73 | "CHECKOUT-CHANGED"
74 | "GIT-CHANGED"
75 | "RETURN-BLOCKED"
76 )
77 }
78
79 #[must_use]
82 pub fn line(&self) -> String {
83 let word = if self.is_stop() { "stopped" } else { "error" };
84 let mut line = format!("{word}[{}]: {}", self.code, self.message);
85 for (p, why) in &self.paths {
86 line.push_str(&format!("; {p} ({why})"));
87 }
88 line
89 }
90
91 #[must_use]
93 pub fn result(&self, command: &str) -> CommandResult {
94 let status = match self.code {
95 "IN-PLACE-PARALLEL" => CommandStatus::UsageError,
96 _ if self.is_stop() => CommandStatus::NeedsHuman,
97 _ => CommandStatus::GitPrecondition,
98 };
99 CommandResult::new(command, status, &self.message)
100 .with_code(self.code)
101 .with_fix(self.fix.clone())
102 .with_data(CommandData::InPlace(Box::new(InPlaceReport {
103 paths: self
104 .paths
105 .iter()
106 .map(|(path, reason)| RefusedPath {
107 path: path.clone(),
108 reason: reason.clone(),
109 })
110 .collect(),
111 })))
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum Original {
119 Branch(String),
121 Detached(String),
123}
124
125impl Original {
126 fn target(&self) -> &str {
127 match self {
128 Self::Branch(b) | Self::Detached(b) => b,
129 }
130 }
131
132 #[must_use]
134 pub fn describe(&self) -> String {
135 match self {
136 Self::Branch(b) => b.clone(),
137 Self::Detached(c) => format!("detached {}", c.chars().take(10).collect::<String>()),
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct Session {
146 pub feature: String,
148 pub branch: String,
150 pub original: Original,
152 pub checkout: PathBuf,
154 pub project: PathBuf,
156 pub tip: String,
158 pub manifest: BTreeMap<String, String>,
160 pub pid: u32,
162 pub started_at: u64,
164 #[serde(skip)]
167 pub notes: Vec<String>,
168 #[serde(default)]
171 pub hidden: Vec<String>,
172 #[serde(default)]
175 pub git: String,
176}
177
178#[must_use]
181pub fn manifest(project: &Path, policy: &Policy) -> BTreeMap<String, String> {
182 JudgeSnapshot::take(project, policy)
183 .entries()
184 .iter()
185 .filter(|(p, _)| !runtime_state(p))
186 .map(|(p, h)| (p.clone(), h.clone()))
187 .collect()
188}
189
190#[must_use]
192pub fn diff(
193 expected: &BTreeMap<String, String>,
194 now: &BTreeMap<String, String>,
195) -> Vec<(String, String)> {
196 let mut out = Vec::new();
197 for (p, h) in expected {
198 match now.get(p) {
199 None => out.push((p.clone(), "deleted".to_owned())),
200 Some(n) if n != h => out.push((p.clone(), "modified".to_owned())),
201 _ => {}
202 }
203 }
204 for p in now.keys().filter(|p| !expected.contains_key(*p)) {
205 out.push((p.clone(), "added".to_owned()));
206 }
207 out
208}
209
210#[must_use]
213pub fn is_tick(before: &[u8], after: &[u8]) -> bool {
214 let changed: Vec<(u8, u8)> = before
215 .iter()
216 .zip(after)
217 .filter(|(a, b)| a != b)
218 .map(|(a, b)| (*a, *b))
219 .collect();
220 before.len() == after.len() && matches!(changed.as_slice(), [(b' ', b'x' | b'X')])
221}
222
223#[must_use]
229pub fn intent_digest(project: &Path, policy: &Policy, tasks_file: &str) -> String {
230 let mut parts = Vec::new();
231 for (path, hash) in manifest(project, policy) {
232 if policy.write_protected(&path) != Some(crate::tools::paths::Class::Intent) {
233 continue;
234 }
235 if path.rsplit('/').next() == Some(tasks_file) {
236 continue;
237 }
238 parts.push(format!("{path} {hash}"));
239 }
240 sha256_hex(parts.join("\n").as_bytes())
241}
242
243pub(crate) fn untick(bytes: &[u8]) -> Vec<u8> {
245 let mut out = bytes.to_vec();
246 for i in 1..out.len().saturating_sub(1) {
247 if out[i - 1] == b'[' && out[i + 1] == b']' && matches!(out[i], b'x' | b'X') {
248 out[i] = b' ';
249 }
250 }
251 out
252}
253
254fn now() -> u64 {
255 std::time::SystemTime::now()
256 .duration_since(std::time::UNIX_EPOCH)
257 .map_or(0, |d| d.as_secs())
258}
259
260impl Session {
261 #[must_use]
263 pub fn path(plane_dir: &Path) -> PathBuf {
264 plane_dir.join(FILE)
265 }
266
267 #[must_use]
269 pub fn load(plane_dir: &Path) -> Option<Self> {
270 let text = std::fs::read_to_string(Self::path(plane_dir)).ok()?;
271 serde_json::from_str(&text).ok()
272 }
273
274 pub fn save(&self, plane_dir: &Path) -> Result<(), String> {
276 std::fs::create_dir_all(plane_dir).map_err(|e| e.to_string())?;
277 let tmp = plane_dir.join(format!("{FILE}.tmp"));
278 let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
279 std::fs::write(&tmp, text).map_err(|e| e.to_string())?;
280 std::fs::rename(&tmp, Self::path(plane_dir)).map_err(|e| e.to_string())
281 }
282
283 #[must_use]
285 pub fn is_live(&self) -> bool {
286 self.pid != std::process::id() && crate::progress::alive(self.pid)
287 }
288
289 pub fn begin(
292 project: &Path,
293 plane_dir: &Path,
294 feature: &str,
295 policy: &Policy,
296 ) -> Result<Self, InPlaceError> {
297 let root_of = Git::at(project).toplevel().ok().flatten().ok_or_else(|| {
298 InPlaceError::new("UNBORN-HEAD", "the project is not inside a git repository")
299 .fix(&["git init && git add -A && git commit -m init"])
300 })?;
301 let checkout = std::fs::canonicalize(&root_of).unwrap_or(root_of);
302 let project = std::fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
303 let git = Git::at(&checkout);
304 if let Some(s) = Self::load(plane_dir).filter(Self::is_live) {
305 return Err(InPlaceError::new(
306 "IN-PLACE-ACTIVE",
307 format!(
308 "an in-place run of {} is running (pid {})",
309 s.feature, s.pid
310 ),
311 ));
312 }
313 if git.head().ok().flatten().is_none() {
314 return Err(
315 InPlaceError::new("UNBORN-HEAD", "the repository has no commits yet")
316 .fix(&["git add -A && git commit -m init"]),
317 );
318 }
319 let mut notes = Vec::new();
324 if let Some(mut prior) = Self::load(plane_dir).filter(|p| {
325 p.feature == feature
326 && git
327 .line(&["symbolic-ref", "-q", "HEAD"])
328 .unwrap_or_default()
329 == format!("refs/heads/{}", p.branch)
330 && git.line(&["rev-parse", "HEAD"]).unwrap_or_default() == p.tip
331 }) {
332 let dirty = worktree::require_clean(&checkout).is_err();
333 if diff(&prior.manifest, &manifest(&project, policy)).is_empty() {
334 if !dirty || resumable(plane_dir, feature, &prior.tip) {
335 prior.pid = std::process::id();
336 prior.started_at = now();
337 prior
338 .save(plane_dir)
339 .map_err(|e| InPlaceError::new("IN-PLACE-ACTIVE", e))?;
340 prior.notes.push(format!(
341 "took over the interrupted in-place run (returns to {})",
342 prior.original.describe()
343 ));
344 return Ok(prior);
345 }
346 } else if dirty
347 && let Some(r) = prior.set_aside(
348 plane_dir,
349 "left by an interrupted in-place run; the spec changed since, so it starts fresh",
350 )? {
351 notes.push(format!(
352 "the spec changed since the run was interrupted: what it left is saved as {r} and the task starts fresh"
353 ));
354 }
355 }
356 if let Err(r) = worktree::require_clean(&checkout) {
357 let mut e = InPlaceError::new(
358 "DIRTY-CHECKOUT",
359 "the checkout has uncommitted changes; an in-place run needs a clean one",
360 )
361 .fix(&["git stash -u", "git commit"]);
362 if let Some((p, _)) = r.paths.iter().find(|(p, _)| {
363 policy.write_protected(p) == Some(crate::tools::paths::Class::Intent)
364 }) {
365 e.fix.push(format!("echo {p} >> .git/info/exclude"));
366 }
367 e.paths = r.paths;
368 return Err(e);
369 }
370 let config =
371 crate::verify::find_config(&project).map(|d| d.join(crate::verify::config::FILE));
372 let tracked = config.as_ref().is_some_and(|c| {
373 git.succeeds(&["ls-files", "--error-unmatch", "--", &c.to_string_lossy()])
374 .unwrap_or(false)
375 });
376 if !tracked {
377 return Err(InPlaceError::new(
378 "CONFIG-NOT-TRACKED",
379 "speccoder.toml is not committed; the checks that judge a run are reviewed like code",
380 )
381 .fix(&["git add speccoder.toml && git commit -m \"speccoder config\""]));
382 }
383 let branch = format!("speccoder/{feature}");
384 let listing = git
385 .run(&["worktree", "list", "--porcelain"])
386 .map(|b| String::from_utf8_lossy(&b).into_owned())
387 .unwrap_or_default();
388 let mut current_wt = String::new();
389 for line in listing.lines() {
390 if let Some(p) = line.strip_prefix("worktree ") {
391 current_wt = p.to_owned();
392 } else if line == format!("branch refs/heads/{branch}")
393 && Path::new(¤t_wt) != checkout
394 {
395 return Err(InPlaceError::new(
396 "BRANCH-IN-WORKTREE",
397 format!("{branch} is checked out in the worktree {current_wt}"),
398 )
399 .fix(&[&format!("git worktree remove {current_wt}")]));
400 }
401 }
402 let head_ref = git
403 .line(&["symbolic-ref", "-q", "HEAD"])
404 .unwrap_or_default();
405 let prior = Self::load(plane_dir);
408 if let Some(p) = prior.as_ref().filter(|p| p.feature != feature) {
409 return Err(InPlaceError::new(
410 "IN-PLACE-SESSION",
411 format!(
412 "an interrupted in-place run of {} holds this checkout",
413 p.feature
414 ),
415 )
416 .fix(&["speccoder run --in-place --return"]));
417 }
418 let original = match (prior, head_ref.strip_prefix("refs/heads/")) {
419 (Some(p), Some(b)) if b == branch => p.original,
420 (_, Some(b)) => Original::Branch(b.to_owned()),
421 (_, None) => Original::Detached(git.line(&["rev-parse", "HEAD"]).unwrap_or_default()),
422 };
423 let mut session = Self {
424 feature: feature.to_owned(),
425 branch: branch.clone(),
426 original: original.clone(),
427 checkout: checkout.clone(),
428 project: project.clone(),
429 tip: String::new(),
430 manifest: BTreeMap::new(),
431 pid: std::process::id(),
432 started_at: now(),
433 notes,
434 hidden: Vec::new(),
435 git: String::new(),
436 };
437 session
440 .save(plane_dir)
441 .map_err(|e| InPlaceError::new("IN-PLACE-ACTIVE", e))?;
442 if original != Original::Branch(branch.clone()) {
443 let exists = git
444 .succeeds(&[
445 "rev-parse",
446 "--verify",
447 "-q",
448 &format!("refs/heads/{branch}"),
449 ])
450 .unwrap_or(false);
451 let switched = if exists {
452 git.run(&["switch", "-q", &branch])
453 } else {
454 git.run(&["switch", "-q", "-c", &branch, "HEAD"])
455 };
456 if let Err(e) = switched {
457 let _ = std::fs::remove_file(Self::path(plane_dir));
458 return Err(InPlaceError::new(
459 "DIRTY-CHECKOUT",
460 format!("cannot switch to {branch}: {}", e.0),
461 ));
462 }
463 if exists
465 && let Original::Branch(b) = &original
466 && !git
467 .succeeds(&["merge-base", "--is-ancestor", b, "HEAD"])
468 .unwrap_or(true)
469 {
470 let mut merge = crate::attempts::IDENTITY.to_vec();
471 merge.extend(["merge", "-q", "--no-edit", b.as_str()]);
472 if git.run(&merge).is_err() {
473 let _ = git.run(&["merge", "--abort"]);
474 let _ = git.run(&["switch", "-q", original.target()]);
475 let _ = std::fs::remove_file(Self::path(plane_dir));
476 return Err(InPlaceError::new(
477 "MERGE-CONFLICT",
478 format!("{b} does not merge cleanly into {branch}"),
479 )
480 .fix(&[&format!(
481 "git switch {branch} && git merge {b} # resolve, commit, then run again"
482 )]));
483 }
484 }
485 }
486 session.tip = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
487 session.manifest = manifest(&project, policy);
488 session.git = git_fingerprint(&checkout, &branch);
489 session.hidden = git
490 .run(&[
491 "ls-files",
492 "-z",
493 "--others",
494 "--ignored",
495 "--exclude-standard",
496 "--directory",
497 ])
498 .map(|b| {
499 String::from_utf8_lossy(&b)
500 .split('\0')
501 .filter(|p| !p.is_empty())
502 .map(str::to_owned)
503 .collect()
504 })
505 .unwrap_or_default();
506 session
507 .save(plane_dir)
508 .map_err(|e| InPlaceError::new("IN-PLACE-ACTIVE", e))?;
509 Ok(session)
510 }
511
512 fn set_aside(&self, plane_dir: &Path, reason: &str) -> Result<Option<String>, InPlaceError> {
516 let _ = std::fs::remove_dir_all(plane_dir.join("resume").join(&self.feature));
517 let _ = crate::approvals::withdraw_waiting(&self.project, plane_dir, &self.feature, reason);
519 if worktree::require_clean(&self.checkout).is_ok() {
520 return Ok(None);
521 }
522 let tmp = plane_dir.join("tmp");
523 let record = crate::attempts::Record {
524 run: None,
525 outcome: "interrupted",
526 reason,
527 pending: None,
528 usage: None,
529 };
530 let saved = crate::attempts::save(&self.checkout, &tmp, &self.feature, "in-place", &record)
531 .map_err(|e| InPlaceError::new("RETURN-BLOCKED", e.0))?;
532 let git = Git::at(&self.checkout);
533 let head_tree =
534 worktree::Checkpoint(git.line(&["rev-parse", "HEAD^{tree}"]).unwrap_or_default());
535 worktree::restore(&self.checkout, &tmp, &head_tree)
536 .map_err(|e| InPlaceError::new("RETURN-BLOCKED", e.0))?;
537 Ok(Some(saved.reference))
538 }
539
540 pub fn check(&self, policy: &Policy) -> Result<(), InPlaceError> {
543 self.check_with(policy, true)
544 }
545
546 pub fn check_before_tick(&self, policy: &Policy) -> Result<(), InPlaceError> {
549 self.check_with(policy, false)
550 }
551
552 fn check_with(&self, policy: &Policy, clean: bool) -> Result<(), InPlaceError> {
553 if !self.git.is_empty() && git_fingerprint(&self.checkout, &self.branch) != self.git {
556 return Err(InPlaceError::new(
557 "GIT-CHANGED",
558 "the repository's configuration, hooks or branches changed during the run; speccoder does not run git against them",
559 )
560 .fix(&[
561 "git -C . config --list --local # review, restore what changed",
562 "speccoder run --in-place --return",
563 ]));
564 }
565 let git = Git::at(&self.checkout);
566 let head_ref = git
567 .line(&["symbolic-ref", "-q", "HEAD"])
568 .unwrap_or_default();
569 let head = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
570 let fix = [
571 format!("git stash -u && git switch {}", self.original.target()),
572 "speccoder run --in-place --return".to_owned(),
573 ];
574 if head_ref != format!("refs/heads/{}", self.branch) || head != self.tip {
575 let mut e = InPlaceError::new(
576 "CHECKOUT-CHANGED",
577 format!(
578 "the checkout moved during the run (HEAD is {}, speccoder left {} at {}); nothing was switched",
579 if head_ref.is_empty() {
580 head.clone()
581 } else {
582 head_ref.clone()
583 },
584 self.branch,
585 self.tip.chars().take(10).collect::<String>()
586 ),
587 );
588 e.fix = fix.to_vec();
589 return Err(e);
590 }
591 if let Some(r) = clean
592 .then(|| worktree::require_clean(&self.checkout).err())
593 .flatten()
594 {
595 let mut e = InPlaceError::new(
596 "CHECKOUT-CHANGED",
597 "the checkout changed during the run; nothing was saved, restored or switched",
598 );
599 e.paths = r.paths;
600 e.fix = fix.to_vec();
601 return Err(e);
602 }
603 let now = manifest(&self.project, policy);
604 let changed = diff(&self.manifest, &now);
605 if !changed.is_empty() {
606 let intent = changed.iter().any(|(p, _)| {
607 policy.write_protected(p) == Some(crate::tools::paths::Class::Intent)
608 });
609 let mut e = InPlaceError::new(
610 if intent {
611 "INTENT-CHANGED"
612 } else {
613 "JUDGE-CHANGED"
614 },
615 if intent {
616 "the spec changed under the running judge; the run stops before judging against it"
617 } else {
618 "a judge input changed under the running judge"
619 },
620 );
621 e.paths = changed;
622 e.fix = vec![
623 "review the change, then run again: the next run judges against the spec as it is"
624 .to_owned(),
625 ];
626 return Err(e);
627 }
628 Ok(())
629 }
630
631 pub fn advance(&mut self, plane_dir: &Path) {
633 self.tip = Git::at(&self.checkout)
634 .line(&["rev-parse", "HEAD"])
635 .unwrap_or_default();
636 let _ = self.save(plane_dir);
637 }
638
639 pub fn accept(
643 &mut self,
644 plane_dir: &Path,
645 file: &Path,
646 before: &[u8],
647 accept: impl Fn(&[u8], &[u8]) -> bool,
648 ) -> Result<(), InPlaceError> {
649 let after = std::fs::read(file).unwrap_or_default();
650 let rel = file
651 .strip_prefix(&self.project)
652 .map(|r| r.to_string_lossy().replace('\\', "/"))
653 .unwrap_or_default();
654 if !accept(before, &after) {
655 return Err(InPlaceError::new(
656 "INTENT-CHANGED",
657 format!("{rel} changed beyond the engine's own write"),
658 ));
659 }
660 self.manifest.insert(rel, sha256_hex(&after));
661 let _ = self.save(plane_dir);
662 Ok(())
663 }
664
665 pub fn finish(&self, plane_dir: &Path) -> Result<(), InPlaceError> {
668 let git = Git::at(&self.checkout);
669 let head = git.line(&["rev-parse", "HEAD"]).unwrap_or_default();
670 let on_branch = git
671 .line(&["symbolic-ref", "-q", "HEAD"])
672 .unwrap_or_default()
673 == format!("refs/heads/{}", self.branch);
674 if !on_branch || head != self.tip || worktree::require_clean(&self.checkout).is_err() {
675 return Err(InPlaceError::new(
676 "RETURN-BLOCKED",
677 format!(
678 "the checkout is not as speccoder left it, so it stays on {}",
679 self.branch
680 ),
681 )
682 .fix(&[
683 &format!("git stash -u && git switch {}", self.original.target()),
684 "speccoder run --in-place --return",
685 ]));
686 }
687 if self.original != Original::Branch(self.branch.clone()) {
688 let back = match &self.original {
689 Original::Branch(b) => git.run(&["switch", "-q", b]),
690 Original::Detached(c) => git.run(&["switch", "-q", "--detach", c]),
691 };
692 if let Err(e) = back {
693 return Err(InPlaceError::new("RETURN-BLOCKED", e.0)
694 .fix(&[&format!("git switch {}", self.original.target())]));
695 }
696 }
697 let _ = std::fs::remove_file(Self::path(plane_dir));
698 Ok(())
699 }
700}
701
702impl Session {
703 pub fn reconcile(
707 &mut self,
708 plane_dir: &Path,
709 tasks_file: &Path,
710 tasks: &speccoder_core::snapshot::ParsedTasks,
711 tick: impl Fn(&Path, &str, &str) -> Result<(), String>,
712 ) -> Vec<String> {
713 let log = Git::at(&self.checkout)
714 .run(&[
715 "log",
716 "--format=%(trailers:key=Spec-Task,valueonly)\u{1f}%(trailers:key=Task-Line,valueonly)\u{1e}",
717 &self.branch,
718 ])
719 .map(|b| String::from_utf8_lossy(&b).into_owned())
720 .unwrap_or_default();
721 let mut newest: BTreeMap<String, String> = BTreeMap::new();
722 for record in log.split('\u{1e}') {
723 let Some((task, line)) = record.trim().split_once('\u{1f}') else {
724 continue;
725 };
726 if let Some(id) = task.trim().strip_prefix(&format!("{}/", self.feature)) {
727 newest
728 .entry(id.to_owned())
729 .or_insert_with(|| line.trim().to_owned());
730 }
731 }
732 let mut ticked = Vec::new();
733 for t in tasks.tasks.iter().filter(|t| !t.done) {
734 if newest
735 .get(&t.id)
736 .is_none_or(|l| l.is_empty() || *l != t.line_hash)
737 {
738 continue;
739 }
740 let before = std::fs::read(tasks_file).unwrap_or_default();
741 if tick(tasks_file, &t.id, &t.line_hash).is_ok()
742 && self.accept(plane_dir, tasks_file, &before, is_tick).is_ok()
743 {
744 ticked.push(t.id.clone());
745 }
746 }
747 if !ticked.is_empty() {
748 self.notes.push(format!(
749 "ticked {} from {} verdict commit(s): the run was interrupted before the tick",
750 ticked.join(", "),
751 ticked.len()
752 ));
753 }
754 ticked
755 }
756}
757
758#[derive(Debug, Default)]
760pub struct Committed {
761 pub commit: Option<String>,
763 pub ticked: bool,
765 pub problems: Vec<String>,
767}
768
769impl Session {
770 pub fn commit_and_tick(
777 &mut self,
778 plane_dir: &Path,
779 tasks_file: &Path,
780 line_hash: &str,
781 message: &str,
782 intent_digest: &str,
783 tick: impl Fn() -> Result<(), String>,
784 ) -> Committed {
785 let mut out = Committed::default();
786 let rel = tasks_file
787 .strip_prefix(&self.checkout)
788 .map(|r| r.to_string_lossy().replace('\\', "/"))
789 .unwrap_or_default();
790 let tracked = Git::at(&self.checkout)
791 .succeeds(&["ls-files", "--error-unmatch", "--", &rel])
792 .unwrap_or(false);
793 let mut trailers = format!(
794 "Mode: in-place\nIntent-Digest: sha256:{intent_digest}\nTask-Line: {line_hash}\n"
795 );
796 if !tracked {
797 trailers.push_str(&format!("Tick: local {rel}\n"));
798 }
799 let message = format!("{}\n{trailers}", message.trim_end());
800 let before = std::fs::read(tasks_file).unwrap_or_default();
801 let tick_now = |out: &mut Committed| match tick() {
802 Ok(()) => out.ticked = true,
803 Err(e) => out.problems.push(format!("tick refused: {e}")),
804 };
805 if tracked {
806 tick_now(&mut out);
807 }
808 if out.ticked || !tracked {
809 match crate::pipeline::commit::commit_attempt_as(&self.checkout, &message, !tracked) {
810 Ok(id) => out.commit = Some(id),
811 Err(e) => out.problems.push(format!("commit failed: {e}")),
812 }
813 }
814 if !tracked && out.commit.is_some() {
815 tick_now(&mut out);
816 }
817 if out.ticked
818 && let Err(e) = self.accept(plane_dir, tasks_file, &before, is_tick)
819 {
820 out.problems.push(e.message);
821 }
822 self.advance(plane_dir);
823 out
824 }
825}
826
827impl Session {
828 pub fn commit_round(
833 &mut self,
834 plane_dir: &Path,
835 tasks_file: &Path,
836 before: &[u8],
837 message: &str,
838 intent_digest: &str,
839 ) -> Result<String, String> {
840 self.accept(plane_dir, tasks_file, before, |b, a| a.starts_with(b))
841 .map_err(|e| e.message)?;
842 let rel = tasks_file
843 .strip_prefix(&self.checkout)
844 .map(|r| r.to_string_lossy().replace('\\', "/"))
845 .unwrap_or_default();
846 let tracked = Git::at(&self.checkout)
847 .succeeds(&["ls-files", "--error-unmatch", "--", &rel])
848 .unwrap_or(false);
849 let mut trailers = format!("Mode: in-place\nIntent-Digest: sha256:{intent_digest}\n");
850 if !tracked {
851 trailers.push_str(&format!("Tasks: local {rel}\n"));
852 }
853 let message = format!("{}\n{trailers}", message.trim_end());
854 let id = crate::pipeline::commit::commit_attempt_as(&self.checkout, &message, !tracked)?;
855 self.advance(plane_dir);
856 Ok(id)
857 }
858
859 #[must_use]
863 pub fn confine(
864 &self,
865 policy: Policy,
866 runner: crate::sandbox::Runner,
867 ) -> (Policy, crate::sandbox::Runner, Option<String>) {
868 let secrets: Vec<PathBuf> = self
869 .hidden
870 .iter()
871 .map(|h| h.trim_end_matches('/'))
872 .filter(|h| policy.protected().is_secret(h))
873 .map(|h| self.checkout.join(h))
874 .collect();
875 let (runner, notice) = runner.carve(&[self.checkout.join(".git")], &secrets);
876 (
877 policy.in_place().with_hidden(self.hidden.clone()),
878 runner,
879 notice,
880 )
881 }
882}
883
884fn git_fingerprint(checkout: &Path, branch: &str) -> String {
888 let git = Git::at(checkout);
889 let Some(dir) = git
890 .line(&["rev-parse", "--path-format=absolute", "--git-common-dir"])
891 .ok()
892 .map(PathBuf::from)
893 else {
894 return String::new();
895 };
896 let mut parts = Vec::new();
897 let mut files = vec![dir.join("config")];
898 for sub in ["hooks", "info"] {
899 files.extend(
900 ignore::WalkBuilder::new(dir.join(sub))
901 .standard_filters(false)
902 .build()
903 .flatten()
904 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
905 .map(ignore::DirEntry::into_path),
906 );
907 }
908 files.sort();
909 for f in files {
910 let hash = std::fs::read(&f).map_or_else(|_| "-".to_owned(), |b| sha256_hex(&b));
911 parts.push(format!("{} {hash}", f.display()));
912 }
913 let own = format!("refs/heads/{branch} ");
914 let refs = git
915 .run(&[
916 "for-each-ref",
917 "--format=%(refname) %(objectname)",
918 "refs/heads",
919 "refs/tags",
920 ])
921 .map(|b| String::from_utf8_lossy(&b).into_owned())
922 .unwrap_or_default();
923 parts.extend(
924 refs.lines()
925 .filter(|l| !l.starts_with(&own))
926 .map(str::to_owned),
927 );
928 sha256_hex(parts.join("\n").as_bytes())
929}
930
931fn resumable(plane_dir: &Path, feature: &str, head: &str) -> bool {
933 std::fs::read_dir(plane_dir.join("resume").join(feature))
934 .into_iter()
935 .flatten()
936 .flatten()
937 .filter_map(|e| crate::resume::load(&e.path()))
938 .any(|m| m.head == head)
939}
940
941pub fn return_checkout(plane_dir: &Path) -> Result<String, InPlaceError> {
944 let Some(session) = Session::load(plane_dir) else {
945 return Err(InPlaceError::new(
946 "IN-PLACE-SESSION",
947 "there is no in-place run to return from",
948 ));
949 };
950 if session.is_live() {
951 return Err(InPlaceError::new(
952 "IN-PLACE-ACTIVE",
953 format!("the in-place run (pid {}) is still running", session.pid),
954 ));
955 }
956 let git = Git::at(&session.checkout);
957 let saved = session.set_aside(
958 plane_dir,
959 "left by an interrupted in-place run; saved by --return",
960 )?;
961 let fresh = Session {
962 tip: git.line(&["rev-parse", "HEAD"]).unwrap_or_default(),
963 ..session.clone()
964 };
965 fresh.finish(plane_dir)?;
966 Ok(match saved {
967 Some(r) => format!(
968 "back on {}; what the interrupted run left is saved as {r}",
969 session.original.describe()
970 ),
971 None => format!("back on {}", session.original.describe()),
972 })
973}
974
975#[must_use]
978pub fn session_at(path: &Path, state: &Path) -> Option<Session> {
979 let root = crate::format::open(path, None).map_or_else(
980 |_| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()),
981 |o| PathBuf::from(&o.snapshot.root),
982 );
983 Session::load(&worktree::project_state_dir(state, &root))
984}
985
986impl Session {
987 #[must_use]
989 pub fn mode_line(&self) -> String {
990 format!(
991 "in-place · {} in {} (returns to {})",
992 self.branch,
993 self.checkout.display(),
994 self.original.describe()
995 )
996 }
997}
998
999#[must_use]
1002pub fn session_elsewhere(path: &Path, feature: Option<&str>, state: &Path) -> Option<InPlaceError> {
1003 let opened = crate::format::open(path, feature).ok()?;
1004 let plane_dir = worktree::project_state_dir(state, Path::new(&opened.snapshot.root));
1005 let session = Session::load(&plane_dir)?;
1006 let wanted = opened
1007 .snapshot
1008 .feature
1009 .as_ref()
1010 .map(|f| f.feature.name.as_str());
1011 if wanted.is_some_and(|w| w != session.feature) {
1012 return None;
1013 }
1014 Some(
1015 InPlaceError::new(
1016 "IN-PLACE-SESSION",
1017 format!(
1018 "{} is being run in place in {} (on {})",
1019 session.feature,
1020 session.checkout.display(),
1021 session.branch
1022 ),
1023 )
1024 .fix(&[
1025 "speccoder run --in-place",
1026 "speccoder run --in-place --return",
1027 ]),
1028 )
1029}
1030
1031#[must_use]
1033pub fn return_in_place(path: &Path, state: &Path) -> CommandResult {
1034 let root = crate::format::open(path, None).map_or_else(
1035 |_| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()),
1036 |o| PathBuf::from(&o.snapshot.root),
1037 );
1038 let plane_dir = worktree::project_state_dir(state, &root);
1039 match return_checkout(&plane_dir) {
1040 Ok(message) => CommandResult::new("run", speccoder_core::CommandStatus::Ok, &message),
1041 Err(e) => e.result("run"),
1042 }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048
1049 #[test]
1050 fn only_the_engines_tick_is_a_tick() {
1051 let before = b"- [ ] T001 a\n- [ ] T002 b\n";
1052 let mut after = before.to_vec();
1053 after[3] = b'x';
1054 assert!(is_tick(before, &after));
1055 let mut two = after.clone();
1056 two[16] = b'X';
1057 assert!(!is_tick(before, &two), "two ticks are not one");
1058 let mut other = before.to_vec();
1059 other[8] = b'Z';
1060 assert!(!is_tick(before, &other), "any other byte is not a tick");
1061 assert!(!is_tick(before, b"- [ ] T001 a\n"));
1062 }
1063
1064 #[test]
1065 fn the_intent_digest_ignores_ticks_only() {
1066 assert_eq!(
1067 untick(b"- [x] T1\n- [X] T2\n- [ ] T3"),
1068 b"- [ ] T1\n- [ ] T2\n- [ ] T3"
1069 );
1070 assert_eq!(untick(b"a [xy] b"), b"a [xy] b");
1071 }
1072
1073 #[test]
1076 #[ignore = "a timing check: run in release"]
1077 fn a_large_protected_set_is_rechecked_within_a_second() {
1078 let dir = tempfile::tempdir().unwrap();
1079 let chunk = "x".repeat(10 * 1024);
1080 for f in 0..40 {
1081 let feature = dir.path().join(format!("specs/{f:03}-f"));
1082 std::fs::create_dir_all(&feature).unwrap();
1083 for i in 0..50 {
1084 std::fs::write(feature.join(format!("checklist-{i}.md")), &chunk).unwrap();
1085 }
1086 }
1087 let policy = Policy::new(&[], &[]);
1088 let first = manifest(dir.path(), &policy);
1089 assert!(first.len() >= 2000, "{} files", first.len());
1090 let start = std::time::Instant::now();
1091 let again = manifest(dir.path(), &policy);
1092 let took = start.elapsed();
1093 assert!(diff(&first, &again).is_empty());
1094 assert!(took <= std::time::Duration::from_secs(1), "took {took:?}");
1095 }
1096
1097 #[test]
1098 fn manifests_diff_by_path() {
1099 let a: BTreeMap<String, String> = [("s.md", "1"), ("t.md", "2")]
1100 .iter()
1101 .map(|(p, h)| ((*p).to_owned(), (*h).to_owned()))
1102 .collect();
1103 let mut b = a.clone();
1104 b.insert("t.md".to_owned(), "3".to_owned());
1105 b.remove("s.md");
1106 b.insert("n.md".to_owned(), "4".to_owned());
1107 let d = diff(&a, &b);
1108 assert_eq!(
1109 d,
1110 [
1111 ("s.md".to_owned(), "deleted".to_owned()),
1112 ("t.md".to_owned(), "modified".to_owned()),
1113 ("n.md".to_owned(), "added".to_owned())
1114 ]
1115 );
1116 assert!(
1117 runtime_state(".specify/workflows/runs/x.json") && runtime_state("a/__pycache__/b.pyc")
1118 );
1119 }
1120}