1use std::fs::{self, File, OpenOptions};
4use std::io::{BufRead, BufReader, Read, Write};
5use std::path::{Component, Path, PathBuf};
6use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::time::SystemTime;
9
10use fallow_config::WorkspaceInfo;
11use fallow_types::audit_cache::{
12 AuditContextDirectoryFingerprint, AuditContextFileFingerprint, AuditContextPathState,
13 AuditMaterializedContextFingerprint,
14};
15use fallow_types::source_fingerprint::SourceFingerprint;
16use xxhash_rust::xxh3::xxh3_64;
17
18use crate::{EngineError, EngineResult};
19
20pub use crate::base_worktree_orphans::*;
21pub use crate::base_worktree_registration::*;
22
23const RAW_MATERIALIZATION_MARKER: &str = "fallow-raw-materialized-v1";
24
25pub const AUDIT_MATERIALIZED_CONTEXT_DIRS: &[&str] = &["node_modules", ".nuxt", ".astro"];
27const AUDIT_WORKSPACE_GENERATED_CONTEXT_DIRS: &[&str] = &[".nuxt", ".astro"];
28
29const AUDIT_LOCKFILES: &[&str] = &[
30 "package-lock.json",
31 "npm-shrinkwrap.json",
32 "pnpm-lock.yaml",
33 "yarn.lock",
34 "bun.lock",
35 "bun.lockb",
36];
37
38const NODE_MODULES_MARKERS: &[&str] = &[".package-lock.json", ".modules.yaml", ".yarn-state.yml"];
39const NUXT_MARKERS: &[&str] = &[
40 "tsconfig.json",
41 "tsconfig.app.json",
42 "imports.d.ts",
43 "components.d.ts",
44 "types/nitro-routes.d.ts",
45 "types/nitro-imports.d.ts",
46];
47const ASTRO_MARKERS: &[&str] = &["types.d.ts", "content.d.ts", "env.d.ts"];
48const AUDIT_CONTEXT_FILE_MAX_BYTES: u64 = 16 * 1024 * 1024;
49const CONTEXT_SYMLINK_STATE: &str = "symlink";
50const CONTEXT_SPECIAL_FILE_STATE: &str = "not_regular_file";
51const CONTEXT_OVERSIZED_FILE_STATE: &str = "file_too_large";
52const CONTEXT_PARENT_UNAVAILABLE_STATE: &str = "parent_context_unavailable";
53const CONTEXT_CHANGED_DURING_READ_STATE: &str = "changed_during_read";
54
55#[cfg(any(
58 target_os = "linux",
59 target_os = "android",
60 target_os = "macos",
61 target_os = "ios",
62 target_os = "freebsd",
63 target_os = "dragonfly",
64 target_os = "openbsd",
65 target_os = "netbsd"
66))]
67const UNIX_CONTEXT_OPEN_FLAGS: i32 = libc::O_NOFOLLOW | libc::O_NONBLOCK;
68
69#[cfg(windows)]
70const WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
71#[cfg(windows)]
72const WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
73
74#[derive(Debug, Clone)]
76pub struct ResolvedAuditBase {
77 pub git_ref: String,
79 pub description: Option<String>,
81 pub inherited_refs: Vec<String>,
85}
86
87#[derive(Debug)]
90pub struct TemporaryBaseWorktree {
91 path: PathBuf,
92}
93
94impl TemporaryBaseWorktree {
95 pub fn create(
106 repo_root: &Path,
107 base_ref: &str,
108 workspace_patterns: &[String],
109 ) -> EngineResult<Self> {
110 sweep_orphan_audit_worktrees(repo_root);
111 let path = base_worktree_path()?;
112 create_detached_base_worktree(repo_root, &path, base_ref)?;
113 if let Err(error) = deregister_base_worktree(repo_root, &path) {
114 remove_registered_worktree(repo_root, &path);
115 let _ = fs::remove_dir_all(&path);
116 return Err(EngineError::new(format!(
117 "could not deregister the temporary worktree for base ref `{base_ref}`: {error}"
118 )));
119 }
120 let worktree = Self { path };
121 materialize_base_dependency_context(repo_root, &worktree.path, workspace_patterns);
122 Ok(worktree)
123 }
124
125 #[must_use]
127 pub fn path(&self) -> &Path {
128 &self.path
129 }
130}
131
132pub fn materialize_base_dependency_context(
136 repo_root: &Path,
137 worktree_path: &Path,
138 workspace_patterns: &[String],
139) {
140 for slot in audit_materialized_context_slots(repo_root, workspace_patterns) {
141 let Ok(source) = canonical_context_directory(&slot.source) else {
142 continue;
143 };
144
145 if validate_materialized_path(&slot.relative).is_err()
146 || create_safe_parent_directories(worktree_path, &slot.relative).is_err()
147 {
148 continue;
149 }
150 let destination = worktree_path.join(&slot.relative);
151 match fs::symlink_metadata(&destination) {
152 Ok(metadata) if metadata.file_type().is_dir() => continue,
153 Ok(metadata) if metadata.file_type().is_symlink() => {
154 if fs::remove_file(&destination).is_err() {
155 continue;
156 }
157 }
158 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
159 Ok(_) | Err(_) => continue,
160 }
161
162 let _ = symlink_dependency_dir(&source, &destination);
163 }
164}
165
166#[must_use]
171pub fn audit_materialized_context_fingerprint(
172 root: &Path,
173 workspace_patterns: &[String],
174) -> AuditMaterializedContextFingerprint {
175 let lockfiles = AUDIT_LOCKFILES
176 .iter()
177 .map(|name| fingerprint_context_file(root, &root.join(name)))
178 .collect();
179 let directories = audit_materialized_context_slots(root, workspace_patterns)
180 .iter()
181 .map(fingerprint_context_directory)
182 .collect();
183 AuditMaterializedContextFingerprint {
184 lockfiles,
185 directories,
186 }
187}
188
189#[derive(Debug)]
190struct AuditMaterializedContextSlot {
191 kind: &'static str,
192 relative: PathBuf,
193 source: PathBuf,
194}
195
196fn audit_materialized_context_slots(
197 root: &Path,
198 workspace_patterns: &[String],
199) -> Vec<AuditMaterializedContextSlot> {
200 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
201 let mut slots = AUDIT_MATERIALIZED_CONTEXT_DIRS
202 .iter()
203 .map(|&kind| AuditMaterializedContextSlot {
204 kind,
205 relative: PathBuf::from(kind),
206 source: canonical_root.join(kind),
207 })
208 .collect::<Vec<_>>();
209
210 for workspace in
211 crate::discover::discover_workspace_packages_with_patterns(root, workspace_patterns)
212 {
213 let Ok(canonical_workspace) = dunce::canonicalize(&workspace.root) else {
214 continue;
215 };
216 let Ok(relative_workspace) = canonical_workspace.strip_prefix(&canonical_root) else {
217 continue;
218 };
219 if relative_workspace.as_os_str().is_empty() {
220 continue;
221 }
222 for &kind in AUDIT_WORKSPACE_GENERATED_CONTEXT_DIRS {
223 slots.push(AuditMaterializedContextSlot {
224 kind,
225 relative: relative_workspace.join(kind),
226 source: canonical_workspace.join(kind),
227 });
228 }
229 }
230
231 slots.sort_by(|left, right| left.relative.cmp(&right.relative));
232 slots.dedup_by(|left, right| left.relative == right.relative);
233 slots
234}
235
236fn fingerprint_context_directory(
237 slot: &AuditMaterializedContextSlot,
238) -> AuditContextDirectoryFingerprint {
239 let path = &slot.source;
240 let (state, canonical_path, source) = match canonical_context_directory(path) {
241 Ok(canonical_path) => {
242 let source = fs::symlink_metadata(&canonical_path)
243 .ok()
244 .as_ref()
245 .map(SourceFingerprint::from_metadata);
246 (AuditContextPathState::Present, Some(canonical_path), source)
247 }
248 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
249 (AuditContextPathState::Missing, None, None)
250 }
251 Err(error) => (
252 AuditContextPathState::Unreadable(error.kind().to_string()),
253 None,
254 fs::symlink_metadata(path)
255 .ok()
256 .as_ref()
257 .map(SourceFingerprint::from_metadata),
258 ),
259 };
260 let canonical_path_display = canonical_path
261 .as_ref()
262 .map(|path| path.to_string_lossy().replace('\\', "/"));
263 let markers = context_markers(slot.kind)
264 .iter()
265 .map(|marker| {
266 let relative = slot
267 .relative
268 .join(marker)
269 .to_string_lossy()
270 .replace('\\', "/");
271 canonical_path.as_ref().map_or_else(
272 || unavailable_context_file(&relative, &state),
273 |path| fingerprint_context_file_at(&path.join(marker), &relative),
274 )
275 })
276 .collect();
277 AuditContextDirectoryFingerprint {
278 name: slot.relative.to_string_lossy().replace('\\', "/"),
279 state,
280 canonical_path: canonical_path_display,
281 source,
282 markers,
283 }
284}
285
286fn canonical_context_directory(path: &Path) -> std::io::Result<PathBuf> {
287 let canonical_path = dunce::canonicalize(path)?;
288 match fs::symlink_metadata(&canonical_path) {
289 Ok(metadata) if metadata.file_type().is_dir() => Ok(canonical_path),
290 Ok(_) => Err(std::io::Error::new(
291 std::io::ErrorKind::InvalidInput,
292 CONTEXT_SPECIAL_FILE_STATE,
293 )),
294 Err(error) => Err(error),
295 }
296}
297
298fn context_markers(name: &str) -> &'static [&'static str] {
299 match name {
300 "node_modules" => NODE_MODULES_MARKERS,
301 ".nuxt" => NUXT_MARKERS,
302 ".astro" => ASTRO_MARKERS,
303 _ => &[],
304 }
305}
306
307fn fingerprint_context_file(root: &Path, path: &Path) -> AuditContextFileFingerprint {
308 let relative = path
309 .strip_prefix(root)
310 .unwrap_or(path)
311 .to_string_lossy()
312 .replace('\\', "/");
313 fingerprint_context_file_at(path, &relative)
314}
315
316fn fingerprint_context_file_at(path: &Path, relative: &str) -> AuditContextFileFingerprint {
317 fingerprint_context_file_at_with_hooks(path, relative, || {}, || {})
318}
319
320fn fingerprint_context_file_at_with_hooks(
321 path: &Path,
322 relative: &str,
323 before_open: impl FnOnce(),
324 after_read: impl FnOnce(),
325) -> AuditContextFileFingerprint {
326 before_open();
327 let mut file = match open_context_file(path) {
328 Ok(file) => file,
329 Err(error) => return classify_unopened_context_file(path, relative, error.kind()),
330 };
331 let opened_metadata = match file.metadata() {
332 Ok(metadata) if context_handle_is_regular_file(&metadata) => metadata,
333 Ok(metadata) => {
334 return unreadable_context_file(
335 relative,
336 CONTEXT_SPECIAL_FILE_STATE,
337 Some(SourceFingerprint::from_metadata(&metadata)),
338 );
339 }
340 Err(error) => return unreadable_context_file(relative, error.kind().to_string(), None),
341 };
342 let source = Some(SourceFingerprint::from_metadata(&opened_metadata));
343 if opened_metadata.len() > AUDIT_CONTEXT_FILE_MAX_BYTES {
344 return unreadable_context_file(relative, CONTEXT_OVERSIZED_FILE_STATE, source);
345 }
346
347 let capacity = usize::try_from(opened_metadata.len()).unwrap_or(0);
348 let mut bytes = Vec::with_capacity(capacity);
349 let read_limit = AUDIT_CONTEXT_FILE_MAX_BYTES.saturating_add(1);
350 if let Err(error) = (&mut file).take(read_limit).read_to_end(&mut bytes) {
351 return unreadable_context_file(relative, error.kind().to_string(), source);
352 }
353 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > AUDIT_CONTEXT_FILE_MAX_BYTES {
354 return unreadable_context_file(relative, CONTEXT_OVERSIZED_FILE_STATE, source);
355 }
356 after_read();
357 let final_metadata = match file.metadata() {
358 Ok(metadata) => metadata,
359 Err(error) => return unreadable_context_file(relative, error.kind().to_string(), source),
360 };
361 if SourceFingerprint::from_metadata(&opened_metadata)
362 != SourceFingerprint::from_metadata(&final_metadata)
363 {
364 return unreadable_context_file(relative, CONTEXT_CHANGED_DURING_READ_STATE, source);
365 }
366
367 AuditContextFileFingerprint {
368 path: relative.to_string(),
369 state: AuditContextPathState::Present,
370 source,
371 content_hash: Some(format!("{:016x}", xxh3_64(&bytes))),
372 }
373}
374
375#[expect(
376 clippy::filetype_is_file,
377 reason = "failed atomic opens are classified conservatively without treating arbitrary non-directories as readable files"
378)]
379fn classify_unopened_context_file(
380 path: &Path,
381 relative: &str,
382 open_error: std::io::ErrorKind,
383) -> AuditContextFileFingerprint {
384 match fs::symlink_metadata(path) {
385 Ok(metadata) if metadata.file_type().is_symlink() => unreadable_context_file(
386 relative,
387 CONTEXT_SYMLINK_STATE,
388 Some(SourceFingerprint::from_metadata(&metadata)),
389 ),
390 Ok(metadata) if !metadata.file_type().is_file() => unreadable_context_file(
391 relative,
392 CONTEXT_SPECIAL_FILE_STATE,
393 Some(SourceFingerprint::from_metadata(&metadata)),
394 ),
395 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
396 missing_context_file(relative)
397 }
398 Ok(metadata) => unreadable_context_file(
399 relative,
400 open_error.to_string(),
401 Some(SourceFingerprint::from_metadata(&metadata)),
402 ),
403 Err(_) => unreadable_context_file(relative, open_error.to_string(), None),
404 }
405}
406
407fn open_context_file(path: &Path) -> std::io::Result<File> {
408 let mut options = OpenOptions::new();
409 options.read(true);
410
411 #[cfg(any(
412 target_os = "linux",
413 target_os = "android",
414 target_os = "macos",
415 target_os = "ios",
416 target_os = "freebsd",
417 target_os = "dragonfly",
418 target_os = "openbsd",
419 target_os = "netbsd"
420 ))]
421 {
422 use std::os::unix::fs::OpenOptionsExt as _;
423
424 options.custom_flags(UNIX_CONTEXT_OPEN_FLAGS);
425 }
426 #[cfg(all(
427 unix,
428 not(any(
429 target_os = "linux",
430 target_os = "android",
431 target_os = "macos",
432 target_os = "ios",
433 target_os = "freebsd",
434 target_os = "dragonfly",
435 target_os = "openbsd",
436 target_os = "netbsd"
437 ))
438 ))]
439 {
440 return Err(std::io::Error::new(
441 std::io::ErrorKind::Unsupported,
442 "atomic no-follow context reads are unavailable on this Unix target",
443 ));
444 }
445 #[cfg(windows)]
446 {
447 use std::os::windows::fs::OpenOptionsExt as _;
448
449 options.custom_flags(WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT);
450 }
451
452 options.open(path)
453}
454
455#[cfg(windows)]
456#[expect(
457 clippy::filetype_is_file,
458 reason = "security boundary intentionally accepts regular files only and rejects reparse points, directories, and special files"
459)]
460fn context_handle_is_regular_file(metadata: &fs::Metadata) -> bool {
461 use std::os::windows::fs::MetadataExt as _;
462
463 metadata.file_type().is_file()
464 && metadata.file_attributes() & WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT == 0
465}
466
467#[cfg(not(windows))]
468#[expect(
469 clippy::filetype_is_file,
470 reason = "security boundary intentionally accepts regular files only and rejects directories, sockets, devices, and pipes"
471)]
472fn context_handle_is_regular_file(metadata: &fs::Metadata) -> bool {
473 metadata.file_type().is_file()
474}
475
476fn missing_context_file(relative: &str) -> AuditContextFileFingerprint {
477 AuditContextFileFingerprint {
478 path: relative.to_string(),
479 state: AuditContextPathState::Missing,
480 source: None,
481 content_hash: None,
482 }
483}
484
485fn unreadable_context_file(
486 relative: &str,
487 reason: impl Into<String>,
488 source: Option<SourceFingerprint>,
489) -> AuditContextFileFingerprint {
490 AuditContextFileFingerprint {
491 path: relative.to_string(),
492 state: AuditContextPathState::Unreadable(reason.into()),
493 source,
494 content_hash: None,
495 }
496}
497
498fn unavailable_context_file(
499 relative: &str,
500 parent_state: &AuditContextPathState,
501) -> AuditContextFileFingerprint {
502 if matches!(parent_state, AuditContextPathState::Missing) {
503 missing_context_file(relative)
504 } else {
505 unreadable_context_file(relative, CONTEXT_PARENT_UNAVAILABLE_STATE, None)
506 }
507}
508
509#[cfg(unix)]
510fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
511 std::os::unix::fs::symlink(source, destination)
512}
513
514#[cfg(windows)]
515fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
516 std::os::windows::fs::symlink_dir(source, destination)
517}
518
519pub fn create_detached_base_worktree(
533 repo_root: &Path,
534 destination: &Path,
535 base_ref: &str,
536) -> EngineResult<()> {
537 if !destination.is_absolute() {
538 return Err(EngineError::new(format!(
539 "base worktree destination must be absolute: {}",
540 destination.display()
541 )));
542 }
543
544 register_no_checkout_worktree(repo_root, destination, base_ref)?;
545 let result = make_worktree_root_private(destination)
546 .and_then(|()| resolve_registered_commit(destination, base_ref))
547 .and_then(|commit| {
548 populate_worktree_index(destination, &commit)?;
549 materialize_committed_tree(repo_root, destination, &commit)
550 })
551 .and_then(|()| write_raw_materialization_marker(destination));
552 if let Err(error) = result {
553 remove_registered_worktree(repo_root, destination);
554 let _ = fs::remove_dir_all(destination);
555 return Err(error);
556 }
557 Ok(())
558}
559
560#[cfg(unix)]
561fn make_worktree_root_private(destination: &Path) -> EngineResult<()> {
562 use std::os::unix::fs::PermissionsExt as _;
563
564 fs::set_permissions(destination, fs::Permissions::from_mode(0o700)).map_err(|error| {
565 EngineError::new(format!(
566 "could not make base worktree private at `{}`: {error}",
567 destination.display()
568 ))
569 })
570}
571
572#[cfg(not(unix))]
573#[expect(
574 clippy::unnecessary_wraps,
575 reason = "shared cross-platform signature; Unix applies privacy permissions"
576)]
577fn make_worktree_root_private(_destination: &Path) -> EngineResult<()> {
578 Ok(())
579}
580
581#[must_use]
587pub fn detached_base_worktree_is_raw_materialized(worktree_root: &Path) -> bool {
588 raw_materialization_marker_path(worktree_root).is_ok_and(|path| path.is_file())
589}
590
591fn write_raw_materialization_marker(worktree_root: &Path) -> EngineResult<()> {
592 let marker = raw_materialization_marker_path(worktree_root)?;
593 fs::write(&marker, b"raw-v1\n").map_err(|error| {
594 EngineError::new(format!(
595 "could not record raw base-worktree materialization at `{}`: {error}",
596 marker.display()
597 ))
598 })
599}
600
601fn raw_materialization_marker_path(worktree_root: &Path) -> EngineResult<PathBuf> {
602 let marker = run_git(
603 worktree_root,
604 &["rev-parse", "--git-path", RAW_MATERIALIZATION_MARKER],
605 )
606 .ok_or_else(|| EngineError::new("could not resolve base-worktree materialization marker"))?;
607 let marker = PathBuf::from(marker);
608 if marker.is_absolute() {
609 Ok(marker)
610 } else {
611 Ok(worktree_root.join(marker))
612 }
613}
614
615fn register_no_checkout_worktree(
616 repo_root: &Path,
617 destination: &Path,
618 base_ref: &str,
619) -> EngineResult<()> {
620 let mut command = git_command(repo_root);
621 command.args([
622 "worktree",
623 "add",
624 "--detach",
625 "--quiet",
626 "--no-checkout",
627 "--",
628 ]);
629 command.arg(destination).arg(base_ref);
630 let output = command.output().map_err(|error| {
631 EngineError::new(format!(
632 "could not create a temporary worktree for base ref `{base_ref}`: {error}"
633 ))
634 })?;
635 if !output.status.success() {
636 return Err(EngineError::new(format!(
637 "could not create a temporary worktree for base ref `{base_ref}`: {}",
638 String::from_utf8_lossy(&output.stderr).trim()
639 )));
640 }
641 Ok(())
642}
643
644fn resolve_registered_commit(destination: &Path, base_ref: &str) -> EngineResult<String> {
645 run_git(destination, &["rev-parse", "--verify", "HEAD^{commit}"]).ok_or_else(|| {
646 EngineError::new(format!(
647 "could not resolve the commit for base ref `{base_ref}` after creating the worktree"
648 ))
649 })
650}
651
652fn populate_worktree_index(destination: &Path, commit: &str) -> EngineResult<()> {
653 let disabled_hooks_path = destination.join(".fallow-disabled-git-hooks");
654 let output = git_command(destination)
655 .env("GIT_CONFIG_COUNT", "2")
656 .env("GIT_CONFIG_KEY_0", "core.hooksPath")
657 .env("GIT_CONFIG_VALUE_0", disabled_hooks_path)
658 .env("GIT_CONFIG_KEY_1", "core.fsmonitor")
659 .env("GIT_CONFIG_VALUE_1", "false")
660 .args(["read-tree", "--reset", commit])
661 .output()
662 .map_err(|error| {
663 EngineError::new(format!("could not populate base worktree index: {error}"))
664 })?;
665 if !output.status.success() {
666 return Err(EngineError::new(format!(
667 "could not populate base worktree index: {}",
668 String::from_utf8_lossy(&output.stderr).trim()
669 )));
670 }
671 Ok(())
672}
673
674#[derive(Debug, Clone, Copy, PartialEq, Eq)]
675enum TreeEntryKind {
676 Regular,
677 Executable,
678 Symlink,
679 Gitlink,
680}
681
682#[derive(Debug)]
683struct TreeEntry {
684 kind: TreeEntryKind,
685 object_id: String,
686 path: PathBuf,
687}
688
689struct MaterializationScope {
713 subdir_prefix: Option<String>,
716 sparse_dirs: Option<Vec<String>>,
719}
720
721impl MaterializationScope {
722 fn should_materialize(&self, path: &Path) -> bool {
723 let Some(relative) = forward_slash_path(path) else {
724 return true;
725 };
726 if let Some(prefix) = self.subdir_prefix.as_deref() {
727 if relative == prefix || relative.starts_with(&format!("{prefix}/")) {
728 return true;
729 }
730 if !relative.contains('/') {
735 return true;
736 }
737 return is_ancestor_ignore_file(&relative, prefix);
738 }
739 if let Some(dirs) = self.sparse_dirs.as_deref() {
740 if !relative.contains('/') {
741 return true;
742 }
743 return dirs
744 .iter()
745 .any(|dir| relative == *dir || relative.starts_with(&format!("{dir}/")));
746 }
747 true
748 }
749}
750
751fn forward_slash_path(path: &Path) -> Option<String> {
755 let raw = path.to_str()?;
756 Some(raw.replace('\\', "/"))
757}
758
759fn is_ancestor_ignore_file(relative: &str, prefix: &str) -> bool {
763 const IGNORE_FILES: &[&str] = &[".gitignore", ".gitattributes"];
764 let Some(file_name) = relative.rsplit('/').next() else {
765 return false;
766 };
767 if !IGNORE_FILES.contains(&file_name) {
768 return false;
769 }
770 let parent = relative.rsplit_once('/').map_or("", |(parent, _)| parent);
771 parent.is_empty() || prefix == parent || prefix.starts_with(&format!("{parent}/"))
772}
773
774fn materialization_scope(repo_root: &Path) -> MaterializationScope {
775 MaterializationScope {
776 subdir_prefix: analysis_subdir_prefix(repo_root),
777 sparse_dirs: sparse_cone_dirs(repo_root),
778 }
779}
780
781fn analysis_subdir_prefix(repo_root: &Path) -> Option<String> {
785 let toplevel = run_git(repo_root, &["rev-parse", "--show-toplevel"])?;
786 let toplevel = PathBuf::from(toplevel);
787 let canonical_toplevel = dunce::canonicalize(&toplevel).unwrap_or(toplevel);
788 let canonical_root = dunce::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
789 let relative = canonical_root.strip_prefix(&canonical_toplevel).ok()?;
790 if relative.as_os_str().is_empty() {
791 return None;
792 }
793 let prefix = forward_slash_path(relative)?;
794 if prefix.is_empty() {
795 return None;
796 }
797 Some(prefix)
798}
799
800fn sparse_cone_dirs(repo_root: &Path) -> Option<Vec<String>> {
807 if run_git(repo_root, &["config", "--get", "core.sparseCheckout"])? != "true" {
808 return None;
809 }
810 if run_git(repo_root, &["config", "--get", "core.sparseCheckoutCone"])? != "true" {
811 return None;
812 }
813 let output = git_command(repo_root)
814 .args(["sparse-checkout", "list"])
815 .output()
816 .ok()?;
817 if !output.status.success() {
818 return None;
819 }
820 let list = String::from_utf8(output.stdout).ok()?;
821 let mut dirs = Vec::new();
822 for line in list.lines() {
823 let pattern = line.trim().trim_matches('/');
824 if pattern.is_empty() {
825 continue;
826 }
827 if pattern.contains(['*', '?', '[', '!']) {
830 return None;
831 }
832 dirs.push(pattern.replace('\\', "/"));
833 }
834 Some(dirs)
835}
836
837fn materialize_committed_tree(
838 repo_root: &Path,
839 destination: &Path,
840 commit: &str,
841) -> EngineResult<()> {
842 let entries = committed_tree_entries(repo_root, commit)?;
843 let scope = materialization_scope(repo_root);
844 let entries: Vec<TreeEntry> = entries
845 .into_iter()
846 .filter(|entry| scope.should_materialize(&entry.path))
847 .collect();
848 let mut blobs = BatchBlobReader::spawn(repo_root)?;
849 let mut symlinks = Vec::new();
850
851 for entry in entries {
852 create_safe_parent_directories(destination, &entry.path)?;
853 let output_path = destination.join(&entry.path);
854 match entry.kind {
855 TreeEntryKind::Regular | TreeEntryKind::Executable => {
856 let mut file = OpenOptions::new()
857 .create_new(true)
858 .write(true)
859 .open(&output_path)
860 .map_err(|error| materialization_error(&entry.path, error))?;
861 blobs.copy_blob(&entry.object_id, &entry.path, &mut file)?;
862 set_regular_file_mode(&output_path, entry.kind == TreeEntryKind::Executable)?;
863 }
864 TreeEntryKind::Symlink => {
865 let target = blobs.read_blob(&entry.object_id, &entry.path)?;
866 symlinks.push((entry.path, target));
867 }
868 TreeEntryKind::Gitlink => {
869 fs::create_dir(&output_path)
870 .map_err(|error| materialization_error(&entry.path, error))?;
871 }
872 }
873 }
874
875 blobs.finish()?;
876 for (path, target) in symlinks {
877 create_safe_parent_directories(destination, &path)?;
878 create_materialized_symlink(&destination.join(&path), &target)
879 .map_err(|error| materialization_error(&path, error))?;
880 }
881 Ok(())
882}
883
884fn committed_tree_entries(repo_root: &Path, commit: &str) -> EngineResult<Vec<TreeEntry>> {
885 let output = git_command(repo_root)
886 .args(["ls-tree", "-r", "-z", "--full-tree", commit])
887 .output()
888 .map_err(|error| EngineError::new(format!("could not read base commit tree: {error}")))?;
889 if !output.status.success() {
890 return Err(EngineError::new(format!(
891 "could not read base commit tree: {}",
892 String::from_utf8_lossy(&output.stderr).trim()
893 )));
894 }
895
896 output
897 .stdout
898 .split(|byte| *byte == 0)
899 .filter(|record| !record.is_empty())
900 .map(parse_tree_entry)
901 .collect()
902}
903
904fn parse_tree_entry(record: &[u8]) -> EngineResult<TreeEntry> {
905 let tab = record
906 .iter()
907 .position(|byte| *byte == b'\t')
908 .ok_or_else(|| EngineError::new("could not parse base commit tree entry without a path"))?;
909 let header = std::str::from_utf8(&record[..tab])
910 .map_err(|error| EngineError::new(format!("invalid Git tree header: {error}")))?;
911 let mut fields = header.split_ascii_whitespace();
912 let mode = fields.next().unwrap_or_default();
913 let object_type = fields.next().unwrap_or_default();
914 let object_id = fields.next().unwrap_or_default();
915 if fields.next().is_some() || object_id.is_empty() {
916 return Err(EngineError::new(format!(
917 "could not parse Git tree header `{header}`"
918 )));
919 }
920 let kind = match (mode, object_type) {
921 ("100644", "blob") => TreeEntryKind::Regular,
922 ("100755", "blob") => TreeEntryKind::Executable,
923 ("120000", "blob") => TreeEntryKind::Symlink,
924 ("160000", "commit") => TreeEntryKind::Gitlink,
925 _ => {
926 return Err(EngineError::new(format!(
927 "unsupported Git tree entry mode `{mode}` and type `{object_type}`"
928 )));
929 }
930 };
931 let path = git_path_from_bytes(&record[tab + 1..])?;
932 validate_materialized_path(&path)?;
933 Ok(TreeEntry {
934 kind,
935 object_id: object_id.to_owned(),
936 path,
937 })
938}
939
940#[cfg(unix)]
941#[expect(
942 clippy::unnecessary_wraps,
943 reason = "shared cross-platform signature; non-Unix path decoding is fallible"
944)]
945fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
946 use std::os::unix::ffi::OsStringExt as _;
947
948 Ok(std::ffi::OsString::from_vec(bytes.to_vec()).into())
949}
950
951#[cfg(not(unix))]
952fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
953 String::from_utf8(bytes.to_vec())
954 .map(PathBuf::from)
955 .map_err(|error| EngineError::new(format!("Git tree path is not valid UTF-8: {error}")))
956}
957
958fn validate_materialized_path(path: &Path) -> EngineResult<()> {
959 if path.as_os_str().is_empty() || path.is_absolute() {
960 return Err(unsafe_tree_path(path));
961 }
962
963 let mut saw_component = false;
964 for component in path.components() {
965 let Component::Normal(segment) = component else {
966 return Err(unsafe_tree_path(path));
967 };
968 saw_component = true;
969 if segment.to_str().is_some_and(is_git_admin_alias) {
970 return Err(unsafe_tree_path(path));
971 }
972 }
973 if !saw_component {
974 return Err(unsafe_tree_path(path));
975 }
976 Ok(())
977}
978
979fn is_git_admin_alias(segment: &str) -> bool {
980 let normalized = segment.trim_end_matches([' ', '.']).to_ascii_lowercase();
981 normalized == ".git" || normalized == "git~1"
982}
983
984fn unsafe_tree_path(path: &Path) -> EngineError {
985 EngineError::new(format!(
986 "refusing to materialize unsafe Git tree path `{}`",
987 path.display()
988 ))
989}
990
991fn create_safe_parent_directories(root: &Path, relative: &Path) -> EngineResult<()> {
992 let Some(parent) = relative.parent() else {
993 return Ok(());
994 };
995 let mut current = root.to_path_buf();
996 for component in parent.components() {
997 let Component::Normal(segment) = component else {
998 return Err(unsafe_tree_path(relative));
999 };
1000 current.push(segment);
1001 match fs::symlink_metadata(¤t) {
1002 Ok(metadata) if metadata.file_type().is_dir() => {}
1003 Ok(_) => {
1004 return Err(EngineError::new(format!(
1005 "refusing to materialize through non-directory path `{}`",
1006 current.display()
1007 )));
1008 }
1009 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1010 fs::create_dir(¤t).map_err(|error| materialization_error(relative, error))?;
1011 }
1012 Err(error) => return Err(materialization_error(relative, error)),
1013 }
1014 }
1015 Ok(())
1016}
1017
1018fn materialization_error(path: &Path, error: impl std::fmt::Display) -> EngineError {
1019 EngineError::new(format!(
1020 "could not materialize base commit path `{}`: {error}",
1021 path.display()
1022 ))
1023}
1024
1025#[cfg(unix)]
1026fn set_regular_file_mode(path: &Path, executable: bool) -> EngineResult<()> {
1027 use std::os::unix::fs::PermissionsExt as _;
1028
1029 let mode = if executable { 0o755 } else { 0o644 };
1030 let permissions = fs::Permissions::from_mode(mode);
1031 fs::set_permissions(path, permissions).map_err(|error| materialization_error(path, error))
1032}
1033
1034#[cfg(not(unix))]
1035#[expect(
1036 clippy::unnecessary_wraps,
1037 reason = "shared cross-platform signature; Unix permission updates are fallible"
1038)]
1039fn set_regular_file_mode(_path: &Path, _executable: bool) -> EngineResult<()> {
1040 Ok(())
1041}
1042
1043#[cfg(unix)]
1044fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
1045 use std::os::unix::ffi::OsStringExt as _;
1046
1047 std::os::unix::fs::symlink(std::ffi::OsString::from_vec(target.to_vec()), path)
1048}
1049
1050#[cfg(windows)]
1051fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
1052 let target_path = PathBuf::from(String::from_utf8_lossy(target).into_owned());
1053 let resolved_target = path
1054 .parent()
1055 .map_or_else(|| target_path.clone(), |parent| parent.join(&target_path));
1056 let result = if resolved_target.is_dir() {
1057 std::os::windows::fs::symlink_dir(&target_path, path)
1058 } else {
1059 std::os::windows::fs::symlink_file(&target_path, path)
1060 };
1061 result.or_else(|_| fs::write(path, target))
1062}
1063
1064#[cfg(not(any(unix, windows)))]
1065fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
1066 fs::write(path, target)
1067}
1068
1069struct BatchBlobReader {
1070 child: Option<Child>,
1071 stdin: Option<ChildStdin>,
1072 stdout: BufReader<ChildStdout>,
1073}
1074
1075impl BatchBlobReader {
1076 fn spawn(repo_root: &Path) -> EngineResult<Self> {
1077 let mut command = git_command(repo_root);
1078 command
1079 .args(["cat-file", "--batch"])
1080 .stdin(Stdio::piped())
1081 .stdout(Stdio::piped())
1082 .stderr(Stdio::null());
1083 let mut child = command.spawn().map_err(|error| {
1084 EngineError::new(format!("could not start Git object reader: {error}"))
1085 })?;
1086 let stdin = child
1087 .stdin
1088 .take()
1089 .ok_or_else(|| EngineError::new("Git object reader has no stdin pipe"))?;
1090 let stdout = child
1091 .stdout
1092 .take()
1093 .ok_or_else(|| EngineError::new("Git object reader has no stdout pipe"))?;
1094 Ok(Self {
1095 child: Some(child),
1096 stdin: Some(stdin),
1097 stdout: BufReader::new(stdout),
1098 })
1099 }
1100
1101 fn copy_blob(&mut self, object_id: &str, path: &Path, target: &mut File) -> EngineResult<()> {
1102 let size = self.request_blob(object_id, path)?;
1103 let copied = std::io::copy(&mut self.stdout.by_ref().take(size), target)
1104 .map_err(|error| materialization_error(path, error))?;
1105 if copied != size {
1106 return Err(EngineError::new(format!(
1107 "Git object reader returned {copied} of {size} bytes for `{}`",
1108 path.display()
1109 )));
1110 }
1111 self.consume_blob_terminator(path)
1112 }
1113
1114 fn read_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<Vec<u8>> {
1115 let size = self.request_blob(object_id, path)?;
1116 let size = usize::try_from(size).map_err(|error| materialization_error(path, error))?;
1117 let mut bytes = vec![0; size];
1118 self.stdout
1119 .read_exact(&mut bytes)
1120 .map_err(|error| materialization_error(path, error))?;
1121 self.consume_blob_terminator(path)?;
1122 Ok(bytes)
1123 }
1124
1125 fn request_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<u64> {
1126 let stdin = self
1127 .stdin
1128 .as_mut()
1129 .ok_or_else(|| EngineError::new("Git object reader stdin is closed"))?;
1130 writeln!(stdin, "{object_id}").map_err(|error| materialization_error(path, error))?;
1131 stdin
1132 .flush()
1133 .map_err(|error| materialization_error(path, error))?;
1134
1135 let mut header = Vec::new();
1136 self.stdout
1137 .read_until(b'\n', &mut header)
1138 .map_err(|error| materialization_error(path, error))?;
1139 let header = std::str::from_utf8(&header)
1140 .map_err(|error| materialization_error(path, error))?
1141 .trim_end();
1142 let mut fields = header.split_ascii_whitespace();
1143 let returned_id = fields.next().unwrap_or_default();
1144 let object_type = fields.next().unwrap_or_default();
1145 let size = fields.next().unwrap_or_default();
1146 if returned_id != object_id || object_type != "blob" || fields.next().is_some() {
1147 return Err(EngineError::new(format!(
1148 "unexpected Git object response `{header}` for `{}`",
1149 path.display()
1150 )));
1151 }
1152 size.parse::<u64>()
1153 .map_err(|error| materialization_error(path, error))
1154 }
1155
1156 fn consume_blob_terminator(&mut self, path: &Path) -> EngineResult<()> {
1157 let mut terminator = [0; 1];
1158 self.stdout
1159 .read_exact(&mut terminator)
1160 .map_err(|error| materialization_error(path, error))?;
1161 if terminator != *b"\n" {
1162 return Err(EngineError::new(format!(
1163 "Git object response for `{}` had no terminator",
1164 path.display()
1165 )));
1166 }
1167 Ok(())
1168 }
1169
1170 fn finish(mut self) -> EngineResult<()> {
1171 self.stdin.take();
1172 let status = self
1173 .child
1174 .take()
1175 .ok_or_else(|| EngineError::new("Git object reader is already closed"))?
1176 .wait()
1177 .map_err(|error| {
1178 EngineError::new(format!("could not wait for Git object reader: {error}"))
1179 })?;
1180 if !status.success() {
1181 return Err(EngineError::new(format!(
1182 "Git object reader exited with status {status}"
1183 )));
1184 }
1185 Ok(())
1186 }
1187}
1188
1189impl Drop for BatchBlobReader {
1190 fn drop(&mut self) {
1191 self.stdin.take();
1192 if let Some(mut child) = self.child.take() {
1193 let _ = child.kill();
1194 let _ = child.wait();
1195 }
1196 }
1197}
1198
1199fn remove_registered_worktree(repo_root: &Path, destination: &Path) {
1200 let _ = git_command(repo_root)
1201 .args(["worktree", "remove", "--force"])
1202 .arg(destination)
1203 .output();
1204}
1205
1206impl Drop for TemporaryBaseWorktree {
1207 fn drop(&mut self) {
1208 let _ = fs::remove_dir_all(&self.path);
1211 }
1212}
1213
1214#[must_use]
1225pub fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
1226 let Some(git_root) = git_toplevel(current_root) else {
1227 return base_worktree_root.to_path_buf();
1228 };
1229 let current_root =
1230 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
1231 match current_root.strip_prefix(&git_root) {
1232 Ok(relative) => base_worktree_root.join(relative),
1233 Err(error) => {
1234 tracing::warn!(
1235 current_root = %current_root.display(),
1236 git_root = %git_root.display(),
1237 error = %error,
1238 "Could not remap the analysis root into the base worktree; falling back to the worktree root"
1239 );
1240 base_worktree_root.to_path_buf()
1241 }
1242 }
1243}
1244
1245#[must_use]
1255pub fn remap_cache_dir_for_base_worktree(
1256 current_root: &Path,
1257 base_worktree_root: &Path,
1258 cache_dir: &Path,
1259) -> PathBuf {
1260 if !cache_dir.is_absolute() {
1261 return cache_dir.to_path_buf();
1262 }
1263 if let Ok(relative) = cache_dir.strip_prefix(current_root) {
1264 return base_worktree_root.join(relative);
1265 }
1266 let canonical_root =
1267 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
1268 let canonical_cache =
1269 dunce::canonicalize(cache_dir).unwrap_or_else(|_| cache_dir.to_path_buf());
1270 [cache_dir, canonical_cache.as_path()]
1271 .into_iter()
1272 .find_map(|candidate| candidate.strip_prefix(&canonical_root).ok())
1273 .map_or_else(
1274 || cache_dir.to_path_buf(),
1275 |relative| base_worktree_root.join(relative),
1276 )
1277}
1278
1279#[derive(Debug, Clone, PartialEq, Eq)]
1282pub enum BaseAnalysisRoot {
1283 Present(PathBuf),
1286 NewInHead(PathBuf),
1290}
1291
1292#[must_use]
1301pub fn resolve_base_analysis_root(
1302 current_root: &Path,
1303 base_worktree_root: &Path,
1304) -> BaseAnalysisRoot {
1305 let root = base_analysis_root(current_root, base_worktree_root);
1306 if root.is_dir() {
1307 BaseAnalysisRoot::Present(root)
1308 } else {
1309 BaseAnalysisRoot::NewInHead(root)
1310 }
1311}
1312
1313#[must_use]
1342pub fn auto_detect_audit_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
1343 let mut base = detect_audit_base_ref(root)?;
1344 base.inherited_refs = merged_default_branch_refs(root, &base.git_ref);
1345 Some(base)
1346}
1347
1348fn detect_audit_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
1349 if let Some(upstream) = git_upstream_ref(root) {
1350 if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
1351 return Some(ResolvedAuditBase {
1352 git_ref: sha,
1353 description: Some(format!("merge-base with {upstream}")),
1354 inherited_refs: Vec::new(),
1355 });
1356 }
1357 return Some(ResolvedAuditBase {
1358 description: Some(format!("{upstream} (tip)")),
1359 git_ref: upstream,
1360 inherited_refs: Vec::new(),
1361 });
1362 }
1363
1364 if let Some(remote_ref) = detect_remote_default_ref(root) {
1365 if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
1366 return Some(ResolvedAuditBase {
1367 git_ref: sha,
1368 description: Some(format!("merge-base with {remote_ref}")),
1369 inherited_refs: Vec::new(),
1370 });
1371 }
1372 return Some(ResolvedAuditBase {
1373 description: Some(format!("{remote_ref} (tip)")),
1374 git_ref: remote_ref,
1375 inherited_refs: Vec::new(),
1376 });
1377 }
1378
1379 for candidate in ["main", "master"] {
1380 if git_ref_exists(root, candidate) {
1381 return Some(ResolvedAuditBase {
1382 git_ref: candidate.to_string(),
1383 description: Some(format!("local {candidate}")),
1384 inherited_refs: Vec::new(),
1385 });
1386 }
1387 }
1388
1389 None
1390}
1391
1392#[must_use]
1405pub fn merged_default_branch_refs(root: &Path, base: &str) -> Vec<String> {
1406 let Some(default_ref) = detect_remote_default_ref(root) else {
1407 return Vec::new();
1408 };
1409 let merge_head = run_git(root, &["rev-parse", "--verify", "--quiet", "MERGE_HEAD"])
1410 .filter(|merge_head| git_is_ancestor(root, merge_head, &default_ref) == Some(true));
1411 let merged_default = git_merge_base(root, &default_ref, "HEAD");
1412 let mut refs: Vec<String> = merge_head
1415 .into_iter()
1416 .chain(merged_default)
1417 .filter(|commit| git_is_ancestor(root, commit, base) == Some(false))
1418 .collect();
1419 refs.dedup();
1420 refs
1421}
1422
1423fn git_is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> Option<bool> {
1426 let status = git_command(root)
1427 .args(["merge-base", "--is-ancestor", ancestor, descendant])
1428 .stdout(Stdio::null())
1429 .stderr(Stdio::null())
1430 .status()
1431 .ok()?;
1432 match status.code() {
1433 Some(0) => Some(true),
1434 Some(1) => Some(false),
1435 _ => None,
1436 }
1437}
1438
1439#[must_use]
1441pub fn short_head_sha(root: &Path) -> Option<String> {
1442 run_git(root, &["rev-parse", "--short", "HEAD"])
1443}
1444
1445pub fn head_sha(root: &Path) -> std::io::Result<Option<String>> {
1455 let output = git_command(root).args(["rev-parse", "HEAD"]).output()?;
1456 if !output.status.success() {
1457 return Ok(None);
1458 }
1459 let value = String::from_utf8_lossy(&output.stdout);
1460 let trimmed = value.trim();
1461 Ok((!trimmed.is_empty()).then(|| trimmed.to_owned()))
1462}
1463
1464#[must_use]
1470pub fn default_workspace_ref(root: &Path, workspace_patterns: &[String]) -> Option<String> {
1471 let workspaces =
1472 crate::discover::discover_workspace_packages_with_patterns(root, workspace_patterns);
1473 default_workspace_ref_for_workspaces(root, &workspaces)
1474}
1475
1476#[must_use]
1478pub fn default_workspace_ref_for_workspaces(
1479 root: &Path,
1480 workspaces: &[WorkspaceInfo],
1481) -> Option<String> {
1482 if workspaces.is_empty() || !crate::churn::is_git_repo(root) {
1483 return None;
1484 }
1485 detect_remote_default_ref(root)
1486}
1487
1488#[must_use]
1493pub fn current_user_identities(root: &Path) -> Vec<String> {
1494 let mut ids = Vec::new();
1495 if let Some(email) = read_git_config(root, "user.email") {
1496 if let Some((local, _)) = email.split_once('@') {
1497 ids.push(local.rsplit('+').next().unwrap_or(local).to_owned());
1498 }
1499 ids.push(email);
1500 }
1501 if let Some(name) = read_git_config(root, "user.name") {
1502 ids.push(name);
1503 }
1504 ids
1505}
1506
1507fn read_git_config(root: &Path, key: &str) -> Option<String> {
1508 run_git(root, &["config", "--get", key])
1509}
1510
1511fn git_ref_exists(root: &Path, reference: &str) -> bool {
1512 run_git(root, &["rev-parse", "--verify", "--quiet", reference]).is_some()
1513}
1514
1515fn git_toplevel(root: &Path) -> Option<PathBuf> {
1522 let toplevel = PathBuf::from(run_git(root, &["rev-parse", "--show-toplevel"])?);
1523 Some(dunce::canonicalize(&toplevel).unwrap_or(toplevel))
1524}
1525
1526fn git_upstream_ref(root: &Path) -> Option<String> {
1527 run_git(
1528 root,
1529 &[
1530 "rev-parse",
1531 "--abbrev-ref",
1532 "--symbolic-full-name",
1533 "@{upstream}",
1534 ],
1535 )
1536}
1537
1538fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
1539 run_git(root, &["merge-base", a, b])
1540}
1541
1542#[must_use]
1549pub fn detect_remote_default_ref(root: &Path) -> Option<String> {
1550 if let Some(full_ref) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
1551 && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
1552 {
1553 return Some(format!("origin/{branch}"));
1554 }
1555 ["origin/main", "origin/master"]
1556 .into_iter()
1557 .find(|candidate| git_ref_exists(root, candidate))
1558 .map(str::to_string)
1559}
1560
1561fn base_worktree_path() -> EngineResult<PathBuf> {
1562 let nanos = SystemTime::now()
1563 .duration_since(SystemTime::UNIX_EPOCH)
1564 .map_err(|err| EngineError::new(format!("system clock before unix epoch: {err}")))?
1565 .as_nanos();
1566 Ok(std::env::temp_dir().join(base_worktree_name(nanos)))
1567}
1568
1569fn base_worktree_name(nanos: u128) -> String {
1579 static SEQ: AtomicU64 = AtomicU64::new(0);
1580 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
1581 format!("fallow-audit-base-{}-{nanos}-{seq}", std::process::id())
1582}
1583
1584fn git_command(root: &Path) -> Command {
1586 crate::git_env::git_command(root)
1587}
1588
1589fn run_git(root: &Path, args: &[&str]) -> Option<String> {
1597 let output = git_command(root).args(args).output().ok()?;
1598 if !output.status.success() {
1599 return None;
1600 }
1601 let value = String::from_utf8(output.stdout).ok()?;
1602 let trimmed = value.trim();
1603 (!trimmed.is_empty()).then(|| trimmed.to_owned())
1604}
1605
1606#[cfg(test)]
1607mod tests {
1608 use std::fs;
1609 use std::path::PathBuf;
1610
1611 use super::*;
1612
1613 fn git(root: &Path, args: &[&str]) -> String {
1614 let output = git_command(root)
1615 .args(args)
1616 .output()
1617 .expect("git command starts");
1618 assert!(
1619 output.status.success(),
1620 "git {args:?} failed: {}",
1621 String::from_utf8_lossy(&output.stderr)
1622 );
1623 String::from_utf8_lossy(&output.stdout).trim().to_owned()
1624 }
1625
1626 fn init_repo(root: &Path) {
1627 fs::create_dir_all(root).expect("create repo");
1628 git(root, &["init", "-b", "main"]);
1629 git(root, &["config", "user.name", "Test User"]);
1630 git(root, &["config", "user.email", "test@example.com"]);
1631 git(root, &["config", "commit.gpgsign", "false"]);
1632 }
1633
1634 fn commit_all(root: &Path, message: &str) {
1635 git(root, &["add", "."]);
1636 git(root, &["commit", "-m", message]);
1637 }
1638
1639 fn seeded_repo(parent: &Path) -> PathBuf {
1641 let root = parent.join("repo");
1642 init_repo(&root);
1643 fs::write(root.join("README.md"), "seed\n").expect("write seed");
1644 commit_all(&root, "initial");
1645 root
1646 }
1647
1648 fn commit_file(repo: &Path, name: &str, body: &str) -> String {
1650 fs::write(repo.join(name), body).expect("write file");
1651 commit_all(repo, name);
1652 git(repo, &["rev-parse", "HEAD"])
1653 }
1654
1655 #[cfg(unix)]
1656 fn write_executable(path: &Path, source: &str) {
1657 use std::os::unix::fs::PermissionsExt as _;
1658
1659 fs::write(path, source).expect("write executable");
1660 let mut permissions = fs::metadata(path)
1661 .expect("executable metadata")
1662 .permissions();
1663 permissions.set_mode(0o755);
1664 fs::set_permissions(path, permissions).expect("set executable mode");
1665 }
1666
1667 #[test]
1676 fn base_worktree_names_are_unique_when_the_clock_read_repeats() {
1677 const N: usize = 64;
1678 const SAME_TICK: u128 = 1_788_187_156_297_209_000;
1679
1680 let barrier = std::sync::Barrier::new(N);
1681 let names = std::sync::Mutex::new(Vec::with_capacity(N));
1682 std::thread::scope(|scope| {
1683 for _ in 0..N {
1684 let barrier = &barrier;
1685 let names = &names;
1686 scope.spawn(move || {
1687 barrier.wait();
1688 names
1689 .lock()
1690 .expect("names lock")
1691 .push(base_worktree_name(SAME_TICK));
1692 });
1693 }
1694 });
1695
1696 let mut names = names.into_inner().expect("names lock");
1697 assert_eq!(names.len(), N);
1698 names.sort();
1699 names.dedup();
1700 assert_eq!(names.len(), N, "base worktree names collided");
1701 }
1702
1703 #[test]
1705 fn base_worktree_path_keeps_the_pid_as_the_first_segment() {
1706 let path = base_worktree_path().expect("path should build");
1707 let name = path
1708 .file_name()
1709 .and_then(|name| name.to_str())
1710 .expect("worktree name should be utf-8");
1711 let pid = name
1712 .strip_prefix("fallow-audit-base-")
1713 .and_then(|rest| rest.split('-').next())
1714 .expect("pid segment should be present");
1715 assert_eq!(pid, std::process::id().to_string());
1716 }
1717
1718 #[test]
1724 fn detached_worktree_from_a_subdir_skips_sibling_subtrees() {
1725 let temp = tempfile::tempdir().expect("temp dir");
1726 let repo = temp.path().join("repo");
1727 init_repo(&repo);
1728 fs::create_dir_all(repo.join("sub")).expect("create sub dir");
1729 fs::create_dir_all(repo.join("big")).expect("create big dir");
1730 fs::write(repo.join("sub/a.ts"), "export const a = 1;\n").expect("write sub file");
1731 fs::write(repo.join("big/b.ts"), "export const b = 1;\n").expect("write big file");
1732 fs::write(repo.join("top.ts"), "export const top = 1;\n").expect("write top file");
1733 commit_all(&repo, "initial");
1734
1735 let destination = temp.path().join("base");
1736 create_detached_base_worktree(&repo.join("sub"), &destination, "HEAD")
1737 .expect("base worktree should be created");
1738
1739 assert!(
1740 destination.join("sub/a.ts").is_file(),
1741 "the requested subtree must be materialized"
1742 );
1743 assert!(
1744 destination.join("top.ts").is_file(),
1745 "top-level files shape subdir discovery and stay materialized"
1746 );
1747 assert!(
1748 !destination.join("big/b.ts").exists(),
1749 "sibling subtrees must not be materialized: {}",
1750 destination.join("big/b.ts").display()
1751 );
1752
1753 remove_registered_worktree(&repo, &destination);
1754 let _ = fs::remove_dir_all(&destination);
1755 }
1756
1757 #[test]
1762 fn detached_worktree_at_the_root_respects_the_sparse_cone() {
1763 let temp = tempfile::tempdir().expect("temp dir");
1764 let repo = temp.path().join("repo");
1765 init_repo(&repo);
1766 fs::create_dir_all(repo.join("sub")).expect("create sub dir");
1767 fs::create_dir_all(repo.join("big")).expect("create big dir");
1768 fs::write(repo.join("sub/a.ts"), "export const a = 1;\n").expect("write sub file");
1769 fs::write(repo.join("big/b.ts"), "export const b = 1;\n").expect("write big file");
1770 commit_all(&repo, "initial");
1771 git(&repo, &["sparse-checkout", "init", "--cone"]);
1772 git(&repo, &["sparse-checkout", "set", "sub"]);
1773
1774 let destination = temp.path().join("base");
1775 create_detached_base_worktree(&repo, &destination, "HEAD")
1776 .expect("base worktree should be created");
1777
1778 assert!(
1779 destination.join("sub/a.ts").is_file(),
1780 "the sparse cone must be materialized"
1781 );
1782 assert!(
1783 !destination.join("big/b.ts").exists(),
1784 "paths outside the sparse cone must not be materialized: {}",
1785 destination.join("big/b.ts").display()
1786 );
1787
1788 remove_registered_worktree(&repo, &destination);
1789 let _ = fs::remove_dir_all(&destination);
1790 }
1791
1792 #[test]
1796 fn materialization_scope_filters_to_the_needed_working_set() {
1797 let subdir = MaterializationScope {
1798 subdir_prefix: Some("apps/web".to_string()),
1799 sparse_dirs: None,
1800 };
1801 assert!(subdir.should_materialize(Path::new("apps/web/a.ts")));
1802 assert!(subdir.should_materialize(Path::new("top.ts")));
1803 assert!(subdir.should_materialize(Path::new(".gitignore")));
1804 assert!(subdir.should_materialize(Path::new("apps/.gitignore")));
1805 assert!(!subdir.should_materialize(Path::new("apps/other/b.ts")));
1806 assert!(!subdir.should_materialize(Path::new("apps/.gitignore.bak")));
1807
1808 let sparse = MaterializationScope {
1809 subdir_prefix: None,
1810 sparse_dirs: Some(vec!["apps/web".to_string()]),
1811 };
1812 assert!(sparse.should_materialize(Path::new("apps/web/a.ts")));
1813 assert!(sparse.should_materialize(Path::new("top.ts")));
1814 assert!(!sparse.should_materialize(Path::new("apps/other/b.ts")));
1815
1816 let full = MaterializationScope {
1817 subdir_prefix: None,
1818 sparse_dirs: None,
1819 };
1820 assert!(full.should_materialize(Path::new("apps/other/b.ts")));
1821 }
1822
1823 #[test]
1824 fn default_workspace_ref_skips_projects_without_workspaces() {
1825 assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[]).is_none());
1826 }
1827
1828 #[test]
1829 fn default_workspace_ref_skips_non_git_workspace_projects() {
1830 let workspace = WorkspaceInfo {
1831 root: PathBuf::from("/repo/packages/app"),
1832 name: "app".to_owned(),
1833 is_internal_dependency: false,
1834 };
1835
1836 assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[workspace]).is_none());
1837 }
1838
1839 #[test]
1840 fn current_user_identities_empty_when_git_config_is_unavailable() {
1841 assert!(current_user_identities(Path::new("/repo")).is_empty());
1842 }
1843
1844 #[test]
1845 fn short_head_sha_omits_git_line_ending() {
1846 let temp = tempfile::tempdir().expect("temp dir");
1847 let repo = temp.path().join("repo");
1848 init_repo(&repo);
1849 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1850 commit_all(&repo, "initial");
1851
1852 let sha = short_head_sha(&repo).expect("HEAD sha");
1853 assert_eq!(sha, sha.trim());
1854 assert!(!sha.is_empty());
1855 }
1856
1857 #[test]
1858 fn short_head_sha_is_absent_outside_a_git_repo() {
1859 let temp = tempfile::tempdir().expect("temp dir");
1860
1861 assert_eq!(short_head_sha(temp.path()), None);
1862 }
1863
1864 #[test]
1870 fn auto_detect_audit_base_ref_omits_git_line_endings_for_the_upstream_merge_base() {
1871 let temp = tempfile::tempdir().expect("temp dir");
1872 let repo = temp.path().join("repo");
1873 init_repo(&repo);
1874 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1875 commit_all(&repo, "initial");
1876 let fork_point = git(&repo, &["rev-parse", "HEAD"]);
1877 git(&repo, &["remote", "add", "origin", &repo.to_string_lossy()]);
1878 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1879 git(&repo, &["checkout", "-b", "feature"]);
1880 git(
1881 &repo,
1882 &["branch", "--set-upstream-to=origin/main", "feature"],
1883 );
1884 fs::write(repo.join("feature.txt"), "my change\n").expect("write feature file");
1885 commit_all(&repo, "feature");
1886
1887 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1888
1889 assert_eq!(detected.git_ref, fork_point);
1890 assert_eq!(
1891 detected.description.as_deref(),
1892 Some("merge-base with origin/main")
1893 );
1894 assert!(crate::validate::validate_git_ref(&detected.git_ref).is_ok());
1895 }
1896
1897 #[test]
1901 fn auto_detect_audit_base_ref_omits_git_line_endings_for_the_remote_default() {
1902 let temp = tempfile::tempdir().expect("temp dir");
1903 let repo = temp.path().join("repo");
1904 init_repo(&repo);
1905 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1906 commit_all(&repo, "initial");
1907 let fork_point = git(&repo, &["rev-parse", "HEAD"]);
1908 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1909 git(
1910 &repo,
1911 &[
1912 "symbolic-ref",
1913 "refs/remotes/origin/HEAD",
1914 "refs/remotes/origin/main",
1915 ],
1916 );
1917
1918 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1919
1920 assert_eq!(detected.git_ref, fork_point);
1921 assert_eq!(
1922 detected.description.as_deref(),
1923 Some("merge-base with origin/main")
1924 );
1925 assert!(crate::validate::validate_git_ref(&detected.git_ref).is_ok());
1926 }
1927
1928 #[test]
1929 fn auto_detect_audit_base_ref_resolves_origin_default_to_merge_base() {
1930 let temp = tempfile::tempdir().expect("temp dir");
1931 let repo = seeded_repo(temp.path());
1932 let head = git(&repo, &["rev-parse", "HEAD"]);
1933 git(&repo, &["branch", "trunk"]);
1934 git(&repo, &["update-ref", "refs/remotes/origin/trunk", "trunk"]);
1935 git(
1936 &repo,
1937 &[
1938 "symbolic-ref",
1939 "refs/remotes/origin/HEAD",
1940 "refs/remotes/origin/trunk",
1941 ],
1942 );
1943
1944 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1945
1946 assert_eq!(detected.git_ref, head);
1949 assert_eq!(
1950 detected.description.as_deref(),
1951 Some("merge-base with origin/trunk")
1952 );
1953 }
1954
1955 #[test]
1960 fn auto_detect_audit_base_ref_ignores_stale_local_main() {
1961 let temp = tempfile::tempdir().expect("temp dir");
1962 let repo = seeded_repo(temp.path());
1963 let stale = git(&repo, &["rev-parse", "HEAD"]);
1964
1965 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1966 git(
1967 &repo,
1968 &[
1969 "symbolic-ref",
1970 "refs/remotes/origin/HEAD",
1971 "refs/remotes/origin/main",
1972 ],
1973 );
1974 let fork_point = commit_file(&repo, "teammate.txt", "merged work\n");
1975 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1976
1977 git(&repo, &["checkout", "-b", "feature", &fork_point]);
1980 commit_file(&repo, "feature.txt", "my change\n");
1981 git(&repo, &["branch", "-f", "main", &stale]);
1982
1983 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1984
1985 assert_eq!(
1986 detected.git_ref, fork_point,
1987 "base must be the fork point (origin/main), not stale local main"
1988 );
1989 assert_eq!(
1990 detected.description.as_deref(),
1991 Some("merge-base with origin/main")
1992 );
1993 }
1994
1995 #[test]
1996 fn auto_detect_audit_base_ref_prefers_configured_upstream() {
1997 let temp = tempfile::tempdir().expect("temp dir");
1998 let repo = seeded_repo(temp.path());
1999 let fork_point = git(&repo, &["rev-parse", "HEAD"]);
2000 git(&repo, &["remote", "add", "origin", &repo.to_string_lossy()]);
2003 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
2004 git(&repo, &["checkout", "-b", "feature"]);
2005 git(
2006 &repo,
2007 &["branch", "--set-upstream-to=origin/main", "feature"],
2008 );
2009 commit_file(&repo, "feature.txt", "my change\n");
2010
2011 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
2012
2013 assert_eq!(detected.git_ref, fork_point);
2014 assert_eq!(
2015 detected.description.as_deref(),
2016 Some("merge-base with origin/main")
2017 );
2018 }
2019
2020 fn repo_with_newer_main(root: &Path) -> (PathBuf, String, String) {
2024 let repo = seeded_repo(root);
2025 let fork_point = git(&repo, &["rev-parse", "HEAD"]);
2026 git(&repo, &["remote", "add", "origin", &repo.to_string_lossy()]);
2027 commit_file(&repo, "main.txt", "main change\n");
2028 let main_tip = git(&repo, &["rev-parse", "HEAD"]);
2029 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
2030 git(&repo, &["checkout", "-b", "feature", &fork_point]);
2031 commit_file(&repo, "feature.txt", "my change\n");
2032 (repo, fork_point, main_tip)
2033 }
2034
2035 #[test]
2036 fn merged_default_branch_refs_is_empty_without_a_merge() {
2037 let temp = tempfile::tempdir().expect("temp dir");
2038 let (repo, fork_point, _) = repo_with_newer_main(temp.path());
2039
2040 assert!(merged_default_branch_refs(&repo, &fork_point).is_empty());
2041 }
2042
2043 #[test]
2044 fn merged_default_branch_refs_names_the_merge_head_of_the_default_branch() {
2045 let temp = tempfile::tempdir().expect("temp dir");
2046 let (repo, fork_point, main_tip) = repo_with_newer_main(temp.path());
2047 git(&repo, &["merge", "--no-commit", "--no-ff", "origin/main"]);
2048
2049 assert_eq!(merged_default_branch_refs(&repo, &fork_point), [main_tip]);
2050 }
2051
2052 #[test]
2053 fn merged_default_branch_refs_names_the_merged_default_after_a_merge_commit() {
2054 let temp = tempfile::tempdir().expect("temp dir");
2055 let (repo, fork_point, main_tip) = repo_with_newer_main(temp.path());
2056 let pushed = git(&repo, &["rev-parse", "HEAD"]);
2057 git(&repo, &["merge", "--no-ff", "--no-edit", "origin/main"]);
2058
2059 assert_eq!(
2060 merged_default_branch_refs(&repo, &pushed),
2061 std::slice::from_ref(&main_tip)
2062 );
2063 assert!(merged_default_branch_refs(&repo, &main_tip).is_empty());
2065 assert_ne!(fork_point, main_tip);
2066 }
2067
2068 #[test]
2069 fn merged_default_branch_refs_names_the_new_default_after_a_rebase() {
2070 let temp = tempfile::tempdir().expect("temp dir");
2071 let (repo, fork_point, main_tip) = repo_with_newer_main(temp.path());
2072 git(&repo, &["rebase", "-q", "origin/main"]);
2073
2074 assert_eq!(merged_default_branch_refs(&repo, &fork_point), [main_tip]);
2078 }
2079
2080 #[test]
2081 fn merged_default_branch_refs_ignores_a_branch_that_the_default_branch_lacks() {
2082 let temp = tempfile::tempdir().expect("temp dir");
2083 let (repo, fork_point, _) = repo_with_newer_main(temp.path());
2084 git(&repo, &["checkout", "-b", "side", &fork_point]);
2085 commit_file(&repo, "side.txt", "side change\n");
2086 git(&repo, &["checkout", "feature"]);
2087 let base = git(&repo, &["rev-parse", "HEAD"]);
2088 git(&repo, &["merge", "--no-commit", "--no-ff", "side"]);
2089
2090 assert!(merged_default_branch_refs(&repo, &base).is_empty());
2091 }
2092
2093 #[test]
2094 fn merged_default_branch_refs_skips_a_commit_that_git_cannot_place() {
2095 let temp = tempfile::tempdir().expect("temp dir");
2096 let (repo, _, _) = repo_with_newer_main(temp.path());
2097 git(&repo, &["merge", "--no-commit", "--no-ff", "origin/main"]);
2098
2099 assert!(
2101 merged_default_branch_refs(&repo, "0000000000000000000000000000000000000000")
2102 .is_empty()
2103 );
2104 }
2105
2106 #[test]
2107 fn auto_detect_audit_base_ref_falls_back_to_local_main_without_remote() {
2108 let temp = tempfile::tempdir().expect("temp dir");
2109 let repo = seeded_repo(temp.path());
2110
2111 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
2112
2113 assert_eq!(detected.git_ref, "main");
2114 assert_eq!(detected.description.as_deref(), Some("local main"));
2115 }
2116
2117 #[test]
2118 fn auto_detect_audit_base_ref_falls_back_to_local_master_without_remote() {
2119 let temp = tempfile::tempdir().expect("temp dir");
2120 let repo = temp.path().join("repo");
2121 fs::create_dir_all(&repo).expect("create repo");
2122 git(&repo, &["init", "-b", "master"]);
2123 git(&repo, &["config", "user.name", "Test User"]);
2124 git(&repo, &["config", "user.email", "test@example.com"]);
2125 git(&repo, &["config", "commit.gpgsign", "false"]);
2126 fs::write(repo.join("README.md"), "seed\n").expect("write seed");
2127 commit_all(&repo, "initial");
2128
2129 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
2130
2131 assert_eq!(detected.git_ref, "master");
2132 assert_eq!(detected.description.as_deref(), Some("local master"));
2133 }
2134
2135 #[test]
2136 fn auto_detect_audit_base_ref_returns_none_outside_git_repo() {
2137 let temp = tempfile::tempdir().expect("temp dir");
2138
2139 assert!(auto_detect_audit_base_ref(temp.path()).is_none());
2140 }
2141
2142 #[test]
2148 fn auto_detect_audit_base_ref_falls_back_to_remote_tip_without_common_ancestor() {
2149 let temp = tempfile::tempdir().expect("temp dir");
2150 let repo = seeded_repo(temp.path());
2151 git(&repo, &["checkout", "--orphan", "unrelated"]);
2152 let unrelated = commit_file(&repo, "unrelated.txt", "no shared history\n");
2153 git(
2154 &repo,
2155 &["update-ref", "refs/remotes/origin/main", &unrelated],
2156 );
2157 git(
2158 &repo,
2159 &[
2160 "symbolic-ref",
2161 "refs/remotes/origin/HEAD",
2162 "refs/remotes/origin/main",
2163 ],
2164 );
2165 git(&repo, &["checkout", "main"]);
2166
2167 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
2168
2169 assert_eq!(detected.git_ref, "origin/main");
2170 assert_eq!(detected.description.as_deref(), Some("origin/main (tip)"));
2171 }
2172
2173 #[test]
2177 fn base_analysis_root_preserves_repo_subdirectory_roots() {
2178 let temp = tempfile::tempdir().expect("temp dir");
2179 let repo = temp.path().join("repo");
2180 init_repo(&repo);
2181 let app_root = repo.join("apps").join("mobile");
2182 fs::create_dir_all(&app_root).expect("create app root");
2183 let base_worktree = temp.path().join("base-worktree");
2184
2185 assert_eq!(
2186 base_analysis_root(&app_root, &base_worktree),
2187 base_worktree.join("apps").join("mobile")
2188 );
2189 }
2190
2191 #[cfg(unix)]
2196 #[test]
2197 fn base_analysis_root_maps_a_symlinked_root_spelling() {
2198 let temp = tempfile::tempdir().expect("temp dir");
2199 let real_parent = temp.path().join("real");
2200 let repo = real_parent.join("repo");
2201 init_repo(&repo);
2202 let app_root = repo.join("apps").join("mobile");
2203 fs::create_dir_all(&app_root).expect("create app root");
2204 let linked_parent = temp.path().join("linked");
2205 std::os::unix::fs::symlink(&real_parent, &linked_parent).expect("link the parent");
2206 let base_worktree = temp.path().join("base-worktree");
2207
2208 let linked_app_root = linked_parent.join("repo").join("apps").join("mobile");
2209 assert_eq!(
2210 base_analysis_root(&linked_app_root, &base_worktree),
2211 base_worktree.join("apps").join("mobile")
2212 );
2213 }
2214
2215 #[test]
2216 fn remap_cache_dir_moves_a_cache_under_the_root_into_the_base_worktree() {
2217 let temp = tempfile::tempdir().expect("temp dir");
2218 let root = temp.path().join("repo");
2219 let base = temp.path().join("fallow-base");
2220 let cache_dir = root.join(".cache").join("fallow");
2221
2222 assert_eq!(
2223 remap_cache_dir_for_base_worktree(&root, &base, &cache_dir),
2224 base.join(".cache").join("fallow")
2225 );
2226 }
2227
2228 #[test]
2229 fn remap_cache_dir_keeps_an_absolute_cache_outside_the_root() {
2230 let temp = tempfile::tempdir().expect("temp dir");
2231 let root = temp.path().join("repo");
2232 let base = temp.path().join("fallow-base");
2233 let cache_dir = temp.path().join("shared").join("fallow-cache");
2234
2235 assert_eq!(
2236 remap_cache_dir_for_base_worktree(&root, &base, &cache_dir),
2237 cache_dir
2238 );
2239 }
2240
2241 #[test]
2242 fn remap_cache_dir_keeps_a_relative_cache() {
2243 let temp = tempfile::tempdir().expect("temp dir");
2244 let root = temp.path().join("repo");
2245 let base = temp.path().join("fallow-base");
2246 let cache_dir = Path::new(".fallow").join("cache");
2247
2248 assert_eq!(
2249 remap_cache_dir_for_base_worktree(&root, &base, &cache_dir),
2250 cache_dir
2251 );
2252 }
2253
2254 #[cfg(unix)]
2258 #[test]
2259 fn remap_cache_dir_maps_a_symlinked_root_spelling() {
2260 let temp = tempfile::tempdir().expect("temp dir");
2261 let real_root = temp.path().join("real");
2262 fs::create_dir_all(&real_root).expect("create real root");
2263 let linked_root = temp.path().join("linked");
2264 std::os::unix::fs::symlink(&real_root, &linked_root).expect("link the root");
2265 let base = temp.path().join("fallow-base");
2266 let real_cache = dunce::canonicalize(&real_root)
2267 .expect("canonical root")
2268 .join(".fallow");
2269
2270 assert_eq!(
2271 remap_cache_dir_for_base_worktree(&linked_root, &base, &real_cache),
2272 base.join(".fallow")
2273 );
2274 }
2275
2276 #[test]
2277 fn head_sha_is_none_before_the_first_commit() {
2278 let temp = tempfile::tempdir().expect("temp dir");
2279 let repo = temp.path().join("repo");
2280 init_repo(&repo);
2281
2282 assert_eq!(head_sha(&repo).expect("git starts"), None);
2283 }
2284
2285 #[test]
2286 fn head_sha_returns_the_full_sha() {
2287 let temp = tempfile::tempdir().expect("temp dir");
2288 let repo = seeded_repo(temp.path());
2289
2290 let sha = head_sha(&repo).expect("git starts").expect("HEAD resolves");
2291 assert_eq!(sha, git(&repo, &["rev-parse", "HEAD"]));
2292 }
2293
2294 #[test]
2299 fn resolve_base_analysis_root_reports_a_root_absent_from_the_base() {
2300 let temp = tempfile::tempdir().expect("temp dir");
2301 let repo = temp.path().join("repo");
2302 init_repo(&repo);
2303 let existing_root = repo.join("apps").join("mobile");
2304 fs::create_dir_all(&existing_root).expect("create existing root");
2305 let new_root = repo.join("apps").join("new");
2306 fs::create_dir_all(&new_root).expect("create new root");
2307
2308 let base_worktree = temp.path().join("base-worktree");
2309 fs::create_dir_all(base_worktree.join("apps").join("mobile"))
2310 .expect("create base subdirectory");
2311
2312 assert_eq!(
2313 resolve_base_analysis_root(&existing_root, &base_worktree),
2314 BaseAnalysisRoot::Present(base_worktree.join("apps").join("mobile"))
2315 );
2316 assert_eq!(
2317 resolve_base_analysis_root(&new_root, &base_worktree),
2318 BaseAnalysisRoot::NewInHead(base_worktree.join("apps").join("new"))
2319 );
2320 }
2321
2322 #[cfg(unix)]
2323 #[test]
2324 fn temporary_base_worktree_does_not_run_post_checkout_hook() {
2325 let temp = tempfile::tempdir().expect("temp dir");
2326 let repo = temp.path().join("repo");
2327 init_repo(&repo);
2328 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
2329 commit_all(&repo, "initial");
2330
2331 let sentinel = temp.path().join("post-checkout-ran");
2332 write_executable(
2333 &repo.join(".git/hooks/post-checkout"),
2334 &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
2335 );
2336
2337 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD", &[])
2338 .expect("temporary worktree should be created");
2339
2340 assert_eq!(
2341 fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
2342 "committed\n"
2343 );
2344 assert!(
2345 !sentinel.exists(),
2346 "creating a base view must not execute post-checkout hooks"
2347 );
2348 }
2349
2350 #[cfg(unix)]
2351 #[test]
2352 fn temporary_base_worktree_does_not_run_post_index_change_hook() {
2353 let temp = tempfile::tempdir().expect("temp dir");
2354 let repo = temp.path().join("repo");
2355 init_repo(&repo);
2356 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
2357 commit_all(&repo, "initial");
2358
2359 let sentinel = temp.path().join("post-index-change-ran");
2360 write_executable(
2361 &repo.join(".git/hooks/post-index-change"),
2362 &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
2363 );
2364
2365 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD", &[])
2366 .expect("temporary worktree should be created");
2367
2368 assert_eq!(
2369 fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
2370 "committed\n"
2371 );
2372 assert!(
2373 !sentinel.exists(),
2374 "creating a base view must not execute post-index-change hooks"
2375 );
2376 }
2377
2378 #[cfg(unix)]
2379 #[test]
2380 fn temporary_base_worktree_does_not_run_smudge_filter() {
2381 let temp = tempfile::tempdir().expect("temp dir");
2382 let repo = temp.path().join("repo");
2383 init_repo(&repo);
2384 fs::write(
2385 repo.join(".gitattributes"),
2386 "filtered.txt filter=sentinel\n",
2387 )
2388 .expect("write attributes");
2389 fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
2390 commit_all(&repo, "initial");
2391
2392 let sentinel = temp.path().join("smudge-ran");
2393 let filter = temp.path().join("smudge-filter.sh");
2394 write_executable(
2395 &filter,
2396 &format!(
2397 "#!/bin/sh\nprintf ran > '{}'\ncat >/dev/null\nprintf 'smudged bytes\\n'\n",
2398 sentinel.display()
2399 ),
2400 );
2401 git(
2402 &repo,
2403 &[
2404 "config",
2405 "filter.sentinel.smudge",
2406 filter.to_str().expect("filter path is UTF-8"),
2407 ],
2408 );
2409
2410 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD", &[])
2411 .expect("temporary worktree should be created");
2412
2413 assert_eq!(
2414 fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
2415 b"committed raw bytes\n"
2416 );
2417 assert!(
2418 !sentinel.exists(),
2419 "creating a base view must not execute smudge filters"
2420 );
2421 }
2422
2423 #[cfg(unix)]
2424 #[test]
2425 fn temporary_base_worktree_does_not_start_process_filter() {
2426 let temp = tempfile::tempdir().expect("temp dir");
2427 let repo = temp.path().join("repo");
2428 init_repo(&repo);
2429 fs::write(
2430 repo.join(".gitattributes"),
2431 "filtered.txt filter=sentinel\n",
2432 )
2433 .expect("write attributes");
2434 fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
2435 commit_all(&repo, "initial");
2436
2437 let sentinel = temp.path().join("process-filter-ran");
2438 let filter = temp.path().join("process-filter.sh");
2439 write_executable(
2440 &filter,
2441 &format!("#!/bin/sh\nprintf ran > '{}'\nexit 1\n", sentinel.display()),
2442 );
2443 git(
2444 &repo,
2445 &[
2446 "config",
2447 "filter.sentinel.process",
2448 filter.to_str().expect("filter path is UTF-8"),
2449 ],
2450 );
2451
2452 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD", &[])
2453 .expect("temporary worktree should be created");
2454
2455 assert_eq!(
2456 fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
2457 b"committed raw bytes\n"
2458 );
2459 assert!(
2460 !sentinel.exists(),
2461 "creating a base view must not start process filters"
2462 );
2463 }
2464
2465 #[test]
2466 fn failed_registration_does_not_remove_existing_worktree() {
2467 let temp = tempfile::tempdir().expect("temp dir");
2468 let repo = temp.path().join("repo");
2469 init_repo(&repo);
2470 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
2471 commit_all(&repo, "initial");
2472 let destination = temp.path().join("base");
2473
2474 create_detached_base_worktree(&repo, &destination, "HEAD")
2475 .expect("first worktree should be created");
2476 let second = create_detached_base_worktree(&repo, &destination, "HEAD");
2477
2478 assert!(second.is_err(), "duplicate destination must fail");
2479 assert!(
2480 destination.join("tracked.txt").is_file(),
2481 "failed registration must not remove the existing worktree"
2482 );
2483 assert_eq!(git(&destination, &["rev-parse", "HEAD"]).len(), 40);
2484
2485 remove_registered_worktree(&repo, &destination);
2486 let _ = fs::remove_dir_all(destination);
2487 }
2488
2489 #[cfg(unix)]
2490 #[test]
2491 fn temporary_base_worktree_preserves_modes_symlinks_and_gitlinks() {
2492 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
2493
2494 let temp = tempfile::tempdir().expect("temp dir");
2495 let repo = temp.path().join("repo");
2496 init_repo(&repo);
2497 fs::write(repo.join("regular.txt"), "regular\n").expect("write regular file");
2498 let executable = repo.join("run.sh");
2499 fs::write(&executable, "#!/bin/sh\nexit 0\n").expect("write executable");
2500 let mut permissions = fs::metadata(&executable)
2501 .expect("executable metadata")
2502 .permissions();
2503 permissions.set_mode(0o755);
2504 fs::set_permissions(&executable, permissions).expect("set executable mode");
2505 std::os::unix::fs::symlink("regular.txt", repo.join("regular-link"))
2506 .expect("create symlink");
2507 commit_all(&repo, "files");
2508
2509 let gitlink_commit = git(&repo, &["rev-parse", "HEAD"]);
2510 git(
2511 &repo,
2512 &[
2513 "update-index",
2514 "--add",
2515 "--cacheinfo",
2516 &format!("160000,{gitlink_commit},vendor/submodule"),
2517 ],
2518 );
2519 git(&repo, &["commit", "-m", "gitlink"]);
2520
2521 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD", &[])
2522 .expect("temporary worktree should be created");
2523 let regular_mode = fs::metadata(worktree.path().join("regular.txt"))
2524 .expect("regular metadata")
2525 .mode();
2526 let executable_mode = fs::metadata(worktree.path().join("run.sh"))
2527 .expect("executable metadata")
2528 .mode();
2529
2530 assert_eq!(regular_mode & 0o111, 0);
2531 assert_ne!(executable_mode & 0o111, 0);
2532 assert_eq!(
2533 fs::read_link(worktree.path().join("regular-link")).expect("read symlink"),
2534 PathBuf::from("regular.txt")
2535 );
2536 let gitlink = worktree.path().join("vendor/submodule");
2537 assert!(gitlink.is_dir(), "gitlink must materialize as a directory");
2538 assert!(
2539 fs::read_dir(gitlink)
2540 .expect("read gitlink directory")
2541 .next()
2542 .is_none(),
2543 "an uninitialized gitlink directory must remain empty"
2544 );
2545 let path = worktree.path().to_path_buf();
2548 drop(worktree);
2549 assert!(!path.exists(), "temporary worktree must clean up on drop");
2550 }
2551
2552 #[test]
2553 fn materialized_tree_paths_reject_traversal_and_git_admin_aliases() {
2554 for path in [
2555 Path::new("../escape"),
2556 Path::new("/absolute"),
2557 Path::new(".git/config"),
2558 Path::new("nested/.GIT/config"),
2559 Path::new("nested/.git. /config"),
2560 Path::new("nested/git~1/config"),
2561 ] {
2562 assert!(
2563 validate_materialized_path(path).is_err(),
2564 "unsafe path should be rejected: {}",
2565 path.display()
2566 );
2567 }
2568 assert!(validate_materialized_path(Path::new("src/.github/file.ts")).is_ok());
2569 }
2570
2571 #[cfg(unix)]
2572 #[test]
2573 fn parent_directory_creation_refuses_symlink_traversal() {
2574 let temp = tempfile::tempdir().expect("temp dir");
2575 let root = temp.path().join("root");
2576 let outside = temp.path().join("outside");
2577 fs::create_dir(&root).expect("create root");
2578 fs::create_dir(&outside).expect("create outside");
2579 std::os::unix::fs::symlink(&outside, root.join("link")).expect("create parent symlink");
2580
2581 let result = create_safe_parent_directories(&root, Path::new("link/escaped.txt"));
2582
2583 assert!(result.is_err(), "symlink parent must be rejected");
2584 assert!(!outside.join("escaped.txt").exists());
2585 }
2586
2587 #[test]
2588 fn audit_context_fingerprint_tracks_bounded_lockfiles_and_markers() {
2589 let temp = tempfile::tempdir().expect("temp dir");
2590 let root = temp.path();
2591 fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 9\n").expect("lockfile");
2592 fs::create_dir(root.join("node_modules")).expect("node_modules");
2593 fs::write(
2594 root.join("node_modules/.modules.yaml"),
2595 "layoutVersion: 5\n",
2596 )
2597 .expect("node marker");
2598
2599 let first = audit_materialized_context_fingerprint(root, &[]);
2600 let unchanged = audit_materialized_context_fingerprint(root, &[]);
2601 assert_eq!(
2602 first, unchanged,
2603 "unchanged context must preserve a warm key"
2604 );
2605
2606 fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 10\n").expect("mutate lockfile");
2607 let lock_changed = audit_materialized_context_fingerprint(root, &[]);
2608 assert_ne!(
2609 first, lock_changed,
2610 "lockfile content must invalidate the key"
2611 );
2612
2613 fs::write(
2614 root.join("node_modules/.modules.yaml"),
2615 "layoutVersion: 6\n",
2616 )
2617 .expect("mutate node marker");
2618 let marker_changed = audit_materialized_context_fingerprint(root, &[]);
2619 assert_ne!(
2620 lock_changed, marker_changed,
2621 "bounded dependency markers must invalidate the key"
2622 );
2623
2624 fs::create_dir(root.join(".nuxt")).expect("nuxt context");
2625 fs::write(root.join(".nuxt/imports.d.ts"), "export {}\n").expect("nuxt marker");
2626 assert_ne!(
2627 marker_changed,
2628 audit_materialized_context_fingerprint(root, &[]),
2629 "missing and materialized generated context must differ"
2630 );
2631 }
2632
2633 #[test]
2634 fn audit_context_fingerprint_tracks_nested_workspace_generated_roots() {
2635 let temp = tempfile::tempdir().expect("temp dir");
2636 let root = temp.path();
2637 fs::write(
2638 root.join("package.json"),
2639 r#"{"private":true,"workspaces":["packages/*"]}"#,
2640 )
2641 .expect("root package");
2642 let nuxt = root.join("packages/nuxt-app");
2643 let astro = root.join("packages/astro-app");
2644 fs::create_dir_all(nuxt.join(".nuxt")).expect("nested nuxt context");
2645 fs::create_dir_all(astro.join(".astro")).expect("nested astro context");
2646 fs::write(nuxt.join("package.json"), r#"{"name":"nuxt-app"}"#).expect("nuxt package");
2647 fs::write(astro.join("package.json"), r#"{"name":"astro-app"}"#).expect("astro package");
2648 fs::write(nuxt.join(".nuxt/imports.d.ts"), "export {};\n").expect("nuxt marker");
2649 fs::write(astro.join(".astro/types.d.ts"), "export {};\n").expect("astro marker");
2650
2651 let first = audit_materialized_context_fingerprint(root, &[]);
2652 assert!(
2653 first
2654 .directories
2655 .iter()
2656 .any(|directory| directory.name == "packages/nuxt-app/.nuxt")
2657 );
2658 assert!(
2659 first
2660 .directories
2661 .iter()
2662 .any(|directory| directory.name == "packages/astro-app/.astro")
2663 );
2664
2665 fs::write(
2666 nuxt.join(".nuxt/imports.d.ts"),
2667 "export type Changed = true;\n",
2668 )
2669 .expect("mutate nuxt marker");
2670 assert_ne!(
2671 first,
2672 audit_materialized_context_fingerprint(root, &[]),
2673 "nested workspace marker changes must invalidate the audit context"
2674 );
2675 }
2676
2677 #[cfg(unix)]
2678 #[test]
2679 fn materialize_base_context_symlinks_nested_workspace_generated_roots() {
2680 let host = tempfile::tempdir().expect("host");
2681 let worktree = tempfile::tempdir().expect("worktree");
2682 fs::write(
2683 host.path().join("package.json"),
2684 r#"{"private":true,"workspaces":["packages/*"]}"#,
2685 )
2686 .expect("root package");
2687
2688 for (workspace, generated, marker) in [
2689 ("nuxt-app", ".nuxt", "imports.d.ts"),
2690 ("astro-app", ".astro", "types.d.ts"),
2691 ] {
2692 let host_workspace = host.path().join("packages").join(workspace);
2693 let worktree_workspace = worktree.path().join("packages").join(workspace);
2694 fs::create_dir_all(host_workspace.join(generated)).expect("host generated context");
2695 fs::create_dir_all(&worktree_workspace).expect("worktree workspace");
2696 fs::write(
2697 host_workspace.join("package.json"),
2698 format!(r#"{{"name":"{workspace}"}}"#),
2699 )
2700 .expect("workspace package");
2701 fs::write(host_workspace.join(generated).join(marker), "export {};\n")
2702 .expect("generated marker");
2703 }
2704
2705 materialize_base_dependency_context(host.path(), worktree.path(), &[]);
2706
2707 for (workspace, generated, marker) in [
2708 ("nuxt-app", ".nuxt", "imports.d.ts"),
2709 ("astro-app", ".astro", "types.d.ts"),
2710 ] {
2711 let mirrored = worktree
2712 .path()
2713 .join("packages")
2714 .join(workspace)
2715 .join(generated);
2716 assert!(
2717 fs::symlink_metadata(&mirrored)
2718 .expect("mirrored generated root")
2719 .file_type()
2720 .is_symlink(),
2721 "{workspace}/{generated} must reuse the host generated root"
2722 );
2723 assert!(mirrored.join(marker).is_file());
2724 }
2725 }
2726
2727 #[cfg(unix)]
2728 #[test]
2729 fn materialize_base_context_resolves_symlinked_source_directories() {
2730 let host = tempfile::tempdir().expect("host");
2731 let targets = tempfile::tempdir().expect("targets");
2732 let worktree = tempfile::tempdir().expect("worktree");
2733
2734 for (kind, marker) in [
2735 ("node_modules", ".modules.yaml"),
2736 (".nuxt", "imports.d.ts"),
2737 (".astro", "types.d.ts"),
2738 ] {
2739 let target = targets.path().join(kind);
2740 fs::create_dir(&target).expect("source target");
2741 fs::write(target.join(marker), "generated context\n").expect("context marker");
2742 std::os::unix::fs::symlink(&target, host.path().join(kind))
2743 .expect("source directory symlink");
2744 }
2745
2746 materialize_base_dependency_context(host.path(), worktree.path(), &[]);
2747
2748 let fingerprint = audit_materialized_context_fingerprint(host.path(), &[]);
2749 for kind in AUDIT_MATERIALIZED_CONTEXT_DIRS {
2750 let target = dunce::canonicalize(targets.path().join(kind)).expect("canonical target");
2751 let mirrored = worktree.path().join(kind);
2752 assert_eq!(
2753 fs::read_link(&mirrored).expect("materialized symlink"),
2754 target,
2755 "{kind} must link directly to the validated canonical target"
2756 );
2757 let directory = fingerprint
2758 .directories
2759 .iter()
2760 .find(|directory| directory.name == *kind)
2761 .expect("fingerprinted context directory");
2762 assert_eq!(directory.state, AuditContextPathState::Present);
2763 assert!(directory.markers.iter().any(|marker| {
2764 matches!(marker.state, AuditContextPathState::Present)
2765 && marker.content_hash.is_some()
2766 }));
2767 }
2768 }
2769
2770 #[cfg(unix)]
2773 #[test]
2774 fn materialize_base_context_covers_config_pattern_workspaces() {
2775 let host = tempfile::tempdir().expect("host");
2776 let worktree = tempfile::tempdir().expect("worktree");
2777 fs::write(
2778 host.path().join("package.json"),
2779 r#"{"private":true,"workspaces":["packages/*"]}"#,
2780 )
2781 .expect("root package");
2782 let host_workspace = host.path().join("apps/web");
2783 fs::create_dir_all(host_workspace.join(".nuxt")).expect("host generated context");
2784 fs::write(host_workspace.join("package.json"), r#"{"name":"web"}"#)
2785 .expect("workspace package");
2786 fs::create_dir_all(worktree.path().join("apps/web")).expect("base workspace");
2787 let patterns = vec!["apps/*".to_owned()];
2788 let names = |patterns: &[String]| {
2789 audit_materialized_context_fingerprint(host.path(), patterns)
2790 .directories
2791 .into_iter()
2792 .map(|directory| directory.name)
2793 .collect::<Vec<_>>()
2794 };
2795
2796 assert!(!names(&[]).contains(&"apps/web/.nuxt".to_owned()));
2797 assert!(names(&patterns).contains(&"apps/web/.nuxt".to_owned()));
2798
2799 materialize_base_dependency_context(host.path(), worktree.path(), &patterns);
2800 assert!(
2801 fs::symlink_metadata(worktree.path().join("apps/web/.nuxt"))
2802 .expect("linked generated context")
2803 .file_type()
2804 .is_symlink()
2805 );
2806 }
2807
2808 #[cfg(unix)]
2809 #[test]
2810 fn materialize_base_context_refuses_symlinked_workspace_parent() {
2811 let host = tempfile::tempdir().expect("host");
2812 let worktree = tempfile::tempdir().expect("worktree");
2813 let outside = tempfile::tempdir().expect("outside");
2814 fs::write(
2815 host.path().join("package.json"),
2816 r#"{"private":true,"workspaces":["packages/*"]}"#,
2817 )
2818 .expect("root package");
2819 let host_workspace = host.path().join("packages/app");
2820 fs::create_dir_all(host_workspace.join(".nuxt")).expect("host generated context");
2821 fs::write(host_workspace.join("package.json"), r#"{"name":"app"}"#)
2822 .expect("workspace package");
2823 fs::write(host_workspace.join(".nuxt/imports.d.ts"), "export {};\n")
2824 .expect("generated marker");
2825
2826 let outside_workspace = outside.path().join("app");
2827 fs::create_dir_all(&outside_workspace).expect("outside workspace");
2828 let outside_generated = outside_workspace.join(".nuxt");
2829 std::os::unix::fs::symlink("missing-target", &outside_generated)
2830 .expect("outside sentinel symlink");
2831 std::os::unix::fs::symlink(outside.path(), worktree.path().join("packages"))
2832 .expect("hostile workspace parent symlink");
2833
2834 materialize_base_dependency_context(host.path(), worktree.path(), &[]);
2835
2836 assert_eq!(
2837 fs::read_link(&outside_generated).expect("sentinel symlink must survive"),
2838 PathBuf::from("missing-target")
2839 );
2840 assert!(
2841 !outside.path().join(".nuxt").exists(),
2842 "materialization must not create generated context outside the worktree"
2843 );
2844 }
2845
2846 #[test]
2847 fn audit_context_fingerprint_rejects_oversized_files_without_reading_them() {
2848 let temp = tempfile::tempdir().expect("temp dir");
2849 let path = temp.path().join("pnpm-lock.yaml");
2850 let file = File::create(&path).expect("oversized file");
2851 file.set_len(AUDIT_CONTEXT_FILE_MAX_BYTES.saturating_add(1))
2852 .expect("set oversized length");
2853
2854 let fingerprint = fingerprint_context_file_at(&path, "pnpm-lock.yaml");
2855
2856 assert_eq!(
2857 fingerprint.state,
2858 AuditContextPathState::Unreadable(CONTEXT_OVERSIZED_FILE_STATE.to_string())
2859 );
2860 assert!(fingerprint.source.is_some());
2861 assert!(fingerprint.content_hash.is_none());
2862 }
2863
2864 #[cfg(unix)]
2865 #[test]
2866 fn audit_context_fingerprint_rejects_symlinked_files_without_following_them() {
2867 let temp = tempfile::tempdir().expect("temp dir");
2868 let target = temp.path().join("target-lock.yaml");
2869 let link = temp.path().join("pnpm-lock.yaml");
2870 fs::write(&target, "secret target contents\n").expect("target file");
2871 std::os::unix::fs::symlink(&target, &link).expect("lockfile symlink");
2872
2873 let fingerprint = fingerprint_context_file_at(&link, "pnpm-lock.yaml");
2874
2875 assert_eq!(
2876 fingerprint.state,
2877 AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
2878 );
2879 assert!(fingerprint.content_hash.is_none());
2880 }
2881
2882 #[cfg(unix)]
2883 #[test]
2884 fn audit_context_fingerprint_does_not_follow_symlink_swapped_before_open() {
2885 let temp = tempfile::tempdir().expect("temp dir");
2886 let path = temp.path().join("pnpm-lock.yaml");
2887 let target = temp.path().join("target-lock.yaml");
2888 fs::write(&path, "original contents\n").expect("original file");
2889 fs::write(&target, "secret target contents\n").expect("target file");
2890
2891 let fingerprint = fingerprint_context_file_at_with_hooks(
2892 &path,
2893 "pnpm-lock.yaml",
2894 || {
2895 fs::remove_file(&path).expect("remove original");
2896 std::os::unix::fs::symlink(&target, &path).expect("replacement symlink");
2897 },
2898 || {},
2899 );
2900
2901 assert_eq!(
2902 fingerprint.state,
2903 AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
2904 );
2905 assert!(fingerprint.content_hash.is_none());
2906 }
2907
2908 #[test]
2909 fn audit_context_fingerprint_rejects_file_changed_during_read() {
2910 let temp = tempfile::tempdir().expect("temp dir");
2911 let path = temp.path().join("pnpm-lock.yaml");
2912 fs::write(&path, "original contents\n").expect("original file");
2913
2914 let fingerprint = fingerprint_context_file_at_with_hooks(
2915 &path,
2916 "pnpm-lock.yaml",
2917 || {},
2918 || {
2919 OpenOptions::new()
2920 .write(true)
2921 .open(&path)
2922 .expect("open replacement")
2923 .set_len(1)
2924 .expect("truncate replacement");
2925 },
2926 );
2927
2928 assert_eq!(
2929 fingerprint.state,
2930 AuditContextPathState::Unreadable(CONTEXT_CHANGED_DURING_READ_STATE.to_string())
2931 );
2932 assert!(fingerprint.content_hash.is_none());
2933 }
2934
2935 #[cfg(unix)]
2936 #[test]
2937 fn unix_context_open_does_not_block_on_fifo() {
2938 let temp = tempfile::tempdir().expect("temp dir");
2939 let fifo = temp.path().join("pnpm-lock.yaml");
2940 let status = std::process::Command::new("mkfifo")
2941 .arg(&fifo)
2942 .status()
2943 .expect("run mkfifo");
2944 assert!(status.success(), "mkfifo must create the test pipe");
2945
2946 let fallback_fifo = fifo.clone();
2947 let fallback_writer = std::thread::spawn(move || {
2948 std::thread::sleep(std::time::Duration::from_secs(1));
2949 OpenOptions::new()
2950 .read(true)
2951 .write(true)
2952 .open(fallback_fifo)
2953 .expect("open fallback FIFO writer")
2954 });
2955 let started = std::time::Instant::now();
2956 let fingerprint = fingerprint_context_file_at(&fifo, "pnpm-lock.yaml");
2957 let elapsed = started.elapsed();
2958
2959 assert!(
2960 elapsed < std::time::Duration::from_millis(500),
2961 "nonblocking FIFO open took {elapsed:?}"
2962 );
2963 assert_eq!(
2964 fingerprint.state,
2965 AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
2966 );
2967 assert!(fingerprint.content_hash.is_none());
2968 drop(fallback_writer.join().expect("fallback writer"));
2969 }
2970
2971 #[cfg(unix)]
2972 #[test]
2973 fn audit_context_fingerprint_rejects_special_files_without_opening_them() {
2974 use std::os::unix::net::UnixListener;
2975
2976 let temp = tempfile::tempdir().expect("temp dir");
2977 let socket = temp.path().join("pnpm-lock.yaml");
2978 let _listener = UnixListener::bind(&socket).expect("unix socket");
2979
2980 let fingerprint = fingerprint_context_file_at(&socket, "pnpm-lock.yaml");
2981
2982 assert_eq!(
2983 fingerprint.state,
2984 AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
2985 );
2986 assert!(fingerprint.content_hash.is_none());
2987 }
2988}