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 with `cp -c` (clonefile), Linux with `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// Clone support per (source dir, destination dir), probed once per process
12static PROBES: LazyLock<Mutex<HashMap<(PathBuf, PathBuf), bool>>> =
13    LazyLock::new(|| Mutex::new(HashMap::new()));
14
15fn err(msg: String) -> io::Error {
16    io::Error::other(msg)
17}
18
19fn clone_flags() -> &'static [&'static str] {
20    if cfg!(target_os = "macos") {
21        &["-cRp"]
22    } else {
23        // fails instead of copying when the volume cannot clone
24        &["-a", "--reflink=always"]
25    }
26}
27
28fn clone_path(src: &Path, dst: &Path) -> io::Result<()> {
29    let out = Command::new("cp")
30        .args(clone_flags())
31        .arg(src)
32        .arg(dst)
33        .output()?;
34    if !out.status.success() {
35        return Err(err(format!(
36            "cp {}: {}",
37            src.display(),
38            String::from_utf8_lossy(&out.stderr).trim()
39        )));
40    }
41    Ok(())
42}
43
44/// Runs git in `dir` and returns its trimmed stdout
45pub fn git(dir: &Path, args: &[&str]) -> io::Result<String> {
46    let out = Command::new("git").arg("-C").arg(dir).args(args).output()?;
47    if !out.status.success() {
48        return Err(err(format!(
49            "git {args:?}: {}",
50            String::from_utf8_lossy(&out.stderr).trim()
51        )));
52    }
53    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
54}
55
56/// Whether a file in `src_dir` clones into `dst_dir`: both on one volume that supports it.
57/// Probed with one small file, since filesystem names miss mount options like XFS without reflink
58pub fn can_clone(src_dir: &Path, dst_dir: &Path) -> bool {
59    let key = (src_dir.to_path_buf(), dst_dir.to_path_buf());
60    if let Some(known) = PROBES.lock().unwrap().get(&key) {
61        return *known;
62    }
63    let src = src_dir.join(".cow-probe-src");
64    let dst = dst_dir.join(".cow-probe-dst");
65    let _ = fs::remove_file(&dst);
66    let ok = fs::write(&src, b"x").is_ok() && clone_path(&src, &dst).is_ok();
67    let _ = fs::remove_file(&src);
68    let _ = fs::remove_file(&dst);
69    PROBES.lock().unwrap().insert(key, ok);
70    ok
71}
72
73// Held while a template changes, so a clone never sees it half updated, across processes too
74fn lock_template(template: &Path) -> io::Result<File> {
75    let name = template.file_name().unwrap_or_default().to_string_lossy();
76    let file = File::create(template.with_file_name(format!(".{name}.lock")))?;
77    file.lock()?;
78    Ok(file)
79}
80
81// Create the template checkout, or move it to `start`
82fn refresh_template(repo: &Path, template: &Path, start: &str) -> io::Result<()> {
83    if template.join(".git").exists()
84        && git(template, &["checkout", "--detach", "--force", start]).is_ok()
85    {
86        return Ok(());
87    }
88    let _ = fs::remove_dir_all(template);
89    let _ = git(repo, &["worktree", "prune"]);
90    git(
91        repo,
92        &[
93            "worktree",
94            "add",
95            "--detach",
96            &template.to_string_lossy(),
97            start,
98        ],
99    )?;
100    Ok(())
101}
102
103// Gitignored directories of a checkout at any depth, which hold whatever its tooling produced.
104// A path with a hidden component is local tool or agent config, so it is never shared
105fn ignored_dirs(src: &Path) -> io::Result<Vec<String>> {
106    let out = git(
107        src,
108        &[
109            "ls-files",
110            "--others",
111            "--ignored",
112            "--exclude-standard",
113            "--directory",
114        ],
115    )?;
116    Ok(out
117        .lines()
118        .filter_map(|l| l.strip_suffix('/'))
119        .filter(|n| !n.is_empty() && !n.split('/').any(|c| c.starts_with('.')))
120        .map(String::from)
121        .collect())
122}
123
124// Swap a clone of each ignored directory of `src` into the template. Loose ignored files stay
125// out, so local secrets such as .env never reach a worktree
126fn warm_template(src: &Path, template: &Path) -> io::Result<()> {
127    let name = template.file_name().unwrap_or_default().to_string_lossy();
128    let staging = template.with_file_name(format!(".{name}.warm"));
129    for dir in ignored_dirs(src)? {
130        // A directory the template commit tracks is not build output
131        if !git(template, &["ls-files", "--", &dir])
132            .unwrap_or_default()
133            .is_empty()
134        {
135            continue;
136        }
137        let _ = fs::remove_dir_all(&staging);
138        fs::create_dir_all(&staging)?;
139        // One directory that cannot be cloned only costs that directory's sharing
140        let _ = (|| {
141            clone_path(&src.join(&dir), &staging)?;
142            let dst = template.join(&dir);
143            let _ = fs::remove_dir_all(&dst);
144            if let Some(parent) = dst.parent() {
145                fs::create_dir_all(parent)?;
146            }
147            let base = Path::new(&dir).file_name().unwrap_or_default();
148            fs::rename(staging.join(base), dst)
149        })();
150        let _ = fs::remove_dir_all(&staging);
151    }
152    Ok(())
153}
154
155/// Refresh an existing `template` with the gitignored directories of `src`, a checkout or a
156/// worktree whose work merged
157pub fn warm(src: &Path, template: &Path) -> io::Result<()> {
158    if !template.join(".git").exists() {
159        return Err(err(format!("no template at {}", template.display())));
160    }
161    let _lock = lock_template(template)?;
162    warm_template(src, template)
163}
164
165fn populate(
166    repo: &Path,
167    template: &Path,
168    wt: &Path,
169    new_branch: Option<&str>,
170    commitish: Option<&str>,
171) -> io::Result<()> {
172    // --no-checkout creates the gitdir entry and the `.git` file, so git state stays per worktree
173    let wt_s = wt.to_string_lossy();
174    let mut args = vec!["worktree", "add", "--no-checkout"];
175    if let Some(b) = new_branch {
176        args.extend(["-b", b]);
177    }
178    args.push(&wt_s);
179    args.extend(commitish);
180    git(repo, &args)?;
181
182    for entry in fs::read_dir(template)? {
183        let entry = entry?;
184        if entry.file_name() == ".git" {
185            continue;
186        }
187        clone_path(&entry.path(), wt)?;
188    }
189
190    // The template index describes the cloned files. Refresh its stat data for the new inodes,
191    // then reset only the files where the worktree's commit differs from the template's
192    let tpl_git = git(template, &["rev-parse", "--absolute-git-dir"])?;
193    let wt_git = git(wt, &["rev-parse", "--absolute-git-dir"])?;
194    fs::copy(
195        Path::new(&tpl_git).join("index"),
196        Path::new(&wt_git).join("index"),
197    )?;
198    let _ = git(wt, &["update-index", "-q", "--refresh"]);
199    git(wt, &["reset", "--hard", "-q"])?;
200    Ok(())
201}
202
203/// Create `wt` by cloning `template`, refreshed to `template_start` and warmed from `repo` first.
204/// `new_branch` and `commitish` are `git worktree add`'s; on error nothing is left behind
205pub fn add(
206    repo: &Path,
207    template: &Path,
208    template_start: &str,
209    wt: &Path,
210    new_branch: Option<&str>,
211    commitish: Option<&str>,
212) -> io::Result<()> {
213    let wt_parent = wt
214        .parent()
215        .ok_or_else(|| err("worktree has no parent".into()))?;
216    let tpl_parent = template
217        .parent()
218        .ok_or_else(|| err("template has no parent".into()))?;
219    fs::create_dir_all(wt_parent)?;
220    fs::create_dir_all(tpl_parent)?;
221    if !can_clone(tpl_parent, wt_parent) {
222        return Err(err("volume cannot clone files".into()));
223    }
224
225    let _lock = lock_template(template)?;
226    refresh_template(repo, template, template_start)?;
227    if template.join(".gitmodules").exists() {
228        // A cloned submodule `.git` file would point into the template's gitdir
229        return Err(err("submodules are not supported".into()));
230    }
231    // Bare repositories have no ignored files, so the listing fails or is empty and nothing changes
232    let _ = warm_template(repo, template);
233
234    // Only a branch this call creates may be removed on failure, never one that was there before
235    let ours = new_branch
236        .filter(|b| git(repo, &["rev-parse", "--verify", &format!("refs/heads/{b}")]).is_err());
237    let res = populate(repo, template, wt, new_branch, commitish);
238    if res.is_err() {
239        let _ = git(
240            repo,
241            &["worktree", "remove", "--force", &wt.to_string_lossy()],
242        );
243        let _ = fs::remove_dir_all(wt);
244        let _ = git(repo, &["worktree", "prune"]);
245        if let Some(b) = ours {
246            let _ = git(repo, &["branch", "-D", b]);
247        }
248    }
249    res
250}