1use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::git::{Git, nul_fields};
18
19pub const FILE: &str = ".worktreeinclude";
21
22#[must_use]
26pub fn matched(checkout: &Path, patterns: &[String]) -> Vec<String> {
27 let file = checkout.join(FILE);
28 let from_file = file.is_file();
29 if patterns.is_empty() && !from_file {
30 return Vec::new();
31 }
32 let g = Git::at(checkout);
33 let mut args: Vec<String> = ["ls-files", "-z", "--others", "--ignored"]
34 .map(str::to_owned)
35 .to_vec();
36 args.extend(patterns.iter().map(|p| format!("--exclude={p}")));
37 if from_file {
38 args.push(format!("--exclude-from={}", file.display()));
39 }
40 args.extend(["--".to_owned(), ".".to_owned()]);
41 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
42 let named = g.run(&argv).map(|o| nul_fields(&o)).unwrap_or_default();
43 if named.is_empty() {
44 return Vec::new();
45 }
46 let mut input = Vec::new();
49 for n in &named {
50 input.extend_from_slice(n.as_bytes());
51 input.push(0);
52 }
53 let ignored = g
54 .run_with_stdin(&["check-ignore", "-z", "--stdin"], &input)
55 .map(|o| nul_fields(&o))
56 .unwrap_or_default();
57 let home = std::fs::canonicalize(checkout).unwrap_or_else(|_| checkout.to_path_buf());
58 let mut out: Vec<String> = named
59 .into_iter()
60 .filter(|n| ignored.contains(n))
61 .filter(|n| {
62 let p = checkout.join(n);
63 std::fs::symlink_metadata(&p).is_ok_and(|m| m.file_type().is_file())
64 && std::fs::canonicalize(&p).is_ok_and(|c| c.starts_with(&home))
65 })
66 .collect();
67 out.sort();
68 out.dedup();
69 out
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct Record {
75 #[serde(default)]
77 pub files: Vec<String>,
78}
79
80#[must_use]
82pub fn record_path(worktree: &Path) -> PathBuf {
83 let name = worktree
84 .file_name()
85 .map(|n| n.to_string_lossy().into_owned())
86 .unwrap_or_default();
87 worktree.with_file_name(format!("{name}.include.json"))
88}
89
90#[must_use]
92pub fn load(worktree: &Path) -> Record {
93 std::fs::read_to_string(record_path(worktree))
94 .ok()
95 .and_then(|t| serde_json::from_str(&t).ok())
96 .unwrap_or_default()
97}
98
99pub fn forget(worktree: &Path) {
101 let _ = std::fs::remove_file(record_path(worktree));
102}
103
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
106pub struct Copied {
107 pub files: Vec<String>,
109 pub now: Vec<String>,
111 pub skipped: Vec<String>,
113}
114
115pub fn copy(
124 worktree: &Path,
125 project: &Path,
126 checkout: &Path,
127 patterns: &[String],
128) -> Result<Copied, String> {
129 let names = matched(checkout, patterns);
130 let mut out = Copied::default();
131 let root =
132 std::fs::canonicalize(worktree).map_err(|e| format!("{}: {e}", worktree.display()))?;
133 let inner = std::fs::canonicalize(project)
134 .ok()
135 .and_then(|p| p.strip_prefix(&root).ok().map(Path::to_path_buf))
136 .unwrap_or_default();
137 let tracked: Vec<String> = if names.is_empty() {
139 Vec::new()
140 } else {
141 let mut args = vec!["ls-files", "-z", "--"];
142 args.extend(names.iter().map(String::as_str));
143 Git::at(project)
144 .run(&args)
145 .map(|o| nul_fields(&o))
146 .unwrap_or_default()
147 };
148 for n in &names {
149 if tracked.contains(n) {
150 out.skipped
151 .push(format!("{n}: tracked in the worktree, left as committed"));
152 continue;
153 }
154 let rel = inner.join(n);
155 match copy_one(&root, &rel, &checkout.join(n)) {
156 Ok(changed) => {
157 let shown = rel.to_string_lossy().into_owned();
158 if changed {
159 out.now.push(shown.clone());
160 }
161 out.files.push(shown);
162 }
163 Err(why) => out.skipped.push(format!("{n}: {why}")),
164 }
165 }
166 let old = load(worktree);
170 for f in old.files.iter().filter(|f| !out.files.contains(f)) {
171 if own_file(&root, Path::new(f))
172 && !Git::at(&root)
173 .run(&["ls-files", "-z", "--", f])
174 .is_ok_and(|o| !o.is_empty())
175 {
176 let _ = std::fs::remove_file(root.join(f));
177 }
178 }
179 let record = Record {
180 files: out.files.clone(),
181 };
182 if record != old {
183 let path = record_path(worktree);
184 let text = serde_json::to_string_pretty(&record).map_err(|e| e.to_string())?;
185 if record.files.is_empty() {
186 let _ = std::fs::remove_file(&path);
187 } else {
188 std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))?;
189 }
190 }
191 register(worktree);
192 Ok(out)
193}
194
195fn own_file(root: &Path, rel: &Path) -> bool {
197 let mut at = root.to_path_buf();
198 for c in rel.components() {
199 let std::path::Component::Normal(c) = c else {
200 return false;
201 };
202 at.push(c);
203 if std::fs::symlink_metadata(&at).map_or(true, |m| m.file_type().is_symlink()) {
204 return false;
205 }
206 }
207 std::fs::symlink_metadata(&at).is_ok_and(|m| m.is_file())
208}
209
210fn copy_one(root: &Path, rel: &Path, source: &Path) -> Result<bool, String> {
214 let mut at = root.to_path_buf();
215 let parts: Vec<_> = rel.components().collect();
216 let Some((name, dirs)) = parts.split_last() else {
217 return Err("not a file".to_owned());
218 };
219 for d in dirs {
220 let std::path::Component::Normal(d) = d else {
221 return Err("not a plain path".to_owned());
222 };
223 at.push(d);
224 match std::fs::symlink_metadata(&at) {
225 Ok(m) if m.file_type().is_symlink() => {
226 return Err(format!("`{}` in the worktree is a link", at.display()));
227 }
228 Ok(m) if m.is_dir() => {}
229 Ok(_) => return Err(format!("`{}` in the worktree is a file", at.display())),
230 Err(_) => std::fs::create_dir(&at).map_err(|e| format!("{}: {e}", at.display()))?,
231 }
232 }
233 let std::path::Component::Normal(name) = name else {
234 return Err("not a plain path".to_owned());
235 };
236 let dest = at.join(name);
237 let bytes = std::fs::read(source).map_err(|e| format!("{}: {e}", source.display()))?;
238 match std::fs::symlink_metadata(&dest) {
239 Ok(m) if m.file_type().is_symlink() => {
242 std::fs::remove_file(&dest).map_err(|e| format!("{}: {e}", dest.display()))?;
243 }
244 Ok(m) if m.is_dir() => return Err("a directory in the worktree".to_owned()),
245 Ok(_) if std::fs::read(&dest).is_ok_and(|b| b == bytes) => return Ok(false),
246 _ => {}
247 }
248 std::fs::write(&dest, &bytes).map_err(|e| format!("{}: {e}", dest.display()))?;
249 Ok(true)
250}
251
252#[must_use]
256pub fn values(worktree: &Path) -> Vec<(String, String)> {
257 let protected = crate::tools::paths::Protected::default();
258 load(worktree)
259 .files
260 .iter()
261 .filter(|f| protected.is_secret(f))
262 .filter_map(|f| {
263 let name = Path::new(f).file_name()?.to_string_lossy().into_owned();
264 let text = std::fs::read_to_string(worktree.join(f)).ok()?;
265 Some(crate::secrets::to_mask(&name, &text))
266 })
267 .flatten()
268 .collect()
269}
270
271pub fn register(worktree: &Path) {
273 for (k, v) in values(worktree) {
274 crate::secrets::register(&k, &v);
275 }
276}
277
278#[must_use]
283pub fn pathspecs(top: &Path) -> Vec<String> {
284 let files = load(top).files;
285 if files.is_empty() {
286 return Vec::new();
287 }
288 let mut input = Vec::new();
289 for f in &files {
290 input.extend_from_slice(f.as_bytes());
291 input.push(0);
292 }
293 let ignored = Git::at(top)
294 .run_with_stdin(&["check-ignore", "-z", "--stdin"], &input)
295 .map(|o| nul_fields(&o))
296 .unwrap_or_default();
297 files
298 .iter()
299 .filter(|f| !ignored.contains(f))
300 .map(|f| format!(":(exclude,top,literal){f}"))
301 .collect()
302}
303
304#[must_use]
306pub fn added_secret(patch: &str) -> Option<String> {
307 patch
308 .lines()
309 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
310 .find_map(crate::secrets::found_in)
311}
312
313pub fn check_staged(top: &Path) -> Result<(), String> {
321 let copied = load(top).files;
322 if copied.is_empty() {
323 return Ok(());
324 }
325 register(top);
326 let g = Git::at(top);
327 let staged = g
328 .run(&["diff", "--cached", "--name-only", "-z", "--no-renames"])
329 .map(|o| nul_fields(&o))
330 .map_err(|e| e.0)?;
331 let held: Vec<&String> = staged.iter().filter(|p| copied.contains(p)).collect();
332 if !held.is_empty() {
333 let mut reset = vec!["reset", "-q", "--"];
334 reset.extend(held.iter().map(|p| p.as_str()));
335 if g.run(&reset).is_err() {
336 let mut rm = vec!["rm", "--cached", "-q", "--ignore-unmatch", "--"];
337 rm.extend(held.iter().map(|p| p.as_str()));
338 let _ = g.run(&rm);
339 }
340 return Err(format!(
341 "refused: {} is copied from your checkout ([worktree] include) and never committed; unstaged, nothing committed",
342 held.iter()
343 .map(|p| format!("`{p}`"))
344 .collect::<Vec<_>>()
345 .join(", ")
346 ));
347 }
348 let patch = g
349 .run(&["diff", "--cached", "-U0", "--no-color", "--no-ext-diff"])
350 .map(|o| String::from_utf8_lossy(&o).into_owned())
351 .map_err(|e| e.0)?;
352 if let Some(name) = added_secret(&patch) {
353 return Err(format!(
354 "refused: the change adds the value of {name}, copied from your checkout ([worktree] include); nothing committed"
355 ));
356 }
357 Ok(())
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 fn git(dir: &Path, args: &[&str]) {
365 let ok = std::process::Command::new("git")
366 .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
367 .args([
368 "-c",
369 "commit.gpgsign=false",
370 "-c",
371 "init.defaultBranch=main",
372 ])
373 .args(args)
374 .current_dir(dir)
375 .status()
376 .is_ok_and(|s| s.success());
377 assert!(ok, "git {args:?}");
378 }
379
380 fn checkout() -> (tempfile::TempDir, PathBuf) {
383 let dir = tempfile::tempdir().unwrap();
384 let r = dir.path().join("checkout");
385 std::fs::create_dir_all(r.join("local")).unwrap();
386 std::fs::write(r.join(".gitignore"), ".env\nlocal/\nnotes.txt\n").unwrap();
387 std::fs::write(r.join("app.json"), "{}").unwrap();
388 git(&r, &["init", "-q"]);
389 git(&r, &["add", "-A"]);
390 git(&r, &["commit", "-qm", "init"]);
391 std::fs::write(r.join(".env"), "API_TOKEN=include-test-Qw7 value\n").unwrap();
392 std::fs::write(r.join("local/db.json"), "{\"pw\": 1}").unwrap();
393 std::fs::write(r.join("notes.txt"), "x").unwrap();
394 std::fs::write(r.join("untracked.json"), "x").unwrap();
395 let wt = dir.path().join("wt");
396 git(
397 &r,
398 &[
399 "worktree",
400 "add",
401 "-q",
402 "--detach",
403 &wt.display().to_string(),
404 ],
405 );
406 (dir, r)
407 }
408
409 #[test]
410 fn only_ignored_files_the_patterns_name_are_matched() {
411 let (_d, r) = checkout();
412 let pats = |p: &[&str]| p.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
413 assert!(matched(&r, &[]).is_empty());
414 assert_eq!(matched(&r, &pats(&[".env"])), [".env"]);
415 assert_eq!(matched(&r, &pats(&["*.json"])), ["local/db.json"]);
417 std::fs::write(r.join(FILE), "notes.txt\n# a comment\n").unwrap();
419 assert_eq!(matched(&r, &pats(&[".env"])), [".env", "notes.txt"]);
420 let outside = tempfile::tempdir().unwrap();
422 std::fs::write(outside.path().join("secret"), "x").unwrap();
423 std::fs::remove_file(r.join("notes.txt")).unwrap();
424 std::os::unix::fs::symlink(outside.path().join("secret"), r.join("notes.txt")).unwrap();
425 assert_eq!(matched(&r, &pats(&[".env"])), [".env"]);
426 }
427
428 #[test]
429 fn copies_land_in_the_worktree_only_and_follow_changes() {
430 let (d, r) = checkout();
431 let wt = d.path().join("wt");
432 let pats = vec![".env".to_owned(), "local/".to_owned()];
433 let c = copy(&wt, &wt, &r, &pats).unwrap();
434 assert_eq!(c.files, [".env", "local/db.json"]);
435 assert_eq!(c.now, c.files);
436 assert_eq!(load(&wt).files, c.files);
437 assert!(
438 std::fs::read_to_string(wt.join(".env"))
439 .unwrap()
440 .contains("Qw7")
441 );
442 assert!(copy(&wt, &wt, &r, &pats).unwrap().now.is_empty());
444 std::fs::write(r.join(".env"), "API_TOKEN=include-test-Qw8 value\n").unwrap();
445 assert_eq!(copy(&wt, &wt, &r, &pats).unwrap().now, [".env"]);
446 let elsewhere = tempfile::tempdir().unwrap();
448 std::fs::remove_dir_all(wt.join("local")).unwrap();
449 std::os::unix::fs::symlink(elsewhere.path(), wt.join("local")).unwrap();
450 let c = copy(&wt, &wt, &r, &pats).unwrap();
451 assert_eq!(c.files, [".env"]);
452 assert!(
453 c.skipped[0].starts_with("local/db.json: "),
454 "{:?}",
455 c.skipped
456 );
457 assert!(
458 std::fs::read_dir(elsewhere.path())
459 .unwrap()
460 .next()
461 .is_none()
462 );
463 let c = copy(&wt, &wt, &r, &[]).unwrap();
465 assert!(c.files.is_empty());
466 assert!(!wt.join(".env").exists());
467 assert!(load(&wt).files.is_empty());
468 assert!(r.join(".env").is_file(), "the checkout's stays");
469 copy(&wt, &wt, &r, &pats).unwrap();
470 std::fs::write(wt.join(".gitignore"), "local/\n").unwrap();
472 git(&wt, &["add", "-f", ".env", ".gitignore"]);
473 git(&wt, &["commit", "-qm", "track it"]);
474 std::fs::write(wt.join(".env"), "MINE=1\n").unwrap();
475 let c = copy(&wt, &wt, &r, &pats).unwrap();
476 assert!(
477 c.skipped.iter().any(|s| s.starts_with(".env: tracked")),
478 "{:?}",
479 c.skipped
480 );
481 assert_eq!(
482 std::fs::read_to_string(wt.join(".env")).unwrap(),
483 "MINE=1\n"
484 );
485 }
486
487 #[test]
488 fn a_staged_copy_or_a_staged_value_is_refused() {
489 let (d, r) = checkout();
490 let wt = d.path().join("wt");
491 copy(&wt, &wt, &r, &[".env".to_owned()]).unwrap();
492 assert_eq!(
493 values(&wt),
494 [("API_TOKEN".to_owned(), "include-test-Qw7 value".to_owned())]
495 );
496 assert!(check_staged(&wt).is_ok());
497 git(&wt, &["add", "-f", ".env"]);
498 let e = check_staged(&wt).unwrap_err();
499 assert!(e.contains("`.env` is copied from your checkout"), "{e}");
500 let staged = std::process::Command::new("git")
501 .args(["diff", "--cached", "--name-only"])
502 .current_dir(&wt)
503 .output()
504 .unwrap();
505 assert!(staged.stdout.is_empty(), "unstaged");
506 std::fs::write(wt.join("app.json"), "{\"t\": \"include-test-Qw7 value\"}").unwrap();
507 git(&wt, &["add", "app.json"]);
508 let e = check_staged(&wt).unwrap_err();
509 assert!(e.contains("adds the value of API_TOKEN"), "{e}");
510 assert!(!e.contains("Qw7"));
511 assert!(pathspecs(&wt).is_empty(), "ignored: nothing to name");
513 std::fs::write(wt.join(".gitignore"), "local/\n").unwrap();
514 assert_eq!(pathspecs(&wt), [":(exclude,top,literal).env"]);
515 }
516}