use std::path::{Path, PathBuf};
pub(super) struct CheckWorkspace {
workspace: car_multi::AgentWorkspace,
_directory: tempfile::TempDir,
}
impl CheckWorkspace {
pub fn new(source: &Path) -> Result<Self, String> {
let directory = tempfile::tempdir().map_err(|e| e.to_string())?;
let git = source.join(".git").exists();
let config = if git {
car_multi::WorkspaceConfig::git_worktree_at(source, directory.path())
} else {
car_multi::WorkspaceConfig::directory(directory.path())
};
let workspace = car_multi::AgentWorkspace::provision(&config, "baseline")?;
let destination = workspace.path();
if git {
for entry in std::fs::read_dir(destination).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if entry.file_name() == ".git" {
continue;
}
if entry.file_type().map_err(|e| e.to_string())?.is_dir() {
std::fs::remove_dir_all(entry.path()).map_err(|e| e.to_string())?;
} else {
std::fs::remove_file(entry.path()).map_err(|e| e.to_string())?;
}
}
let output = std::process::Command::new("git")
.arg("-C")
.arg(source)
.args([
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"-z",
])
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err("cannot enumerate baseline inputs".into());
}
for raw in output.stdout.split(|b| *b == 0).filter(|p| !p.is_empty()) {
#[cfg(unix)]
let relative = {
use std::os::unix::ffi::OsStrExt;
PathBuf::from(std::ffi::OsStr::from_bytes(raw))
};
#[cfg(not(unix))]
let relative = PathBuf::from(std::str::from_utf8(raw).map_err(|e| e.to_string())?);
if relative.is_absolute()
|| relative
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err("invalid baseline input path".into());
}
let from = source.join(&relative);
match std::fs::symlink_metadata(&from) {
Ok(_) => copy(&from, &destination.join(relative), source, destination)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.to_string()),
}
}
let index = |root: &Path| -> Result<PathBuf, String> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["rev-parse", "--git-path", "index"])
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err("cannot locate baseline index".into());
}
let path = PathBuf::from(
String::from_utf8(output.stdout)
.map_err(|e| e.to_string())?
.trim(),
);
Ok(if path.is_absolute() {
path
} else {
root.join(path)
})
};
let from_index = index(source)?;
let to_index = index(destination)?;
std::fs::copy(&from_index, &to_index).map_err(|e| e.to_string())?;
for entry in std::fs::read_dir(from_index.parent().ok_or("missing index parent")?)
.map_err(|e| e.to_string())?
{
let entry = entry.map_err(|e| e.to_string())?;
if entry
.file_name()
.to_string_lossy()
.starts_with("sharedindex.")
&& entry.file_type().map_err(|e| e.to_string())?.is_file()
{
std::fs::copy(
entry.path(),
to_index
.parent()
.ok_or("missing snapshot index parent")?
.join(entry.file_name()),
)
.map_err(|e| e.to_string())?;
}
}
} else {
copy(source, destination, source, destination)?;
}
Ok(Self {
workspace,
_directory: directory,
})
}
pub fn path(&self) -> &Path {
self.workspace.path()
}
}
fn copy(
source: &Path,
destination: &Path,
source_root: &Path,
destination_root: &Path,
) -> Result<(), String> {
let metadata = std::fs::symlink_metadata(source).map_err(|e| e.to_string())?;
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
if metadata.file_type().is_symlink() {
let resolved = source
.canonicalize()
.map_err(|e| format!("cannot resolve baseline symlink {}: {e}", source.display()))?;
let root = source_root.canonicalize().map_err(|e| e.to_string())?;
let relative = resolved
.strip_prefix(&root)
.map_err(|_| format!("baseline symlink leaves the worktree: {}", source.display()))?;
let target = destination_root.join(relative);
#[cfg(unix)]
std::os::unix::fs::symlink(target, destination).map_err(|e| e.to_string())?;
#[cfg(windows)]
{
if source.is_dir() {
std::os::windows::fs::symlink_dir(target, destination)
} else {
std::os::windows::fs::symlink_file(target, destination)
}
.map_err(|e| e.to_string())?;
}
} else if metadata.is_dir() {
if source != source_root && source.join(".git").exists() {
return Err(format!(
"nested Git input needs its own baseline snapshot: {}",
source.display()
));
}
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
for entry in std::fs::read_dir(source).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if entry.file_name() != ".git" {
copy(
&entry.path(),
&destination.join(entry.file_name()),
source_root,
destination_root,
)?;
}
}
} else if metadata.is_file() {
std::fs::copy(source, destination).map_err(|e| e.to_string())?;
} else {
return Err(format!("unsupported baseline input {}", source.display()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn baseline_symlinks_point_into_the_copy_or_fail_preparation() {
let source = tempfile::tempdir().unwrap();
std::fs::write(source.path().join("input.txt"), "original").unwrap();
std::os::unix::fs::symlink(source.path().join("input.txt"), source.path().join("alias"))
.unwrap();
let workspace = CheckWorkspace::new(source.path()).unwrap();
std::fs::write(workspace.path().join("alias"), "copy mutation").unwrap();
assert_eq!(
std::fs::read_to_string(source.path().join("input.txt")).unwrap(),
"original"
);
let external = tempfile::NamedTempFile::new().unwrap();
std::os::unix::fs::symlink(external.path(), source.path().join("outside")).unwrap();
assert!(CheckWorkspace::new(source.path()).is_err());
}
#[test]
fn copies_current_git_inputs_without_modifying_the_source_index() {
let source = tempfile::tempdir().unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.arg("-C")
.arg(source.path())
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
out.stdout
};
git(&["init", "-q"]);
std::fs::write(source.path().join("existing.txt"), "initial").unwrap();
std::fs::write(source.path().join("deleted.txt"), "delete me").unwrap();
git(&["add", "."]);
git(&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.invalid",
"commit",
"-qm",
"fixture",
]);
std::fs::write(source.path().join("existing.txt"), "staged").unwrap();
git(&["add", "existing.txt"]);
std::fs::write(
source.path().join("existing.txt"),
"unstaged current content",
)
.unwrap();
std::fs::remove_file(source.path().join("deleted.txt")).unwrap();
std::fs::write(source.path().join("untracked.txt"), "unfinished work").unwrap();
let before = git(&["diff", "--cached", "--binary"]);
let workspace = CheckWorkspace::new(source.path()).unwrap();
let path = workspace.path().to_path_buf();
let copied_index = std::process::Command::new("git")
.arg("-C")
.arg(&path)
.args(["diff", "--cached", "--binary"])
.output()
.unwrap();
assert!(copied_index.status.success());
assert_eq!(copied_index.stdout, before);
assert_eq!(
std::fs::read_to_string(path.join("existing.txt")).unwrap(),
"unstaged current content"
);
assert_eq!(
std::fs::read_to_string(path.join("untracked.txt")).unwrap(),
"unfinished work"
);
assert!(!path.join("deleted.txt").exists());
std::fs::write(path.join("existing.txt"), "check side effect").unwrap();
drop(workspace);
assert!(!path.exists());
assert_eq!(git(&["diff", "--cached", "--binary"]), before);
assert_eq!(
std::fs::read_to_string(source.path().join("existing.txt")).unwrap(),
"unstaged current content"
);
}
}