use std::collections::HashMap;
use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{LazyLock, Mutex};
static PROBES: LazyLock<Mutex<HashMap<(PathBuf, PathBuf), bool>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn err(msg: String) -> io::Error {
io::Error::other(msg)
}
fn clone_flags() -> &'static [&'static str] {
if cfg!(target_os = "macos") {
&["-cRp"]
} else {
&["-a", "--reflink=always"]
}
}
fn clone_path(src: &Path, dst: &Path) -> io::Result<()> {
let out = Command::new("cp")
.args(clone_flags())
.arg(src)
.arg(dst)
.output()?;
if !out.status.success() {
return Err(err(format!(
"cp {}: {}",
src.display(),
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
}
pub fn git(dir: &Path, args: &[&str]) -> io::Result<String> {
let out = Command::new("git").arg("-C").arg(dir).args(args).output()?;
if !out.status.success() {
return Err(err(format!(
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn can_clone(src_dir: &Path, dst_dir: &Path) -> bool {
let key = (src_dir.to_path_buf(), dst_dir.to_path_buf());
if let Some(known) = PROBES.lock().unwrap().get(&key) {
return *known;
}
let src = src_dir.join(".cow-probe-src");
let dst = dst_dir.join(".cow-probe-dst");
let _ = fs::remove_file(&dst);
let ok = fs::write(&src, b"x").is_ok() && clone_path(&src, &dst).is_ok();
let _ = fs::remove_file(&src);
let _ = fs::remove_file(&dst);
PROBES.lock().unwrap().insert(key, ok);
ok
}
fn lock_template(template: &Path) -> io::Result<File> {
let name = template.file_name().unwrap_or_default().to_string_lossy();
let file = File::create(template.with_file_name(format!(".{name}.lock")))?;
file.lock()?;
Ok(file)
}
fn refresh_template(repo: &Path, template: &Path, start: &str) -> io::Result<()> {
if template.join(".git").exists()
&& git(template, &["checkout", "--detach", "--force", start]).is_ok()
{
return Ok(());
}
let _ = fs::remove_dir_all(template);
let _ = git(repo, &["worktree", "prune"]);
git(
repo,
&[
"worktree",
"add",
"--detach",
&template.to_string_lossy(),
start,
],
)?;
Ok(())
}
fn ignored_dirs(src: &Path) -> io::Result<Vec<String>> {
let out = git(
src,
&[
"ls-files",
"--others",
"--ignored",
"--exclude-standard",
"--directory",
],
)?;
Ok(out
.lines()
.filter_map(|l| l.strip_suffix('/'))
.filter(|n| !n.is_empty() && !n.split('/').any(|c| c.starts_with('.')))
.map(String::from)
.collect())
}
fn warm_template(src: &Path, template: &Path) -> io::Result<()> {
let name = template.file_name().unwrap_or_default().to_string_lossy();
let staging = template.with_file_name(format!(".{name}.warm"));
for dir in ignored_dirs(src)? {
if !git(template, &["ls-files", "--", &dir])
.unwrap_or_default()
.is_empty()
{
continue;
}
let _ = fs::remove_dir_all(&staging);
fs::create_dir_all(&staging)?;
let _ = (|| {
clone_path(&src.join(&dir), &staging)?;
let dst = template.join(&dir);
let _ = fs::remove_dir_all(&dst);
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
let base = Path::new(&dir).file_name().unwrap_or_default();
fs::rename(staging.join(base), dst)
})();
let _ = fs::remove_dir_all(&staging);
}
Ok(())
}
pub fn warm(src: &Path, template: &Path) -> io::Result<()> {
if !template.join(".git").exists() {
return Err(err(format!("no template at {}", template.display())));
}
let _lock = lock_template(template)?;
warm_template(src, template)
}
fn populate(
repo: &Path,
template: &Path,
wt: &Path,
new_branch: Option<&str>,
commitish: Option<&str>,
) -> io::Result<()> {
let wt_s = wt.to_string_lossy();
let mut args = vec!["worktree", "add", "--no-checkout"];
if let Some(b) = new_branch {
args.extend(["-b", b]);
}
args.push(&wt_s);
args.extend(commitish);
git(repo, &args)?;
for entry in fs::read_dir(template)? {
let entry = entry?;
if entry.file_name() == ".git" {
continue;
}
clone_path(&entry.path(), wt)?;
}
let tpl_git = git(template, &["rev-parse", "--absolute-git-dir"])?;
let wt_git = git(wt, &["rev-parse", "--absolute-git-dir"])?;
fs::copy(
Path::new(&tpl_git).join("index"),
Path::new(&wt_git).join("index"),
)?;
let _ = git(wt, &["update-index", "-q", "--refresh"]);
git(wt, &["reset", "--hard", "-q"])?;
Ok(())
}
pub fn add(
repo: &Path,
template: &Path,
template_start: &str,
wt: &Path,
new_branch: Option<&str>,
commitish: Option<&str>,
) -> io::Result<()> {
let wt_parent = wt
.parent()
.ok_or_else(|| err("worktree has no parent".into()))?;
let tpl_parent = template
.parent()
.ok_or_else(|| err("template has no parent".into()))?;
fs::create_dir_all(wt_parent)?;
fs::create_dir_all(tpl_parent)?;
if !can_clone(tpl_parent, wt_parent) {
return Err(err("volume cannot clone files".into()));
}
let _lock = lock_template(template)?;
refresh_template(repo, template, template_start)?;
if template.join(".gitmodules").exists() {
return Err(err("submodules are not supported".into()));
}
let _ = warm_template(repo, template);
let ours = new_branch
.filter(|b| git(repo, &["rev-parse", "--verify", &format!("refs/heads/{b}")]).is_err());
let res = populate(repo, template, wt, new_branch, commitish);
if res.is_err() {
let _ = git(
repo,
&["worktree", "remove", "--force", &wt.to_string_lossy()],
);
let _ = fs::remove_dir_all(wt);
let _ = git(repo, &["worktree", "prune"]);
if let Some(b) = ours {
let _ = git(repo, &["branch", "-D", b]);
}
}
res
}