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