Skip to main content

fallow_engine/
repo_refs.rs

1//! Engine-owned repository reference probes and temporary repo views.
2
3use 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
22/// Host directories shared with detached audit base views.
23pub 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/// Resolved base ref for changed-code audit.
70#[derive(Debug, Clone)]
71pub struct ResolvedAuditBase {
72    /// Git ref or SHA used for comparison.
73    pub git_ref: String,
74    /// Human-readable source of the resolved ref.
75    pub description: Option<String>,
76}
77
78/// Temporary detached worktree for comparing audit results against a base ref.
79#[derive(Debug)]
80pub struct TemporaryBaseWorktree {
81    repo_root: PathBuf,
82    path: PathBuf,
83}
84
85impl TemporaryBaseWorktree {
86    /// Create a detached base worktree for `base_ref`.
87    ///
88    /// # Errors
89    ///
90    /// Returns an engine error when the temp path cannot be generated, `git`
91    /// cannot be started, or the worktree cannot be created.
92    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    /// Path to the detached worktree.
103    #[must_use]
104    pub fn path(&self) -> &Path {
105        &self.path
106    }
107}
108
109/// Share dependency and generated context from the host checkout with a base view.
110pub 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/// Build a bounded fingerprint of the host context materialized into a base view.
138#[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
479/// Register a detached worktree without checking files out, then materialize
480/// the committed tree directly from Git objects.
481///
482/// This deliberately avoids Git's checkout pipeline. No checkout hook,
483/// smudge filter, process filter, line-ending conversion, or working-tree
484/// encoding is invoked. Regular files contain the raw committed blob bytes.
485///
486/// # Errors
487///
488/// Returns an engine error when the destination is not absolute, Git cannot
489/// create the administrative worktree, the tree contains an unsafe path, or
490/// an object cannot be materialized. A failed materialization removes both
491/// the worktree registration and its partial directory.
492pub 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/// Return whether a linked worktree was completely materialized by the raw
542/// object path used by [`create_detached_base_worktree`].
543///
544/// Reusable audit caches created by older versions have no marker and must be
545/// rebuilt once so smudged or checkout-generated contents are not reused.
546#[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
649/// Which committed-tree paths a base worktree actually needs on disk.
650///
651/// The raw object materialization deliberately bypasses git's checkout
652/// pipeline (no hooks, smudge filters, or line-ending conversion). Before
653/// that change the worktree checkout honored the host's sparse-checkout cone
654/// and, for a subdirectory analysis root, only the cone was ever read. The
655/// unscoped materialization reads EVERY blob in the commit instead: on a
656/// blobless partial clone (`actions/checkout` sets `--filter=blob:none`
657/// whenever `sparse-checkout` is set) each out-of-cone blob triggers a lazy
658/// promisor fetch via `git-remote-https`. For a large monorepo checked out
659/// sparsely to one subdirectory that turns a seconds-long snapshot into a
660/// fetch of the whole monorepo, which presents as `fallow audit` hanging to
661/// the CI timeout with `git` / `git-remote-https` orphans (issue #2615).
662///
663/// The scope restores the old working set without reintroducing checkout:
664/// - a subdirectory analysis root materializes only that subtree (plus
665///   top-level files and ancestor ignore files, so gitignore parity holds),
666/// - a repository-root run on a sparse checkout materializes the sparse cone
667///   (top-level files plus the listed cone directories),
668/// - otherwise everything is materialized as before.
669///
670/// Both probes fail open to full materialization: a probe error is at worst a
671/// slower snapshot, never a missing-file misattribution.
672struct MaterializationScope {
673    /// Forward-slash repo-relative analysis subdir (e.g. `apps/web`), or
674    /// `None` when the requested root is the repository top level.
675    subdir_prefix: Option<String>,
676    /// Cone-mode sparse directories (forward-slash, no trailing slash), or
677    /// `None` when sparse-checkout is off, non-cone, or unreadable.
678    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            // Top-level files and ancestor ignore files shape discovery of the
691            // subtree (root `.gitignore` applies hierarchically). They are few
692            // and already present in a sparse checkout, so keeping them is
693            // free and preserves ignore parity with a full snapshot.
694            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
711/// Forward-slash repo-relative path for scope matching, or `None` when the
712/// path is not valid UTF-8. Non-UTF-8 tree paths are rare; failing open keeps
713/// them materialized rather than risking a misattributed base snapshot.
714fn forward_slash_path(path: &Path) -> Option<String> {
715    let raw = path.to_str()?;
716    Some(raw.replace('\\', "/"))
717}
718
719/// True for an ignore/attributes file that governs `prefix` from an ancestor
720/// directory (including the repository root), e.g. `.gitignore` or
721/// `apps/.gitignore` for prefix `apps/web`.
722fn 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
741/// Repo-relative forward-slash subdir of the requested analysis root, or
742/// `None` when it is the repository top level (or the top level cannot be
743/// resolved, which fails open to full materialization).
744fn 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
760/// Cone-mode sparse-checkout directories of the host checkout, or `None` when
761/// sparse-checkout is off, non-cone, or unreadable (fail open to full).
762///
763/// `git sparse-checkout list` exits non-zero on a non-sparse worktree, which
764/// is the common full-clone case. Non-cone mode uses glob patterns that this
765/// matcher does not implement, so it also falls back to full materialization.
766fn 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        // Cone mode lists directories; a stray glob (non-cone residue) cannot
788        // be matched exactly, so fail open rather than under-materialize.
789        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(&current) {
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(&current).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/// Resolve the analysis root inside a detached base worktree.
1180///
1181/// This is the one implementation for `fallow audit`, `fallow security --base`
1182/// and the typed routes. Both sides of the prefix comparison are real paths,
1183/// because a caller can spell the root through a symbolic link (`/tmp` on
1184/// macOS resolves to `/private/tmp`) while git reports the resolved top level.
1185/// A comparison across the two path spaces fails, and the base snapshot then
1186/// covers the whole base worktree while the head snapshot stays scoped. Only
1187/// the relative remainder joins `base_worktree_root`, so no canonical spelling
1188/// reaches the result.
1189#[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/// Analysis root for a detached base worktree, and whether the base commit
1211/// contains it at all.
1212#[derive(Debug, Clone, PartialEq, Eq)]
1213pub enum BaseAnalysisRoot {
1214    /// The head analysis root maps onto a directory that the base commit
1215    /// contains, so the base snapshot is analyzed there.
1216    Present(PathBuf),
1217    /// The head analysis root maps onto a directory the base commit does not
1218    /// contain, such as a package added on the branch. Everything under it is
1219    /// new, so the base snapshot for that root is empty.
1220    NewInHead(PathBuf),
1221}
1222
1223/// Resolve the analysis root inside a detached base worktree and report
1224/// whether the base commit contains it.
1225///
1226/// A root that the base commit does not contain is the ordinary shape of
1227/// auditing a package added on the branch. Analyzing the whole base worktree
1228/// instead would compare a subdirectory head snapshot against a
1229/// whole-repository base snapshot, whose key spaces do not intersect, and
1230/// refusing the call would blame a `root` the caller spelled correctly.
1231#[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/// Auto-detect the base ref used by changed-code audit when no explicit base
1245/// or environment override is set.
1246///
1247/// The base is the `git merge-base` (fork point) against the branch's upstream
1248/// or the remote default, mirroring the `fallow hooks install --target git`
1249/// pre-commit hook (issue #242). Resolving to the merge-base SHA, rather than a
1250/// bare branch name, fixes the long-standing bug where the default branch was
1251/// discovered via `origin/HEAD` but returned as the bare name `main` (issue
1252/// #1168): git resolves a bare `main` to the LOCAL `refs/heads/main`, which is
1253/// stale on worktree checkouts cut from `origin/main`, so the audit diffed
1254/// every branch against an ancient base and false-failed the gate.
1255///
1256/// Resolution order:
1257/// 1. `@{upstream}` merge-base, so a branch forked off a non-default
1258///    integration branch compares against where it actually forked.
1259/// 2. Remote default (`origin/HEAD` -> `origin/main` -> `origin/master`)
1260///    merge-base. The remote-tracking ref refreshes on fetch, unlike a
1261///    long-stale local branch; the merge-base is also immune to an unfetched
1262///    `origin/main` in the false-fail direction.
1263/// 3. Local `main` / `master` when there is no `origin` remote, preserving the
1264///    historical behavior for air-gapped and local-only repositories.
1265///
1266/// A branch with no common ancestor with its base (a shallow clone, unrelated
1267/// history) falls back to the remote-tracking tip rather than failing the
1268/// detection outright.
1269#[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/// Short SHA for the current HEAD.
1310#[must_use]
1311pub fn short_head_sha(root: &Path) -> Option<String> {
1312    run_git(root, &["rev-parse", "--short", "HEAD"])
1313}
1314
1315/// Resolve a concrete `--changed-workspaces` ref for project-level next steps.
1316///
1317/// Returns `None` when the project has no workspaces, is not a git repository,
1318/// or has no resolvable remote default branch.
1319#[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/// Resolve a concrete `--changed-workspaces` ref using existing workspace data.
1326#[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/// Git identities for the current user in forms useful for self-routing.
1352///
1353/// Includes `user.email`, its local-part handle, a GitHub no-reply unwrapped
1354/// handle when applicable, and `user.name`. Missing config values are ignored.
1355#[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
1378/// The repository top level as a real path.
1379///
1380/// Git resolves symbolic links in the toplevel it reports on every host
1381/// checked, so the extra canonicalization is a by-construction guard rather
1382/// than a behavior change. It keeps both sides of the prefix comparison in
1383/// `base_analysis_root` in one path space.
1384fn 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
1425/// Compose the directory name for a base worktree taken at clock read `nanos`.
1426///
1427/// The pid stays the FIRST `-`-separated segment so the CLI orphan sweep keeps
1428/// parsing it. A process-global monotonic counter is the final segment: `nanos`
1429/// is NOT monotonic and repeats across threads, so two audits running
1430/// concurrently in one process could otherwise compose the same name and the
1431/// loser's `git worktree add` fails with "already exists". `nanos` is a
1432/// parameter so that collision is reproducible in a test without depending on
1433/// the host clock resolution.
1434fn 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    // Repository probes never consume input and must not retain an embedder's protocol stdin.
1448    command.stdin(Stdio::null()).arg("-C").arg(root);
1449    command
1450}
1451
1452/// Run `git <args>` in `root` and return trimmed, non-empty stdout, or `None`
1453/// on a non-zero exit, empty output, or non-UTF-8 output.
1454///
1455/// Trimming belongs to this contract: git terminates every line it prints, and
1456/// callers feed these values straight back to git as refs and compare them as
1457/// paths, where a trailing newline is rejected or silently mismatches. Non-UTF-8
1458/// output stays `None` rather than becoming a mangled ref or path.
1459fn 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    /// A repository on `main` with one seed commit and no remote.
1507    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    /// Add a tracked file, commit it, and return the new HEAD SHA.
1516    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    /// Concurrent callers whose clock reads land in the same tick must still
1535    /// each get a distinct name. Before the monotonic counter they composed the
1536    /// identical name, so the second `git worktree add` failed with "already
1537    /// exists" and the audit aborted with `FALLOW_AUDIT_BASE_WORKTREE_FAILED`.
1538    ///
1539    /// The tick is pinned rather than sampled: a real `SystemTime` read is fine
1540    /// enough on most hosts that the collision would surface only as a rare
1541    /// flake, which is exactly the failure this guards.
1542    #[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    /// The pid stays the first segment so the CLI orphan sweep keeps parsing it.
1571    #[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    /// A subdirectory analysis root only materializes its own subtree (plus
1586    /// top-level files). Without this, a sparse checkout of one subdirectory
1587    /// of a large monorepo materializes the whole monorepo, and on a blobless
1588    /// partial clone each out-of-cone blob triggers a lazy promisor fetch that
1589    /// presents as `fallow audit` hanging to the CI timeout (issue #2615).
1590    #[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    /// A repository-root run on a sparse checkout materializes the cone, not
1625    /// the whole monorepo. This is the `actions/checkout` sparse-checkout
1626    /// shape from issue #2615: cone mode lists the sparse directory, and the
1627    /// blobless partial clone has no out-of-cone blobs locally.
1628    #[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    /// Pure scope unit coverage: subdir runs keep their subtree plus top-level
1660    /// and ancestor ignore files; root sparse runs keep the cone; full clones
1661    /// keep everything.
1662    #[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    /// Regression for issue #2699: the detected ref is handed straight back to
1732    /// git as a diff target, so it must carry no line ending. Without the
1733    /// trimmed probe contract the upstream is `origin/main\n`, the merge-base
1734    /// call against it fails, and the detection degrades to the tip branch with
1735    /// an unusable ref.
1736    #[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    /// Regression for issue #2699 on the remote-default branch of the
1765    /// detection, where the line ending survives `strip_prefix` and reappears
1766    /// inside the composed `origin/<branch>` ref.
1767    #[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        // trunk == HEAD, so the merge-base is HEAD's own SHA. The bare branch
1814        // name `trunk` is never returned: it would resolve to a local ref.
1815        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    /// Regression for issue #1168: a worktree checkout whose local `main` is
1823    /// stale relative to a fresh `origin/main`. The base must be the fork point
1824    /// (merge-base with `origin/main`), NOT the stale local-`main` commit that
1825    /// the old bare-name resolution diffed against.
1826    #[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        // Cut a feature branch from the fresh origin tip using the raw SHA (no
1845        // upstream tracking), then leave local `main` behind at the stale commit.
1846        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        // Configure `origin` so refs/remotes/origin/* are recognized as
1868        // tracking refs and `--set-upstream-to` is accepted.
1869        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    /// When the remote default shares no history with HEAD (the merge-base
1924    /// failure a shallow clone also hits), auto-detect falls back to the
1925    /// remote-tracking ref tip rather than failing the detection. That tip is
1926    /// the only branch that returns a ref git composed rather than printed, so
1927    /// it also pins the trimming for issue #2699.
1928    #[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    /// The repository top level is compared as a path prefix, so a line ending
1955    /// on it makes every subdirectory root fall back to the whole base
1956    /// worktree instead of the matching subdirectory (issue #2699).
1957    #[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    /// A caller can spell the analysis root through a symbolic link, and git
1973    /// reports the resolved top level. The two path spaces must meet, or the
1974    /// prefix comparison fails and the base snapshot covers the whole base
1975    /// worktree while the head snapshot stays scoped (issue #2740).
1976    #[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    /// Auditing a package added on the branch is the ordinary case where the
1997    /// remapped root is absent from the base worktree. Consumers need that
1998    /// reported rather than validating the joined path and refusing the call
1999    /// (issue #2699).
2000    #[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}