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.trim());
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}"])
606        .map(|commit| commit.trim().to_owned())
607        .ok_or_else(|| {
608            EngineError::new(format!(
609                "could not resolve the commit for base ref `{base_ref}` after creating the worktree"
610            ))
611        })
612}
613
614fn populate_worktree_index(destination: &Path, commit: &str) -> EngineResult<()> {
615    let disabled_hooks_path = destination.join(".fallow-disabled-git-hooks");
616    let output = git_command(destination)
617        .env("GIT_CONFIG_COUNT", "2")
618        .env("GIT_CONFIG_KEY_0", "core.hooksPath")
619        .env("GIT_CONFIG_VALUE_0", disabled_hooks_path)
620        .env("GIT_CONFIG_KEY_1", "core.fsmonitor")
621        .env("GIT_CONFIG_VALUE_1", "false")
622        .args(["read-tree", "--reset", commit])
623        .output()
624        .map_err(|error| {
625            EngineError::new(format!("could not populate base worktree index: {error}"))
626        })?;
627    if !output.status.success() {
628        return Err(EngineError::new(format!(
629            "could not populate base worktree index: {}",
630            String::from_utf8_lossy(&output.stderr).trim()
631        )));
632    }
633    Ok(())
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637enum TreeEntryKind {
638    Regular,
639    Executable,
640    Symlink,
641    Gitlink,
642}
643
644#[derive(Debug)]
645struct TreeEntry {
646    kind: TreeEntryKind,
647    object_id: String,
648    path: PathBuf,
649}
650
651/// Which committed-tree paths a base worktree actually needs on disk.
652///
653/// The raw object materialization deliberately bypasses git's checkout
654/// pipeline (no hooks, smudge filters, or line-ending conversion). Before
655/// that change the worktree checkout honored the host's sparse-checkout cone
656/// and, for a subdirectory analysis root, only the cone was ever read. The
657/// unscoped materialization reads EVERY blob in the commit instead: on a
658/// blobless partial clone (`actions/checkout` sets `--filter=blob:none`
659/// whenever `sparse-checkout` is set) each out-of-cone blob triggers a lazy
660/// promisor fetch via `git-remote-https`. For a large monorepo checked out
661/// sparsely to one subdirectory that turns a seconds-long snapshot into a
662/// fetch of the whole monorepo, which presents as `fallow audit` hanging to
663/// the CI timeout with `git` / `git-remote-https` orphans (issue #2615).
664///
665/// The scope restores the old working set without reintroducing checkout:
666/// - a subdirectory analysis root materializes only that subtree (plus
667///   top-level files and ancestor ignore files, so gitignore parity holds),
668/// - a repository-root run on a sparse checkout materializes the sparse cone
669///   (top-level files plus the listed cone directories),
670/// - otherwise everything is materialized as before.
671///
672/// Both probes fail open to full materialization: a probe error is at worst a
673/// slower snapshot, never a missing-file misattribution.
674struct MaterializationScope {
675    /// Forward-slash repo-relative analysis subdir (e.g. `apps/web`), or
676    /// `None` when the requested root is the repository top level.
677    subdir_prefix: Option<String>,
678    /// Cone-mode sparse directories (forward-slash, no trailing slash), or
679    /// `None` when sparse-checkout is off, non-cone, or unreadable.
680    sparse_dirs: Option<Vec<String>>,
681}
682
683impl MaterializationScope {
684    fn should_materialize(&self, path: &Path) -> bool {
685        let Some(relative) = forward_slash_path(path) else {
686            return true;
687        };
688        if let Some(prefix) = self.subdir_prefix.as_deref() {
689            if relative == prefix || relative.starts_with(&format!("{prefix}/")) {
690                return true;
691            }
692            // Top-level files and ancestor ignore files shape discovery of the
693            // subtree (root `.gitignore` applies hierarchically). They are few
694            // and already present in a sparse checkout, so keeping them is
695            // free and preserves ignore parity with a full snapshot.
696            if !relative.contains('/') {
697                return true;
698            }
699            return is_ancestor_ignore_file(&relative, prefix);
700        }
701        if let Some(dirs) = self.sparse_dirs.as_deref() {
702            if !relative.contains('/') {
703                return true;
704            }
705            return dirs
706                .iter()
707                .any(|dir| relative == *dir || relative.starts_with(&format!("{dir}/")));
708        }
709        true
710    }
711}
712
713/// Forward-slash repo-relative path for scope matching, or `None` when the
714/// path is not valid UTF-8. Non-UTF-8 tree paths are rare; failing open keeps
715/// them materialized rather than risking a misattributed base snapshot.
716fn forward_slash_path(path: &Path) -> Option<String> {
717    let raw = path.to_str()?;
718    Some(raw.replace('\\', "/"))
719}
720
721/// True for an ignore/attributes file that governs `prefix` from an ancestor
722/// directory (including the repository root), e.g. `.gitignore` or
723/// `apps/.gitignore` for prefix `apps/web`.
724fn is_ancestor_ignore_file(relative: &str, prefix: &str) -> bool {
725    const IGNORE_FILES: &[&str] = &[".gitignore", ".gitattributes"];
726    let Some(file_name) = relative.rsplit('/').next() else {
727        return false;
728    };
729    if !IGNORE_FILES.contains(&file_name) {
730        return false;
731    }
732    let parent = relative.rsplit_once('/').map_or("", |(parent, _)| parent);
733    parent.is_empty() || prefix == parent || prefix.starts_with(&format!("{parent}/"))
734}
735
736fn materialization_scope(repo_root: &Path) -> MaterializationScope {
737    MaterializationScope {
738        subdir_prefix: analysis_subdir_prefix(repo_root),
739        sparse_dirs: sparse_cone_dirs(repo_root),
740    }
741}
742
743/// Repo-relative forward-slash subdir of the requested analysis root, or
744/// `None` when it is the repository top level (or the top level cannot be
745/// resolved, which fails open to full materialization).
746fn analysis_subdir_prefix(repo_root: &Path) -> Option<String> {
747    let toplevel = run_git(repo_root, &["rev-parse", "--show-toplevel"])?;
748    let toplevel = PathBuf::from(toplevel.trim());
749    let canonical_toplevel = dunce::canonicalize(&toplevel).unwrap_or(toplevel);
750    let canonical_root = dunce::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
751    let relative = canonical_root.strip_prefix(&canonical_toplevel).ok()?;
752    if relative.as_os_str().is_empty() {
753        return None;
754    }
755    let prefix = forward_slash_path(relative)?;
756    if prefix.is_empty() {
757        return None;
758    }
759    Some(prefix)
760}
761
762/// Cone-mode sparse-checkout directories of the host checkout, or `None` when
763/// sparse-checkout is off, non-cone, or unreadable (fail open to full).
764///
765/// `git sparse-checkout list` exits non-zero on a non-sparse worktree, which
766/// is the common full-clone case. Non-cone mode uses glob patterns that this
767/// matcher does not implement, so it also falls back to full materialization.
768fn sparse_cone_dirs(repo_root: &Path) -> Option<Vec<String>> {
769    if run_git(repo_root, &["config", "--get", "core.sparseCheckout"])?.trim() != "true" {
770        return None;
771    }
772    if run_git(repo_root, &["config", "--get", "core.sparseCheckoutCone"])?.trim() != "true" {
773        return None;
774    }
775    let output = git_command(repo_root)
776        .args(["sparse-checkout", "list"])
777        .output()
778        .ok()?;
779    if !output.status.success() {
780        return None;
781    }
782    let list = String::from_utf8(output.stdout).ok()?;
783    let mut dirs = Vec::new();
784    for line in list.lines() {
785        let pattern = line.trim().trim_matches('/');
786        if pattern.is_empty() {
787            continue;
788        }
789        // Cone mode lists directories; a stray glob (non-cone residue) cannot
790        // be matched exactly, so fail open rather than under-materialize.
791        if pattern.contains(['*', '?', '[', '!']) {
792            return None;
793        }
794        dirs.push(pattern.replace('\\', "/"));
795    }
796    Some(dirs)
797}
798
799fn materialize_committed_tree(
800    repo_root: &Path,
801    destination: &Path,
802    commit: &str,
803) -> EngineResult<()> {
804    let entries = committed_tree_entries(repo_root, commit)?;
805    let scope = materialization_scope(repo_root);
806    let entries: Vec<TreeEntry> = entries
807        .into_iter()
808        .filter(|entry| scope.should_materialize(&entry.path))
809        .collect();
810    let mut blobs = BatchBlobReader::spawn(repo_root)?;
811    let mut symlinks = Vec::new();
812
813    for entry in entries {
814        create_safe_parent_directories(destination, &entry.path)?;
815        let output_path = destination.join(&entry.path);
816        match entry.kind {
817            TreeEntryKind::Regular | TreeEntryKind::Executable => {
818                let mut file = OpenOptions::new()
819                    .create_new(true)
820                    .write(true)
821                    .open(&output_path)
822                    .map_err(|error| materialization_error(&entry.path, error))?;
823                blobs.copy_blob(&entry.object_id, &entry.path, &mut file)?;
824                set_regular_file_mode(&output_path, entry.kind == TreeEntryKind::Executable)?;
825            }
826            TreeEntryKind::Symlink => {
827                let target = blobs.read_blob(&entry.object_id, &entry.path)?;
828                symlinks.push((entry.path, target));
829            }
830            TreeEntryKind::Gitlink => {
831                fs::create_dir(&output_path)
832                    .map_err(|error| materialization_error(&entry.path, error))?;
833            }
834        }
835    }
836
837    blobs.finish()?;
838    for (path, target) in symlinks {
839        create_safe_parent_directories(destination, &path)?;
840        create_materialized_symlink(&destination.join(&path), &target)
841            .map_err(|error| materialization_error(&path, error))?;
842    }
843    Ok(())
844}
845
846fn committed_tree_entries(repo_root: &Path, commit: &str) -> EngineResult<Vec<TreeEntry>> {
847    let output = git_command(repo_root)
848        .args(["ls-tree", "-r", "-z", "--full-tree", commit])
849        .output()
850        .map_err(|error| EngineError::new(format!("could not read base commit tree: {error}")))?;
851    if !output.status.success() {
852        return Err(EngineError::new(format!(
853            "could not read base commit tree: {}",
854            String::from_utf8_lossy(&output.stderr).trim()
855        )));
856    }
857
858    output
859        .stdout
860        .split(|byte| *byte == 0)
861        .filter(|record| !record.is_empty())
862        .map(parse_tree_entry)
863        .collect()
864}
865
866fn parse_tree_entry(record: &[u8]) -> EngineResult<TreeEntry> {
867    let tab = record
868        .iter()
869        .position(|byte| *byte == b'\t')
870        .ok_or_else(|| EngineError::new("could not parse base commit tree entry without a path"))?;
871    let header = std::str::from_utf8(&record[..tab])
872        .map_err(|error| EngineError::new(format!("invalid Git tree header: {error}")))?;
873    let mut fields = header.split_ascii_whitespace();
874    let mode = fields.next().unwrap_or_default();
875    let object_type = fields.next().unwrap_or_default();
876    let object_id = fields.next().unwrap_or_default();
877    if fields.next().is_some() || object_id.is_empty() {
878        return Err(EngineError::new(format!(
879            "could not parse Git tree header `{header}`"
880        )));
881    }
882    let kind = match (mode, object_type) {
883        ("100644", "blob") => TreeEntryKind::Regular,
884        ("100755", "blob") => TreeEntryKind::Executable,
885        ("120000", "blob") => TreeEntryKind::Symlink,
886        ("160000", "commit") => TreeEntryKind::Gitlink,
887        _ => {
888            return Err(EngineError::new(format!(
889                "unsupported Git tree entry mode `{mode}` and type `{object_type}`"
890            )));
891        }
892    };
893    let path = git_path_from_bytes(&record[tab + 1..])?;
894    validate_materialized_path(&path)?;
895    Ok(TreeEntry {
896        kind,
897        object_id: object_id.to_owned(),
898        path,
899    })
900}
901
902#[cfg(unix)]
903#[expect(
904    clippy::unnecessary_wraps,
905    reason = "shared cross-platform signature; non-Unix path decoding is fallible"
906)]
907fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
908    use std::os::unix::ffi::OsStringExt as _;
909
910    Ok(std::ffi::OsString::from_vec(bytes.to_vec()).into())
911}
912
913#[cfg(not(unix))]
914fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
915    String::from_utf8(bytes.to_vec())
916        .map(PathBuf::from)
917        .map_err(|error| EngineError::new(format!("Git tree path is not valid UTF-8: {error}")))
918}
919
920fn validate_materialized_path(path: &Path) -> EngineResult<()> {
921    if path.as_os_str().is_empty() || path.is_absolute() {
922        return Err(unsafe_tree_path(path));
923    }
924
925    let mut saw_component = false;
926    for component in path.components() {
927        let Component::Normal(segment) = component else {
928            return Err(unsafe_tree_path(path));
929        };
930        saw_component = true;
931        if segment.to_str().is_some_and(is_git_admin_alias) {
932            return Err(unsafe_tree_path(path));
933        }
934    }
935    if !saw_component {
936        return Err(unsafe_tree_path(path));
937    }
938    Ok(())
939}
940
941fn is_git_admin_alias(segment: &str) -> bool {
942    let normalized = segment.trim_end_matches([' ', '.']).to_ascii_lowercase();
943    normalized == ".git" || normalized == "git~1"
944}
945
946fn unsafe_tree_path(path: &Path) -> EngineError {
947    EngineError::new(format!(
948        "refusing to materialize unsafe Git tree path `{}`",
949        path.display()
950    ))
951}
952
953fn create_safe_parent_directories(root: &Path, relative: &Path) -> EngineResult<()> {
954    let Some(parent) = relative.parent() else {
955        return Ok(());
956    };
957    let mut current = root.to_path_buf();
958    for component in parent.components() {
959        let Component::Normal(segment) = component else {
960            return Err(unsafe_tree_path(relative));
961        };
962        current.push(segment);
963        match fs::symlink_metadata(&current) {
964            Ok(metadata) if metadata.file_type().is_dir() => {}
965            Ok(_) => {
966                return Err(EngineError::new(format!(
967                    "refusing to materialize through non-directory path `{}`",
968                    current.display()
969                )));
970            }
971            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
972                fs::create_dir(&current).map_err(|error| materialization_error(relative, error))?;
973            }
974            Err(error) => return Err(materialization_error(relative, error)),
975        }
976    }
977    Ok(())
978}
979
980fn materialization_error(path: &Path, error: impl std::fmt::Display) -> EngineError {
981    EngineError::new(format!(
982        "could not materialize base commit path `{}`: {error}",
983        path.display()
984    ))
985}
986
987#[cfg(unix)]
988fn set_regular_file_mode(path: &Path, executable: bool) -> EngineResult<()> {
989    use std::os::unix::fs::PermissionsExt as _;
990
991    let mode = if executable { 0o755 } else { 0o644 };
992    let permissions = fs::Permissions::from_mode(mode);
993    fs::set_permissions(path, permissions).map_err(|error| materialization_error(path, error))
994}
995
996#[cfg(not(unix))]
997#[expect(
998    clippy::unnecessary_wraps,
999    reason = "shared cross-platform signature; Unix permission updates are fallible"
1000)]
1001fn set_regular_file_mode(_path: &Path, _executable: bool) -> EngineResult<()> {
1002    Ok(())
1003}
1004
1005#[cfg(unix)]
1006fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
1007    use std::os::unix::ffi::OsStringExt as _;
1008
1009    std::os::unix::fs::symlink(std::ffi::OsString::from_vec(target.to_vec()), path)
1010}
1011
1012#[cfg(windows)]
1013fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
1014    let target_path = PathBuf::from(String::from_utf8_lossy(target).into_owned());
1015    let resolved_target = path
1016        .parent()
1017        .map_or_else(|| target_path.clone(), |parent| parent.join(&target_path));
1018    let result = if resolved_target.is_dir() {
1019        std::os::windows::fs::symlink_dir(&target_path, path)
1020    } else {
1021        std::os::windows::fs::symlink_file(&target_path, path)
1022    };
1023    result.or_else(|_| fs::write(path, target))
1024}
1025
1026#[cfg(not(any(unix, windows)))]
1027fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
1028    fs::write(path, target)
1029}
1030
1031struct BatchBlobReader {
1032    child: Option<Child>,
1033    stdin: Option<ChildStdin>,
1034    stdout: BufReader<ChildStdout>,
1035}
1036
1037impl BatchBlobReader {
1038    fn spawn(repo_root: &Path) -> EngineResult<Self> {
1039        let mut command = git_command(repo_root);
1040        command
1041            .args(["cat-file", "--batch"])
1042            .stdin(Stdio::piped())
1043            .stdout(Stdio::piped())
1044            .stderr(Stdio::null());
1045        let mut child = command.spawn().map_err(|error| {
1046            EngineError::new(format!("could not start Git object reader: {error}"))
1047        })?;
1048        let stdin = child
1049            .stdin
1050            .take()
1051            .ok_or_else(|| EngineError::new("Git object reader has no stdin pipe"))?;
1052        let stdout = child
1053            .stdout
1054            .take()
1055            .ok_or_else(|| EngineError::new("Git object reader has no stdout pipe"))?;
1056        Ok(Self {
1057            child: Some(child),
1058            stdin: Some(stdin),
1059            stdout: BufReader::new(stdout),
1060        })
1061    }
1062
1063    fn copy_blob(&mut self, object_id: &str, path: &Path, target: &mut File) -> EngineResult<()> {
1064        let size = self.request_blob(object_id, path)?;
1065        let copied = std::io::copy(&mut self.stdout.by_ref().take(size), target)
1066            .map_err(|error| materialization_error(path, error))?;
1067        if copied != size {
1068            return Err(EngineError::new(format!(
1069                "Git object reader returned {copied} of {size} bytes for `{}`",
1070                path.display()
1071            )));
1072        }
1073        self.consume_blob_terminator(path)
1074    }
1075
1076    fn read_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<Vec<u8>> {
1077        let size = self.request_blob(object_id, path)?;
1078        let size = usize::try_from(size).map_err(|error| materialization_error(path, error))?;
1079        let mut bytes = vec![0; size];
1080        self.stdout
1081            .read_exact(&mut bytes)
1082            .map_err(|error| materialization_error(path, error))?;
1083        self.consume_blob_terminator(path)?;
1084        Ok(bytes)
1085    }
1086
1087    fn request_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<u64> {
1088        let stdin = self
1089            .stdin
1090            .as_mut()
1091            .ok_or_else(|| EngineError::new("Git object reader stdin is closed"))?;
1092        writeln!(stdin, "{object_id}").map_err(|error| materialization_error(path, error))?;
1093        stdin
1094            .flush()
1095            .map_err(|error| materialization_error(path, error))?;
1096
1097        let mut header = Vec::new();
1098        self.stdout
1099            .read_until(b'\n', &mut header)
1100            .map_err(|error| materialization_error(path, error))?;
1101        let header = std::str::from_utf8(&header)
1102            .map_err(|error| materialization_error(path, error))?
1103            .trim_end();
1104        let mut fields = header.split_ascii_whitespace();
1105        let returned_id = fields.next().unwrap_or_default();
1106        let object_type = fields.next().unwrap_or_default();
1107        let size = fields.next().unwrap_or_default();
1108        if returned_id != object_id || object_type != "blob" || fields.next().is_some() {
1109            return Err(EngineError::new(format!(
1110                "unexpected Git object response `{header}` for `{}`",
1111                path.display()
1112            )));
1113        }
1114        size.parse::<u64>()
1115            .map_err(|error| materialization_error(path, error))
1116    }
1117
1118    fn consume_blob_terminator(&mut self, path: &Path) -> EngineResult<()> {
1119        let mut terminator = [0; 1];
1120        self.stdout
1121            .read_exact(&mut terminator)
1122            .map_err(|error| materialization_error(path, error))?;
1123        if terminator != *b"\n" {
1124            return Err(EngineError::new(format!(
1125                "Git object response for `{}` had no terminator",
1126                path.display()
1127            )));
1128        }
1129        Ok(())
1130    }
1131
1132    fn finish(mut self) -> EngineResult<()> {
1133        self.stdin.take();
1134        let status = self
1135            .child
1136            .take()
1137            .ok_or_else(|| EngineError::new("Git object reader is already closed"))?
1138            .wait()
1139            .map_err(|error| {
1140                EngineError::new(format!("could not wait for Git object reader: {error}"))
1141            })?;
1142        if !status.success() {
1143            return Err(EngineError::new(format!(
1144                "Git object reader exited with status {status}"
1145            )));
1146        }
1147        Ok(())
1148    }
1149}
1150
1151impl Drop for BatchBlobReader {
1152    fn drop(&mut self) {
1153        self.stdin.take();
1154        if let Some(mut child) = self.child.take() {
1155            let _ = child.kill();
1156            let _ = child.wait();
1157        }
1158    }
1159}
1160
1161fn remove_registered_worktree(repo_root: &Path, destination: &Path) {
1162    let _ = git_command(repo_root)
1163        .args(["worktree", "remove", "--force"])
1164        .arg(destination)
1165        .output();
1166}
1167
1168impl Drop for TemporaryBaseWorktree {
1169    fn drop(&mut self) {
1170        let mut command = git_command(&self.repo_root);
1171        command
1172            .arg("worktree")
1173            .arg("remove")
1174            .arg("--force")
1175            .arg(&self.path);
1176        let _ = command.output();
1177        let _ = std::fs::remove_dir_all(&self.path);
1178    }
1179}
1180
1181/// Resolve the analysis root inside a detached base worktree.
1182#[must_use]
1183pub fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
1184    let Some(git_root) = git_toplevel(current_root) else {
1185        return base_worktree_root.to_path_buf();
1186    };
1187    let current_root =
1188        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
1189    match current_root.strip_prefix(&git_root) {
1190        Ok(relative) => base_worktree_root.join(relative),
1191        Err(_) => base_worktree_root.to_path_buf(),
1192    }
1193}
1194
1195/// Auto-detect the base ref used by changed-code audit.
1196#[must_use]
1197pub fn auto_detect_audit_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
1198    if let Some(upstream) = git_upstream_ref(root) {
1199        if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
1200            return Some(ResolvedAuditBase {
1201                git_ref: sha,
1202                description: Some(format!("merge-base with {upstream}")),
1203            });
1204        }
1205        return Some(ResolvedAuditBase {
1206            description: Some(format!("{upstream} (tip)")),
1207            git_ref: upstream,
1208        });
1209    }
1210
1211    if let Some(remote_ref) = detect_remote_default_ref(root) {
1212        if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
1213            return Some(ResolvedAuditBase {
1214                git_ref: sha,
1215                description: Some(format!("merge-base with {remote_ref}")),
1216            });
1217        }
1218        return Some(ResolvedAuditBase {
1219            description: Some(format!("{remote_ref} (tip)")),
1220            git_ref: remote_ref,
1221        });
1222    }
1223
1224    for candidate in ["main", "master"] {
1225        if git_ref_exists(root, candidate) {
1226            return Some(ResolvedAuditBase {
1227                git_ref: candidate.to_string(),
1228                description: Some(format!("local {candidate}")),
1229            });
1230        }
1231    }
1232
1233    None
1234}
1235
1236/// Short SHA for the current HEAD.
1237#[must_use]
1238pub fn short_head_sha(root: &Path) -> Option<String> {
1239    run_git(root, &["rev-parse", "--short", "HEAD"])
1240        .map(|value| value.trim().to_owned())
1241        .filter(|value| !value.is_empty())
1242}
1243
1244/// Resolve a concrete `--changed-workspaces` ref for project-level next steps.
1245///
1246/// Returns `None` when the project has no workspaces, is not a git repository,
1247/// or has no resolvable remote default branch.
1248#[must_use]
1249pub fn default_workspace_ref(root: &Path) -> Option<String> {
1250    let workspaces = crate::discover::discover_workspace_packages(root);
1251    default_workspace_ref_for_workspaces(root, &workspaces)
1252}
1253
1254/// Resolve a concrete `--changed-workspaces` ref using existing workspace data.
1255#[must_use]
1256pub fn default_workspace_ref_for_workspaces(
1257    root: &Path,
1258    workspaces: &[WorkspaceInfo],
1259) -> Option<String> {
1260    if workspaces.is_empty() || !crate::churn::is_git_repo(root) {
1261        return None;
1262    }
1263    if let Some(reference) = run_git(
1264        root,
1265        &[
1266            "symbolic-ref",
1267            "--quiet",
1268            "--short",
1269            "refs/remotes/origin/HEAD",
1270        ],
1271    ) {
1272        let reference = reference.trim();
1273        if !reference.is_empty() {
1274            return Some(reference.to_owned());
1275        }
1276    }
1277    ["origin/main", "origin/master"]
1278        .into_iter()
1279        .find(|candidate| git_ref_exists(root, candidate))
1280        .map(str::to_owned)
1281}
1282
1283/// Git identities for the current user in forms useful for self-routing.
1284///
1285/// Includes `user.email`, its local-part handle, a GitHub no-reply unwrapped
1286/// handle when applicable, and `user.name`. Missing config values are ignored.
1287#[must_use]
1288pub fn current_user_identities(root: &Path) -> Vec<String> {
1289    let mut ids = Vec::new();
1290    if let Some(email) = read_git_config(root, "user.email") {
1291        if let Some((local, _)) = email.split_once('@') {
1292            ids.push(local.rsplit('+').next().unwrap_or(local).to_owned());
1293        }
1294        ids.push(email);
1295    }
1296    if let Some(name) = read_git_config(root, "user.name") {
1297        ids.push(name);
1298    }
1299    ids
1300}
1301
1302fn read_git_config(root: &Path, key: &str) -> Option<String> {
1303    let value = run_git(root, &["config", "--get", key])?;
1304    let trimmed = value.trim();
1305    (!trimmed.is_empty()).then(|| trimmed.to_owned())
1306}
1307
1308fn git_ref_exists(root: &Path, reference: &str) -> bool {
1309    run_git(root, &["rev-parse", "--verify", "--quiet", reference]).is_some()
1310}
1311
1312fn git_toplevel(root: &Path) -> Option<PathBuf> {
1313    run_git(root, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
1314}
1315
1316fn git_upstream_ref(root: &Path) -> Option<String> {
1317    run_git(
1318        root,
1319        &[
1320            "rev-parse",
1321            "--abbrev-ref",
1322            "--symbolic-full-name",
1323            "@{upstream}",
1324        ],
1325    )
1326}
1327
1328fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
1329    run_git(root, &["merge-base", a, b])
1330}
1331
1332fn detect_remote_default_ref(root: &Path) -> Option<String> {
1333    if let Some(full_ref) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
1334        && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
1335    {
1336        return Some(format!("origin/{branch}"));
1337    }
1338    ["origin/main", "origin/master"]
1339        .into_iter()
1340        .find(|candidate| git_ref_exists(root, candidate))
1341        .map(str::to_string)
1342}
1343
1344fn base_worktree_path() -> EngineResult<PathBuf> {
1345    let nanos = SystemTime::now()
1346        .duration_since(SystemTime::UNIX_EPOCH)
1347        .map_err(|err| EngineError::new(format!("system clock before unix epoch: {err}")))?
1348        .as_nanos();
1349    Ok(std::env::temp_dir().join(base_worktree_name(nanos)))
1350}
1351
1352/// Compose the directory name for a base worktree taken at clock read `nanos`.
1353///
1354/// The pid stays the FIRST `-`-separated segment so the CLI orphan sweep keeps
1355/// parsing it. A process-global monotonic counter is the final segment: `nanos`
1356/// is NOT monotonic and repeats across threads, so two audits running
1357/// concurrently in one process could otherwise compose the same name and the
1358/// loser's `git worktree add` fails with "already exists". `nanos` is a
1359/// parameter so that collision is reproducible in a test without depending on
1360/// the host clock resolution.
1361fn base_worktree_name(nanos: u128) -> String {
1362    static SEQ: AtomicU64 = AtomicU64::new(0);
1363    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
1364    format!("fallow-audit-base-{}-{nanos}-{seq}", std::process::id())
1365}
1366
1367#[expect(
1368    clippy::disallowed_methods,
1369    reason = "canonical engine-owned git spawn wrapper for repository refs"
1370)]
1371fn git_command(root: &Path) -> Command {
1372    let mut command = Command::new("git");
1373    crate::changed_files::clear_ambient_git_env(&mut command);
1374    // Repository probes never consume input and must not retain an embedder's protocol stdin.
1375    command.stdin(Stdio::null()).arg("-C").arg(root);
1376    command
1377}
1378
1379fn run_git(root: &Path, args: &[&str]) -> Option<String> {
1380    let output = git_command(root).args(args).output().ok()?;
1381    if !output.status.success() {
1382        return None;
1383    }
1384    String::from_utf8(output.stdout).ok()
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389    use std::fs;
1390    use std::path::PathBuf;
1391    use std::process::Command;
1392
1393    use super::*;
1394
1395    fn git(root: &Path, args: &[&str]) -> String {
1396        let output = Command::new("git")
1397            .args(args)
1398            .current_dir(root)
1399            .env_remove("GIT_DIR")
1400            .env_remove("GIT_WORK_TREE")
1401            .output()
1402            .expect("git command starts");
1403        assert!(
1404            output.status.success(),
1405            "git {args:?} failed: {}",
1406            String::from_utf8_lossy(&output.stderr)
1407        );
1408        String::from_utf8_lossy(&output.stdout).trim().to_owned()
1409    }
1410
1411    fn init_repo(root: &Path) {
1412        fs::create_dir_all(root).expect("create repo");
1413        git(root, &["init", "-b", "main"]);
1414        git(root, &["config", "user.name", "Test User"]);
1415        git(root, &["config", "user.email", "test@example.com"]);
1416        git(root, &["config", "commit.gpgsign", "false"]);
1417    }
1418
1419    fn commit_all(root: &Path, message: &str) {
1420        git(root, &["add", "."]);
1421        git(root, &["commit", "-m", message]);
1422    }
1423
1424    #[cfg(unix)]
1425    fn write_executable(path: &Path, source: &str) {
1426        use std::os::unix::fs::PermissionsExt as _;
1427
1428        fs::write(path, source).expect("write executable");
1429        let mut permissions = fs::metadata(path)
1430            .expect("executable metadata")
1431            .permissions();
1432        permissions.set_mode(0o755);
1433        fs::set_permissions(path, permissions).expect("set executable mode");
1434    }
1435
1436    /// Concurrent callers whose clock reads land in the same tick must still
1437    /// each get a distinct name. Before the monotonic counter they composed the
1438    /// identical name, so the second `git worktree add` failed with "already
1439    /// exists" and the audit aborted with `FALLOW_AUDIT_BASE_WORKTREE_FAILED`.
1440    ///
1441    /// The tick is pinned rather than sampled: a real `SystemTime` read is fine
1442    /// enough on most hosts that the collision would surface only as a rare
1443    /// flake, which is exactly the failure this guards.
1444    #[test]
1445    fn base_worktree_names_are_unique_when_the_clock_read_repeats() {
1446        const N: usize = 64;
1447        const SAME_TICK: u128 = 1_788_187_156_297_209_000;
1448
1449        let barrier = std::sync::Barrier::new(N);
1450        let names = std::sync::Mutex::new(Vec::with_capacity(N));
1451        std::thread::scope(|scope| {
1452            for _ in 0..N {
1453                let barrier = &barrier;
1454                let names = &names;
1455                scope.spawn(move || {
1456                    barrier.wait();
1457                    names
1458                        .lock()
1459                        .expect("names lock")
1460                        .push(base_worktree_name(SAME_TICK));
1461                });
1462            }
1463        });
1464
1465        let mut names = names.into_inner().expect("names lock");
1466        assert_eq!(names.len(), N);
1467        names.sort();
1468        names.dedup();
1469        assert_eq!(names.len(), N, "base worktree names collided");
1470    }
1471
1472    /// The pid stays the first segment so the CLI orphan sweep keeps parsing it.
1473    #[test]
1474    fn base_worktree_path_keeps_the_pid_as_the_first_segment() {
1475        let path = base_worktree_path().expect("path should build");
1476        let name = path
1477            .file_name()
1478            .and_then(|name| name.to_str())
1479            .expect("worktree name should be utf-8");
1480        let pid = name
1481            .strip_prefix("fallow-audit-base-")
1482            .and_then(|rest| rest.split('-').next())
1483            .expect("pid segment should be present");
1484        assert_eq!(pid, std::process::id().to_string());
1485    }
1486
1487    /// A subdirectory analysis root only materializes its own subtree (plus
1488    /// top-level files). Without this, a sparse checkout of one subdirectory
1489    /// of a large monorepo materializes the whole monorepo, and on a blobless
1490    /// partial clone each out-of-cone blob triggers a lazy promisor fetch that
1491    /// presents as `fallow audit` hanging to the CI timeout (issue #2615).
1492    #[test]
1493    fn detached_worktree_from_a_subdir_skips_sibling_subtrees() {
1494        let temp = tempfile::tempdir().expect("temp dir");
1495        let repo = temp.path().join("repo");
1496        init_repo(&repo);
1497        fs::create_dir_all(repo.join("sub")).expect("create sub dir");
1498        fs::create_dir_all(repo.join("big")).expect("create big dir");
1499        fs::write(repo.join("sub/a.ts"), "export const a = 1;\n").expect("write sub file");
1500        fs::write(repo.join("big/b.ts"), "export const b = 1;\n").expect("write big file");
1501        fs::write(repo.join("top.ts"), "export const top = 1;\n").expect("write top file");
1502        commit_all(&repo, "initial");
1503
1504        let destination = temp.path().join("base");
1505        create_detached_base_worktree(&repo.join("sub"), &destination, "HEAD")
1506            .expect("base worktree should be created");
1507
1508        assert!(
1509            destination.join("sub/a.ts").is_file(),
1510            "the requested subtree must be materialized"
1511        );
1512        assert!(
1513            destination.join("top.ts").is_file(),
1514            "top-level files shape subdir discovery and stay materialized"
1515        );
1516        assert!(
1517            !destination.join("big/b.ts").exists(),
1518            "sibling subtrees must not be materialized: {}",
1519            destination.join("big/b.ts").display()
1520        );
1521
1522        remove_registered_worktree(&repo, &destination);
1523        let _ = fs::remove_dir_all(&destination);
1524    }
1525
1526    /// A repository-root run on a sparse checkout materializes the cone, not
1527    /// the whole monorepo. This is the `actions/checkout` sparse-checkout
1528    /// shape from issue #2615: cone mode lists the sparse directory, and the
1529    /// blobless partial clone has no out-of-cone blobs locally.
1530    #[test]
1531    fn detached_worktree_at_the_root_respects_the_sparse_cone() {
1532        let temp = tempfile::tempdir().expect("temp dir");
1533        let repo = temp.path().join("repo");
1534        init_repo(&repo);
1535        fs::create_dir_all(repo.join("sub")).expect("create sub dir");
1536        fs::create_dir_all(repo.join("big")).expect("create big dir");
1537        fs::write(repo.join("sub/a.ts"), "export const a = 1;\n").expect("write sub file");
1538        fs::write(repo.join("big/b.ts"), "export const b = 1;\n").expect("write big file");
1539        commit_all(&repo, "initial");
1540        git(&repo, &["sparse-checkout", "init", "--cone"]);
1541        git(&repo, &["sparse-checkout", "set", "sub"]);
1542
1543        let destination = temp.path().join("base");
1544        create_detached_base_worktree(&repo, &destination, "HEAD")
1545            .expect("base worktree should be created");
1546
1547        assert!(
1548            destination.join("sub/a.ts").is_file(),
1549            "the sparse cone must be materialized"
1550        );
1551        assert!(
1552            !destination.join("big/b.ts").exists(),
1553            "paths outside the sparse cone must not be materialized: {}",
1554            destination.join("big/b.ts").display()
1555        );
1556
1557        remove_registered_worktree(&repo, &destination);
1558        let _ = fs::remove_dir_all(&destination);
1559    }
1560
1561    /// Pure scope unit coverage: subdir runs keep their subtree plus top-level
1562    /// and ancestor ignore files; root sparse runs keep the cone; full clones
1563    /// keep everything.
1564    #[test]
1565    fn materialization_scope_filters_to_the_needed_working_set() {
1566        let subdir = MaterializationScope {
1567            subdir_prefix: Some("apps/web".to_string()),
1568            sparse_dirs: None,
1569        };
1570        assert!(subdir.should_materialize(Path::new("apps/web/a.ts")));
1571        assert!(subdir.should_materialize(Path::new("top.ts")));
1572        assert!(subdir.should_materialize(Path::new(".gitignore")));
1573        assert!(subdir.should_materialize(Path::new("apps/.gitignore")));
1574        assert!(!subdir.should_materialize(Path::new("apps/other/b.ts")));
1575        assert!(!subdir.should_materialize(Path::new("apps/.gitignore.bak")));
1576
1577        let sparse = MaterializationScope {
1578            subdir_prefix: None,
1579            sparse_dirs: Some(vec!["apps/web".to_string()]),
1580        };
1581        assert!(sparse.should_materialize(Path::new("apps/web/a.ts")));
1582        assert!(sparse.should_materialize(Path::new("top.ts")));
1583        assert!(!sparse.should_materialize(Path::new("apps/other/b.ts")));
1584
1585        let full = MaterializationScope {
1586            subdir_prefix: None,
1587            sparse_dirs: None,
1588        };
1589        assert!(full.should_materialize(Path::new("apps/other/b.ts")));
1590    }
1591
1592    #[test]
1593    fn default_workspace_ref_skips_projects_without_workspaces() {
1594        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[]).is_none());
1595    }
1596
1597    #[test]
1598    fn default_workspace_ref_skips_non_git_workspace_projects() {
1599        let workspace = WorkspaceInfo {
1600            root: PathBuf::from("/repo/packages/app"),
1601            name: "app".to_owned(),
1602            is_internal_dependency: false,
1603        };
1604
1605        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[workspace]).is_none());
1606    }
1607
1608    #[test]
1609    fn current_user_identities_empty_when_git_config_is_unavailable() {
1610        assert!(current_user_identities(Path::new("/repo")).is_empty());
1611    }
1612
1613    #[test]
1614    fn short_head_sha_omits_git_line_ending() {
1615        let temp = tempfile::tempdir().expect("temp dir");
1616        let repo = temp.path().join("repo");
1617        init_repo(&repo);
1618        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1619        commit_all(&repo, "initial");
1620
1621        let sha = short_head_sha(&repo).expect("HEAD sha");
1622        assert_eq!(sha, sha.trim());
1623        assert!(!sha.is_empty());
1624    }
1625
1626    #[cfg(unix)]
1627    #[test]
1628    fn temporary_base_worktree_does_not_run_post_checkout_hook() {
1629        let temp = tempfile::tempdir().expect("temp dir");
1630        let repo = temp.path().join("repo");
1631        init_repo(&repo);
1632        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1633        commit_all(&repo, "initial");
1634
1635        let sentinel = temp.path().join("post-checkout-ran");
1636        write_executable(
1637            &repo.join(".git/hooks/post-checkout"),
1638            &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
1639        );
1640
1641        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1642            .expect("temporary worktree should be created");
1643
1644        assert_eq!(
1645            fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
1646            "committed\n"
1647        );
1648        assert!(
1649            !sentinel.exists(),
1650            "creating a base view must not execute post-checkout hooks"
1651        );
1652    }
1653
1654    #[cfg(unix)]
1655    #[test]
1656    fn temporary_base_worktree_does_not_run_post_index_change_hook() {
1657        let temp = tempfile::tempdir().expect("temp dir");
1658        let repo = temp.path().join("repo");
1659        init_repo(&repo);
1660        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1661        commit_all(&repo, "initial");
1662
1663        let sentinel = temp.path().join("post-index-change-ran");
1664        write_executable(
1665            &repo.join(".git/hooks/post-index-change"),
1666            &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
1667        );
1668
1669        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1670            .expect("temporary worktree should be created");
1671
1672        assert_eq!(
1673            fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
1674            "committed\n"
1675        );
1676        assert!(
1677            !sentinel.exists(),
1678            "creating a base view must not execute post-index-change hooks"
1679        );
1680    }
1681
1682    #[cfg(unix)]
1683    #[test]
1684    fn temporary_base_worktree_does_not_run_smudge_filter() {
1685        let temp = tempfile::tempdir().expect("temp dir");
1686        let repo = temp.path().join("repo");
1687        init_repo(&repo);
1688        fs::write(
1689            repo.join(".gitattributes"),
1690            "filtered.txt filter=sentinel\n",
1691        )
1692        .expect("write attributes");
1693        fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
1694        commit_all(&repo, "initial");
1695
1696        let sentinel = temp.path().join("smudge-ran");
1697        let filter = temp.path().join("smudge-filter.sh");
1698        write_executable(
1699            &filter,
1700            &format!(
1701                "#!/bin/sh\nprintf ran > '{}'\ncat >/dev/null\nprintf 'smudged bytes\\n'\n",
1702                sentinel.display()
1703            ),
1704        );
1705        git(
1706            &repo,
1707            &[
1708                "config",
1709                "filter.sentinel.smudge",
1710                filter.to_str().expect("filter path is UTF-8"),
1711            ],
1712        );
1713
1714        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1715            .expect("temporary worktree should be created");
1716
1717        assert_eq!(
1718            fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
1719            b"committed raw bytes\n"
1720        );
1721        assert!(
1722            !sentinel.exists(),
1723            "creating a base view must not execute smudge filters"
1724        );
1725    }
1726
1727    #[cfg(unix)]
1728    #[test]
1729    fn temporary_base_worktree_does_not_start_process_filter() {
1730        let temp = tempfile::tempdir().expect("temp dir");
1731        let repo = temp.path().join("repo");
1732        init_repo(&repo);
1733        fs::write(
1734            repo.join(".gitattributes"),
1735            "filtered.txt filter=sentinel\n",
1736        )
1737        .expect("write attributes");
1738        fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
1739        commit_all(&repo, "initial");
1740
1741        let sentinel = temp.path().join("process-filter-ran");
1742        let filter = temp.path().join("process-filter.sh");
1743        write_executable(
1744            &filter,
1745            &format!("#!/bin/sh\nprintf ran > '{}'\nexit 1\n", sentinel.display()),
1746        );
1747        git(
1748            &repo,
1749            &[
1750                "config",
1751                "filter.sentinel.process",
1752                filter.to_str().expect("filter path is UTF-8"),
1753            ],
1754        );
1755
1756        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1757            .expect("temporary worktree should be created");
1758
1759        assert_eq!(
1760            fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
1761            b"committed raw bytes\n"
1762        );
1763        assert!(
1764            !sentinel.exists(),
1765            "creating a base view must not start process filters"
1766        );
1767    }
1768
1769    #[test]
1770    fn failed_registration_does_not_remove_existing_worktree() {
1771        let temp = tempfile::tempdir().expect("temp dir");
1772        let repo = temp.path().join("repo");
1773        init_repo(&repo);
1774        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1775        commit_all(&repo, "initial");
1776        let destination = temp.path().join("base");
1777
1778        create_detached_base_worktree(&repo, &destination, "HEAD")
1779            .expect("first worktree should be created");
1780        let second = create_detached_base_worktree(&repo, &destination, "HEAD");
1781
1782        assert!(second.is_err(), "duplicate destination must fail");
1783        assert!(
1784            destination.join("tracked.txt").is_file(),
1785            "failed registration must not remove the existing worktree"
1786        );
1787        assert_eq!(git(&destination, &["rev-parse", "HEAD"]).len(), 40);
1788
1789        remove_registered_worktree(&repo, &destination);
1790        let _ = fs::remove_dir_all(destination);
1791    }
1792
1793    #[cfg(unix)]
1794    #[test]
1795    fn temporary_base_worktree_preserves_modes_symlinks_and_gitlinks() {
1796        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1797
1798        let temp = tempfile::tempdir().expect("temp dir");
1799        let repo = temp.path().join("repo");
1800        init_repo(&repo);
1801        fs::write(repo.join("regular.txt"), "regular\n").expect("write regular file");
1802        let executable = repo.join("run.sh");
1803        fs::write(&executable, "#!/bin/sh\nexit 0\n").expect("write executable");
1804        let mut permissions = fs::metadata(&executable)
1805            .expect("executable metadata")
1806            .permissions();
1807        permissions.set_mode(0o755);
1808        fs::set_permissions(&executable, permissions).expect("set executable mode");
1809        std::os::unix::fs::symlink("regular.txt", repo.join("regular-link"))
1810            .expect("create symlink");
1811        commit_all(&repo, "files");
1812
1813        let gitlink_commit = git(&repo, &["rev-parse", "HEAD"]);
1814        git(
1815            &repo,
1816            &[
1817                "update-index",
1818                "--add",
1819                "--cacheinfo",
1820                &format!("160000,{gitlink_commit},vendor/submodule"),
1821            ],
1822        );
1823        git(&repo, &["commit", "-m", "gitlink"]);
1824
1825        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1826            .expect("temporary worktree should be created");
1827        let regular_mode = fs::metadata(worktree.path().join("regular.txt"))
1828            .expect("regular metadata")
1829            .mode();
1830        let executable_mode = fs::metadata(worktree.path().join("run.sh"))
1831            .expect("executable metadata")
1832            .mode();
1833
1834        assert_eq!(regular_mode & 0o111, 0);
1835        assert_ne!(executable_mode & 0o111, 0);
1836        assert_eq!(
1837            fs::read_link(worktree.path().join("regular-link")).expect("read symlink"),
1838            PathBuf::from("regular.txt")
1839        );
1840        let gitlink = worktree.path().join("vendor/submodule");
1841        assert!(gitlink.is_dir(), "gitlink must materialize as a directory");
1842        assert!(
1843            fs::read_dir(gitlink)
1844                .expect("read gitlink directory")
1845                .next()
1846                .is_none(),
1847            "an uninitialized gitlink directory must remain empty"
1848        );
1849        assert!(
1850            git(
1851                worktree.path(),
1852                &["ls-files", "--stage", "vendor/submodule"]
1853            )
1854            .starts_with(&format!("160000 {gitlink_commit} 0\t")),
1855            "the linked worktree index must retain the gitlink object id"
1856        );
1857
1858        let path = worktree.path().to_path_buf();
1859        drop(worktree);
1860        assert!(!path.exists(), "temporary worktree must clean up on drop");
1861    }
1862
1863    #[test]
1864    fn materialized_tree_paths_reject_traversal_and_git_admin_aliases() {
1865        for path in [
1866            Path::new("../escape"),
1867            Path::new("/absolute"),
1868            Path::new(".git/config"),
1869            Path::new("nested/.GIT/config"),
1870            Path::new("nested/.git. /config"),
1871            Path::new("nested/git~1/config"),
1872        ] {
1873            assert!(
1874                validate_materialized_path(path).is_err(),
1875                "unsafe path should be rejected: {}",
1876                path.display()
1877            );
1878        }
1879        assert!(validate_materialized_path(Path::new("src/.github/file.ts")).is_ok());
1880    }
1881
1882    #[cfg(unix)]
1883    #[test]
1884    fn parent_directory_creation_refuses_symlink_traversal() {
1885        let temp = tempfile::tempdir().expect("temp dir");
1886        let root = temp.path().join("root");
1887        let outside = temp.path().join("outside");
1888        fs::create_dir(&root).expect("create root");
1889        fs::create_dir(&outside).expect("create outside");
1890        std::os::unix::fs::symlink(&outside, root.join("link")).expect("create parent symlink");
1891
1892        let result = create_safe_parent_directories(&root, Path::new("link/escaped.txt"));
1893
1894        assert!(result.is_err(), "symlink parent must be rejected");
1895        assert!(!outside.join("escaped.txt").exists());
1896    }
1897
1898    #[test]
1899    fn audit_context_fingerprint_tracks_bounded_lockfiles_and_markers() {
1900        let temp = tempfile::tempdir().expect("temp dir");
1901        let root = temp.path();
1902        fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 9\n").expect("lockfile");
1903        fs::create_dir(root.join("node_modules")).expect("node_modules");
1904        fs::write(
1905            root.join("node_modules/.modules.yaml"),
1906            "layoutVersion: 5\n",
1907        )
1908        .expect("node marker");
1909
1910        let first = audit_materialized_context_fingerprint(root);
1911        let unchanged = audit_materialized_context_fingerprint(root);
1912        assert_eq!(
1913            first, unchanged,
1914            "unchanged context must preserve a warm key"
1915        );
1916
1917        fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 10\n").expect("mutate lockfile");
1918        let lock_changed = audit_materialized_context_fingerprint(root);
1919        assert_ne!(
1920            first, lock_changed,
1921            "lockfile content must invalidate the key"
1922        );
1923
1924        fs::write(
1925            root.join("node_modules/.modules.yaml"),
1926            "layoutVersion: 6\n",
1927        )
1928        .expect("mutate node marker");
1929        let marker_changed = audit_materialized_context_fingerprint(root);
1930        assert_ne!(
1931            lock_changed, marker_changed,
1932            "bounded dependency markers must invalidate the key"
1933        );
1934
1935        fs::create_dir(root.join(".nuxt")).expect("nuxt context");
1936        fs::write(root.join(".nuxt/imports.d.ts"), "export {}\n").expect("nuxt marker");
1937        assert_ne!(
1938            marker_changed,
1939            audit_materialized_context_fingerprint(root),
1940            "missing and materialized generated context must differ"
1941        );
1942    }
1943
1944    #[test]
1945    fn audit_context_fingerprint_tracks_nested_workspace_generated_roots() {
1946        let temp = tempfile::tempdir().expect("temp dir");
1947        let root = temp.path();
1948        fs::write(
1949            root.join("package.json"),
1950            r#"{"private":true,"workspaces":["packages/*"]}"#,
1951        )
1952        .expect("root package");
1953        let nuxt = root.join("packages/nuxt-app");
1954        let astro = root.join("packages/astro-app");
1955        fs::create_dir_all(nuxt.join(".nuxt")).expect("nested nuxt context");
1956        fs::create_dir_all(astro.join(".astro")).expect("nested astro context");
1957        fs::write(nuxt.join("package.json"), r#"{"name":"nuxt-app"}"#).expect("nuxt package");
1958        fs::write(astro.join("package.json"), r#"{"name":"astro-app"}"#).expect("astro package");
1959        fs::write(nuxt.join(".nuxt/imports.d.ts"), "export {};\n").expect("nuxt marker");
1960        fs::write(astro.join(".astro/types.d.ts"), "export {};\n").expect("astro marker");
1961
1962        let first = audit_materialized_context_fingerprint(root);
1963        assert!(
1964            first
1965                .directories
1966                .iter()
1967                .any(|directory| directory.name == "packages/nuxt-app/.nuxt")
1968        );
1969        assert!(
1970            first
1971                .directories
1972                .iter()
1973                .any(|directory| directory.name == "packages/astro-app/.astro")
1974        );
1975
1976        fs::write(
1977            nuxt.join(".nuxt/imports.d.ts"),
1978            "export type Changed = true;\n",
1979        )
1980        .expect("mutate nuxt marker");
1981        assert_ne!(
1982            first,
1983            audit_materialized_context_fingerprint(root),
1984            "nested workspace marker changes must invalidate the audit context"
1985        );
1986    }
1987
1988    #[cfg(unix)]
1989    #[test]
1990    fn materialize_base_context_symlinks_nested_workspace_generated_roots() {
1991        let host = tempfile::tempdir().expect("host");
1992        let worktree = tempfile::tempdir().expect("worktree");
1993        fs::write(
1994            host.path().join("package.json"),
1995            r#"{"private":true,"workspaces":["packages/*"]}"#,
1996        )
1997        .expect("root package");
1998
1999        for (workspace, generated, marker) in [
2000            ("nuxt-app", ".nuxt", "imports.d.ts"),
2001            ("astro-app", ".astro", "types.d.ts"),
2002        ] {
2003            let host_workspace = host.path().join("packages").join(workspace);
2004            let worktree_workspace = worktree.path().join("packages").join(workspace);
2005            fs::create_dir_all(host_workspace.join(generated)).expect("host generated context");
2006            fs::create_dir_all(&worktree_workspace).expect("worktree workspace");
2007            fs::write(
2008                host_workspace.join("package.json"),
2009                format!(r#"{{"name":"{workspace}"}}"#),
2010            )
2011            .expect("workspace package");
2012            fs::write(host_workspace.join(generated).join(marker), "export {};\n")
2013                .expect("generated marker");
2014        }
2015
2016        materialize_base_dependency_context(host.path(), worktree.path());
2017
2018        for (workspace, generated, marker) in [
2019            ("nuxt-app", ".nuxt", "imports.d.ts"),
2020            ("astro-app", ".astro", "types.d.ts"),
2021        ] {
2022            let mirrored = worktree
2023                .path()
2024                .join("packages")
2025                .join(workspace)
2026                .join(generated);
2027            assert!(
2028                fs::symlink_metadata(&mirrored)
2029                    .expect("mirrored generated root")
2030                    .file_type()
2031                    .is_symlink(),
2032                "{workspace}/{generated} must reuse the host generated root"
2033            );
2034            assert!(mirrored.join(marker).is_file());
2035        }
2036    }
2037
2038    #[cfg(unix)]
2039    #[test]
2040    fn materialize_base_context_resolves_symlinked_source_directories() {
2041        let host = tempfile::tempdir().expect("host");
2042        let targets = tempfile::tempdir().expect("targets");
2043        let worktree = tempfile::tempdir().expect("worktree");
2044
2045        for (kind, marker) in [
2046            ("node_modules", ".modules.yaml"),
2047            (".nuxt", "imports.d.ts"),
2048            (".astro", "types.d.ts"),
2049        ] {
2050            let target = targets.path().join(kind);
2051            fs::create_dir(&target).expect("source target");
2052            fs::write(target.join(marker), "generated context\n").expect("context marker");
2053            std::os::unix::fs::symlink(&target, host.path().join(kind))
2054                .expect("source directory symlink");
2055        }
2056
2057        materialize_base_dependency_context(host.path(), worktree.path());
2058
2059        let fingerprint = audit_materialized_context_fingerprint(host.path());
2060        for kind in AUDIT_MATERIALIZED_CONTEXT_DIRS {
2061            let target = dunce::canonicalize(targets.path().join(kind)).expect("canonical target");
2062            let mirrored = worktree.path().join(kind);
2063            assert_eq!(
2064                fs::read_link(&mirrored).expect("materialized symlink"),
2065                target,
2066                "{kind} must link directly to the validated canonical target"
2067            );
2068            let directory = fingerprint
2069                .directories
2070                .iter()
2071                .find(|directory| directory.name == *kind)
2072                .expect("fingerprinted context directory");
2073            assert_eq!(directory.state, AuditContextPathState::Present);
2074            assert!(directory.markers.iter().any(|marker| {
2075                matches!(marker.state, AuditContextPathState::Present)
2076                    && marker.content_hash.is_some()
2077            }));
2078        }
2079    }
2080
2081    #[cfg(unix)]
2082    #[test]
2083    fn materialize_base_context_refuses_symlinked_workspace_parent() {
2084        let host = tempfile::tempdir().expect("host");
2085        let worktree = tempfile::tempdir().expect("worktree");
2086        let outside = tempfile::tempdir().expect("outside");
2087        fs::write(
2088            host.path().join("package.json"),
2089            r#"{"private":true,"workspaces":["packages/*"]}"#,
2090        )
2091        .expect("root package");
2092        let host_workspace = host.path().join("packages/app");
2093        fs::create_dir_all(host_workspace.join(".nuxt")).expect("host generated context");
2094        fs::write(host_workspace.join("package.json"), r#"{"name":"app"}"#)
2095            .expect("workspace package");
2096        fs::write(host_workspace.join(".nuxt/imports.d.ts"), "export {};\n")
2097            .expect("generated marker");
2098
2099        let outside_workspace = outside.path().join("app");
2100        fs::create_dir_all(&outside_workspace).expect("outside workspace");
2101        let outside_generated = outside_workspace.join(".nuxt");
2102        std::os::unix::fs::symlink("missing-target", &outside_generated)
2103            .expect("outside sentinel symlink");
2104        std::os::unix::fs::symlink(outside.path(), worktree.path().join("packages"))
2105            .expect("hostile workspace parent symlink");
2106
2107        materialize_base_dependency_context(host.path(), worktree.path());
2108
2109        assert_eq!(
2110            fs::read_link(&outside_generated).expect("sentinel symlink must survive"),
2111            PathBuf::from("missing-target")
2112        );
2113        assert!(
2114            !outside.path().join(".nuxt").exists(),
2115            "materialization must not create generated context outside the worktree"
2116        );
2117    }
2118
2119    #[test]
2120    fn audit_context_fingerprint_rejects_oversized_files_without_reading_them() {
2121        let temp = tempfile::tempdir().expect("temp dir");
2122        let path = temp.path().join("pnpm-lock.yaml");
2123        let file = File::create(&path).expect("oversized file");
2124        file.set_len(AUDIT_CONTEXT_FILE_MAX_BYTES.saturating_add(1))
2125            .expect("set oversized length");
2126
2127        let fingerprint = fingerprint_context_file_at(&path, "pnpm-lock.yaml");
2128
2129        assert_eq!(
2130            fingerprint.state,
2131            AuditContextPathState::Unreadable(CONTEXT_OVERSIZED_FILE_STATE.to_string())
2132        );
2133        assert!(fingerprint.source.is_some());
2134        assert!(fingerprint.content_hash.is_none());
2135    }
2136
2137    #[cfg(unix)]
2138    #[test]
2139    fn audit_context_fingerprint_rejects_symlinked_files_without_following_them() {
2140        let temp = tempfile::tempdir().expect("temp dir");
2141        let target = temp.path().join("target-lock.yaml");
2142        let link = temp.path().join("pnpm-lock.yaml");
2143        fs::write(&target, "secret target contents\n").expect("target file");
2144        std::os::unix::fs::symlink(&target, &link).expect("lockfile symlink");
2145
2146        let fingerprint = fingerprint_context_file_at(&link, "pnpm-lock.yaml");
2147
2148        assert_eq!(
2149            fingerprint.state,
2150            AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
2151        );
2152        assert!(fingerprint.content_hash.is_none());
2153    }
2154
2155    #[cfg(unix)]
2156    #[test]
2157    fn audit_context_fingerprint_does_not_follow_symlink_swapped_before_open() {
2158        let temp = tempfile::tempdir().expect("temp dir");
2159        let path = temp.path().join("pnpm-lock.yaml");
2160        let target = temp.path().join("target-lock.yaml");
2161        fs::write(&path, "original contents\n").expect("original file");
2162        fs::write(&target, "secret target contents\n").expect("target file");
2163
2164        let fingerprint = fingerprint_context_file_at_with_hooks(
2165            &path,
2166            "pnpm-lock.yaml",
2167            || {
2168                fs::remove_file(&path).expect("remove original");
2169                std::os::unix::fs::symlink(&target, &path).expect("replacement symlink");
2170            },
2171            || {},
2172        );
2173
2174        assert_eq!(
2175            fingerprint.state,
2176            AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
2177        );
2178        assert!(fingerprint.content_hash.is_none());
2179    }
2180
2181    #[test]
2182    fn audit_context_fingerprint_rejects_file_changed_during_read() {
2183        let temp = tempfile::tempdir().expect("temp dir");
2184        let path = temp.path().join("pnpm-lock.yaml");
2185        fs::write(&path, "original contents\n").expect("original file");
2186
2187        let fingerprint = fingerprint_context_file_at_with_hooks(
2188            &path,
2189            "pnpm-lock.yaml",
2190            || {},
2191            || {
2192                OpenOptions::new()
2193                    .write(true)
2194                    .open(&path)
2195                    .expect("open replacement")
2196                    .set_len(1)
2197                    .expect("truncate replacement");
2198            },
2199        );
2200
2201        assert_eq!(
2202            fingerprint.state,
2203            AuditContextPathState::Unreadable(CONTEXT_CHANGED_DURING_READ_STATE.to_string())
2204        );
2205        assert!(fingerprint.content_hash.is_none());
2206    }
2207
2208    #[cfg(unix)]
2209    #[test]
2210    fn unix_context_open_does_not_block_on_fifo() {
2211        let temp = tempfile::tempdir().expect("temp dir");
2212        let fifo = temp.path().join("pnpm-lock.yaml");
2213        let status = Command::new("mkfifo")
2214            .arg(&fifo)
2215            .status()
2216            .expect("run mkfifo");
2217        assert!(status.success(), "mkfifo must create the test pipe");
2218
2219        let fallback_fifo = fifo.clone();
2220        let fallback_writer = std::thread::spawn(move || {
2221            std::thread::sleep(std::time::Duration::from_secs(1));
2222            OpenOptions::new()
2223                .read(true)
2224                .write(true)
2225                .open(fallback_fifo)
2226                .expect("open fallback FIFO writer")
2227        });
2228        let started = std::time::Instant::now();
2229        let fingerprint = fingerprint_context_file_at(&fifo, "pnpm-lock.yaml");
2230        let elapsed = started.elapsed();
2231
2232        assert!(
2233            elapsed < std::time::Duration::from_millis(500),
2234            "nonblocking FIFO open took {elapsed:?}"
2235        );
2236        assert_eq!(
2237            fingerprint.state,
2238            AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
2239        );
2240        assert!(fingerprint.content_hash.is_none());
2241        drop(fallback_writer.join().expect("fallback writer"));
2242    }
2243
2244    #[cfg(unix)]
2245    #[test]
2246    fn audit_context_fingerprint_rejects_special_files_without_opening_them() {
2247        use std::os::unix::net::UnixListener;
2248
2249        let temp = tempfile::tempdir().expect("temp dir");
2250        let socket = temp.path().join("pnpm-lock.yaml");
2251        let _listener = UnixListener::bind(&socket).expect("unix socket");
2252
2253        let fingerprint = fingerprint_context_file_at(&socket, "pnpm-lock.yaml");
2254
2255        assert_eq!(
2256            fingerprint.state,
2257            AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
2258        );
2259        assert!(fingerprint.content_hash.is_none());
2260    }
2261}