1use std::fs;
11use std::io::Write as _;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15const UNREGISTERED_GITDIR_NAME: &str = "fallow-audit-unregistered";
17
18const LEGACY_UNREGISTERED_GITDIR_STUB: &str = "gitdir: fallow-audit-unregistered\n";
22
23const FALLOW_BASE_WORKTREE_PREFIX: &str = "fallow-audit-base-";
25
26#[must_use]
40pub fn unregistered_gitdir_stub(snapshot: &Path) -> String {
41 let gitdir = snapshot.join(".git").join(UNREGISTERED_GITDIR_NAME);
42 format!("gitdir: {}\n", gitdir.display())
43}
44
45#[must_use]
48pub fn is_unregistered_gitdir_stub(snapshot: &Path, contents: &str) -> bool {
49 if contents == LEGACY_UNREGISTERED_GITDIR_STUB {
50 return true;
51 }
52 let Some(gitdir) = parse_worktree_gitdir(contents) else {
53 return false;
54 };
55 let Some(gitfile) = gitdir
56 .file_name()
57 .is_some_and(|name| name == UNREGISTERED_GITDIR_NAME)
58 .then(|| gitdir.parent())
59 .flatten()
60 else {
61 return false;
62 };
63 if gitfile.file_name().is_none_or(|name| name != ".git") {
64 return false;
65 }
66 gitfile
67 .parent()
68 .is_some_and(|owner| owner == snapshot || same_existing_path(owner, snapshot))
69}
70
71fn same_existing_path(left: &Path, right: &Path) -> bool {
72 match (dunce::canonicalize(left), dunce::canonicalize(right)) {
73 (Ok(left), Ok(right)) => left == right,
74 _ => false,
75 }
76}
77
78#[must_use]
83pub fn enclosing_unregistered_snapshot(dir: &Path) -> Option<PathBuf> {
84 let dir = std::path::absolute(dir).ok()?;
85 for candidate in dir.ancestors() {
86 let gitfile = candidate.join(".git");
87 let Ok(metadata) = fs::symlink_metadata(&gitfile) else {
88 continue;
89 };
90 if !is_regular_file(&metadata) {
91 return None;
92 }
93 let contents = fs::read_to_string(&gitfile).ok()?;
94 return is_unregistered_gitdir_stub(candidate, &contents).then(|| candidate.to_path_buf());
95 }
96 None
97}
98
99pub fn confine_git_to_host(command: &mut Command, cwd: &Path) {
108 let Some(snapshot) = enclosing_unregistered_snapshot(cwd) else {
109 return;
110 };
111 command.env(
112 "GIT_DIR",
113 snapshot.join(".git").join(UNREGISTERED_GITDIR_NAME),
114 );
115 if let Some(parent) = snapshot.parent() {
116 command.env("GIT_CEILING_DIRECTORIES", parent);
117 }
118}
119
120pub fn deregister_base_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
136 if !path.exists() {
137 return Ok(());
138 }
139 let gitfile = path.join(".git");
140 let metadata = fs::symlink_metadata(&gitfile)?;
141 if !is_regular_file(&metadata) {
142 return Err(std::io::Error::new(
143 std::io::ErrorKind::InvalidData,
144 "refusing to deregister through a non-file audit worktree .git entry",
145 ));
146 }
147 let contents = fs::read_to_string(&gitfile)?;
148 if is_unregistered_gitdir_stub(path, &contents) {
149 if contents == unregistered_gitdir_stub(path) {
150 return Ok(());
151 }
152 return write_unregistered_gitdir_stub(&gitfile);
153 }
154 let Some(admin_dir) = trusted_base_worktree_admin_dir(repo_root, path) else {
155 return Err(std::io::Error::new(
156 std::io::ErrorKind::InvalidData,
157 "refusing to deregister an unverified audit worktree admin entry",
158 ));
159 };
160 match fs::remove_dir_all(&admin_dir) {
161 Ok(()) => {}
162 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
163 Err(err) => return Err(err),
164 }
165 write_unregistered_gitdir_stub(&gitfile)
166}
167
168pub fn write_unregistered_gitdir_stub(gitfile: &Path) -> std::io::Result<()> {
176 let mut options = fs::OpenOptions::new();
177 options.write(true).truncate(true);
178 match fs::symlink_metadata(gitfile) {
179 Ok(metadata) if is_regular_file(&metadata) => {}
180 Ok(_) => {
181 return Err(std::io::Error::new(
182 std::io::ErrorKind::InvalidData,
183 "refusing to replace non-file audit worktree .git entry",
184 ));
185 }
186 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
187 options.create_new(true);
188 }
189 Err(error) => return Err(error),
190 }
191 let snapshot = gitfile.parent().ok_or_else(|| {
192 std::io::Error::new(
193 std::io::ErrorKind::InvalidInput,
194 "audit worktree .git entry has no parent directory",
195 )
196 })?;
197 let stub = unregistered_gitdir_stub(snapshot);
198 let mut file = options.open(gitfile)?;
199 file.write_all(stub.as_bytes())?;
200 file.sync_all()
201}
202
203#[must_use]
206pub fn trusted_base_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
207 let gitfile = path.join(".git");
208 let metadata = fs::symlink_metadata(&gitfile).ok()?;
209 if !is_regular_file(&metadata) {
210 return None;
211 }
212 let contents = fs::read_to_string(&gitfile).ok()?;
213 let admin_dir = parse_worktree_gitdir(&contents)?;
214 if !is_fallow_admin_dir(&admin_dir) {
215 return None;
216 }
217 let common_dir = crate::changed_files::resolve_git_common_dir(repo_root).ok()?;
218 let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
219 let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
220 if admin_parent != worktrees_dir {
221 return None;
222 }
223 let backlink = fs::read_to_string(admin_dir.join("gitdir")).ok()?;
224 let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
225 let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
226 (actual_gitfile == expected_gitfile).then_some(admin_dir)
227}
228
229#[must_use]
231pub fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
232 contents
233 .lines()
234 .find_map(|line| line.trim().strip_prefix("gitdir:"))
235 .map(|rest| PathBuf::from(rest.trim()))
236}
237
238#[expect(
239 clippy::filetype_is_file,
240 reason = "a gitfile must be a regular file, not an arbitrary non-directory"
241)]
242fn is_regular_file(metadata: &fs::Metadata) -> bool {
243 metadata.file_type().is_file()
244}
245
246fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
249 admin_dir
250 .file_name()
251 .and_then(|name| name.to_str())
252 .is_some_and(|name| name.starts_with(FALLOW_BASE_WORKTREE_PREFIX))
253}
254
255#[cfg(test)]
256mod tests {
257 use std::process::Command;
258
259 use super::*;
260 use crate::repo_refs::{TemporaryBaseWorktree, create_detached_base_worktree};
261
262 fn git(root: &Path, args: &[&str]) -> String {
263 let output = crate::changed_files::clear_ambient_git_env(&mut Command::new("git"))
264 .arg("-C")
265 .arg(root)
266 .args(args)
267 .output()
268 .expect("git command starts");
269 assert!(
270 output.status.success(),
271 "git {args:?} failed: {}",
272 String::from_utf8_lossy(&output.stderr)
273 );
274 String::from_utf8_lossy(&output.stdout).trim().to_owned()
275 }
276
277 fn committed_repo(parent: &Path) -> PathBuf {
278 let repo = parent.join("repo");
279 fs::create_dir_all(&repo).expect("create repo");
280 git(&repo, &["init", "-b", "main"]);
281 git(&repo, &["config", "user.name", "Test User"]);
282 git(&repo, &["config", "user.email", "test@example.com"]);
283 git(&repo, &["config", "commit.gpgsign", "false"]);
284 fs::write(repo.join(".gitignore"), "ignored/\n").expect("write gitignore");
285 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
286 git(&repo, &["add", "."]);
287 git(&repo, &["commit", "-m", "initial"]);
288 repo
289 }
290
291 fn registered_worktrees(repo: &Path) -> Vec<String> {
292 git(repo, &["worktree", "list", "--porcelain"])
293 .lines()
294 .filter_map(|line| line.strip_prefix("worktree "))
295 .map(str::to_owned)
296 .collect()
297 }
298
299 #[test]
303 fn temporary_base_worktree_is_unregistered_while_it_is_in_use() {
304 let temp = tempfile::tempdir().expect("temp dir");
305 let repo = committed_repo(temp.path());
306 let before = registered_worktrees(&repo);
307
308 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD", &[])
309 .expect("temporary worktree should be created");
310
311 assert_eq!(
312 registered_worktrees(&repo),
313 before,
314 "the base snapshot must be deregistered before analysis reads it"
315 );
316 assert_eq!(
317 fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
318 "committed\n"
319 );
320 assert_eq!(
321 fs::read_to_string(worktree.path().join(".git")).expect("read gitfile"),
322 unregistered_gitdir_stub(worktree.path()),
323 "the gitfile stays as a stub so gitignore handling still applies"
324 );
325
326 let path = worktree.path().to_path_buf();
327 drop(worktree);
328 assert!(!path.exists(), "temporary worktree must clean up on drop");
329 assert_eq!(registered_worktrees(&repo), before);
330 }
331
332 #[test]
333 fn registered_snapshot_index_keeps_gitlinks_until_deregistration() {
334 let temp = tempfile::tempdir().expect("temp dir");
335 let repo = committed_repo(temp.path());
336 let gitlink_commit = git(&repo, &["rev-parse", "HEAD"]);
337 let cacheinfo = format!("160000,{gitlink_commit},vendor/submodule");
338 git(&repo, &["update-index", "--add", "--cacheinfo", &cacheinfo]);
339 git(&repo, &["commit", "-m", "gitlink"]);
340 let destination = temp.path().join("fallow-audit-base-index-probe");
341
342 create_detached_base_worktree(&repo, &destination, "HEAD")
343 .expect("registered worktree should be created");
344 assert!(
345 git(&destination, &["ls-files", "--stage", "vendor/submodule"])
346 .starts_with(&format!("160000 {gitlink_commit} 0\t")),
347 "the linked worktree index must retain the gitlink object id"
348 );
349
350 deregister_base_worktree(&repo, &destination).expect("snapshot deregisters");
351 assert_eq!(registered_worktrees(&repo).len(), 1);
352 assert!(destination.join("tracked.txt").is_file());
353 }
354
355 #[cfg(unix)]
358 fn write_crafted_gitdir(dir: &Path, marker: &Path) {
359 fs::create_dir_all(dir.join("objects")).expect("create objects");
360 fs::create_dir_all(dir.join("refs")).expect("create refs");
361 fs::write(dir.join("objects/.keep"), "").expect("write objects keep");
362 fs::write(dir.join("refs/.keep"), "").expect("write refs keep");
363 fs::write(dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
364 fs::write(
365 dir.join("config"),
366 format!(
367 "[core]\n\trepositoryformatversion = 0\n\tfsmonitor = \"touch '{}'; false\"\n",
368 marker.display()
369 ),
370 )
371 .expect("write config");
372 }
373
374 #[cfg(unix)]
375 fn raw_git_status(cwd: &Path, confine: bool) -> bool {
376 let mut command = Command::new("git");
377 crate::changed_files::clear_ambient_git_env(&mut command);
378 if confine {
379 confine_git_to_host(&mut command, cwd);
380 }
381 command
382 .args(["status", "--porcelain"])
383 .current_dir(cwd)
384 .output()
385 .expect("git command starts")
386 .status
387 .success()
388 }
389
390 #[cfg(unix)]
394 #[test]
395 fn crafted_gitdirs_in_the_base_commit_are_never_read() {
396 let temp = tempfile::tempdir().expect("temp dir");
397 let repo = committed_repo(temp.path());
398 let marker = temp.path().join("fsmonitor-ran");
399 write_crafted_gitdir(&repo.join(UNREGISTERED_GITDIR_NAME), &marker);
400 write_crafted_gitdir(&repo.join("pkg"), &marker);
401 git(&repo, &["add", "-f", "."]);
402 git(&repo, &["commit", "-m", "crafted"]);
403
404 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD", &[])
405 .expect("temporary worktree should be created");
406 let snapshot = worktree.path();
407 assert!(
408 snapshot
409 .join(UNREGISTERED_GITDIR_NAME)
410 .join("config")
411 .is_file()
412 );
413
414 assert!(
415 !raw_git_status(snapshot, false),
416 "Git at the snapshot root must not find a repository"
417 );
418 assert!(!raw_git_status(&snapshot.join("pkg"), true));
419 assert!(!crate::churn::is_git_repo(snapshot));
420 assert!(!crate::churn::is_git_repo(&snapshot.join("pkg")));
421 assert!(
422 crate::changed_files::resolve_git_toplevel(&snapshot.join("pkg")).is_err(),
423 "changed-file probes must not find the nested bare repository"
424 );
425 for dir in [snapshot.to_path_buf(), snapshot.join("pkg")] {
426 let status = crate::git_env::git_command(&dir)
427 .args(["status", "--porcelain"])
428 .output()
429 .expect("git command starts")
430 .status;
431 assert!(
432 !status.success(),
433 "the shared Git wrapper found a repository"
434 );
435 }
436 assert!(
437 !marker.exists(),
438 "a config from the base commit ran a command"
439 );
440 }
441
442 #[cfg(unix)]
445 #[test]
446 fn shared_git_wrapper_refuses_implicit_bare_repositories() {
447 let temp = tempfile::tempdir().expect("temp dir");
448 let repo = committed_repo(temp.path());
449 let marker = temp.path().join("fsmonitor-ran");
450 let bare = temp.path().join("bare");
451 write_crafted_gitdir(&bare, &marker);
452 fs::create_dir_all(repo.join("sub")).expect("create subdirectory");
453
454 let run = |dir: &Path| {
455 crate::git_env::git_command(dir)
456 .args(["status", "--porcelain"])
457 .output()
458 .expect("git command starts")
459 .status
460 .success()
461 };
462
463 assert!(!run(&bare), "an implicit bare repository must be refused");
464 assert!(!marker.exists());
465 assert!(run(&repo), "an ordinary checkout must keep working");
466 assert!(
467 run(&repo.join("sub")),
468 "a checkout subdirectory must keep working"
469 );
470 }
471
472 #[test]
476 fn bare_clone_worktree_layout_keeps_working() {
477 let temp = tempfile::tempdir().expect("temp dir");
478 let source = committed_repo(temp.path());
479 let bare = temp.path().join("bare.git");
480 let linked = temp.path().join("linked");
481 let parent = temp.path();
482 git(
483 parent,
484 &[
485 "clone",
486 "--quiet",
487 "--bare",
488 source.to_str().expect("UTF-8"),
489 "bare.git",
490 ],
491 );
492 git(
493 &bare,
494 &[
495 "worktree",
496 "add",
497 "--quiet",
498 linked.to_str().expect("UTF-8"),
499 "main",
500 ],
501 );
502 assert!(
503 fs::symlink_metadata(linked.join(".git"))
504 .expect("gitfile")
505 .is_file()
506 );
507
508 let toplevel = crate::git_env::git_command(&linked)
509 .args(["rev-parse", "--show-toplevel"])
510 .output()
511 .expect("git command starts");
512 assert!(toplevel.status.success(), "{toplevel:?}");
513 assert!(crate::churn::is_git_repo(&linked));
514 assert!(crate::changed_files::resolve_git_toplevel(&linked).is_ok());
515
516 let worktree = TemporaryBaseWorktree::create(&linked, "HEAD", &[])
517 .expect("base snapshot from a bare-clone worktree");
518 assert!(worktree.path().join("tracked.txt").is_file());
519 assert_eq!(
520 registered_worktrees(&bare).len(),
521 2,
522 "only the bare clone and its linked worktree stay registered"
523 );
524 }
525
526 #[test]
529 fn unregistered_snapshot_keeps_gitignore_handling() {
530 let temp = tempfile::tempdir().expect("temp dir");
531 let repo = committed_repo(temp.path());
532 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD", &[])
533 .expect("temporary worktree should be created");
534 fs::create_dir_all(worktree.path().join("ignored")).expect("create ignored dir");
535 fs::write(worktree.path().join("ignored/skip.ts"), "").expect("write ignored file");
536
537 let walked: Vec<PathBuf> = ignore::WalkBuilder::new(worktree.path())
538 .build()
539 .filter_map(Result::ok)
540 .map(|entry| entry.into_path())
541 .collect();
542
543 assert!(walked.iter().any(|path| path.ends_with("tracked.txt")));
544 assert!(
545 !walked.iter().any(|path| path.ends_with("ignored/skip.ts")),
546 "the base walk must honor .gitignore: {walked:?}"
547 );
548 assert_eq!(
549 enclosing_unregistered_snapshot(&worktree.path().join("ignored")).as_deref(),
550 Some(worktree.path())
551 );
552 }
553}