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