Skip to main content

git_cow/
lib.rs

1//! Git worktrees cloned from a per-repository template checkout, sharing disk blocks until written.
2//! macOS clones whole directories with clonefile(2), Linux uses `cp --reflink=always` (FICLONE)
3
4use std::collections::HashMap;
5use std::fs::{self, File};
6use std::io;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::sync::{LazyLock, Mutex};
10
11/// Hidden directories that hold only build caches, so they are shared like any other ignored
12/// directory. Every other path with a hidden component is tool or agent config and stays out
13pub const DEFAULT_SHARE: &[&str] = &[
14    ".uv-cache",
15    ".next",
16    ".turbo",
17    ".nuxt",
18    ".svelte-kit",
19    ".parcel-cache",
20    ".gradle",
21    ".mypy_cache",
22    ".ruff_cache",
23    ".pytest_cache",
24];
25
26// Clone support per (source dir, destination dir), probed once per process
27static PROBES: LazyLock<Mutex<HashMap<(PathBuf, PathBuf), bool>>> =
28    LazyLock::new(|| Mutex::new(HashMap::new()));
29
30fn err(msg: String) -> io::Error {
31    io::Error::other(msg)
32}
33
34/// What to share and how often to refresh the template from the checkout
35#[derive(Clone, Debug)]
36pub struct Options {
37    /// Hidden directories shared like any other ignored directory
38    pub share: Vec<String>,
39    /// [`add_with`] warms the template from the checkout at most this often, zero means every time
40    /// and `Duration::MAX` never
41    pub warm_interval: std::time::Duration,
42}
43
44impl Default for Options {
45    fn default() -> Self {
46        Options {
47            share: DEFAULT_SHARE.iter().map(|s| s.to_string()).collect(),
48            warm_interval: std::time::Duration::from_secs(3600),
49        }
50    }
51}
52
53impl Options {
54    /// The defaults plus every `cow.share` entry and `cow.warmInterval` from `repo`'s git config
55    pub fn from_repo(repo: &Path) -> Options {
56        let mut opts = Options::default();
57        let extra = git(repo, &["config", "--get-all", "cow.share"]).unwrap_or_default();
58        for d in extra.lines().map(str::trim).filter(|d| !d.is_empty()) {
59            if !opts.share.iter().any(|l| l == d) {
60                opts.share.push(d.to_string());
61            }
62        }
63        if let Some(i) = git(repo, &["config", "--get", "cow.warmInterval"])
64            .ok()
65            .and_then(|v| parse_interval(&v))
66        {
67            opts.warm_interval = i;
68        }
69        opts
70    }
71}
72
73// "0", "90" (seconds), "30m", "1h", "never"
74fn parse_interval(v: &str) -> Option<std::time::Duration> {
75    let v = v.trim();
76    if v == "never" {
77        return Some(std::time::Duration::MAX);
78    }
79    let (n, unit) = match v.strip_suffix('h') {
80        Some(n) => (n, 3600),
81        None => match v.strip_suffix('m') {
82            Some(n) => (n, 60),
83            None => (v.strip_suffix('s').unwrap_or(v), 1),
84        },
85    };
86    n.trim()
87        .parse::<u64>()
88        .ok()
89        .map(|n| std::time::Duration::from_secs(n * unit))
90}
91
92// Clone `src`, a file or a whole directory, to `dst_dir/<its name>` in one call
93#[cfg(target_os = "macos")]
94fn clone_into(src: &Path, dst_dir: &Path) -> io::Result<()> {
95    use std::ffi::{CString, c_char, c_int};
96    use std::os::unix::ffi::OsStrExt;
97    unsafe extern "C" {
98        fn clonefile(src: *const c_char, dst: *const c_char, flags: u32) -> c_int;
99    }
100    const CLONE_NOFOLLOW: u32 = 1;
101    let name = src
102        .file_name()
103        .ok_or_else(|| err(format!("{} has no name", src.display())))?;
104    let s = CString::new(src.as_os_str().as_bytes()).map_err(io::Error::other)?;
105    let d = CString::new(dst_dir.join(name).as_os_str().as_bytes()).map_err(io::Error::other)?;
106    if unsafe { clonefile(s.as_ptr(), d.as_ptr(), CLONE_NOFOLLOW) } != 0 {
107        let e = io::Error::last_os_error();
108        return Err(err(format!("clonefile {}: {e}", src.display())));
109    }
110    Ok(())
111}
112
113#[cfg(not(target_os = "macos"))]
114fn clone_into(src: &Path, dst_dir: &Path) -> io::Result<()> {
115    // fails instead of copying when the volume cannot clone
116    let out = Command::new("cp")
117        .args(["-a", "--reflink=always"])
118        .arg(src)
119        .arg(dst_dir)
120        .output()?;
121    if !out.status.success() {
122        return Err(err(format!(
123            "cp {}: {}",
124            src.display(),
125            String::from_utf8_lossy(&out.stderr).trim()
126        )));
127    }
128    Ok(())
129}
130
131/// Runs git in `dir` and returns its trimmed stdout
132pub fn git(dir: &Path, args: &[&str]) -> io::Result<String> {
133    let out = Command::new("git").arg("-C").arg(dir).args(args).output()?;
134    if !out.status.success() {
135        return Err(err(format!(
136            "git {args:?}: {}",
137            String::from_utf8_lossy(&out.stderr).trim()
138        )));
139    }
140    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
141}
142
143/// Whether a file in `src_dir` clones into `dst_dir`: both on one volume that supports it.
144/// Probed with one small file, since filesystem names miss mount options like XFS without reflink
145pub fn can_clone(src_dir: &Path, dst_dir: &Path) -> bool {
146    let key = (src_dir.to_path_buf(), dst_dir.to_path_buf());
147    // Held through the probe, since threads probing at once would share its file names
148    let mut probes = PROBES.lock().unwrap();
149    if let Some(known) = probes.get(&key) {
150        return *known;
151    }
152    let id = std::process::id();
153    let src = src_dir.join(format!(".cow-probe-{id}"));
154    let into = dst_dir.join(format!(".cow-probe-dir-{id}"));
155    let ok = fs::write(&src, b"x").is_ok()
156        && fs::create_dir_all(&into).is_ok()
157        && clone_into(&src, &into).is_ok();
158    let _ = fs::remove_file(&src);
159    let _ = fs::remove_dir_all(&into);
160    probes.insert(key, ok);
161    ok
162}
163
164// A file next to the template, `.<name>.<ext>`
165fn beside(template: &Path, ext: &str) -> PathBuf {
166    let name = template.file_name().unwrap_or_default().to_string_lossy();
167    template.with_file_name(format!(".{name}.{ext}"))
168}
169
170// Held while a template changes, so a clone never sees it half updated, across processes too
171fn lock_template(template: &Path) -> io::Result<File> {
172    let file = File::create(beside(template, "lock"))?;
173    file.lock()?;
174    Ok(file)
175}
176
177// Create the template checkout, or move it to `start`
178fn refresh_template(repo: &Path, template: &Path, start: &str) -> io::Result<()> {
179    if template.join(".git").exists()
180        && git(template, &["checkout", "--detach", "--force", start]).is_ok()
181    {
182        return Ok(());
183    }
184    let _ = fs::remove_dir_all(template);
185    if clone_checkout(repo, template, start).is_ok() {
186        return Ok(());
187    }
188    let _ = fs::remove_dir_all(template);
189    git(
190        repo,
191        &[
192            "worktree",
193            "add",
194            "-f",
195            "--detach",
196            &template.to_string_lossy(),
197            start,
198        ],
199    )?;
200    Ok(())
201}
202
203// Make the extra checkout by cloning the user's checkout, so only files that differ from `start`
204// take disk. Edits, untracked and ignored files are cleaned out, the warm-up adds build output back
205fn clone_checkout(repo: &Path, template: &Path, start: &str) -> io::Result<()> {
206    let top = PathBuf::from(git(repo, &["rev-parse", "--show-toplevel"])?);
207    let parent = template
208        .parent()
209        .ok_or_else(|| err("template has no parent".into()))?;
210    if !can_clone(&top, parent) {
211        return Err(err("checkout and template cannot share blocks".into()));
212    }
213    git(
214        repo,
215        &[
216            "worktree",
217            "add",
218            "-f",
219            "--no-checkout",
220            "--detach",
221            &template.to_string_lossy(),
222            start,
223        ],
224    )?;
225    for name in git(&top, &["ls-tree", "--name-only", "HEAD"])?.lines() {
226        let src = top.join(name);
227        if src.symlink_metadata().is_ok() {
228            clone_into(&src, template)?;
229        }
230    }
231    let src_git = git(&top, &["rev-parse", "--absolute-git-dir"])?;
232    let tpl_git = git(template, &["rev-parse", "--absolute-git-dir"])?;
233    fs::copy(
234        Path::new(&src_git).join("index"),
235        Path::new(&tpl_git).join("index"),
236    )?;
237    let _ = git(template, &["update-index", "-q", "--refresh"]);
238    git(template, &["reset", "--hard", "-q"])?;
239    git(template, &["clean", "-fdxq"])?;
240    Ok(())
241}
242
243// Gitignored directories of a checkout at any depth, which hold whatever its tooling produced.
244// A hidden component must be in `share`, anything else hidden is tool or agent config
245fn ignored_dirs(src: &Path, share: &[String]) -> io::Result<Vec<String>> {
246    let out = git(
247        src,
248        &[
249            "ls-files",
250            "--others",
251            "--ignored",
252            "--exclude-standard",
253            "--directory",
254        ],
255    )?;
256    Ok(out
257        .lines()
258        .filter_map(|l| l.strip_suffix('/'))
259        .filter(|n| {
260            !n.is_empty()
261                && n.split('/')
262                    .all(|c| !c.starts_with('.') || share.iter().any(|s| s == c))
263        })
264        .map(String::from)
265        .collect())
266}
267
268// Swap a clone of each ignored directory of `src` into the template. Loose ignored files stay
269// out, so local secrets such as .env never reach a worktree
270fn warm_template(src: &Path, template: &Path, share: &[String]) -> io::Result<()> {
271    let staging = beside(template, "warm");
272    for dir in ignored_dirs(src, share)? {
273        // A directory the template commit tracks is not build output
274        if !git(template, &["ls-files", "--", &dir])
275            .unwrap_or_default()
276            .is_empty()
277        {
278            continue;
279        }
280        let _ = fs::remove_dir_all(&staging);
281        fs::create_dir_all(&staging)?;
282        // One directory that cannot be cloned only costs that directory's sharing
283        let _ = (|| {
284            clone_into(&src.join(&dir), &staging)?;
285            let dst = template.join(&dir);
286            let _ = fs::remove_dir_all(&dst);
287            if let Some(parent) = dst.parent() {
288                fs::create_dir_all(parent)?;
289            }
290            let base = Path::new(&dir).file_name().unwrap_or_default();
291            fs::rename(staging.join(base), dst)
292        })();
293        let _ = fs::remove_dir_all(&staging);
294    }
295    Ok(())
296}
297
298/// Refresh an existing `template` with the gitignored directories of `src`, a checkout or a
299/// worktree whose work merged. Shares the hidden directories in [`DEFAULT_SHARE`]
300pub fn warm(src: &Path, template: &Path) -> io::Result<()> {
301    warm_with(src, template, &Options::default())
302}
303
304/// [`warm`] with your own [`Options`]. It always warms, the interval only applies to [`add_with`]
305pub fn warm_with(src: &Path, template: &Path, opts: &Options) -> io::Result<()> {
306    if !template.join(".git").exists() {
307        return Err(err(format!("no template at {}", template.display())));
308    }
309    let _lock = lock_template(template)?;
310    warm_template(src, template, &opts.share)?;
311    stamp(template)
312}
313
314/// Remove `template`, its registration in `repo`, and the lock, stamp and staging files next to it.
315/// Call it only when no [`add`] or [`warm`] on this template can run: one waiting for the lock would
316/// get a deleted file, and the next caller a new one
317pub fn remove_template(repo: &Path, template: &Path) -> io::Result<()> {
318    if template.parent().is_none_or(|p| !p.exists()) {
319        return Ok(());
320    }
321    let _lock = lock_template(template)?;
322    if template.join(".git").exists() {
323        git(
324            repo,
325            &["worktree", "remove", "--force", &template.to_string_lossy()],
326        )?;
327    }
328    let _ = fs::remove_dir_all(template);
329    let _ = fs::remove_dir_all(beside(template, "warm"));
330    for ext in ["warmed", "lock"] {
331        match fs::remove_file(beside(template, ext)) {
332            Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e),
333            _ => {}
334        }
335    }
336    Ok(())
337}
338
339// Marks when the template was last warmed, so add_with can skip a warm-up it did recently
340fn stamp_path(template: &Path) -> PathBuf {
341    beside(template, "warmed")
342}
343
344fn stamp(template: &Path) -> io::Result<()> {
345    fs::write(stamp_path(template), b"")
346}
347
348fn warmed_within(template: &Path, interval: std::time::Duration) -> bool {
349    fs::metadata(stamp_path(template))
350        .and_then(|m| m.modified())
351        .ok()
352        .and_then(|t| t.elapsed().ok())
353        .is_some_and(|age| age < interval)
354}
355
356fn populate(
357    repo: &Path,
358    template: &Path,
359    wt: &Path,
360    new_branch: Option<&str>,
361    commitish: Option<&str>,
362) -> io::Result<()> {
363    // --no-checkout creates the gitdir entry and the `.git` file, so git state stays per worktree
364    let wt_s = wt.to_string_lossy();
365    let mut args = vec!["worktree", "add", "--no-checkout"];
366    if let Some(b) = new_branch {
367        args.extend(["-b", b]);
368    }
369    args.push(&wt_s);
370    args.extend(commitish);
371    git(repo, &args)?;
372
373    for entry in fs::read_dir(template)? {
374        let entry = entry?;
375        if entry.file_name() == ".git" {
376            continue;
377        }
378        clone_into(&entry.path(), wt)?;
379    }
380
381    // The template index describes the cloned files. Refresh its stat data for the new inodes,
382    // then reset only the files where the worktree's commit differs from the template's
383    let tpl_git = git(template, &["rev-parse", "--absolute-git-dir"])?;
384    let wt_git = git(wt, &["rev-parse", "--absolute-git-dir"])?;
385    fs::copy(
386        Path::new(&tpl_git).join("index"),
387        Path::new(&wt_git).join("index"),
388    )?;
389    let _ = git(wt, &["update-index", "-q", "--refresh"]);
390    git(wt, &["reset", "--hard", "-q"])?;
391    Ok(())
392}
393
394/// Create `wt` by cloning `template`, refreshed to `template_start` and warmed from `repo` first.
395/// `new_branch` and `commitish` are `git worktree add`'s; on error nothing is left behind
396pub fn add(
397    repo: &Path,
398    template: &Path,
399    template_start: &str,
400    wt: &Path,
401    new_branch: Option<&str>,
402    commitish: Option<&str>,
403) -> io::Result<()> {
404    add_with(
405        repo,
406        template,
407        template_start,
408        wt,
409        new_branch,
410        commitish,
411        &Options::default(),
412    )
413}
414
415/// [`add`] with your own [`Options`]
416pub fn add_with(
417    repo: &Path,
418    template: &Path,
419    template_start: &str,
420    wt: &Path,
421    new_branch: Option<&str>,
422    commitish: Option<&str>,
423    opts: &Options,
424) -> io::Result<()> {
425    let wt_parent = wt
426        .parent()
427        .ok_or_else(|| err("worktree has no parent".into()))?;
428    let tpl_parent = template
429        .parent()
430        .ok_or_else(|| err("template has no parent".into()))?;
431    fs::create_dir_all(wt_parent)?;
432    fs::create_dir_all(tpl_parent)?;
433    if !can_clone(tpl_parent, wt_parent) {
434        return Err(err("volume cannot clone files".into()));
435    }
436
437    let _lock = lock_template(template)?;
438    refresh_template(repo, template, template_start)?;
439    if template.join(".gitmodules").exists() {
440        // A cloned submodule `.git` file would point into the template's gitdir
441        return Err(err("submodules are not supported".into()));
442    }
443    // Re-cloning big caches on every add costs seconds, and a template an hour old only means a
444    // little more rebuilding. Bare repositories have no ignored files, so nothing changes for them
445    let never = opts.warm_interval == std::time::Duration::MAX;
446    if !never && (opts.warm_interval.is_zero() || !warmed_within(template, opts.warm_interval)) {
447        let _ = warm_template(repo, template, &opts.share);
448        let _ = stamp(template);
449    }
450
451    // Only a branch this call creates may be removed on failure, never one that was there before
452    let ours = new_branch
453        .filter(|b| git(repo, &["rev-parse", "--verify", &format!("refs/heads/{b}")]).is_err());
454    let res = populate(repo, template, wt, new_branch, commitish);
455    if res.is_err() {
456        let _ = git(
457            repo,
458            &["worktree", "remove", "--force", &wt.to_string_lossy()],
459        );
460        let _ = fs::remove_dir_all(wt);
461        if let Some(b) = ours {
462            let _ = git(repo, &["branch", "-D", b]);
463        }
464    }
465    res
466}
467
468/// How [`add_or_plain`] made the worktree
469#[derive(Debug)]
470pub enum Added {
471    /// Cloned from the template
472    Cloned,
473    /// With `git worktree add`, after cloning failed for this reason
474    Plain(io::Error),
475}
476
477/// [`add_with`], or `git worktree add` with the same `wt`, `new_branch` and `commitish` when
478/// cloning fails. A failed clone leaves nothing behind, so the plain add starts clean
479pub fn add_or_plain(
480    repo: &Path,
481    template: &Path,
482    template_start: &str,
483    wt: &Path,
484    new_branch: Option<&str>,
485    commitish: Option<&str>,
486    opts: &Options,
487) -> io::Result<Added> {
488    let Err(why) = add_with(
489        repo,
490        template,
491        template_start,
492        wt,
493        new_branch,
494        commitish,
495        opts,
496    ) else {
497        return Ok(Added::Cloned);
498    };
499    let wt_s = wt.to_string_lossy();
500    let mut args = vec!["worktree", "add"];
501    if let Some(b) = new_branch {
502        args.extend(["-b", b]);
503    }
504    args.push(&wt_s);
505    args.extend(commitish);
506    git(repo, &args).map_err(|e| err(format!("{e}, after cloning failed: {why}")))?;
507    Ok(Added::Plain(why))
508}