use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
use crate::format::CommitPrecondition;
use crate::git::{Git, GitError, nul_fields};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
pub message: String,
pub paths: Vec<(String, String)>,
pub hint: String,
pub code: &'static str,
}
impl Refusal {
fn new(message: impl Into<String>, hint: impl Into<String>) -> Self {
Self {
code: "GIT",
message: message.into(),
paths: Vec::new(),
hint: hint.into(),
}
}
}
impl From<GitError> for Refusal {
fn from(e: GitError) -> Self {
Self::new(
e.0,
"check that git is installed and the repository is healthy",
)
}
}
#[must_use]
pub fn state_dir() -> Option<PathBuf> {
let var = |k| {
std::env::var_os(k)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
};
var("SPECCODER_STATE_DIR")
.or_else(|| var("XDG_STATE_HOME").map(|p| p.join("speccoder")))
.or_else(|| var("HOME").map(|h| h.join(".local/state/speccoder")))
}
pub fn repo_state_dir(state: &Path, repo: &Git) -> Result<PathBuf, GitError> {
let common = repo.line(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
let canonical = fs::canonicalize(&common).unwrap_or_else(|_| PathBuf::from(&common));
let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
let id: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
Ok(state.join(id))
}
#[must_use]
pub fn project_state_dir(state: &Path, project: &Path) -> PathBuf {
let git = Git::at(project);
if let Ok(Some(_)) = git.toplevel()
&& let Ok(dir) = repo_state_dir(state, &git)
{
return dir;
}
let canonical = fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
let id: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
state.join(id)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Worktree {
pub path: PathBuf,
pub branch: String,
pub base: String,
pub created: bool,
}
#[derive(Debug)]
pub struct Scratch {
repo: PathBuf,
pub path: PathBuf,
}
pub fn scratch(checkout: &Path, under: &Path) -> Result<Scratch, GitError> {
let git = Git::at(checkout);
let top = git
.toplevel()?
.ok_or_else(|| GitError("not a git repository".to_owned()))?;
fs::create_dir_all(under).map_err(|e| GitError(format!("{}: {e}", under.display())))?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let path = under.join(format!("scratch-{}-{nanos}", std::process::id()));
let path_str = path.display().to_string();
Git::at(&top).run(&["worktree", "add", "--quiet", "--detach", &path_str, "HEAD"])?;
Ok(Scratch { repo: top, path })
}
impl Drop for Scratch {
fn drop(&mut self) {
crate::prepare::forget(&self.path);
crate::include::forget(&self.path);
let path = self.path.display().to_string();
let git = Git::at(&self.repo);
let _ = git.run(&["worktree", "remove", "--force", &path]);
let _ = fs::remove_dir_all(&self.path);
let _ = git.run(&["worktree", "prune"]);
}
}
pub fn ensure_worktree(
repo_root: &Path,
state: &Path,
feature: &str,
committed: impl Into<CommitPrecondition>,
) -> Result<Worktree, Refusal> {
let artifacts = &committed.into();
let repo = Git::at(repo_root);
let Some(_head) = repo.head()? else {
return Err(Refusal {
code: "NO-REPOSITORY",
..Refusal::new(
"the repository has no commits yet; a worktree needs one",
artifacts.no_commits_hint,
)
});
};
require_committed(&repo, artifacts)?;
let branch = format!("speccoder/{feature}");
let path = repo_state_dir(state, &repo)?
.join("worktrees")
.join(feature);
repo.run(&["worktree", "prune"])?;
if let Some(existing) = worktree_for(&repo, &branch)? {
bring_spec_changes(&repo, &existing, &branch, artifacts)?;
let base = Git::at(&existing).line(&["rev-parse", "HEAD"])?;
return Ok(Worktree {
path: existing,
branch,
base,
created: false,
});
}
if path.exists() {
return Err(Refusal::new(
format!(
"{} exists but is not a worktree of this repository",
path.display()
),
"move it away; speccoder does not delete directories it did not create",
));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Refusal::new(
format!("{}: {e}", parent.display()),
"check permissions of the state directory",
)
})?;
}
let path_str = path.to_string_lossy().into_owned();
let branch_ref = format!("refs/heads/{branch}");
if repo.succeeds(&["rev-parse", "--verify", "-q", &branch_ref])? {
repo.run(&["worktree", "add", "--quiet", &path_str, &branch])?;
} else {
repo.run(&[
"worktree", "add", "--quiet", "-b", &branch, &path_str, "HEAD",
])?;
}
let path = fs::canonicalize(&path).unwrap_or(path);
let base = Git::at(&path).line(&["rev-parse", "HEAD"])?;
Ok(Worktree {
path,
branch,
base,
created: true,
})
}
fn bring_spec_changes(
repo: &Git,
worktree: &Path,
branch: &str,
artifacts: &CommitPrecondition,
) -> Result<(), Refusal> {
let head = repo.line(&["rev-parse", "HEAD"])?;
let base = repo.line(&["merge-base", branch, &head])?;
let mut paths = artifacts.paths();
paths.extend(SETUP.iter().map(|p| (*p).to_owned()));
let mut diff = vec!["diff", "--quiet", &base, &head, "--"];
diff.extend(paths.iter().map(String::as_str));
let moved = !repo.succeeds(&diff)?;
if !moved {
return Ok(());
}
let wt = Git::at(worktree);
for lock in LOCKFILES {
if worktree.join(lock).is_file()
&& wt
.line(&["ls-files", "--error-unmatch", "--", lock])
.is_err()
&& repo.succeeds(&["cat-file", "-e", &format!("{head}:{lock}")])?
{
let _ = fs::remove_file(worktree.join(lock));
}
}
let merged = wt.run(&[
"-c",
"user.name=speccoder",
"-c",
"user.email=speccoder@users.noreply.invalid",
"-c",
"commit.gpgsign=false",
"merge",
"-q",
"--no-edit",
"-m",
"speccoder: bring in the spec and setup changes from the checkout",
&head,
]);
if let Err(e) = merged {
let _ = wt.run(&["merge", "--abort"]);
return Err(Refusal::new(
format!(
"the spec changed in your checkout and could not be merged into {branch}: {}",
e.0.lines().next().unwrap_or_default()
),
format!(
"merge it by hand in {} (git merge {head}), then run again",
worktree.display()
),
));
}
Ok(())
}
#[must_use]
pub fn lane_branch(feature: &str, lane: &str) -> String {
format!("speccoder-lane/{feature}/{lane}")
}
pub fn ensure_lane(
repo_root: &Path,
state: &Path,
feature: &str,
lane: &str,
start: &str,
) -> Result<Worktree, Refusal> {
let repo = Git::at(repo_root);
let branch = lane_branch(feature, lane);
let path = repo_state_dir(state, &repo)?
.join("worktrees")
.join(format!("{feature}@{lane}"));
remove_lane(repo_root, feature, lane)?;
if path.exists() {
return Err(Refusal::new(
format!(
"{} exists but is not a worktree of this repository",
path.display()
),
"move it away; speccoder does not delete directories it did not create",
));
}
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let path_str = path.to_string_lossy().into_owned();
repo.run(&[
"worktree", "add", "--quiet", "-B", &branch, &path_str, start,
])?;
let path = fs::canonicalize(&path).unwrap_or(path);
Ok(Worktree {
path,
branch,
base: start.to_owned(),
created: true,
})
}
pub fn remove_lane(repo_root: &Path, feature: &str, lane: &str) -> Result<(), GitError> {
let repo = Git::at(repo_root);
let branch = lane_branch(feature, lane);
repo.run(&["worktree", "prune"])?;
if let Some(existing) = worktree_for(&repo, &branch)? {
crate::prepare::forget(&existing);
crate::include::forget(&existing);
repo.run(&["worktree", "remove", "--force", &existing.to_string_lossy()])?;
}
if repo.succeeds(&[
"rev-parse",
"--verify",
"-q",
&format!("refs/heads/{branch}"),
])? {
repo.run(&["branch", "-q", "-D", &branch])?;
}
Ok(())
}
pub fn worktree_for(repo: &Git, branch: &str) -> Result<Option<PathBuf>, GitError> {
let fields = nul_fields(&repo.run(&["worktree", "list", "--porcelain", "-z"])?);
let wanted = format!("branch refs/heads/{branch}");
let mut current: Option<PathBuf> = None;
for f in fields {
if let Some(p) = f.strip_prefix("worktree ") {
current = Some(PathBuf::from(p));
} else if f == wanted {
return Ok(current);
}
}
Ok(None)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifacts {
pub specify_dir: String,
pub feature_dir: String,
}
impl From<&Artifacts> for CommitPrecondition {
fn from(a: &Artifacts) -> Self {
crate::format::speckit::precondition(&a.specify_dir, &a.feature_dir)
}
}
fn require_committed(repo: &Git, a: &CommitPrecondition) -> Result<(), Refusal> {
let mut paths: Vec<(String, String)> = Vec::new();
for dir in &a.strict {
let out = repo.run(&[
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
"--ignored=traditional",
"--",
dir,
])?;
paths.extend(status_entries(&out));
}
for dir in &a.tracked {
let out = repo.run(&[
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
"--",
dir,
])?;
paths.extend(
status_entries(&out)
.into_iter()
.filter(|(p, _)| !(a.runtime)(p)),
);
}
for p in &a.essentials {
let tracked = !repo.run(&["ls-files", "--", p])?.is_empty();
if !tracked && repo.dir().join(p).exists() && !paths.iter().any(|(q, _)| q == p) {
let reason = if repo.succeeds(&["check-ignore", "-q", "--", p])? {
"ignored"
} else {
"untracked"
};
paths.push((p.clone(), reason.to_owned()));
}
}
if paths.is_empty() {
return Ok(());
}
paths.sort();
paths.dedup();
Err(Refusal {
code: "UNCOMMITTED-INTENT",
message: a.message.to_owned(),
paths,
hint: a.hint.to_owned(),
})
}
fn status_entries(out: &[u8]) -> Vec<(String, String)> {
let mut entries = Vec::new();
let mut fields = nul_fields(out).into_iter();
while let Some(f) = fields.next() {
if f.len() < 4 {
continue;
}
let (code, path) = f.split_at(3);
let code = code.trim_end();
let reason = match code {
"??" => "untracked",
"!!" => "ignored",
_ => "modified",
};
if code.starts_with('R') || code.starts_with('C') {
fields.next();
}
entries.push((path.to_owned(), reason.to_owned()));
}
entries
}
#[must_use]
pub fn build_output(argv: &[String]) -> &'static [&'static str] {
let program = argv
.first()
.map(|a| a.rsplit('/').next().unwrap_or(a))
.unwrap_or_default();
match program {
"cargo" => &["target/"],
"npm" | "npx" | "pnpm" | "yarn" | "bun" => &["node_modules/"],
"pytest" | "python" | "python3" | "uv" => &[".pytest_cache/", "__pycache__/"],
_ => &[],
}
}
pub const SETUP: &[&str] = &[
"speccoder.toml",
".config/nextest.toml",
".gitignore",
"Cargo.lock",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"uv.lock",
"poetry.lock",
"go.sum",
];
pub const LOCKFILES: &[&str] = &[
"Cargo.lock",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"uv.lock",
"poetry.lock",
"go.sum",
];
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Leftovers {
pub ignore: Vec<String>,
pub commit: Vec<String>,
}
impl Leftovers {
#[must_use]
pub fn is_empty(&self) -> bool {
self.ignore.is_empty() && self.commit.is_empty()
}
#[must_use]
pub fn fix(&self, checkout: &Path, worktree: &Path, also: &[String]) -> String {
let mut steps = Vec::new();
for f in &self.commit {
if !checkout.join(f).exists() {
steps.push(format!("cp '{}' .", worktree.join(f).display()));
}
}
if !self.ignore.is_empty() {
steps.push(format!(
"printf '{}\\n' >> .gitignore",
self.ignore.join("\\n")
));
}
let mut add: Vec<&str> = also
.iter()
.chain(&self.commit)
.map(String::as_str)
.collect();
if !self.ignore.is_empty() {
add.push(".gitignore");
}
steps.push(format!(
"git add {} && git commit -m \"speccoder: setup\"",
add.join(" ")
));
steps.join(" && ")
}
}
#[must_use]
pub fn leftovers(dir: &Path) -> Leftovers {
const BUILD: [&str; 4] = ["target", "node_modules", ".pytest_cache", "__pycache__"];
let Ok(out) = Git::at(dir).run(&["status", "--porcelain=v1", "--untracked-files=normal"])
else {
return Leftovers::default();
};
let mut found = Leftovers::default();
for p in String::from_utf8_lossy(&out)
.lines()
.filter_map(|l| l.strip_prefix("?? "))
{
let p = p.trim_end_matches('/');
let first = p.split('/').next().unwrap_or(p);
if BUILD.contains(&first) {
found.ignore.push(format!("{first}/"));
} else if LOCKFILES.contains(&p) {
found.commit.push(p.to_owned());
}
}
found.ignore.sort();
found.ignore.dedup();
found
}
pub fn require_clean(dir: &Path) -> Result<(), Refusal> {
let out = Git::at(dir).run(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])?;
let paths = status_entries(&out);
if paths.is_empty() {
Ok(())
} else {
Err(Refusal {
code: "DIRTY-CHECKOUT",
message: "the working tree has uncommitted changes".to_owned(),
paths,
hint: "commit or stash them first".to_owned(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Checkpoint(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ChangeKind {
Added,
Modified,
Deleted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
pub path: String,
pub kind: ChangeKind,
}
struct TempIndex(PathBuf);
impl TempIndex {
fn new(tmp: &Path) -> Result<Self, GitError> {
static COUNTER: AtomicU64 = AtomicU64::new(0);
fs::create_dir_all(tmp).map_err(|e| GitError(format!("{}: {e}", tmp.display())))?;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
Ok(Self(
tmp.join(format!("index-{}-{nanos}-{n}", std::process::id())),
))
}
}
impl Drop for TempIndex {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
pub fn checkpoint(dir: &Path, tmp: &Path) -> Result<Checkpoint, GitError> {
let git = Git::at(dir);
let index = TempIndex::new(tmp)?;
let real = git.git_path("index")?;
if real.exists() {
fs::copy(&real, &index.0).map_err(|e| GitError(format!("copy index: {e}")))?;
} else if git.head()?.is_some() {
git.run_with_index(&index.0, &["read-tree", "HEAD"], None)?;
} else {
git.run_with_index(&index.0, &["read-tree", "--empty"], None)?;
}
let excluded = git
.toplevel()?
.map(|top| crate::include::pathspecs(&top))
.unwrap_or_default();
let mut add = vec!["add", "-A", "--", ":/"];
add.extend(excluded.iter().map(String::as_str));
git.run_with_index(&index.0, &add, None)?;
let tree = git.run_with_index(&index.0, &["write-tree"], None)?;
Ok(Checkpoint(String::from_utf8_lossy(&tree).trim().to_owned()))
}
pub fn diff(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<Vec<Change>, GitError> {
let out = Git::at(dir).run(&[
"diff-tree",
"-r",
"-z",
"--no-renames",
"--name-status",
&from.0,
&to.0,
])?;
let fields = nul_fields(&out);
let mut changes = Vec::new();
for pair in fields.chunks(2) {
let [status, path] = pair else { continue };
let kind = match status.chars().next() {
Some('A') => ChangeKind::Added,
Some('D') => ChangeKind::Deleted,
_ => ChangeKind::Modified,
};
changes.push(Change {
path: path.clone(),
kind,
});
}
Ok(changes)
}
pub fn files(dir: &Path, checkpoint: &Checkpoint) -> Result<Vec<String>, GitError> {
let out = Git::at(dir).run(&["ls-tree", "-r", "--name-only", "-z", &checkpoint.0])?;
Ok(nul_fields(&out))
}
pub fn patch(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<String, GitError> {
let out = Git::at(dir).run(&["diff", "--no-color", "--no-renames", "-U0", &from.0, &to.0])?;
Ok(String::from_utf8_lossy(&out).into_owned())
}
pub fn restore(dir: &Path, tmp: &Path, target: &Checkpoint) -> Result<Vec<Change>, GitError> {
let git = Git::at(dir);
let root = git
.toplevel()?
.ok_or_else(|| GitError(format!("{} is not in a git repository", dir.display())))?;
let current = checkpoint(&root, tmp)?;
let changes = diff(&root, target, ¤t)?;
if changes.is_empty() {
return Ok(changes);
}
let mut write: Vec<u8> = Vec::new();
let mut remove: Vec<&str> = Vec::new();
for c in &changes {
if !is_plain_relative(&c.path) {
return Err(GitError(format!(
"refusing to restore unsafe path {}",
c.path
)));
}
match c.kind {
ChangeKind::Added => remove.push(&c.path),
ChangeKind::Deleted | ChangeKind::Modified => {
write.extend_from_slice(c.path.as_bytes());
write.push(0);
}
}
}
let rgit = Git::at(&root);
for path in &remove {
let full = root.join(path);
if fs::symlink_metadata(&full).is_ok() {
fs::remove_file(&full).map_err(|e| GitError(format!("remove {path}: {e}")))?;
prune_empty_parents(&root, &full);
}
}
if !write.is_empty() {
let index = TempIndex::new(tmp)?;
rgit.run_with_index(&index.0, &["read-tree", &target.0], None)?;
rgit.run_with_index(
&index.0,
&["checkout-index", "-f", "-z", "--stdin"],
Some(&write),
)?;
}
Ok(changes)
}
fn is_plain_relative(path: &str) -> bool {
let p = Path::new(path);
!path.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))
}
fn prune_empty_parents(root: &Path, removed: &Path) {
let mut dir = removed.parent();
while let Some(d) = dir {
if d == root || !d.starts_with(root) || fs::remove_dir(d).is_err() {
break;
}
dir = d.parent();
}
}