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
52#[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 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
91pub 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
103pub 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
122fn 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
130fn 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
157fn 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
196fn 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
221fn 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 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 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
252pub fn warm(src: &Path, template: &Path) -> io::Result<()> {
255 warm_with(src, template, &Options::default())
256}
257
258pub 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
268fn 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 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 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
324pub 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
345pub 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 return Err(err("submodules are not supported".into()));
372 }
373 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 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}