1use std::fs;
10use std::path::{Component, Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use sha2::{Digest, Sha256};
15
16use crate::format::CommitPrecondition;
17use crate::git::{Git, GitError, nul_fields};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Refusal {
22 pub message: String,
24 pub paths: Vec<(String, String)>,
26 pub hint: String,
28 pub code: &'static str,
30}
31
32impl Refusal {
33 fn new(message: impl Into<String>, hint: impl Into<String>) -> Self {
34 Self {
35 code: "GIT",
36 message: message.into(),
37 paths: Vec::new(),
38 hint: hint.into(),
39 }
40 }
41}
42
43impl From<GitError> for Refusal {
44 fn from(e: GitError) -> Self {
45 Self::new(
46 e.0,
47 "check that git is installed and the repository is healthy",
48 )
49 }
50}
51
52#[must_use]
55pub fn state_dir() -> Option<PathBuf> {
56 let var = |k| {
57 std::env::var_os(k)
58 .filter(|v| !v.is_empty())
59 .map(PathBuf::from)
60 };
61 var("SPECCODER_STATE_DIR")
62 .or_else(|| var("XDG_STATE_HOME").map(|p| p.join("speccoder")))
63 .or_else(|| var("HOME").map(|h| h.join(".local/state/speccoder")))
64}
65
66pub fn repo_state_dir(state: &Path, repo: &Git) -> Result<PathBuf, GitError> {
69 let common = repo.line(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
70 let canonical = fs::canonicalize(&common).unwrap_or_else(|_| PathBuf::from(&common));
71 let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
72 let id: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
73 Ok(state.join(id))
74}
75
76#[must_use]
79pub fn project_state_dir(state: &Path, project: &Path) -> PathBuf {
80 let git = Git::at(project);
81 if let Ok(Some(_)) = git.toplevel()
82 && let Ok(dir) = repo_state_dir(state, &git)
83 {
84 return dir;
85 }
86 let canonical = fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
87 let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
88 let id: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
89 state.join(id)
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Worktree {
95 pub path: PathBuf,
97 pub branch: String,
99 pub base: String,
101 pub created: bool,
103}
104
105#[derive(Debug)]
109pub struct Scratch {
110 repo: PathBuf,
111 pub path: PathBuf,
113}
114
115pub fn scratch(checkout: &Path, under: &Path) -> Result<Scratch, GitError> {
117 let git = Git::at(checkout);
118 let top = git
119 .toplevel()?
120 .ok_or_else(|| GitError("not a git repository".to_owned()))?;
121 fs::create_dir_all(under).map_err(|e| GitError(format!("{}: {e}", under.display())))?;
122 let nanos = std::time::SystemTime::now()
123 .duration_since(std::time::UNIX_EPOCH)
124 .map_or(0, |d| d.as_nanos());
125 let path = under.join(format!("scratch-{}-{nanos}", std::process::id()));
126 let path_str = path.display().to_string();
127 Git::at(&top).run(&["worktree", "add", "--quiet", "--detach", &path_str, "HEAD"])?;
128 Ok(Scratch { repo: top, path })
129}
130
131impl Drop for Scratch {
132 fn drop(&mut self) {
133 crate::prepare::forget(&self.path);
135 crate::include::forget(&self.path);
136 let path = self.path.display().to_string();
137 let git = Git::at(&self.repo);
138 let _ = git.run(&["worktree", "remove", "--force", &path]);
139 let _ = fs::remove_dir_all(&self.path);
140 let _ = git.run(&["worktree", "prune"]);
141 }
142}
143
144pub fn ensure_worktree(
149 repo_root: &Path,
150 state: &Path,
151 feature: &str,
152 committed: impl Into<CommitPrecondition>,
153) -> Result<Worktree, Refusal> {
154 let artifacts = &committed.into();
155 let repo = Git::at(repo_root);
156 let Some(_head) = repo.head()? else {
157 return Err(Refusal {
158 code: "NO-REPOSITORY",
159 ..Refusal::new(
160 "the repository has no commits yet; a worktree needs one",
161 artifacts.no_commits_hint,
162 )
163 });
164 };
165 require_committed(&repo, artifacts)?;
166
167 let branch = format!("speccoder/{feature}");
168 let path = repo_state_dir(state, &repo)?
169 .join("worktrees")
170 .join(feature);
171
172 repo.run(&["worktree", "prune"])?;
174 if let Some(existing) = worktree_for(&repo, &branch)? {
175 bring_spec_changes(&repo, &existing, &branch, artifacts)?;
176 let base = Git::at(&existing).line(&["rev-parse", "HEAD"])?;
177 return Ok(Worktree {
178 path: existing,
179 branch,
180 base,
181 created: false,
182 });
183 }
184 if path.exists() {
185 return Err(Refusal::new(
186 format!(
187 "{} exists but is not a worktree of this repository",
188 path.display()
189 ),
190 "move it away; speccoder does not delete directories it did not create",
191 ));
192 }
193 if let Some(parent) = path.parent() {
194 fs::create_dir_all(parent).map_err(|e| {
195 Refusal::new(
196 format!("{}: {e}", parent.display()),
197 "check permissions of the state directory",
198 )
199 })?;
200 }
201 let path_str = path.to_string_lossy().into_owned();
202 let branch_ref = format!("refs/heads/{branch}");
203 if repo.succeeds(&["rev-parse", "--verify", "-q", &branch_ref])? {
204 repo.run(&["worktree", "add", "--quiet", &path_str, &branch])?;
206 } else {
207 repo.run(&[
208 "worktree", "add", "--quiet", "-b", &branch, &path_str, "HEAD",
209 ])?;
210 }
211 let path = fs::canonicalize(&path).unwrap_or(path);
212 let base = Git::at(&path).line(&["rev-parse", "HEAD"])?;
213 Ok(Worktree {
214 path,
215 branch,
216 base,
217 created: true,
218 })
219}
220
221fn bring_spec_changes(
226 repo: &Git,
227 worktree: &Path,
228 branch: &str,
229 artifacts: &CommitPrecondition,
230) -> Result<(), Refusal> {
231 let head = repo.line(&["rev-parse", "HEAD"])?;
232 let base = repo.line(&["merge-base", branch, &head])?;
233 let mut paths = artifacts.paths();
237 paths.extend(SETUP.iter().map(|p| (*p).to_owned()));
238 let mut diff = vec!["diff", "--quiet", &base, &head, "--"];
239 diff.extend(paths.iter().map(String::as_str));
240 let moved = !repo.succeeds(&diff)?;
241 if !moved {
242 return Ok(());
243 }
244 let wt = Git::at(worktree);
245 for lock in LOCKFILES {
248 if worktree.join(lock).is_file()
249 && wt
250 .line(&["ls-files", "--error-unmatch", "--", lock])
251 .is_err()
252 && repo.succeeds(&["cat-file", "-e", &format!("{head}:{lock}")])?
253 {
254 let _ = fs::remove_file(worktree.join(lock));
255 }
256 }
257 let merged = wt.run(&[
258 "-c",
259 "user.name=speccoder",
260 "-c",
261 "user.email=speccoder@users.noreply.invalid",
262 "-c",
263 "commit.gpgsign=false",
264 "merge",
265 "-q",
266 "--no-edit",
267 "-m",
268 "speccoder: bring in the spec and setup changes from the checkout",
269 &head,
270 ]);
271 if let Err(e) = merged {
272 let _ = wt.run(&["merge", "--abort"]);
273 return Err(Refusal::new(
274 format!(
275 "the spec changed in your checkout and could not be merged into {branch}: {}",
276 e.0.lines().next().unwrap_or_default()
277 ),
278 format!(
279 "merge it by hand in {} (git merge {head}), then run again",
280 worktree.display()
281 ),
282 ));
283 }
284 Ok(())
285}
286
287#[must_use]
289pub fn lane_branch(feature: &str, lane: &str) -> String {
290 format!("speccoder-lane/{feature}/{lane}")
291}
292
293pub fn ensure_lane(
298 repo_root: &Path,
299 state: &Path,
300 feature: &str,
301 lane: &str,
302 start: &str,
303) -> Result<Worktree, Refusal> {
304 let repo = Git::at(repo_root);
305 let branch = lane_branch(feature, lane);
306 let path = repo_state_dir(state, &repo)?
307 .join("worktrees")
308 .join(format!("{feature}@{lane}"));
309 remove_lane(repo_root, feature, lane)?;
310 if path.exists() {
311 return Err(Refusal::new(
312 format!(
313 "{} exists but is not a worktree of this repository",
314 path.display()
315 ),
316 "move it away; speccoder does not delete directories it did not create",
317 ));
318 }
319 if let Some(parent) = path.parent() {
320 let _ = fs::create_dir_all(parent);
321 }
322 let path_str = path.to_string_lossy().into_owned();
323 repo.run(&[
324 "worktree", "add", "--quiet", "-B", &branch, &path_str, start,
325 ])?;
326 let path = fs::canonicalize(&path).unwrap_or(path);
327 Ok(Worktree {
328 path,
329 branch,
330 base: start.to_owned(),
331 created: true,
332 })
333}
334
335pub fn remove_lane(repo_root: &Path, feature: &str, lane: &str) -> Result<(), GitError> {
337 let repo = Git::at(repo_root);
338 let branch = lane_branch(feature, lane);
339 repo.run(&["worktree", "prune"])?;
340 if let Some(existing) = worktree_for(&repo, &branch)? {
341 crate::prepare::forget(&existing);
343 crate::include::forget(&existing);
344 repo.run(&["worktree", "remove", "--force", &existing.to_string_lossy()])?;
345 }
346 if repo.succeeds(&[
347 "rev-parse",
348 "--verify",
349 "-q",
350 &format!("refs/heads/{branch}"),
351 ])? {
352 repo.run(&["branch", "-q", "-D", &branch])?;
353 }
354 Ok(())
355}
356
357pub fn worktree_for(repo: &Git, branch: &str) -> Result<Option<PathBuf>, GitError> {
359 let fields = nul_fields(&repo.run(&["worktree", "list", "--porcelain", "-z"])?);
360 let wanted = format!("branch refs/heads/{branch}");
361 let mut current: Option<PathBuf> = None;
362 for f in fields {
363 if let Some(p) = f.strip_prefix("worktree ") {
364 current = Some(PathBuf::from(p));
365 } else if f == wanted {
366 return Ok(current);
367 }
368 }
369 Ok(None)
370}
371
372#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct Artifacts {
375 pub specify_dir: String,
377 pub feature_dir: String,
379}
380
381impl From<&Artifacts> for CommitPrecondition {
382 fn from(a: &Artifacts) -> Self {
383 crate::format::speckit::precondition(&a.specify_dir, &a.feature_dir)
384 }
385}
386
387fn require_committed(repo: &Git, a: &CommitPrecondition) -> Result<(), Refusal> {
391 let mut paths: Vec<(String, String)> = Vec::new();
392
393 for dir in &a.strict {
394 let out = repo.run(&[
395 "status",
396 "--porcelain=v1",
397 "-z",
398 "--untracked-files=all",
399 "--ignored=traditional",
400 "--",
401 dir,
402 ])?;
403 paths.extend(status_entries(&out));
404 }
405 for dir in &a.tracked {
406 let out = repo.run(&[
407 "status",
408 "--porcelain=v1",
409 "-z",
410 "--untracked-files=all",
411 "--",
412 dir,
413 ])?;
414 paths.extend(
415 status_entries(&out)
416 .into_iter()
417 .filter(|(p, _)| !(a.runtime)(p)),
418 );
419 }
420
421 for p in &a.essentials {
424 let tracked = !repo.run(&["ls-files", "--", p])?.is_empty();
425 if !tracked && repo.dir().join(p).exists() && !paths.iter().any(|(q, _)| q == p) {
426 let reason = if repo.succeeds(&["check-ignore", "-q", "--", p])? {
427 "ignored"
428 } else {
429 "untracked"
430 };
431 paths.push((p.clone(), reason.to_owned()));
432 }
433 }
434
435 if paths.is_empty() {
436 return Ok(());
437 }
438 paths.sort();
439 paths.dedup();
440 Err(Refusal {
441 code: "UNCOMMITTED-INTENT",
442 message: a.message.to_owned(),
443 paths,
444 hint: a.hint.to_owned(),
445 })
446}
447
448fn status_entries(out: &[u8]) -> Vec<(String, String)> {
450 let mut entries = Vec::new();
451 let mut fields = nul_fields(out).into_iter();
452 while let Some(f) = fields.next() {
453 if f.len() < 4 {
454 continue;
455 }
456 let (code, path) = f.split_at(3);
457 let code = code.trim_end();
458 let reason = match code {
459 "??" => "untracked",
460 "!!" => "ignored",
461 _ => "modified",
462 };
463 if code.starts_with('R') || code.starts_with('C') {
465 fields.next();
466 }
467 entries.push((path.to_owned(), reason.to_owned()));
468 }
469 entries
470}
471
472#[must_use]
474pub fn build_output(argv: &[String]) -> &'static [&'static str] {
475 let program = argv
476 .first()
477 .map(|a| a.rsplit('/').next().unwrap_or(a))
478 .unwrap_or_default();
479 match program {
480 "cargo" => &["target/"],
481 "npm" | "npx" | "pnpm" | "yarn" | "bun" => &["node_modules/"],
482 "pytest" | "python" | "python3" | "uv" => &[".pytest_cache/", "__pycache__/"],
483 _ => &[],
484 }
485}
486
487pub const SETUP: &[&str] = &[
490 "speccoder.toml",
491 ".config/nextest.toml",
492 ".gitignore",
493 "Cargo.lock",
494 "package-lock.json",
495 "pnpm-lock.yaml",
496 "yarn.lock",
497 "uv.lock",
498 "poetry.lock",
499 "go.sum",
500];
501
502pub const LOCKFILES: &[&str] = &[
504 "Cargo.lock",
505 "package-lock.json",
506 "pnpm-lock.yaml",
507 "yarn.lock",
508 "uv.lock",
509 "poetry.lock",
510 "go.sum",
511];
512
513#[derive(Debug, Default, PartialEq, Eq)]
517pub struct Leftovers {
518 pub ignore: Vec<String>,
520 pub commit: Vec<String>,
522}
523
524impl Leftovers {
525 #[must_use]
527 pub fn is_empty(&self) -> bool {
528 self.ignore.is_empty() && self.commit.is_empty()
529 }
530
531 #[must_use]
535 pub fn fix(&self, checkout: &Path, worktree: &Path, also: &[String]) -> String {
536 let mut steps = Vec::new();
537 for f in &self.commit {
538 if !checkout.join(f).exists() {
539 steps.push(format!("cp '{}' .", worktree.join(f).display()));
540 }
541 }
542 if !self.ignore.is_empty() {
543 steps.push(format!(
544 "printf '{}\\n' >> .gitignore",
545 self.ignore.join("\\n")
546 ));
547 }
548 let mut add: Vec<&str> = also
549 .iter()
550 .chain(&self.commit)
551 .map(String::as_str)
552 .collect();
553 if !self.ignore.is_empty() {
554 add.push(".gitignore");
555 }
556 steps.push(format!(
557 "git add {} && git commit -m \"speccoder: setup\"",
558 add.join(" ")
559 ));
560 steps.join(" && ")
561 }
562}
563
564#[must_use]
566pub fn leftovers(dir: &Path) -> Leftovers {
567 const BUILD: [&str; 4] = ["target", "node_modules", ".pytest_cache", "__pycache__"];
568 let Ok(out) = Git::at(dir).run(&["status", "--porcelain=v1", "--untracked-files=normal"])
569 else {
570 return Leftovers::default();
571 };
572 let mut found = Leftovers::default();
573 for p in String::from_utf8_lossy(&out)
574 .lines()
575 .filter_map(|l| l.strip_prefix("?? "))
576 {
577 let p = p.trim_end_matches('/');
578 let first = p.split('/').next().unwrap_or(p);
579 if BUILD.contains(&first) {
580 found.ignore.push(format!("{first}/"));
581 } else if LOCKFILES.contains(&p) {
582 found.commit.push(p.to_owned());
583 }
584 }
585 found.ignore.sort();
586 found.ignore.dedup();
587 found
588}
589
590pub fn require_clean(dir: &Path) -> Result<(), Refusal> {
592 let out = Git::at(dir).run(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])?;
593 let paths = status_entries(&out);
594 if paths.is_empty() {
595 Ok(())
596 } else {
597 Err(Refusal {
598 code: "DIRTY-CHECKOUT",
599 message: "the working tree has uncommitted changes".to_owned(),
600 paths,
601 hint: "commit or stash them first".to_owned(),
602 })
603 }
604}
605
606#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct Checkpoint(pub String);
611
612#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
614pub enum ChangeKind {
615 Added,
617 Modified,
619 Deleted,
621}
622
623#[derive(Debug, Clone, PartialEq, Eq)]
625pub struct Change {
626 pub path: String,
628 pub kind: ChangeKind,
630}
631
632struct TempIndex(PathBuf);
634
635impl TempIndex {
636 fn new(tmp: &Path) -> Result<Self, GitError> {
637 static COUNTER: AtomicU64 = AtomicU64::new(0);
638 fs::create_dir_all(tmp).map_err(|e| GitError(format!("{}: {e}", tmp.display())))?;
639 let nanos = SystemTime::now()
640 .duration_since(UNIX_EPOCH)
641 .map(|d| d.as_nanos())
642 .unwrap_or(0);
643 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
644 Ok(Self(
645 tmp.join(format!("index-{}-{nanos}-{n}", std::process::id())),
646 ))
647 }
648}
649
650impl Drop for TempIndex {
651 fn drop(&mut self) {
652 let _ = fs::remove_file(&self.0);
654 }
655}
656
657pub fn checkpoint(dir: &Path, tmp: &Path) -> Result<Checkpoint, GitError> {
660 let git = Git::at(dir);
661 let index = TempIndex::new(tmp)?;
662 let real = git.git_path("index")?;
664 if real.exists() {
665 fs::copy(&real, &index.0).map_err(|e| GitError(format!("copy index: {e}")))?;
666 } else if git.head()?.is_some() {
667 git.run_with_index(&index.0, &["read-tree", "HEAD"], None)?;
668 } else {
669 git.run_with_index(&index.0, &["read-tree", "--empty"], None)?;
670 }
671 let excluded = git
674 .toplevel()?
675 .map(|top| crate::include::pathspecs(&top))
676 .unwrap_or_default();
677 let mut add = vec!["add", "-A", "--", ":/"];
678 add.extend(excluded.iter().map(String::as_str));
679 git.run_with_index(&index.0, &add, None)?;
680 let tree = git.run_with_index(&index.0, &["write-tree"], None)?;
681 Ok(Checkpoint(String::from_utf8_lossy(&tree).trim().to_owned()))
682}
683
684pub fn diff(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<Vec<Change>, GitError> {
686 let out = Git::at(dir).run(&[
687 "diff-tree",
688 "-r",
689 "-z",
690 "--no-renames",
691 "--name-status",
692 &from.0,
693 &to.0,
694 ])?;
695 let fields = nul_fields(&out);
696 let mut changes = Vec::new();
697 for pair in fields.chunks(2) {
698 let [status, path] = pair else { continue };
699 let kind = match status.chars().next() {
700 Some('A') => ChangeKind::Added,
701 Some('D') => ChangeKind::Deleted,
702 _ => ChangeKind::Modified,
703 };
704 changes.push(Change {
705 path: path.clone(),
706 kind,
707 });
708 }
709 Ok(changes)
710}
711
712pub fn files(dir: &Path, checkpoint: &Checkpoint) -> Result<Vec<String>, GitError> {
714 let out = Git::at(dir).run(&["ls-tree", "-r", "--name-only", "-z", &checkpoint.0])?;
715 Ok(nul_fields(&out))
716}
717
718pub fn patch(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<String, GitError> {
720 let out = Git::at(dir).run(&["diff", "--no-color", "--no-renames", "-U0", &from.0, &to.0])?;
721 Ok(String::from_utf8_lossy(&out).into_owned())
722}
723
724pub fn restore(dir: &Path, tmp: &Path, target: &Checkpoint) -> Result<Vec<Change>, GitError> {
728 let git = Git::at(dir);
729 let root = git
730 .toplevel()?
731 .ok_or_else(|| GitError(format!("{} is not in a git repository", dir.display())))?;
732 let current = checkpoint(&root, tmp)?;
733 let changes = diff(&root, target, ¤t)?;
734 if changes.is_empty() {
735 return Ok(changes);
736 }
737
738 let mut write: Vec<u8> = Vec::new();
741 let mut remove: Vec<&str> = Vec::new();
742 for c in &changes {
743 if !is_plain_relative(&c.path) {
744 return Err(GitError(format!(
745 "refusing to restore unsafe path {}",
746 c.path
747 )));
748 }
749 match c.kind {
750 ChangeKind::Added => remove.push(&c.path),
751 ChangeKind::Deleted | ChangeKind::Modified => {
752 write.extend_from_slice(c.path.as_bytes());
753 write.push(0);
754 }
755 }
756 }
757 let rgit = Git::at(&root);
758 for path in &remove {
759 let full = root.join(path);
760 if fs::symlink_metadata(&full).is_ok() {
763 fs::remove_file(&full).map_err(|e| GitError(format!("remove {path}: {e}")))?;
764 prune_empty_parents(&root, &full);
765 }
766 }
767 if !write.is_empty() {
768 let index = TempIndex::new(tmp)?;
769 rgit.run_with_index(&index.0, &["read-tree", &target.0], None)?;
770 rgit.run_with_index(
771 &index.0,
772 &["checkout-index", "-f", "-z", "--stdin"],
773 Some(&write),
774 )?;
775 }
776 Ok(changes)
777}
778
779fn is_plain_relative(path: &str) -> bool {
780 let p = Path::new(path);
781 !path.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))
782}
783
784fn prune_empty_parents(root: &Path, removed: &Path) {
786 let mut dir = removed.parent();
787 while let Some(d) = dir {
788 if d == root || !d.starts_with(root) || fs::remove_dir(d).is_err() {
789 break;
790 }
791 dir = d.parent();
792 }
793}