1use 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
11pub 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
26static 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#[derive(Clone, Debug)]
36pub struct Options {
37 pub share: Vec<String>,
39 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 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
72fn 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#[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 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
127pub 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
139pub fn can_clone(src_dir: &Path, dst_dir: &Path) -> bool {
142 let key = (src_dir.to_path_buf(), dst_dir.to_path_buf());
143 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
160fn 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
168fn 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
194fn 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
234fn 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
259fn 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 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 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
290pub fn warm(src: &Path, template: &Path) -> io::Result<()> {
293 warm_with(src, template, &Options::default())
294}
295
296pub 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
306fn 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 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 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
362pub 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
383pub 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 return Err(err("submodules are not supported".into()));
410 }
411 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 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}