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
105pub fn ensure_worktree(
110 repo_root: &Path,
111 state: &Path,
112 feature: &str,
113 committed: impl Into<CommitPrecondition>,
114) -> Result<Worktree, Refusal> {
115 let artifacts = &committed.into();
116 let repo = Git::at(repo_root);
117 let Some(_head) = repo.head()? else {
118 return Err(Refusal {
119 code: "NO-REPOSITORY",
120 ..Refusal::new(
121 "the repository has no commits yet; a worktree needs one",
122 artifacts.no_commits_hint,
123 )
124 });
125 };
126 require_committed(&repo, artifacts)?;
127
128 let branch = format!("speccoder/{feature}");
129 let path = repo_state_dir(state, &repo)?
130 .join("worktrees")
131 .join(feature);
132
133 repo.run(&["worktree", "prune"])?;
135 if let Some(existing) = worktree_for(&repo, &branch)? {
136 bring_spec_changes(&repo, &existing, &branch, artifacts)?;
137 let base = Git::at(&existing).line(&["rev-parse", "HEAD"])?;
138 return Ok(Worktree {
139 path: existing,
140 branch,
141 base,
142 created: false,
143 });
144 }
145 if path.exists() {
146 return Err(Refusal::new(
147 format!(
148 "{} exists but is not a worktree of this repository",
149 path.display()
150 ),
151 "move it away; speccoder does not delete directories it did not create",
152 ));
153 }
154 if let Some(parent) = path.parent() {
155 fs::create_dir_all(parent).map_err(|e| {
156 Refusal::new(
157 format!("{}: {e}", parent.display()),
158 "check permissions of the state directory",
159 )
160 })?;
161 }
162 let path_str = path.to_string_lossy().into_owned();
163 let branch_ref = format!("refs/heads/{branch}");
164 if repo.succeeds(&["rev-parse", "--verify", "-q", &branch_ref])? {
165 repo.run(&["worktree", "add", "--quiet", &path_str, &branch])?;
167 } else {
168 repo.run(&[
169 "worktree", "add", "--quiet", "-b", &branch, &path_str, "HEAD",
170 ])?;
171 }
172 let path = fs::canonicalize(&path).unwrap_or(path);
173 let base = Git::at(&path).line(&["rev-parse", "HEAD"])?;
174 Ok(Worktree {
175 path,
176 branch,
177 base,
178 created: true,
179 })
180}
181
182fn bring_spec_changes(
187 repo: &Git,
188 worktree: &Path,
189 branch: &str,
190 artifacts: &CommitPrecondition,
191) -> Result<(), Refusal> {
192 let head = repo.line(&["rev-parse", "HEAD"])?;
193 let base = repo.line(&["merge-base", branch, &head])?;
194 let paths = artifacts.paths();
195 let mut diff = vec!["diff", "--quiet", &base, &head, "--"];
196 diff.extend(paths.iter().map(String::as_str));
197 let moved = !repo.succeeds(&diff)?;
198 if !moved {
199 return Ok(());
200 }
201 let wt = Git::at(worktree);
202 let merged = wt.run(&[
203 "-c",
204 "user.name=speccoder",
205 "-c",
206 "user.email=speccoder@users.noreply.invalid",
207 "-c",
208 "commit.gpgsign=false",
209 "merge",
210 "-q",
211 "--no-edit",
212 "-m",
213 "speccoder: bring in the spec changes from the checkout",
214 &head,
215 ]);
216 if let Err(e) = merged {
217 let _ = wt.run(&["merge", "--abort"]);
218 return Err(Refusal::new(
219 format!(
220 "the spec changed in your checkout and could not be merged into {branch}: {}",
221 e.0.lines().next().unwrap_or_default()
222 ),
223 format!(
224 "merge it by hand in {} (git merge {head}), then run again",
225 worktree.display()
226 ),
227 ));
228 }
229 Ok(())
230}
231
232#[must_use]
234pub fn lane_branch(feature: &str, lane: &str) -> String {
235 format!("speccoder-lane/{feature}/{lane}")
236}
237
238pub fn ensure_lane(
243 repo_root: &Path,
244 state: &Path,
245 feature: &str,
246 lane: &str,
247 start: &str,
248) -> Result<Worktree, Refusal> {
249 let repo = Git::at(repo_root);
250 let branch = lane_branch(feature, lane);
251 let path = repo_state_dir(state, &repo)?
252 .join("worktrees")
253 .join(format!("{feature}@{lane}"));
254 remove_lane(repo_root, feature, lane)?;
255 if path.exists() {
256 return Err(Refusal::new(
257 format!(
258 "{} exists but is not a worktree of this repository",
259 path.display()
260 ),
261 "move it away; speccoder does not delete directories it did not create",
262 ));
263 }
264 if let Some(parent) = path.parent() {
265 let _ = fs::create_dir_all(parent);
266 }
267 let path_str = path.to_string_lossy().into_owned();
268 repo.run(&[
269 "worktree", "add", "--quiet", "-B", &branch, &path_str, start,
270 ])?;
271 let path = fs::canonicalize(&path).unwrap_or(path);
272 Ok(Worktree {
273 path,
274 branch,
275 base: start.to_owned(),
276 created: true,
277 })
278}
279
280pub fn remove_lane(repo_root: &Path, feature: &str, lane: &str) -> Result<(), GitError> {
282 let repo = Git::at(repo_root);
283 let branch = lane_branch(feature, lane);
284 repo.run(&["worktree", "prune"])?;
285 if let Some(existing) = worktree_for(&repo, &branch)? {
286 repo.run(&["worktree", "remove", "--force", &existing.to_string_lossy()])?;
287 }
288 if repo.succeeds(&[
289 "rev-parse",
290 "--verify",
291 "-q",
292 &format!("refs/heads/{branch}"),
293 ])? {
294 repo.run(&["branch", "-q", "-D", &branch])?;
295 }
296 Ok(())
297}
298
299fn worktree_for(repo: &Git, branch: &str) -> Result<Option<PathBuf>, GitError> {
301 let fields = nul_fields(&repo.run(&["worktree", "list", "--porcelain", "-z"])?);
302 let wanted = format!("branch refs/heads/{branch}");
303 let mut current: Option<PathBuf> = None;
304 for f in fields {
305 if let Some(p) = f.strip_prefix("worktree ") {
306 current = Some(PathBuf::from(p));
307 } else if f == wanted {
308 return Ok(current);
309 }
310 }
311 Ok(None)
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct Artifacts {
317 pub specify_dir: String,
319 pub feature_dir: String,
321}
322
323impl From<&Artifacts> for CommitPrecondition {
324 fn from(a: &Artifacts) -> Self {
325 crate::format::speckit::precondition(&a.specify_dir, &a.feature_dir)
326 }
327}
328
329fn require_committed(repo: &Git, a: &CommitPrecondition) -> Result<(), Refusal> {
333 let mut paths: Vec<(String, String)> = Vec::new();
334
335 for dir in &a.strict {
336 let out = repo.run(&[
337 "status",
338 "--porcelain=v1",
339 "-z",
340 "--untracked-files=all",
341 "--ignored=traditional",
342 "--",
343 dir,
344 ])?;
345 paths.extend(status_entries(&out));
346 }
347 for dir in &a.tracked {
348 let out = repo.run(&[
349 "status",
350 "--porcelain=v1",
351 "-z",
352 "--untracked-files=all",
353 "--",
354 dir,
355 ])?;
356 paths.extend(
357 status_entries(&out)
358 .into_iter()
359 .filter(|(p, _)| !(a.runtime)(p)),
360 );
361 }
362
363 for p in &a.essentials {
366 let tracked = !repo.run(&["ls-files", "--", p])?.is_empty();
367 if !tracked && repo.dir().join(p).exists() && !paths.iter().any(|(q, _)| q == p) {
368 let reason = if repo.succeeds(&["check-ignore", "-q", "--", p])? {
369 "ignored"
370 } else {
371 "untracked"
372 };
373 paths.push((p.clone(), reason.to_owned()));
374 }
375 }
376
377 if paths.is_empty() {
378 return Ok(());
379 }
380 paths.sort();
381 paths.dedup();
382 Err(Refusal {
383 code: "UNCOMMITTED-INTENT",
384 message: a.message.to_owned(),
385 paths,
386 hint: a.hint.to_owned(),
387 })
388}
389
390fn status_entries(out: &[u8]) -> Vec<(String, String)> {
392 let mut entries = Vec::new();
393 let mut fields = nul_fields(out).into_iter();
394 while let Some(f) = fields.next() {
395 if f.len() < 4 {
396 continue;
397 }
398 let (code, path) = f.split_at(3);
399 let code = code.trim_end();
400 let reason = match code {
401 "??" => "untracked",
402 "!!" => "ignored",
403 _ => "modified",
404 };
405 if code.starts_with('R') || code.starts_with('C') {
407 fields.next();
408 }
409 entries.push((path.to_owned(), reason.to_owned()));
410 }
411 entries
412}
413
414pub fn require_clean(dir: &Path) -> Result<(), Refusal> {
416 let out = Git::at(dir).run(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])?;
417 let paths = status_entries(&out);
418 if paths.is_empty() {
419 Ok(())
420 } else {
421 Err(Refusal {
422 code: "DIRTY-CHECKOUT",
423 message: "the working tree has uncommitted changes".to_owned(),
424 paths,
425 hint: "commit or stash them first".to_owned(),
426 })
427 }
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct Checkpoint(pub String);
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
438pub enum ChangeKind {
439 Added,
441 Modified,
443 Deleted,
445}
446
447#[derive(Debug, Clone, PartialEq, Eq)]
449pub struct Change {
450 pub path: String,
452 pub kind: ChangeKind,
454}
455
456struct TempIndex(PathBuf);
458
459impl TempIndex {
460 fn new(tmp: &Path) -> Result<Self, GitError> {
461 static COUNTER: AtomicU64 = AtomicU64::new(0);
462 fs::create_dir_all(tmp).map_err(|e| GitError(format!("{}: {e}", tmp.display())))?;
463 let nanos = SystemTime::now()
464 .duration_since(UNIX_EPOCH)
465 .map(|d| d.as_nanos())
466 .unwrap_or(0);
467 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
468 Ok(Self(
469 tmp.join(format!("index-{}-{nanos}-{n}", std::process::id())),
470 ))
471 }
472}
473
474impl Drop for TempIndex {
475 fn drop(&mut self) {
476 let _ = fs::remove_file(&self.0);
478 }
479}
480
481pub fn checkpoint(dir: &Path, tmp: &Path) -> Result<Checkpoint, GitError> {
484 let git = Git::at(dir);
485 let index = TempIndex::new(tmp)?;
486 let real = git.git_path("index")?;
488 if real.exists() {
489 fs::copy(&real, &index.0).map_err(|e| GitError(format!("copy index: {e}")))?;
490 } else if git.head()?.is_some() {
491 git.run_with_index(&index.0, &["read-tree", "HEAD"], None)?;
492 } else {
493 git.run_with_index(&index.0, &["read-tree", "--empty"], None)?;
494 }
495 git.run_with_index(&index.0, &["add", "-A", "--", ":/"], None)?;
496 let tree = git.run_with_index(&index.0, &["write-tree"], None)?;
497 Ok(Checkpoint(String::from_utf8_lossy(&tree).trim().to_owned()))
498}
499
500pub fn diff(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<Vec<Change>, GitError> {
502 let out = Git::at(dir).run(&[
503 "diff-tree",
504 "-r",
505 "-z",
506 "--no-renames",
507 "--name-status",
508 &from.0,
509 &to.0,
510 ])?;
511 let fields = nul_fields(&out);
512 let mut changes = Vec::new();
513 for pair in fields.chunks(2) {
514 let [status, path] = pair else { continue };
515 let kind = match status.chars().next() {
516 Some('A') => ChangeKind::Added,
517 Some('D') => ChangeKind::Deleted,
518 _ => ChangeKind::Modified,
519 };
520 changes.push(Change {
521 path: path.clone(),
522 kind,
523 });
524 }
525 Ok(changes)
526}
527
528pub fn files(dir: &Path, checkpoint: &Checkpoint) -> Result<Vec<String>, GitError> {
530 let out = Git::at(dir).run(&["ls-tree", "-r", "--name-only", "-z", &checkpoint.0])?;
531 Ok(nul_fields(&out))
532}
533
534pub fn patch(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<String, GitError> {
536 let out = Git::at(dir).run(&["diff", "--no-color", "--no-renames", "-U0", &from.0, &to.0])?;
537 Ok(String::from_utf8_lossy(&out).into_owned())
538}
539
540pub fn restore(dir: &Path, tmp: &Path, target: &Checkpoint) -> Result<Vec<Change>, GitError> {
544 let git = Git::at(dir);
545 let root = git
546 .toplevel()?
547 .ok_or_else(|| GitError(format!("{} is not in a git repository", dir.display())))?;
548 let current = checkpoint(&root, tmp)?;
549 let changes = diff(&root, target, ¤t)?;
550 if changes.is_empty() {
551 return Ok(changes);
552 }
553
554 let mut write: Vec<u8> = Vec::new();
557 let mut remove: Vec<&str> = Vec::new();
558 for c in &changes {
559 if !is_plain_relative(&c.path) {
560 return Err(GitError(format!(
561 "refusing to restore unsafe path {}",
562 c.path
563 )));
564 }
565 match c.kind {
566 ChangeKind::Added => remove.push(&c.path),
567 ChangeKind::Deleted | ChangeKind::Modified => {
568 write.extend_from_slice(c.path.as_bytes());
569 write.push(0);
570 }
571 }
572 }
573 let rgit = Git::at(&root);
574 for path in &remove {
575 let full = root.join(path);
576 if fs::symlink_metadata(&full).is_ok() {
579 fs::remove_file(&full).map_err(|e| GitError(format!("remove {path}: {e}")))?;
580 prune_empty_parents(&root, &full);
581 }
582 }
583 if !write.is_empty() {
584 let index = TempIndex::new(tmp)?;
585 rgit.run_with_index(&index.0, &["read-tree", &target.0], None)?;
586 rgit.run_with_index(
587 &index.0,
588 &["checkout-index", "-f", "-z", "--stdin"],
589 Some(&write),
590 )?;
591 }
592 Ok(changes)
593}
594
595fn is_plain_relative(path: &str) -> bool {
596 let p = Path::new(path);
597 !path.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))
598}
599
600fn prune_empty_parents(root: &Path, removed: &Path) {
602 let mut dir = removed.parent();
603 while let Some(d) = dir {
604 if d == root || !d.starts_with(root) || fs::remove_dir(d).is_err() {
605 break;
606 }
607 dir = d.parent();
608 }
609}