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