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,
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 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
73fn 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#[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 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
131pub 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
143pub fn can_clone(src_dir: &Path, dst_dir: &Path) -> bool {
146 let key = (src_dir.to_path_buf(), dst_dir.to_path_buf());
147 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
164fn 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
170fn lock_template(template: &Path) -> io::Result<File> {
172 let file = File::create(beside(template, "lock"))?;
173 file.lock()?;
174 Ok(file)
175}
176
177fn 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
203fn 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
243fn 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
268fn 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 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 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
298pub fn warm(src: &Path, template: &Path) -> io::Result<()> {
301 warm_with(src, template, &Options::default())
302}
303
304pub 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
314pub 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
339fn 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 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 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
394pub 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
415pub 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 return Err(err("submodules are not supported".into()));
442 }
443 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 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#[derive(Debug)]
470pub enum Added {
471 Cloned,
473 Plain(io::Error),
475}
476
477pub 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}