git-cow 0.1.0

Git worktrees that share files with a template through copy-on-write clones (APFS, XFS, btrfs)
Documentation
//! Git worktrees cloned from a per-repository template checkout, sharing disk blocks until written.
//! macOS clones with `cp -c` (clonefile), Linux with `cp --reflink=always` (FICLONE)

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};

// Clone support per (source dir, destination dir), probed once per process
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 {
        // fails instead of copying when the volume cannot clone
        &["-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(())
}

/// Runs git in `dir` and returns its trimmed stdout
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())
}

/// Whether a file in `src_dir` clones into `dst_dir`: both on one volume that supports it.
/// Probed with one small file, since filesystem names miss mount options like XFS without reflink
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
}

// Held while a template changes, so a clone never sees it half updated, across processes too
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)
}

// Create the template checkout, or move it to `start`
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(())
}

// Gitignored directories of a checkout at any depth, which hold whatever its tooling produced.
// A path with a hidden component is local tool or agent config, so it is never shared
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())
}

// Swap a clone of each ignored directory of `src` into the template. Loose ignored files stay
// out, so local secrets such as .env never reach a worktree
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)? {
        // A directory the template commit tracks is not build output
        if !git(template, &["ls-files", "--", &dir])
            .unwrap_or_default()
            .is_empty()
        {
            continue;
        }
        let _ = fs::remove_dir_all(&staging);
        fs::create_dir_all(&staging)?;
        // One directory that cannot be cloned only costs that directory's sharing
        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(())
}

/// Refresh an existing `template` with the gitignored directories of `src`, a checkout or a
/// worktree whose work merged
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<()> {
    // --no-checkout creates the gitdir entry and the `.git` file, so git state stays per worktree
    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)?;
    }

    // The template index describes the cloned files. Refresh its stat data for the new inodes,
    // then reset only the files where the worktree's commit differs from the template's
    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(())
}

/// Create `wt` by cloning `template`, refreshed to `template_start` and warmed from `repo` first.
/// `new_branch` and `commitish` are `git worktree add`'s; on error nothing is left behind
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() {
        // A cloned submodule `.git` file would point into the template's gitdir
        return Err(err("submodules are not supported".into()));
    }
    // Bare repositories have no ignored files, so the listing fails or is empty and nothing changes
    let _ = warm_template(repo, template);

    // Only a branch this call creates may be removed on failure, never one that was there before
    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
}