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