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#[derive(Debug, Clone, PartialEq, Eq)]
1213pub enum BaseAnalysisRoot {
1214 Present(PathBuf),
1217 NewInHead(PathBuf),
1221}
1222
1223#[must_use]
1232pub fn resolve_base_analysis_root(
1233 current_root: &Path,
1234 base_worktree_root: &Path,
1235) -> BaseAnalysisRoot {
1236 let root = base_analysis_root(current_root, base_worktree_root);
1237 if root.is_dir() {
1238 BaseAnalysisRoot::Present(root)
1239 } else {
1240 BaseAnalysisRoot::NewInHead(root)
1241 }
1242}
1243
1244#[must_use]
1270pub fn auto_detect_audit_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
1271 if let Some(upstream) = git_upstream_ref(root) {
1272 if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
1273 return Some(ResolvedAuditBase {
1274 git_ref: sha,
1275 description: Some(format!("merge-base with {upstream}")),
1276 });
1277 }
1278 return Some(ResolvedAuditBase {
1279 description: Some(format!("{upstream} (tip)")),
1280 git_ref: upstream,
1281 });
1282 }
1283
1284 if let Some(remote_ref) = detect_remote_default_ref(root) {
1285 if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
1286 return Some(ResolvedAuditBase {
1287 git_ref: sha,
1288 description: Some(format!("merge-base with {remote_ref}")),
1289 });
1290 }
1291 return Some(ResolvedAuditBase {
1292 description: Some(format!("{remote_ref} (tip)")),
1293 git_ref: remote_ref,
1294 });
1295 }
1296
1297 for candidate in ["main", "master"] {
1298 if git_ref_exists(root, candidate) {
1299 return Some(ResolvedAuditBase {
1300 git_ref: candidate.to_string(),
1301 description: Some(format!("local {candidate}")),
1302 });
1303 }
1304 }
1305
1306 None
1307}
1308
1309#[must_use]
1311pub fn short_head_sha(root: &Path) -> Option<String> {
1312 run_git(root, &["rev-parse", "--short", "HEAD"])
1313}
1314
1315#[must_use]
1320pub fn default_workspace_ref(root: &Path) -> Option<String> {
1321 let workspaces = crate::discover::discover_workspace_packages(root);
1322 default_workspace_ref_for_workspaces(root, &workspaces)
1323}
1324
1325#[must_use]
1327pub fn default_workspace_ref_for_workspaces(
1328 root: &Path,
1329 workspaces: &[WorkspaceInfo],
1330) -> Option<String> {
1331 if workspaces.is_empty() || !crate::churn::is_git_repo(root) {
1332 return None;
1333 }
1334 run_git(
1335 root,
1336 &[
1337 "symbolic-ref",
1338 "--quiet",
1339 "--short",
1340 "refs/remotes/origin/HEAD",
1341 ],
1342 )
1343 .or_else(|| {
1344 ["origin/main", "origin/master"]
1345 .into_iter()
1346 .find(|candidate| git_ref_exists(root, candidate))
1347 .map(str::to_owned)
1348 })
1349}
1350
1351#[must_use]
1356pub fn current_user_identities(root: &Path) -> Vec<String> {
1357 let mut ids = Vec::new();
1358 if let Some(email) = read_git_config(root, "user.email") {
1359 if let Some((local, _)) = email.split_once('@') {
1360 ids.push(local.rsplit('+').next().unwrap_or(local).to_owned());
1361 }
1362 ids.push(email);
1363 }
1364 if let Some(name) = read_git_config(root, "user.name") {
1365 ids.push(name);
1366 }
1367 ids
1368}
1369
1370fn read_git_config(root: &Path, key: &str) -> Option<String> {
1371 run_git(root, &["config", "--get", key])
1372}
1373
1374fn git_ref_exists(root: &Path, reference: &str) -> bool {
1375 run_git(root, &["rev-parse", "--verify", "--quiet", reference]).is_some()
1376}
1377
1378fn git_toplevel(root: &Path) -> Option<PathBuf> {
1385 let toplevel = PathBuf::from(run_git(root, &["rev-parse", "--show-toplevel"])?);
1386 Some(dunce::canonicalize(&toplevel).unwrap_or(toplevel))
1387}
1388
1389fn git_upstream_ref(root: &Path) -> Option<String> {
1390 run_git(
1391 root,
1392 &[
1393 "rev-parse",
1394 "--abbrev-ref",
1395 "--symbolic-full-name",
1396 "@{upstream}",
1397 ],
1398 )
1399}
1400
1401fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
1402 run_git(root, &["merge-base", a, b])
1403}
1404
1405fn detect_remote_default_ref(root: &Path) -> Option<String> {
1406 if let Some(full_ref) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
1407 && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
1408 {
1409 return Some(format!("origin/{branch}"));
1410 }
1411 ["origin/main", "origin/master"]
1412 .into_iter()
1413 .find(|candidate| git_ref_exists(root, candidate))
1414 .map(str::to_string)
1415}
1416
1417fn base_worktree_path() -> EngineResult<PathBuf> {
1418 let nanos = SystemTime::now()
1419 .duration_since(SystemTime::UNIX_EPOCH)
1420 .map_err(|err| EngineError::new(format!("system clock before unix epoch: {err}")))?
1421 .as_nanos();
1422 Ok(std::env::temp_dir().join(base_worktree_name(nanos)))
1423}
1424
1425fn base_worktree_name(nanos: u128) -> String {
1435 static SEQ: AtomicU64 = AtomicU64::new(0);
1436 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
1437 format!("fallow-audit-base-{}-{nanos}-{seq}", std::process::id())
1438}
1439
1440#[expect(
1441 clippy::disallowed_methods,
1442 reason = "canonical engine-owned git spawn wrapper for repository refs"
1443)]
1444fn git_command(root: &Path) -> Command {
1445 let mut command = Command::new("git");
1446 crate::changed_files::clear_ambient_git_env(&mut command);
1447 command.stdin(Stdio::null()).arg("-C").arg(root);
1449 command
1450}
1451
1452fn run_git(root: &Path, args: &[&str]) -> Option<String> {
1460 let output = git_command(root).args(args).output().ok()?;
1461 if !output.status.success() {
1462 return None;
1463 }
1464 let value = String::from_utf8(output.stdout).ok()?;
1465 let trimmed = value.trim();
1466 (!trimmed.is_empty()).then(|| trimmed.to_owned())
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471 use std::fs;
1472 use std::path::PathBuf;
1473 use std::process::Command;
1474
1475 use super::*;
1476
1477 fn git(root: &Path, args: &[&str]) -> String {
1478 let output = Command::new("git")
1479 .args(args)
1480 .current_dir(root)
1481 .env_remove("GIT_DIR")
1482 .env_remove("GIT_WORK_TREE")
1483 .output()
1484 .expect("git command starts");
1485 assert!(
1486 output.status.success(),
1487 "git {args:?} failed: {}",
1488 String::from_utf8_lossy(&output.stderr)
1489 );
1490 String::from_utf8_lossy(&output.stdout).trim().to_owned()
1491 }
1492
1493 fn init_repo(root: &Path) {
1494 fs::create_dir_all(root).expect("create repo");
1495 git(root, &["init", "-b", "main"]);
1496 git(root, &["config", "user.name", "Test User"]);
1497 git(root, &["config", "user.email", "test@example.com"]);
1498 git(root, &["config", "commit.gpgsign", "false"]);
1499 }
1500
1501 fn commit_all(root: &Path, message: &str) {
1502 git(root, &["add", "."]);
1503 git(root, &["commit", "-m", message]);
1504 }
1505
1506 fn seeded_repo(parent: &Path) -> PathBuf {
1508 let root = parent.join("repo");
1509 init_repo(&root);
1510 fs::write(root.join("README.md"), "seed\n").expect("write seed");
1511 commit_all(&root, "initial");
1512 root
1513 }
1514
1515 fn commit_file(repo: &Path, name: &str, body: &str) -> String {
1517 fs::write(repo.join(name), body).expect("write file");
1518 commit_all(repo, name);
1519 git(repo, &["rev-parse", "HEAD"])
1520 }
1521
1522 #[cfg(unix)]
1523 fn write_executable(path: &Path, source: &str) {
1524 use std::os::unix::fs::PermissionsExt as _;
1525
1526 fs::write(path, source).expect("write executable");
1527 let mut permissions = fs::metadata(path)
1528 .expect("executable metadata")
1529 .permissions();
1530 permissions.set_mode(0o755);
1531 fs::set_permissions(path, permissions).expect("set executable mode");
1532 }
1533
1534 #[test]
1543 fn base_worktree_names_are_unique_when_the_clock_read_repeats() {
1544 const N: usize = 64;
1545 const SAME_TICK: u128 = 1_788_187_156_297_209_000;
1546
1547 let barrier = std::sync::Barrier::new(N);
1548 let names = std::sync::Mutex::new(Vec::with_capacity(N));
1549 std::thread::scope(|scope| {
1550 for _ in 0..N {
1551 let barrier = &barrier;
1552 let names = &names;
1553 scope.spawn(move || {
1554 barrier.wait();
1555 names
1556 .lock()
1557 .expect("names lock")
1558 .push(base_worktree_name(SAME_TICK));
1559 });
1560 }
1561 });
1562
1563 let mut names = names.into_inner().expect("names lock");
1564 assert_eq!(names.len(), N);
1565 names.sort();
1566 names.dedup();
1567 assert_eq!(names.len(), N, "base worktree names collided");
1568 }
1569
1570 #[test]
1572 fn base_worktree_path_keeps_the_pid_as_the_first_segment() {
1573 let path = base_worktree_path().expect("path should build");
1574 let name = path
1575 .file_name()
1576 .and_then(|name| name.to_str())
1577 .expect("worktree name should be utf-8");
1578 let pid = name
1579 .strip_prefix("fallow-audit-base-")
1580 .and_then(|rest| rest.split('-').next())
1581 .expect("pid segment should be present");
1582 assert_eq!(pid, std::process::id().to_string());
1583 }
1584
1585 #[test]
1591 fn detached_worktree_from_a_subdir_skips_sibling_subtrees() {
1592 let temp = tempfile::tempdir().expect("temp dir");
1593 let repo = temp.path().join("repo");
1594 init_repo(&repo);
1595 fs::create_dir_all(repo.join("sub")).expect("create sub dir");
1596 fs::create_dir_all(repo.join("big")).expect("create big dir");
1597 fs::write(repo.join("sub/a.ts"), "export const a = 1;\n").expect("write sub file");
1598 fs::write(repo.join("big/b.ts"), "export const b = 1;\n").expect("write big file");
1599 fs::write(repo.join("top.ts"), "export const top = 1;\n").expect("write top file");
1600 commit_all(&repo, "initial");
1601
1602 let destination = temp.path().join("base");
1603 create_detached_base_worktree(&repo.join("sub"), &destination, "HEAD")
1604 .expect("base worktree should be created");
1605
1606 assert!(
1607 destination.join("sub/a.ts").is_file(),
1608 "the requested subtree must be materialized"
1609 );
1610 assert!(
1611 destination.join("top.ts").is_file(),
1612 "top-level files shape subdir discovery and stay materialized"
1613 );
1614 assert!(
1615 !destination.join("big/b.ts").exists(),
1616 "sibling subtrees must not be materialized: {}",
1617 destination.join("big/b.ts").display()
1618 );
1619
1620 remove_registered_worktree(&repo, &destination);
1621 let _ = fs::remove_dir_all(&destination);
1622 }
1623
1624 #[test]
1629 fn detached_worktree_at_the_root_respects_the_sparse_cone() {
1630 let temp = tempfile::tempdir().expect("temp dir");
1631 let repo = temp.path().join("repo");
1632 init_repo(&repo);
1633 fs::create_dir_all(repo.join("sub")).expect("create sub dir");
1634 fs::create_dir_all(repo.join("big")).expect("create big dir");
1635 fs::write(repo.join("sub/a.ts"), "export const a = 1;\n").expect("write sub file");
1636 fs::write(repo.join("big/b.ts"), "export const b = 1;\n").expect("write big file");
1637 commit_all(&repo, "initial");
1638 git(&repo, &["sparse-checkout", "init", "--cone"]);
1639 git(&repo, &["sparse-checkout", "set", "sub"]);
1640
1641 let destination = temp.path().join("base");
1642 create_detached_base_worktree(&repo, &destination, "HEAD")
1643 .expect("base worktree should be created");
1644
1645 assert!(
1646 destination.join("sub/a.ts").is_file(),
1647 "the sparse cone must be materialized"
1648 );
1649 assert!(
1650 !destination.join("big/b.ts").exists(),
1651 "paths outside the sparse cone must not be materialized: {}",
1652 destination.join("big/b.ts").display()
1653 );
1654
1655 remove_registered_worktree(&repo, &destination);
1656 let _ = fs::remove_dir_all(&destination);
1657 }
1658
1659 #[test]
1663 fn materialization_scope_filters_to_the_needed_working_set() {
1664 let subdir = MaterializationScope {
1665 subdir_prefix: Some("apps/web".to_string()),
1666 sparse_dirs: None,
1667 };
1668 assert!(subdir.should_materialize(Path::new("apps/web/a.ts")));
1669 assert!(subdir.should_materialize(Path::new("top.ts")));
1670 assert!(subdir.should_materialize(Path::new(".gitignore")));
1671 assert!(subdir.should_materialize(Path::new("apps/.gitignore")));
1672 assert!(!subdir.should_materialize(Path::new("apps/other/b.ts")));
1673 assert!(!subdir.should_materialize(Path::new("apps/.gitignore.bak")));
1674
1675 let sparse = MaterializationScope {
1676 subdir_prefix: None,
1677 sparse_dirs: Some(vec!["apps/web".to_string()]),
1678 };
1679 assert!(sparse.should_materialize(Path::new("apps/web/a.ts")));
1680 assert!(sparse.should_materialize(Path::new("top.ts")));
1681 assert!(!sparse.should_materialize(Path::new("apps/other/b.ts")));
1682
1683 let full = MaterializationScope {
1684 subdir_prefix: None,
1685 sparse_dirs: None,
1686 };
1687 assert!(full.should_materialize(Path::new("apps/other/b.ts")));
1688 }
1689
1690 #[test]
1691 fn default_workspace_ref_skips_projects_without_workspaces() {
1692 assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[]).is_none());
1693 }
1694
1695 #[test]
1696 fn default_workspace_ref_skips_non_git_workspace_projects() {
1697 let workspace = WorkspaceInfo {
1698 root: PathBuf::from("/repo/packages/app"),
1699 name: "app".to_owned(),
1700 is_internal_dependency: false,
1701 };
1702
1703 assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[workspace]).is_none());
1704 }
1705
1706 #[test]
1707 fn current_user_identities_empty_when_git_config_is_unavailable() {
1708 assert!(current_user_identities(Path::new("/repo")).is_empty());
1709 }
1710
1711 #[test]
1712 fn short_head_sha_omits_git_line_ending() {
1713 let temp = tempfile::tempdir().expect("temp dir");
1714 let repo = temp.path().join("repo");
1715 init_repo(&repo);
1716 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1717 commit_all(&repo, "initial");
1718
1719 let sha = short_head_sha(&repo).expect("HEAD sha");
1720 assert_eq!(sha, sha.trim());
1721 assert!(!sha.is_empty());
1722 }
1723
1724 #[test]
1725 fn short_head_sha_is_absent_outside_a_git_repo() {
1726 let temp = tempfile::tempdir().expect("temp dir");
1727
1728 assert_eq!(short_head_sha(temp.path()), None);
1729 }
1730
1731 #[test]
1737 fn auto_detect_audit_base_ref_omits_git_line_endings_for_the_upstream_merge_base() {
1738 let temp = tempfile::tempdir().expect("temp dir");
1739 let repo = temp.path().join("repo");
1740 init_repo(&repo);
1741 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1742 commit_all(&repo, "initial");
1743 let fork_point = git(&repo, &["rev-parse", "HEAD"]);
1744 git(&repo, &["remote", "add", "origin", &repo.to_string_lossy()]);
1745 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1746 git(&repo, &["checkout", "-b", "feature"]);
1747 git(
1748 &repo,
1749 &["branch", "--set-upstream-to=origin/main", "feature"],
1750 );
1751 fs::write(repo.join("feature.txt"), "my change\n").expect("write feature file");
1752 commit_all(&repo, "feature");
1753
1754 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1755
1756 assert_eq!(detected.git_ref, fork_point);
1757 assert_eq!(
1758 detected.description.as_deref(),
1759 Some("merge-base with origin/main")
1760 );
1761 assert!(crate::validate::validate_git_ref(&detected.git_ref).is_ok());
1762 }
1763
1764 #[test]
1768 fn auto_detect_audit_base_ref_omits_git_line_endings_for_the_remote_default() {
1769 let temp = tempfile::tempdir().expect("temp dir");
1770 let repo = temp.path().join("repo");
1771 init_repo(&repo);
1772 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1773 commit_all(&repo, "initial");
1774 let fork_point = git(&repo, &["rev-parse", "HEAD"]);
1775 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1776 git(
1777 &repo,
1778 &[
1779 "symbolic-ref",
1780 "refs/remotes/origin/HEAD",
1781 "refs/remotes/origin/main",
1782 ],
1783 );
1784
1785 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1786
1787 assert_eq!(detected.git_ref, fork_point);
1788 assert_eq!(
1789 detected.description.as_deref(),
1790 Some("merge-base with origin/main")
1791 );
1792 assert!(crate::validate::validate_git_ref(&detected.git_ref).is_ok());
1793 }
1794
1795 #[test]
1796 fn auto_detect_audit_base_ref_resolves_origin_default_to_merge_base() {
1797 let temp = tempfile::tempdir().expect("temp dir");
1798 let repo = seeded_repo(temp.path());
1799 let head = git(&repo, &["rev-parse", "HEAD"]);
1800 git(&repo, &["branch", "trunk"]);
1801 git(&repo, &["update-ref", "refs/remotes/origin/trunk", "trunk"]);
1802 git(
1803 &repo,
1804 &[
1805 "symbolic-ref",
1806 "refs/remotes/origin/HEAD",
1807 "refs/remotes/origin/trunk",
1808 ],
1809 );
1810
1811 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1812
1813 assert_eq!(detected.git_ref, head);
1816 assert_eq!(
1817 detected.description.as_deref(),
1818 Some("merge-base with origin/trunk")
1819 );
1820 }
1821
1822 #[test]
1827 fn auto_detect_audit_base_ref_ignores_stale_local_main() {
1828 let temp = tempfile::tempdir().expect("temp dir");
1829 let repo = seeded_repo(temp.path());
1830 let stale = git(&repo, &["rev-parse", "HEAD"]);
1831
1832 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1833 git(
1834 &repo,
1835 &[
1836 "symbolic-ref",
1837 "refs/remotes/origin/HEAD",
1838 "refs/remotes/origin/main",
1839 ],
1840 );
1841 let fork_point = commit_file(&repo, "teammate.txt", "merged work\n");
1842 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1843
1844 git(&repo, &["checkout", "-b", "feature", &fork_point]);
1847 commit_file(&repo, "feature.txt", "my change\n");
1848 git(&repo, &["branch", "-f", "main", &stale]);
1849
1850 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1851
1852 assert_eq!(
1853 detected.git_ref, fork_point,
1854 "base must be the fork point (origin/main), not stale local main"
1855 );
1856 assert_eq!(
1857 detected.description.as_deref(),
1858 Some("merge-base with origin/main")
1859 );
1860 }
1861
1862 #[test]
1863 fn auto_detect_audit_base_ref_prefers_configured_upstream() {
1864 let temp = tempfile::tempdir().expect("temp dir");
1865 let repo = seeded_repo(temp.path());
1866 let fork_point = git(&repo, &["rev-parse", "HEAD"]);
1867 git(&repo, &["remote", "add", "origin", &repo.to_string_lossy()]);
1870 git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1871 git(&repo, &["checkout", "-b", "feature"]);
1872 git(
1873 &repo,
1874 &["branch", "--set-upstream-to=origin/main", "feature"],
1875 );
1876 commit_file(&repo, "feature.txt", "my change\n");
1877
1878 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1879
1880 assert_eq!(detected.git_ref, fork_point);
1881 assert_eq!(
1882 detected.description.as_deref(),
1883 Some("merge-base with origin/main")
1884 );
1885 }
1886
1887 #[test]
1888 fn auto_detect_audit_base_ref_falls_back_to_local_main_without_remote() {
1889 let temp = tempfile::tempdir().expect("temp dir");
1890 let repo = seeded_repo(temp.path());
1891
1892 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1893
1894 assert_eq!(detected.git_ref, "main");
1895 assert_eq!(detected.description.as_deref(), Some("local main"));
1896 }
1897
1898 #[test]
1899 fn auto_detect_audit_base_ref_falls_back_to_local_master_without_remote() {
1900 let temp = tempfile::tempdir().expect("temp dir");
1901 let repo = temp.path().join("repo");
1902 fs::create_dir_all(&repo).expect("create repo");
1903 git(&repo, &["init", "-b", "master"]);
1904 git(&repo, &["config", "user.name", "Test User"]);
1905 git(&repo, &["config", "user.email", "test@example.com"]);
1906 git(&repo, &["config", "commit.gpgsign", "false"]);
1907 fs::write(repo.join("README.md"), "seed\n").expect("write seed");
1908 commit_all(&repo, "initial");
1909
1910 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1911
1912 assert_eq!(detected.git_ref, "master");
1913 assert_eq!(detected.description.as_deref(), Some("local master"));
1914 }
1915
1916 #[test]
1917 fn auto_detect_audit_base_ref_returns_none_outside_git_repo() {
1918 let temp = tempfile::tempdir().expect("temp dir");
1919
1920 assert!(auto_detect_audit_base_ref(temp.path()).is_none());
1921 }
1922
1923 #[test]
1929 fn auto_detect_audit_base_ref_falls_back_to_remote_tip_without_common_ancestor() {
1930 let temp = tempfile::tempdir().expect("temp dir");
1931 let repo = seeded_repo(temp.path());
1932 git(&repo, &["checkout", "--orphan", "unrelated"]);
1933 let unrelated = commit_file(&repo, "unrelated.txt", "no shared history\n");
1934 git(
1935 &repo,
1936 &["update-ref", "refs/remotes/origin/main", &unrelated],
1937 );
1938 git(
1939 &repo,
1940 &[
1941 "symbolic-ref",
1942 "refs/remotes/origin/HEAD",
1943 "refs/remotes/origin/main",
1944 ],
1945 );
1946 git(&repo, &["checkout", "main"]);
1947
1948 let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1949
1950 assert_eq!(detected.git_ref, "origin/main");
1951 assert_eq!(detected.description.as_deref(), Some("origin/main (tip)"));
1952 }
1953
1954 #[test]
1958 fn base_analysis_root_preserves_repo_subdirectory_roots() {
1959 let temp = tempfile::tempdir().expect("temp dir");
1960 let repo = temp.path().join("repo");
1961 init_repo(&repo);
1962 let app_root = repo.join("apps").join("mobile");
1963 fs::create_dir_all(&app_root).expect("create app root");
1964 let base_worktree = temp.path().join("base-worktree");
1965
1966 assert_eq!(
1967 base_analysis_root(&app_root, &base_worktree),
1968 base_worktree.join("apps").join("mobile")
1969 );
1970 }
1971
1972 #[cfg(unix)]
1977 #[test]
1978 fn base_analysis_root_maps_a_symlinked_root_spelling() {
1979 let temp = tempfile::tempdir().expect("temp dir");
1980 let real_parent = temp.path().join("real");
1981 let repo = real_parent.join("repo");
1982 init_repo(&repo);
1983 let app_root = repo.join("apps").join("mobile");
1984 fs::create_dir_all(&app_root).expect("create app root");
1985 let linked_parent = temp.path().join("linked");
1986 std::os::unix::fs::symlink(&real_parent, &linked_parent).expect("link the parent");
1987 let base_worktree = temp.path().join("base-worktree");
1988
1989 let linked_app_root = linked_parent.join("repo").join("apps").join("mobile");
1990 assert_eq!(
1991 base_analysis_root(&linked_app_root, &base_worktree),
1992 base_worktree.join("apps").join("mobile")
1993 );
1994 }
1995
1996 #[test]
2001 fn resolve_base_analysis_root_reports_a_root_absent_from_the_base() {
2002 let temp = tempfile::tempdir().expect("temp dir");
2003 let repo = temp.path().join("repo");
2004 init_repo(&repo);
2005 let existing_root = repo.join("apps").join("mobile");
2006 fs::create_dir_all(&existing_root).expect("create existing root");
2007 let new_root = repo.join("apps").join("new");
2008 fs::create_dir_all(&new_root).expect("create new root");
2009
2010 let base_worktree = temp.path().join("base-worktree");
2011 fs::create_dir_all(base_worktree.join("apps").join("mobile"))
2012 .expect("create base subdirectory");
2013
2014 assert_eq!(
2015 resolve_base_analysis_root(&existing_root, &base_worktree),
2016 BaseAnalysisRoot::Present(base_worktree.join("apps").join("mobile"))
2017 );
2018 assert_eq!(
2019 resolve_base_analysis_root(&new_root, &base_worktree),
2020 BaseAnalysisRoot::NewInHead(base_worktree.join("apps").join("new"))
2021 );
2022 }
2023
2024 #[cfg(unix)]
2025 #[test]
2026 fn temporary_base_worktree_does_not_run_post_checkout_hook() {
2027 let temp = tempfile::tempdir().expect("temp dir");
2028 let repo = temp.path().join("repo");
2029 init_repo(&repo);
2030 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
2031 commit_all(&repo, "initial");
2032
2033 let sentinel = temp.path().join("post-checkout-ran");
2034 write_executable(
2035 &repo.join(".git/hooks/post-checkout"),
2036 &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
2037 );
2038
2039 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
2040 .expect("temporary worktree should be created");
2041
2042 assert_eq!(
2043 fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
2044 "committed\n"
2045 );
2046 assert!(
2047 !sentinel.exists(),
2048 "creating a base view must not execute post-checkout hooks"
2049 );
2050 }
2051
2052 #[cfg(unix)]
2053 #[test]
2054 fn temporary_base_worktree_does_not_run_post_index_change_hook() {
2055 let temp = tempfile::tempdir().expect("temp dir");
2056 let repo = temp.path().join("repo");
2057 init_repo(&repo);
2058 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
2059 commit_all(&repo, "initial");
2060
2061 let sentinel = temp.path().join("post-index-change-ran");
2062 write_executable(
2063 &repo.join(".git/hooks/post-index-change"),
2064 &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
2065 );
2066
2067 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
2068 .expect("temporary worktree should be created");
2069
2070 assert_eq!(
2071 fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
2072 "committed\n"
2073 );
2074 assert!(
2075 !sentinel.exists(),
2076 "creating a base view must not execute post-index-change hooks"
2077 );
2078 }
2079
2080 #[cfg(unix)]
2081 #[test]
2082 fn temporary_base_worktree_does_not_run_smudge_filter() {
2083 let temp = tempfile::tempdir().expect("temp dir");
2084 let repo = temp.path().join("repo");
2085 init_repo(&repo);
2086 fs::write(
2087 repo.join(".gitattributes"),
2088 "filtered.txt filter=sentinel\n",
2089 )
2090 .expect("write attributes");
2091 fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
2092 commit_all(&repo, "initial");
2093
2094 let sentinel = temp.path().join("smudge-ran");
2095 let filter = temp.path().join("smudge-filter.sh");
2096 write_executable(
2097 &filter,
2098 &format!(
2099 "#!/bin/sh\nprintf ran > '{}'\ncat >/dev/null\nprintf 'smudged bytes\\n'\n",
2100 sentinel.display()
2101 ),
2102 );
2103 git(
2104 &repo,
2105 &[
2106 "config",
2107 "filter.sentinel.smudge",
2108 filter.to_str().expect("filter path is UTF-8"),
2109 ],
2110 );
2111
2112 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
2113 .expect("temporary worktree should be created");
2114
2115 assert_eq!(
2116 fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
2117 b"committed raw bytes\n"
2118 );
2119 assert!(
2120 !sentinel.exists(),
2121 "creating a base view must not execute smudge filters"
2122 );
2123 }
2124
2125 #[cfg(unix)]
2126 #[test]
2127 fn temporary_base_worktree_does_not_start_process_filter() {
2128 let temp = tempfile::tempdir().expect("temp dir");
2129 let repo = temp.path().join("repo");
2130 init_repo(&repo);
2131 fs::write(
2132 repo.join(".gitattributes"),
2133 "filtered.txt filter=sentinel\n",
2134 )
2135 .expect("write attributes");
2136 fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
2137 commit_all(&repo, "initial");
2138
2139 let sentinel = temp.path().join("process-filter-ran");
2140 let filter = temp.path().join("process-filter.sh");
2141 write_executable(
2142 &filter,
2143 &format!("#!/bin/sh\nprintf ran > '{}'\nexit 1\n", sentinel.display()),
2144 );
2145 git(
2146 &repo,
2147 &[
2148 "config",
2149 "filter.sentinel.process",
2150 filter.to_str().expect("filter path is UTF-8"),
2151 ],
2152 );
2153
2154 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
2155 .expect("temporary worktree should be created");
2156
2157 assert_eq!(
2158 fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
2159 b"committed raw bytes\n"
2160 );
2161 assert!(
2162 !sentinel.exists(),
2163 "creating a base view must not start process filters"
2164 );
2165 }
2166
2167 #[test]
2168 fn failed_registration_does_not_remove_existing_worktree() {
2169 let temp = tempfile::tempdir().expect("temp dir");
2170 let repo = temp.path().join("repo");
2171 init_repo(&repo);
2172 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
2173 commit_all(&repo, "initial");
2174 let destination = temp.path().join("base");
2175
2176 create_detached_base_worktree(&repo, &destination, "HEAD")
2177 .expect("first worktree should be created");
2178 let second = create_detached_base_worktree(&repo, &destination, "HEAD");
2179
2180 assert!(second.is_err(), "duplicate destination must fail");
2181 assert!(
2182 destination.join("tracked.txt").is_file(),
2183 "failed registration must not remove the existing worktree"
2184 );
2185 assert_eq!(git(&destination, &["rev-parse", "HEAD"]).len(), 40);
2186
2187 remove_registered_worktree(&repo, &destination);
2188 let _ = fs::remove_dir_all(destination);
2189 }
2190
2191 #[cfg(unix)]
2192 #[test]
2193 fn temporary_base_worktree_preserves_modes_symlinks_and_gitlinks() {
2194 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
2195
2196 let temp = tempfile::tempdir().expect("temp dir");
2197 let repo = temp.path().join("repo");
2198 init_repo(&repo);
2199 fs::write(repo.join("regular.txt"), "regular\n").expect("write regular file");
2200 let executable = repo.join("run.sh");
2201 fs::write(&executable, "#!/bin/sh\nexit 0\n").expect("write executable");
2202 let mut permissions = fs::metadata(&executable)
2203 .expect("executable metadata")
2204 .permissions();
2205 permissions.set_mode(0o755);
2206 fs::set_permissions(&executable, permissions).expect("set executable mode");
2207 std::os::unix::fs::symlink("regular.txt", repo.join("regular-link"))
2208 .expect("create symlink");
2209 commit_all(&repo, "files");
2210
2211 let gitlink_commit = git(&repo, &["rev-parse", "HEAD"]);
2212 git(
2213 &repo,
2214 &[
2215 "update-index",
2216 "--add",
2217 "--cacheinfo",
2218 &format!("160000,{gitlink_commit},vendor/submodule"),
2219 ],
2220 );
2221 git(&repo, &["commit", "-m", "gitlink"]);
2222
2223 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
2224 .expect("temporary worktree should be created");
2225 let regular_mode = fs::metadata(worktree.path().join("regular.txt"))
2226 .expect("regular metadata")
2227 .mode();
2228 let executable_mode = fs::metadata(worktree.path().join("run.sh"))
2229 .expect("executable metadata")
2230 .mode();
2231
2232 assert_eq!(regular_mode & 0o111, 0);
2233 assert_ne!(executable_mode & 0o111, 0);
2234 assert_eq!(
2235 fs::read_link(worktree.path().join("regular-link")).expect("read symlink"),
2236 PathBuf::from("regular.txt")
2237 );
2238 let gitlink = worktree.path().join("vendor/submodule");
2239 assert!(gitlink.is_dir(), "gitlink must materialize as a directory");
2240 assert!(
2241 fs::read_dir(gitlink)
2242 .expect("read gitlink directory")
2243 .next()
2244 .is_none(),
2245 "an uninitialized gitlink directory must remain empty"
2246 );
2247 assert!(
2248 git(
2249 worktree.path(),
2250 &["ls-files", "--stage", "vendor/submodule"]
2251 )
2252 .starts_with(&format!("160000 {gitlink_commit} 0\t")),
2253 "the linked worktree index must retain the gitlink object id"
2254 );
2255
2256 let path = worktree.path().to_path_buf();
2257 drop(worktree);
2258 assert!(!path.exists(), "temporary worktree must clean up on drop");
2259 }
2260
2261 #[test]
2262 fn materialized_tree_paths_reject_traversal_and_git_admin_aliases() {
2263 for path in [
2264 Path::new("../escape"),
2265 Path::new("/absolute"),
2266 Path::new(".git/config"),
2267 Path::new("nested/.GIT/config"),
2268 Path::new("nested/.git. /config"),
2269 Path::new("nested/git~1/config"),
2270 ] {
2271 assert!(
2272 validate_materialized_path(path).is_err(),
2273 "unsafe path should be rejected: {}",
2274 path.display()
2275 );
2276 }
2277 assert!(validate_materialized_path(Path::new("src/.github/file.ts")).is_ok());
2278 }
2279
2280 #[cfg(unix)]
2281 #[test]
2282 fn parent_directory_creation_refuses_symlink_traversal() {
2283 let temp = tempfile::tempdir().expect("temp dir");
2284 let root = temp.path().join("root");
2285 let outside = temp.path().join("outside");
2286 fs::create_dir(&root).expect("create root");
2287 fs::create_dir(&outside).expect("create outside");
2288 std::os::unix::fs::symlink(&outside, root.join("link")).expect("create parent symlink");
2289
2290 let result = create_safe_parent_directories(&root, Path::new("link/escaped.txt"));
2291
2292 assert!(result.is_err(), "symlink parent must be rejected");
2293 assert!(!outside.join("escaped.txt").exists());
2294 }
2295
2296 #[test]
2297 fn audit_context_fingerprint_tracks_bounded_lockfiles_and_markers() {
2298 let temp = tempfile::tempdir().expect("temp dir");
2299 let root = temp.path();
2300 fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 9\n").expect("lockfile");
2301 fs::create_dir(root.join("node_modules")).expect("node_modules");
2302 fs::write(
2303 root.join("node_modules/.modules.yaml"),
2304 "layoutVersion: 5\n",
2305 )
2306 .expect("node marker");
2307
2308 let first = audit_materialized_context_fingerprint(root);
2309 let unchanged = audit_materialized_context_fingerprint(root);
2310 assert_eq!(
2311 first, unchanged,
2312 "unchanged context must preserve a warm key"
2313 );
2314
2315 fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 10\n").expect("mutate lockfile");
2316 let lock_changed = audit_materialized_context_fingerprint(root);
2317 assert_ne!(
2318 first, lock_changed,
2319 "lockfile content must invalidate the key"
2320 );
2321
2322 fs::write(
2323 root.join("node_modules/.modules.yaml"),
2324 "layoutVersion: 6\n",
2325 )
2326 .expect("mutate node marker");
2327 let marker_changed = audit_materialized_context_fingerprint(root);
2328 assert_ne!(
2329 lock_changed, marker_changed,
2330 "bounded dependency markers must invalidate the key"
2331 );
2332
2333 fs::create_dir(root.join(".nuxt")).expect("nuxt context");
2334 fs::write(root.join(".nuxt/imports.d.ts"), "export {}\n").expect("nuxt marker");
2335 assert_ne!(
2336 marker_changed,
2337 audit_materialized_context_fingerprint(root),
2338 "missing and materialized generated context must differ"
2339 );
2340 }
2341
2342 #[test]
2343 fn audit_context_fingerprint_tracks_nested_workspace_generated_roots() {
2344 let temp = tempfile::tempdir().expect("temp dir");
2345 let root = temp.path();
2346 fs::write(
2347 root.join("package.json"),
2348 r#"{"private":true,"workspaces":["packages/*"]}"#,
2349 )
2350 .expect("root package");
2351 let nuxt = root.join("packages/nuxt-app");
2352 let astro = root.join("packages/astro-app");
2353 fs::create_dir_all(nuxt.join(".nuxt")).expect("nested nuxt context");
2354 fs::create_dir_all(astro.join(".astro")).expect("nested astro context");
2355 fs::write(nuxt.join("package.json"), r#"{"name":"nuxt-app"}"#).expect("nuxt package");
2356 fs::write(astro.join("package.json"), r#"{"name":"astro-app"}"#).expect("astro package");
2357 fs::write(nuxt.join(".nuxt/imports.d.ts"), "export {};\n").expect("nuxt marker");
2358 fs::write(astro.join(".astro/types.d.ts"), "export {};\n").expect("astro marker");
2359
2360 let first = audit_materialized_context_fingerprint(root);
2361 assert!(
2362 first
2363 .directories
2364 .iter()
2365 .any(|directory| directory.name == "packages/nuxt-app/.nuxt")
2366 );
2367 assert!(
2368 first
2369 .directories
2370 .iter()
2371 .any(|directory| directory.name == "packages/astro-app/.astro")
2372 );
2373
2374 fs::write(
2375 nuxt.join(".nuxt/imports.d.ts"),
2376 "export type Changed = true;\n",
2377 )
2378 .expect("mutate nuxt marker");
2379 assert_ne!(
2380 first,
2381 audit_materialized_context_fingerprint(root),
2382 "nested workspace marker changes must invalidate the audit context"
2383 );
2384 }
2385
2386 #[cfg(unix)]
2387 #[test]
2388 fn materialize_base_context_symlinks_nested_workspace_generated_roots() {
2389 let host = tempfile::tempdir().expect("host");
2390 let worktree = tempfile::tempdir().expect("worktree");
2391 fs::write(
2392 host.path().join("package.json"),
2393 r#"{"private":true,"workspaces":["packages/*"]}"#,
2394 )
2395 .expect("root package");
2396
2397 for (workspace, generated, marker) in [
2398 ("nuxt-app", ".nuxt", "imports.d.ts"),
2399 ("astro-app", ".astro", "types.d.ts"),
2400 ] {
2401 let host_workspace = host.path().join("packages").join(workspace);
2402 let worktree_workspace = worktree.path().join("packages").join(workspace);
2403 fs::create_dir_all(host_workspace.join(generated)).expect("host generated context");
2404 fs::create_dir_all(&worktree_workspace).expect("worktree workspace");
2405 fs::write(
2406 host_workspace.join("package.json"),
2407 format!(r#"{{"name":"{workspace}"}}"#),
2408 )
2409 .expect("workspace package");
2410 fs::write(host_workspace.join(generated).join(marker), "export {};\n")
2411 .expect("generated marker");
2412 }
2413
2414 materialize_base_dependency_context(host.path(), worktree.path());
2415
2416 for (workspace, generated, marker) in [
2417 ("nuxt-app", ".nuxt", "imports.d.ts"),
2418 ("astro-app", ".astro", "types.d.ts"),
2419 ] {
2420 let mirrored = worktree
2421 .path()
2422 .join("packages")
2423 .join(workspace)
2424 .join(generated);
2425 assert!(
2426 fs::symlink_metadata(&mirrored)
2427 .expect("mirrored generated root")
2428 .file_type()
2429 .is_symlink(),
2430 "{workspace}/{generated} must reuse the host generated root"
2431 );
2432 assert!(mirrored.join(marker).is_file());
2433 }
2434 }
2435
2436 #[cfg(unix)]
2437 #[test]
2438 fn materialize_base_context_resolves_symlinked_source_directories() {
2439 let host = tempfile::tempdir().expect("host");
2440 let targets = tempfile::tempdir().expect("targets");
2441 let worktree = tempfile::tempdir().expect("worktree");
2442
2443 for (kind, marker) in [
2444 ("node_modules", ".modules.yaml"),
2445 (".nuxt", "imports.d.ts"),
2446 (".astro", "types.d.ts"),
2447 ] {
2448 let target = targets.path().join(kind);
2449 fs::create_dir(&target).expect("source target");
2450 fs::write(target.join(marker), "generated context\n").expect("context marker");
2451 std::os::unix::fs::symlink(&target, host.path().join(kind))
2452 .expect("source directory symlink");
2453 }
2454
2455 materialize_base_dependency_context(host.path(), worktree.path());
2456
2457 let fingerprint = audit_materialized_context_fingerprint(host.path());
2458 for kind in AUDIT_MATERIALIZED_CONTEXT_DIRS {
2459 let target = dunce::canonicalize(targets.path().join(kind)).expect("canonical target");
2460 let mirrored = worktree.path().join(kind);
2461 assert_eq!(
2462 fs::read_link(&mirrored).expect("materialized symlink"),
2463 target,
2464 "{kind} must link directly to the validated canonical target"
2465 );
2466 let directory = fingerprint
2467 .directories
2468 .iter()
2469 .find(|directory| directory.name == *kind)
2470 .expect("fingerprinted context directory");
2471 assert_eq!(directory.state, AuditContextPathState::Present);
2472 assert!(directory.markers.iter().any(|marker| {
2473 matches!(marker.state, AuditContextPathState::Present)
2474 && marker.content_hash.is_some()
2475 }));
2476 }
2477 }
2478
2479 #[cfg(unix)]
2480 #[test]
2481 fn materialize_base_context_refuses_symlinked_workspace_parent() {
2482 let host = tempfile::tempdir().expect("host");
2483 let worktree = tempfile::tempdir().expect("worktree");
2484 let outside = tempfile::tempdir().expect("outside");
2485 fs::write(
2486 host.path().join("package.json"),
2487 r#"{"private":true,"workspaces":["packages/*"]}"#,
2488 )
2489 .expect("root package");
2490 let host_workspace = host.path().join("packages/app");
2491 fs::create_dir_all(host_workspace.join(".nuxt")).expect("host generated context");
2492 fs::write(host_workspace.join("package.json"), r#"{"name":"app"}"#)
2493 .expect("workspace package");
2494 fs::write(host_workspace.join(".nuxt/imports.d.ts"), "export {};\n")
2495 .expect("generated marker");
2496
2497 let outside_workspace = outside.path().join("app");
2498 fs::create_dir_all(&outside_workspace).expect("outside workspace");
2499 let outside_generated = outside_workspace.join(".nuxt");
2500 std::os::unix::fs::symlink("missing-target", &outside_generated)
2501 .expect("outside sentinel symlink");
2502 std::os::unix::fs::symlink(outside.path(), worktree.path().join("packages"))
2503 .expect("hostile workspace parent symlink");
2504
2505 materialize_base_dependency_context(host.path(), worktree.path());
2506
2507 assert_eq!(
2508 fs::read_link(&outside_generated).expect("sentinel symlink must survive"),
2509 PathBuf::from("missing-target")
2510 );
2511 assert!(
2512 !outside.path().join(".nuxt").exists(),
2513 "materialization must not create generated context outside the worktree"
2514 );
2515 }
2516
2517 #[test]
2518 fn audit_context_fingerprint_rejects_oversized_files_without_reading_them() {
2519 let temp = tempfile::tempdir().expect("temp dir");
2520 let path = temp.path().join("pnpm-lock.yaml");
2521 let file = File::create(&path).expect("oversized file");
2522 file.set_len(AUDIT_CONTEXT_FILE_MAX_BYTES.saturating_add(1))
2523 .expect("set oversized length");
2524
2525 let fingerprint = fingerprint_context_file_at(&path, "pnpm-lock.yaml");
2526
2527 assert_eq!(
2528 fingerprint.state,
2529 AuditContextPathState::Unreadable(CONTEXT_OVERSIZED_FILE_STATE.to_string())
2530 );
2531 assert!(fingerprint.source.is_some());
2532 assert!(fingerprint.content_hash.is_none());
2533 }
2534
2535 #[cfg(unix)]
2536 #[test]
2537 fn audit_context_fingerprint_rejects_symlinked_files_without_following_them() {
2538 let temp = tempfile::tempdir().expect("temp dir");
2539 let target = temp.path().join("target-lock.yaml");
2540 let link = temp.path().join("pnpm-lock.yaml");
2541 fs::write(&target, "secret target contents\n").expect("target file");
2542 std::os::unix::fs::symlink(&target, &link).expect("lockfile symlink");
2543
2544 let fingerprint = fingerprint_context_file_at(&link, "pnpm-lock.yaml");
2545
2546 assert_eq!(
2547 fingerprint.state,
2548 AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
2549 );
2550 assert!(fingerprint.content_hash.is_none());
2551 }
2552
2553 #[cfg(unix)]
2554 #[test]
2555 fn audit_context_fingerprint_does_not_follow_symlink_swapped_before_open() {
2556 let temp = tempfile::tempdir().expect("temp dir");
2557 let path = temp.path().join("pnpm-lock.yaml");
2558 let target = temp.path().join("target-lock.yaml");
2559 fs::write(&path, "original contents\n").expect("original file");
2560 fs::write(&target, "secret target contents\n").expect("target file");
2561
2562 let fingerprint = fingerprint_context_file_at_with_hooks(
2563 &path,
2564 "pnpm-lock.yaml",
2565 || {
2566 fs::remove_file(&path).expect("remove original");
2567 std::os::unix::fs::symlink(&target, &path).expect("replacement symlink");
2568 },
2569 || {},
2570 );
2571
2572 assert_eq!(
2573 fingerprint.state,
2574 AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
2575 );
2576 assert!(fingerprint.content_hash.is_none());
2577 }
2578
2579 #[test]
2580 fn audit_context_fingerprint_rejects_file_changed_during_read() {
2581 let temp = tempfile::tempdir().expect("temp dir");
2582 let path = temp.path().join("pnpm-lock.yaml");
2583 fs::write(&path, "original contents\n").expect("original file");
2584
2585 let fingerprint = fingerprint_context_file_at_with_hooks(
2586 &path,
2587 "pnpm-lock.yaml",
2588 || {},
2589 || {
2590 OpenOptions::new()
2591 .write(true)
2592 .open(&path)
2593 .expect("open replacement")
2594 .set_len(1)
2595 .expect("truncate replacement");
2596 },
2597 );
2598
2599 assert_eq!(
2600 fingerprint.state,
2601 AuditContextPathState::Unreadable(CONTEXT_CHANGED_DURING_READ_STATE.to_string())
2602 );
2603 assert!(fingerprint.content_hash.is_none());
2604 }
2605
2606 #[cfg(unix)]
2607 #[test]
2608 fn unix_context_open_does_not_block_on_fifo() {
2609 let temp = tempfile::tempdir().expect("temp dir");
2610 let fifo = temp.path().join("pnpm-lock.yaml");
2611 let status = Command::new("mkfifo")
2612 .arg(&fifo)
2613 .status()
2614 .expect("run mkfifo");
2615 assert!(status.success(), "mkfifo must create the test pipe");
2616
2617 let fallback_fifo = fifo.clone();
2618 let fallback_writer = std::thread::spawn(move || {
2619 std::thread::sleep(std::time::Duration::from_secs(1));
2620 OpenOptions::new()
2621 .read(true)
2622 .write(true)
2623 .open(fallback_fifo)
2624 .expect("open fallback FIFO writer")
2625 });
2626 let started = std::time::Instant::now();
2627 let fingerprint = fingerprint_context_file_at(&fifo, "pnpm-lock.yaml");
2628 let elapsed = started.elapsed();
2629
2630 assert!(
2631 elapsed < std::time::Duration::from_millis(500),
2632 "nonblocking FIFO open took {elapsed:?}"
2633 );
2634 assert_eq!(
2635 fingerprint.state,
2636 AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
2637 );
2638 assert!(fingerprint.content_hash.is_none());
2639 drop(fallback_writer.join().expect("fallback writer"));
2640 }
2641
2642 #[cfg(unix)]
2643 #[test]
2644 fn audit_context_fingerprint_rejects_special_files_without_opening_them() {
2645 use std::os::unix::net::UnixListener;
2646
2647 let temp = tempfile::tempdir().expect("temp dir");
2648 let socket = temp.path().join("pnpm-lock.yaml");
2649 let _listener = UnixListener::bind(&socket).expect("unix socket");
2650
2651 let fingerprint = fingerprint_context_file_at(&socket, "pnpm-lock.yaml");
2652
2653 assert_eq!(
2654 fingerprint.state,
2655 AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
2656 );
2657 assert!(fingerprint.content_hash.is_none());
2658 }
2659}