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