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