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
11static 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 &["-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
44pub 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
56pub 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
73fn 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
81fn 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
103fn 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
124fn 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 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 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
155pub 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 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 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
203pub 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 return Err(err("submodules are not supported".into()));
230 }
231 let _ = warm_template(repo, template);
233
234 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}