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 let path = self.path.display().to_string();
134 let git = Git::at(&self.repo);
135 let _ = git.run(&["worktree", "remove", "--force", &path]);
136 let _ = fs::remove_dir_all(&self.path);
137 let _ = git.run(&["worktree", "prune"]);
138 }
139}
140
141pub fn ensure_worktree(
146 repo_root: &Path,
147 state: &Path,
148 feature: &str,
149 committed: impl Into<CommitPrecondition>,
150) -> Result<Worktree, Refusal> {
151 let artifacts = &committed.into();
152 let repo = Git::at(repo_root);
153 let Some(_head) = repo.head()? else {
154 return Err(Refusal {
155 code: "NO-REPOSITORY",
156 ..Refusal::new(
157 "the repository has no commits yet; a worktree needs one",
158 artifacts.no_commits_hint,
159 )
160 });
161 };
162 require_committed(&repo, artifacts)?;
163
164 let branch = format!("speccoder/{feature}");
165 let path = repo_state_dir(state, &repo)?
166 .join("worktrees")
167 .join(feature);
168
169 repo.run(&["worktree", "prune"])?;
171 if let Some(existing) = worktree_for(&repo, &branch)? {
172 bring_spec_changes(&repo, &existing, &branch, artifacts)?;
173 let base = Git::at(&existing).line(&["rev-parse", "HEAD"])?;
174 return Ok(Worktree {
175 path: existing,
176 branch,
177 base,
178 created: false,
179 });
180 }
181 if path.exists() {
182 return Err(Refusal::new(
183 format!(
184 "{} exists but is not a worktree of this repository",
185 path.display()
186 ),
187 "move it away; speccoder does not delete directories it did not create",
188 ));
189 }
190 if let Some(parent) = path.parent() {
191 fs::create_dir_all(parent).map_err(|e| {
192 Refusal::new(
193 format!("{}: {e}", parent.display()),
194 "check permissions of the state directory",
195 )
196 })?;
197 }
198 let path_str = path.to_string_lossy().into_owned();
199 let branch_ref = format!("refs/heads/{branch}");
200 if repo.succeeds(&["rev-parse", "--verify", "-q", &branch_ref])? {
201 repo.run(&["worktree", "add", "--quiet", &path_str, &branch])?;
203 } else {
204 repo.run(&[
205 "worktree", "add", "--quiet", "-b", &branch, &path_str, "HEAD",
206 ])?;
207 }
208 let path = fs::canonicalize(&path).unwrap_or(path);
209 let base = Git::at(&path).line(&["rev-parse", "HEAD"])?;
210 Ok(Worktree {
211 path,
212 branch,
213 base,
214 created: true,
215 })
216}
217
218fn bring_spec_changes(
223 repo: &Git,
224 worktree: &Path,
225 branch: &str,
226 artifacts: &CommitPrecondition,
227) -> Result<(), Refusal> {
228 let head = repo.line(&["rev-parse", "HEAD"])?;
229 let base = repo.line(&["merge-base", branch, &head])?;
230 let mut paths = artifacts.paths();
234 paths.extend(SETUP.iter().map(|p| (*p).to_owned()));
235 let mut diff = vec!["diff", "--quiet", &base, &head, "--"];
236 diff.extend(paths.iter().map(String::as_str));
237 let moved = !repo.succeeds(&diff)?;
238 if !moved {
239 return Ok(());
240 }
241 let wt = Git::at(worktree);
242 for lock in LOCKFILES {
245 if worktree.join(lock).is_file()
246 && wt
247 .line(&["ls-files", "--error-unmatch", "--", lock])
248 .is_err()
249 && repo.succeeds(&["cat-file", "-e", &format!("{head}:{lock}")])?
250 {
251 let _ = fs::remove_file(worktree.join(lock));
252 }
253 }
254 let merged = wt.run(&[
255 "-c",
256 "user.name=speccoder",
257 "-c",
258 "user.email=speccoder@users.noreply.invalid",
259 "-c",
260 "commit.gpgsign=false",
261 "merge",
262 "-q",
263 "--no-edit",
264 "-m",
265 "speccoder: bring in the spec and setup changes from the checkout",
266 &head,
267 ]);
268 if let Err(e) = merged {
269 let _ = wt.run(&["merge", "--abort"]);
270 return Err(Refusal::new(
271 format!(
272 "the spec changed in your checkout and could not be merged into {branch}: {}",
273 e.0.lines().next().unwrap_or_default()
274 ),
275 format!(
276 "merge it by hand in {} (git merge {head}), then run again",
277 worktree.display()
278 ),
279 ));
280 }
281 Ok(())
282}
283
284#[must_use]
286pub fn lane_branch(feature: &str, lane: &str) -> String {
287 format!("speccoder-lane/{feature}/{lane}")
288}
289
290pub fn ensure_lane(
295 repo_root: &Path,
296 state: &Path,
297 feature: &str,
298 lane: &str,
299 start: &str,
300) -> Result<Worktree, Refusal> {
301 let repo = Git::at(repo_root);
302 let branch = lane_branch(feature, lane);
303 let path = repo_state_dir(state, &repo)?
304 .join("worktrees")
305 .join(format!("{feature}@{lane}"));
306 remove_lane(repo_root, feature, lane)?;
307 if path.exists() {
308 return Err(Refusal::new(
309 format!(
310 "{} exists but is not a worktree of this repository",
311 path.display()
312 ),
313 "move it away; speccoder does not delete directories it did not create",
314 ));
315 }
316 if let Some(parent) = path.parent() {
317 let _ = fs::create_dir_all(parent);
318 }
319 let path_str = path.to_string_lossy().into_owned();
320 repo.run(&[
321 "worktree", "add", "--quiet", "-B", &branch, &path_str, start,
322 ])?;
323 let path = fs::canonicalize(&path).unwrap_or(path);
324 Ok(Worktree {
325 path,
326 branch,
327 base: start.to_owned(),
328 created: true,
329 })
330}
331
332pub fn remove_lane(repo_root: &Path, feature: &str, lane: &str) -> Result<(), GitError> {
334 let repo = Git::at(repo_root);
335 let branch = lane_branch(feature, lane);
336 repo.run(&["worktree", "prune"])?;
337 if let Some(existing) = worktree_for(&repo, &branch)? {
338 repo.run(&["worktree", "remove", "--force", &existing.to_string_lossy()])?;
339 }
340 if repo.succeeds(&[
341 "rev-parse",
342 "--verify",
343 "-q",
344 &format!("refs/heads/{branch}"),
345 ])? {
346 repo.run(&["branch", "-q", "-D", &branch])?;
347 }
348 Ok(())
349}
350
351pub fn worktree_for(repo: &Git, branch: &str) -> Result<Option<PathBuf>, GitError> {
353 let fields = nul_fields(&repo.run(&["worktree", "list", "--porcelain", "-z"])?);
354 let wanted = format!("branch refs/heads/{branch}");
355 let mut current: Option<PathBuf> = None;
356 for f in fields {
357 if let Some(p) = f.strip_prefix("worktree ") {
358 current = Some(PathBuf::from(p));
359 } else if f == wanted {
360 return Ok(current);
361 }
362 }
363 Ok(None)
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct Artifacts {
369 pub specify_dir: String,
371 pub feature_dir: String,
373}
374
375impl From<&Artifacts> for CommitPrecondition {
376 fn from(a: &Artifacts) -> Self {
377 crate::format::speckit::precondition(&a.specify_dir, &a.feature_dir)
378 }
379}
380
381fn require_committed(repo: &Git, a: &CommitPrecondition) -> Result<(), Refusal> {
385 let mut paths: Vec<(String, String)> = Vec::new();
386
387 for dir in &a.strict {
388 let out = repo.run(&[
389 "status",
390 "--porcelain=v1",
391 "-z",
392 "--untracked-files=all",
393 "--ignored=traditional",
394 "--",
395 dir,
396 ])?;
397 paths.extend(status_entries(&out));
398 }
399 for dir in &a.tracked {
400 let out = repo.run(&[
401 "status",
402 "--porcelain=v1",
403 "-z",
404 "--untracked-files=all",
405 "--",
406 dir,
407 ])?;
408 paths.extend(
409 status_entries(&out)
410 .into_iter()
411 .filter(|(p, _)| !(a.runtime)(p)),
412 );
413 }
414
415 for p in &a.essentials {
418 let tracked = !repo.run(&["ls-files", "--", p])?.is_empty();
419 if !tracked && repo.dir().join(p).exists() && !paths.iter().any(|(q, _)| q == p) {
420 let reason = if repo.succeeds(&["check-ignore", "-q", "--", p])? {
421 "ignored"
422 } else {
423 "untracked"
424 };
425 paths.push((p.clone(), reason.to_owned()));
426 }
427 }
428
429 if paths.is_empty() {
430 return Ok(());
431 }
432 paths.sort();
433 paths.dedup();
434 Err(Refusal {
435 code: "UNCOMMITTED-INTENT",
436 message: a.message.to_owned(),
437 paths,
438 hint: a.hint.to_owned(),
439 })
440}
441
442fn status_entries(out: &[u8]) -> Vec<(String, String)> {
444 let mut entries = Vec::new();
445 let mut fields = nul_fields(out).into_iter();
446 while let Some(f) = fields.next() {
447 if f.len() < 4 {
448 continue;
449 }
450 let (code, path) = f.split_at(3);
451 let code = code.trim_end();
452 let reason = match code {
453 "??" => "untracked",
454 "!!" => "ignored",
455 _ => "modified",
456 };
457 if code.starts_with('R') || code.starts_with('C') {
459 fields.next();
460 }
461 entries.push((path.to_owned(), reason.to_owned()));
462 }
463 entries
464}
465
466#[must_use]
468pub fn build_output(argv: &[String]) -> &'static [&'static str] {
469 let program = argv
470 .first()
471 .map(|a| a.rsplit('/').next().unwrap_or(a))
472 .unwrap_or_default();
473 match program {
474 "cargo" => &["target/"],
475 "npm" | "npx" | "pnpm" | "yarn" | "bun" => &["node_modules/"],
476 "pytest" | "python" | "python3" | "uv" => &[".pytest_cache/", "__pycache__/"],
477 _ => &[],
478 }
479}
480
481pub const SETUP: &[&str] = &[
484 "speccoder.toml",
485 ".config/nextest.toml",
486 ".gitignore",
487 "Cargo.lock",
488 "package-lock.json",
489 "pnpm-lock.yaml",
490 "yarn.lock",
491 "uv.lock",
492 "poetry.lock",
493 "go.sum",
494];
495
496pub const LOCKFILES: &[&str] = &[
498 "Cargo.lock",
499 "package-lock.json",
500 "pnpm-lock.yaml",
501 "yarn.lock",
502 "uv.lock",
503 "poetry.lock",
504 "go.sum",
505];
506
507#[derive(Debug, Default, PartialEq, Eq)]
511pub struct Leftovers {
512 pub ignore: Vec<String>,
514 pub commit: Vec<String>,
516}
517
518impl Leftovers {
519 #[must_use]
521 pub fn is_empty(&self) -> bool {
522 self.ignore.is_empty() && self.commit.is_empty()
523 }
524
525 #[must_use]
529 pub fn fix(&self, checkout: &Path, worktree: &Path, also: &[String]) -> String {
530 let mut steps = Vec::new();
531 for f in &self.commit {
532 if !checkout.join(f).exists() {
533 steps.push(format!("cp '{}' .", worktree.join(f).display()));
534 }
535 }
536 if !self.ignore.is_empty() {
537 steps.push(format!(
538 "printf '{}\\n' >> .gitignore",
539 self.ignore.join("\\n")
540 ));
541 }
542 let mut add: Vec<&str> = also
543 .iter()
544 .chain(&self.commit)
545 .map(String::as_str)
546 .collect();
547 if !self.ignore.is_empty() {
548 add.push(".gitignore");
549 }
550 steps.push(format!(
551 "git add {} && git commit -m \"speccoder: setup\"",
552 add.join(" ")
553 ));
554 steps.join(" && ")
555 }
556}
557
558#[must_use]
560pub fn leftovers(dir: &Path) -> Leftovers {
561 const BUILD: [&str; 4] = ["target", "node_modules", ".pytest_cache", "__pycache__"];
562 let Ok(out) = Git::at(dir).run(&["status", "--porcelain=v1", "--untracked-files=normal"])
563 else {
564 return Leftovers::default();
565 };
566 let mut found = Leftovers::default();
567 for p in String::from_utf8_lossy(&out)
568 .lines()
569 .filter_map(|l| l.strip_prefix("?? "))
570 {
571 let p = p.trim_end_matches('/');
572 let first = p.split('/').next().unwrap_or(p);
573 if BUILD.contains(&first) {
574 found.ignore.push(format!("{first}/"));
575 } else if LOCKFILES.contains(&p) {
576 found.commit.push(p.to_owned());
577 }
578 }
579 found.ignore.sort();
580 found.ignore.dedup();
581 found
582}
583
584pub fn require_clean(dir: &Path) -> Result<(), Refusal> {
586 let out = Git::at(dir).run(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])?;
587 let paths = status_entries(&out);
588 if paths.is_empty() {
589 Ok(())
590 } else {
591 Err(Refusal {
592 code: "DIRTY-CHECKOUT",
593 message: "the working tree has uncommitted changes".to_owned(),
594 paths,
595 hint: "commit or stash them first".to_owned(),
596 })
597 }
598}
599
600#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct Checkpoint(pub String);
605
606#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
608pub enum ChangeKind {
609 Added,
611 Modified,
613 Deleted,
615}
616
617#[derive(Debug, Clone, PartialEq, Eq)]
619pub struct Change {
620 pub path: String,
622 pub kind: ChangeKind,
624}
625
626struct TempIndex(PathBuf);
628
629impl TempIndex {
630 fn new(tmp: &Path) -> Result<Self, GitError> {
631 static COUNTER: AtomicU64 = AtomicU64::new(0);
632 fs::create_dir_all(tmp).map_err(|e| GitError(format!("{}: {e}", tmp.display())))?;
633 let nanos = SystemTime::now()
634 .duration_since(UNIX_EPOCH)
635 .map(|d| d.as_nanos())
636 .unwrap_or(0);
637 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
638 Ok(Self(
639 tmp.join(format!("index-{}-{nanos}-{n}", std::process::id())),
640 ))
641 }
642}
643
644impl Drop for TempIndex {
645 fn drop(&mut self) {
646 let _ = fs::remove_file(&self.0);
648 }
649}
650
651pub fn checkpoint(dir: &Path, tmp: &Path) -> Result<Checkpoint, GitError> {
654 let git = Git::at(dir);
655 let index = TempIndex::new(tmp)?;
656 let real = git.git_path("index")?;
658 if real.exists() {
659 fs::copy(&real, &index.0).map_err(|e| GitError(format!("copy index: {e}")))?;
660 } else if git.head()?.is_some() {
661 git.run_with_index(&index.0, &["read-tree", "HEAD"], None)?;
662 } else {
663 git.run_with_index(&index.0, &["read-tree", "--empty"], None)?;
664 }
665 git.run_with_index(&index.0, &["add", "-A", "--", ":/"], None)?;
666 let tree = git.run_with_index(&index.0, &["write-tree"], None)?;
667 Ok(Checkpoint(String::from_utf8_lossy(&tree).trim().to_owned()))
668}
669
670pub fn diff(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<Vec<Change>, GitError> {
672 let out = Git::at(dir).run(&[
673 "diff-tree",
674 "-r",
675 "-z",
676 "--no-renames",
677 "--name-status",
678 &from.0,
679 &to.0,
680 ])?;
681 let fields = nul_fields(&out);
682 let mut changes = Vec::new();
683 for pair in fields.chunks(2) {
684 let [status, path] = pair else { continue };
685 let kind = match status.chars().next() {
686 Some('A') => ChangeKind::Added,
687 Some('D') => ChangeKind::Deleted,
688 _ => ChangeKind::Modified,
689 };
690 changes.push(Change {
691 path: path.clone(),
692 kind,
693 });
694 }
695 Ok(changes)
696}
697
698pub fn files(dir: &Path, checkpoint: &Checkpoint) -> Result<Vec<String>, GitError> {
700 let out = Git::at(dir).run(&["ls-tree", "-r", "--name-only", "-z", &checkpoint.0])?;
701 Ok(nul_fields(&out))
702}
703
704pub fn patch(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<String, GitError> {
706 let out = Git::at(dir).run(&["diff", "--no-color", "--no-renames", "-U0", &from.0, &to.0])?;
707 Ok(String::from_utf8_lossy(&out).into_owned())
708}
709
710pub fn restore(dir: &Path, tmp: &Path, target: &Checkpoint) -> Result<Vec<Change>, GitError> {
714 let git = Git::at(dir);
715 let root = git
716 .toplevel()?
717 .ok_or_else(|| GitError(format!("{} is not in a git repository", dir.display())))?;
718 let current = checkpoint(&root, tmp)?;
719 let changes = diff(&root, target, ¤t)?;
720 if changes.is_empty() {
721 return Ok(changes);
722 }
723
724 let mut write: Vec<u8> = Vec::new();
727 let mut remove: Vec<&str> = Vec::new();
728 for c in &changes {
729 if !is_plain_relative(&c.path) {
730 return Err(GitError(format!(
731 "refusing to restore unsafe path {}",
732 c.path
733 )));
734 }
735 match c.kind {
736 ChangeKind::Added => remove.push(&c.path),
737 ChangeKind::Deleted | ChangeKind::Modified => {
738 write.extend_from_slice(c.path.as_bytes());
739 write.push(0);
740 }
741 }
742 }
743 let rgit = Git::at(&root);
744 for path in &remove {
745 let full = root.join(path);
746 if fs::symlink_metadata(&full).is_ok() {
749 fs::remove_file(&full).map_err(|e| GitError(format!("remove {path}: {e}")))?;
750 prune_empty_parents(&root, &full);
751 }
752 }
753 if !write.is_empty() {
754 let index = TempIndex::new(tmp)?;
755 rgit.run_with_index(&index.0, &["read-tree", &target.0], None)?;
756 rgit.run_with_index(
757 &index.0,
758 &["checkout-index", "-f", "-z", "--stdin"],
759 Some(&write),
760 )?;
761 }
762 Ok(changes)
763}
764
765fn is_plain_relative(path: &str) -> bool {
766 let p = Path::new(path);
767 !path.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))
768}
769
770fn prune_empty_parents(root: &Path, removed: &Path) {
772 let mut dir = removed.parent();
773 while let Some(d) = dir {
774 if d == root || !d.starts_with(root) || fs::remove_dir(d).is_err() {
775 break;
776 }
777 dir = d.parent();
778 }
779}