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,
}
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 paths = artifacts.paths();
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);
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 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)? {
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(())
}
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
}
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)?;
}
git.run_with_index(&index.0, &["add", "-A", "--", ":/"], 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();
}
}