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/// Move a cache directory inside the analysis root to the same place inside
1211/// the base worktree.
1212///
1213/// A base snapshot that wrote into the head tree's cache would mix two
1214/// commits in one cache. A relative cache directory resolves against the base
1215/// worktree already, and an absolute one outside the root is shared on
1216/// purpose, so both stay unchanged. The prefix check runs on the path as
1217/// written first and then on the real paths, so a symlinked root cannot keep
1218/// the base cache in the head tree.
1219#[must_use]
1220pub fn remap_cache_dir_for_base_worktree(
1221    current_root: &Path,
1222    base_worktree_root: &Path,
1223    cache_dir: &Path,
1224) -> PathBuf {
1225    if !cache_dir.is_absolute() {
1226        return cache_dir.to_path_buf();
1227    }
1228    if let Ok(relative) = cache_dir.strip_prefix(current_root) {
1229        return base_worktree_root.join(relative);
1230    }
1231    let canonical_root =
1232        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
1233    let canonical_cache =
1234        dunce::canonicalize(cache_dir).unwrap_or_else(|_| cache_dir.to_path_buf());
1235    [cache_dir, canonical_cache.as_path()]
1236        .into_iter()
1237        .find_map(|candidate| candidate.strip_prefix(&canonical_root).ok())
1238        .map_or_else(
1239            || cache_dir.to_path_buf(),
1240            |relative| base_worktree_root.join(relative),
1241        )
1242}
1243
1244/// Analysis root for a detached base worktree, and whether the base commit
1245/// contains it at all.
1246#[derive(Debug, Clone, PartialEq, Eq)]
1247pub enum BaseAnalysisRoot {
1248    /// The head analysis root maps onto a directory that the base commit
1249    /// contains, so the base snapshot is analyzed there.
1250    Present(PathBuf),
1251    /// The head analysis root maps onto a directory the base commit does not
1252    /// contain, such as a package added on the branch. Everything under it is
1253    /// new, so the base snapshot for that root is empty.
1254    NewInHead(PathBuf),
1255}
1256
1257/// Resolve the analysis root inside a detached base worktree and report
1258/// whether the base commit contains it.
1259///
1260/// A root that the base commit does not contain is the ordinary shape of
1261/// auditing a package added on the branch. Analyzing the whole base worktree
1262/// instead would compare a subdirectory head snapshot against a
1263/// whole-repository base snapshot, whose key spaces do not intersect, and
1264/// refusing the call would blame a `root` the caller spelled correctly.
1265#[must_use]
1266pub fn resolve_base_analysis_root(
1267    current_root: &Path,
1268    base_worktree_root: &Path,
1269) -> BaseAnalysisRoot {
1270    let root = base_analysis_root(current_root, base_worktree_root);
1271    if root.is_dir() {
1272        BaseAnalysisRoot::Present(root)
1273    } else {
1274        BaseAnalysisRoot::NewInHead(root)
1275    }
1276}
1277
1278/// Auto-detect the base ref used by changed-code audit when no explicit base
1279/// or environment override is set.
1280///
1281/// The base is the `git merge-base` (fork point) against the branch's upstream
1282/// or the remote default, mirroring the `fallow hooks install --target git`
1283/// pre-commit hook (issue #242). Resolving to the merge-base SHA, rather than a
1284/// bare branch name, fixes the long-standing bug where the default branch was
1285/// discovered via `origin/HEAD` but returned as the bare name `main` (issue
1286/// #1168): git resolves a bare `main` to the LOCAL `refs/heads/main`, which is
1287/// stale on worktree checkouts cut from `origin/main`, so the audit diffed
1288/// every branch against an ancient base and false-failed the gate.
1289///
1290/// Resolution order:
1291/// 1. `@{upstream}` merge-base, so a branch forked off a non-default
1292///    integration branch compares against where it actually forked.
1293/// 2. Remote default (`origin/HEAD` -> `origin/main` -> `origin/master`)
1294///    merge-base. The remote-tracking ref refreshes on fetch, unlike a
1295///    long-stale local branch; the merge-base is also immune to an unfetched
1296///    `origin/main` in the false-fail direction.
1297/// 3. Local `main` / `master` when there is no `origin` remote, preserving the
1298///    historical behavior for air-gapped and local-only repositories.
1299///
1300/// A branch with no common ancestor with its base (a shallow clone, unrelated
1301/// history) falls back to the remote-tracking tip rather than failing the
1302/// detection outright.
1303#[must_use]
1304pub fn auto_detect_audit_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
1305    if let Some(upstream) = git_upstream_ref(root) {
1306        if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
1307            return Some(ResolvedAuditBase {
1308                git_ref: sha,
1309                description: Some(format!("merge-base with {upstream}")),
1310            });
1311        }
1312        return Some(ResolvedAuditBase {
1313            description: Some(format!("{upstream} (tip)")),
1314            git_ref: upstream,
1315        });
1316    }
1317
1318    if let Some(remote_ref) = detect_remote_default_ref(root) {
1319        if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
1320            return Some(ResolvedAuditBase {
1321                git_ref: sha,
1322                description: Some(format!("merge-base with {remote_ref}")),
1323            });
1324        }
1325        return Some(ResolvedAuditBase {
1326            description: Some(format!("{remote_ref} (tip)")),
1327            git_ref: remote_ref,
1328        });
1329    }
1330
1331    for candidate in ["main", "master"] {
1332        if git_ref_exists(root, candidate) {
1333            return Some(ResolvedAuditBase {
1334                git_ref: candidate.to_string(),
1335                description: Some(format!("local {candidate}")),
1336            });
1337        }
1338    }
1339
1340    None
1341}
1342
1343/// Short SHA for the current HEAD.
1344#[must_use]
1345pub fn short_head_sha(root: &Path) -> Option<String> {
1346    run_git(root, &["rev-parse", "--short", "HEAD"])
1347}
1348
1349/// Full SHA for the current HEAD.
1350///
1351/// `Ok(None)` means git ran and HEAD does not resolve: the directory is not a
1352/// repository, or the current branch has no commit yet. `Err` means git could not
1353/// be started, so a caller can name that cause in its own error text.
1354///
1355/// # Errors
1356///
1357/// Returns the spawn error when the `git` process cannot be started.
1358pub fn head_sha(root: &Path) -> std::io::Result<Option<String>> {
1359    let output = git_command(root).args(["rev-parse", "HEAD"]).output()?;
1360    if !output.status.success() {
1361        return Ok(None);
1362    }
1363    let value = String::from_utf8_lossy(&output.stdout);
1364    let trimmed = value.trim();
1365    Ok((!trimmed.is_empty()).then(|| trimmed.to_owned()))
1366}
1367
1368/// Resolve a concrete `--changed-workspaces` ref for project-level next steps.
1369///
1370/// Returns `None` when the project has no workspaces, is not a git repository,
1371/// or has no resolvable remote default branch.
1372#[must_use]
1373pub fn default_workspace_ref(root: &Path) -> Option<String> {
1374    let workspaces = crate::discover::discover_workspace_packages(root);
1375    default_workspace_ref_for_workspaces(root, &workspaces)
1376}
1377
1378/// Resolve a concrete `--changed-workspaces` ref using existing workspace data.
1379#[must_use]
1380pub fn default_workspace_ref_for_workspaces(
1381    root: &Path,
1382    workspaces: &[WorkspaceInfo],
1383) -> Option<String> {
1384    if workspaces.is_empty() || !crate::churn::is_git_repo(root) {
1385        return None;
1386    }
1387    detect_remote_default_ref(root)
1388}
1389
1390/// Git identities for the current user in forms useful for self-routing.
1391///
1392/// Includes `user.email`, its local-part handle, a GitHub no-reply unwrapped
1393/// handle when applicable, and `user.name`. Missing config values are ignored.
1394#[must_use]
1395pub fn current_user_identities(root: &Path) -> Vec<String> {
1396    let mut ids = Vec::new();
1397    if let Some(email) = read_git_config(root, "user.email") {
1398        if let Some((local, _)) = email.split_once('@') {
1399            ids.push(local.rsplit('+').next().unwrap_or(local).to_owned());
1400        }
1401        ids.push(email);
1402    }
1403    if let Some(name) = read_git_config(root, "user.name") {
1404        ids.push(name);
1405    }
1406    ids
1407}
1408
1409fn read_git_config(root: &Path, key: &str) -> Option<String> {
1410    run_git(root, &["config", "--get", key])
1411}
1412
1413fn git_ref_exists(root: &Path, reference: &str) -> bool {
1414    run_git(root, &["rev-parse", "--verify", "--quiet", reference]).is_some()
1415}
1416
1417/// The repository top level as a real path.
1418///
1419/// Git resolves symbolic links in the toplevel it reports on every host
1420/// checked, so the extra canonicalization is a by-construction guard rather
1421/// than a behavior change. It keeps both sides of the prefix comparison in
1422/// `base_analysis_root` in one path space.
1423fn git_toplevel(root: &Path) -> Option<PathBuf> {
1424    let toplevel = PathBuf::from(run_git(root, &["rev-parse", "--show-toplevel"])?);
1425    Some(dunce::canonicalize(&toplevel).unwrap_or(toplevel))
1426}
1427
1428fn git_upstream_ref(root: &Path) -> Option<String> {
1429    run_git(
1430        root,
1431        &[
1432            "rev-parse",
1433            "--abbrev-ref",
1434            "--symbolic-full-name",
1435            "@{upstream}",
1436        ],
1437    )
1438}
1439
1440fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
1441    run_git(root, &["merge-base", a, b])
1442}
1443
1444/// The remote default branch as a remote-tracking ref, such as
1445/// `origin/main`.
1446///
1447/// Reads `origin/HEAD` first. A clone can lack it (a mirror, a CI checkout, or
1448/// `git remote set-head origin -d`), so `origin/main` and then `origin/master`
1449/// follow. Returns `None` when none of the three exists.
1450#[must_use]
1451pub fn detect_remote_default_ref(root: &Path) -> Option<String> {
1452    if let Some(full_ref) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
1453        && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
1454    {
1455        return Some(format!("origin/{branch}"));
1456    }
1457    ["origin/main", "origin/master"]
1458        .into_iter()
1459        .find(|candidate| git_ref_exists(root, candidate))
1460        .map(str::to_string)
1461}
1462
1463fn base_worktree_path() -> EngineResult<PathBuf> {
1464    let nanos = SystemTime::now()
1465        .duration_since(SystemTime::UNIX_EPOCH)
1466        .map_err(|err| EngineError::new(format!("system clock before unix epoch: {err}")))?
1467        .as_nanos();
1468    Ok(std::env::temp_dir().join(base_worktree_name(nanos)))
1469}
1470
1471/// Compose the directory name for a base worktree taken at clock read `nanos`.
1472///
1473/// The pid stays the FIRST `-`-separated segment so the CLI orphan sweep keeps
1474/// parsing it. A process-global monotonic counter is the final segment: `nanos`
1475/// is NOT monotonic and repeats across threads, so two audits running
1476/// concurrently in one process could otherwise compose the same name and the
1477/// loser's `git worktree add` fails with "already exists". `nanos` is a
1478/// parameter so that collision is reproducible in a test without depending on
1479/// the host clock resolution.
1480fn base_worktree_name(nanos: u128) -> String {
1481    static SEQ: AtomicU64 = AtomicU64::new(0);
1482    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
1483    format!("fallow-audit-base-{}-{nanos}-{seq}", std::process::id())
1484}
1485
1486#[expect(
1487    clippy::disallowed_methods,
1488    reason = "canonical engine-owned git spawn wrapper for repository refs"
1489)]
1490fn git_command(root: &Path) -> Command {
1491    let mut command = Command::new("git");
1492    crate::changed_files::clear_ambient_git_env(&mut command);
1493    // Repository probes never consume input and must not retain an embedder's protocol stdin.
1494    command.stdin(Stdio::null()).arg("-C").arg(root);
1495    command
1496}
1497
1498/// Run `git <args>` in `root` and return trimmed, non-empty stdout, or `None`
1499/// on a non-zero exit, empty output, or non-UTF-8 output.
1500///
1501/// Trimming belongs to this contract: git terminates every line it prints, and
1502/// callers feed these values straight back to git as refs and compare them as
1503/// paths, where a trailing newline is rejected or silently mismatches. Non-UTF-8
1504/// output stays `None` rather than becoming a mangled ref or path.
1505fn run_git(root: &Path, args: &[&str]) -> Option<String> {
1506    let output = git_command(root).args(args).output().ok()?;
1507    if !output.status.success() {
1508        return None;
1509    }
1510    let value = String::from_utf8(output.stdout).ok()?;
1511    let trimmed = value.trim();
1512    (!trimmed.is_empty()).then(|| trimmed.to_owned())
1513}
1514
1515#[cfg(test)]
1516mod tests {
1517    use std::fs;
1518    use std::path::PathBuf;
1519
1520    use super::*;
1521
1522    fn git(root: &Path, args: &[&str]) -> String {
1523        let output = git_command(root)
1524            .args(args)
1525            .output()
1526            .expect("git command starts");
1527        assert!(
1528            output.status.success(),
1529            "git {args:?} failed: {}",
1530            String::from_utf8_lossy(&output.stderr)
1531        );
1532        String::from_utf8_lossy(&output.stdout).trim().to_owned()
1533    }
1534
1535    fn init_repo(root: &Path) {
1536        fs::create_dir_all(root).expect("create repo");
1537        git(root, &["init", "-b", "main"]);
1538        git(root, &["config", "user.name", "Test User"]);
1539        git(root, &["config", "user.email", "test@example.com"]);
1540        git(root, &["config", "commit.gpgsign", "false"]);
1541    }
1542
1543    fn commit_all(root: &Path, message: &str) {
1544        git(root, &["add", "."]);
1545        git(root, &["commit", "-m", message]);
1546    }
1547
1548    /// A repository on `main` with one seed commit and no remote.
1549    fn seeded_repo(parent: &Path) -> PathBuf {
1550        let root = parent.join("repo");
1551        init_repo(&root);
1552        fs::write(root.join("README.md"), "seed\n").expect("write seed");
1553        commit_all(&root, "initial");
1554        root
1555    }
1556
1557    /// Add a tracked file, commit it, and return the new HEAD SHA.
1558    fn commit_file(repo: &Path, name: &str, body: &str) -> String {
1559        fs::write(repo.join(name), body).expect("write file");
1560        commit_all(repo, name);
1561        git(repo, &["rev-parse", "HEAD"])
1562    }
1563
1564    #[cfg(unix)]
1565    fn write_executable(path: &Path, source: &str) {
1566        use std::os::unix::fs::PermissionsExt as _;
1567
1568        fs::write(path, source).expect("write executable");
1569        let mut permissions = fs::metadata(path)
1570            .expect("executable metadata")
1571            .permissions();
1572        permissions.set_mode(0o755);
1573        fs::set_permissions(path, permissions).expect("set executable mode");
1574    }
1575
1576    /// Concurrent callers whose clock reads land in the same tick must still
1577    /// each get a distinct name. Before the monotonic counter they composed the
1578    /// identical name, so the second `git worktree add` failed with "already
1579    /// exists" and the audit aborted with `FALLOW_AUDIT_BASE_WORKTREE_FAILED`.
1580    ///
1581    /// The tick is pinned rather than sampled: a real `SystemTime` read is fine
1582    /// enough on most hosts that the collision would surface only as a rare
1583    /// flake, which is exactly the failure this guards.
1584    #[test]
1585    fn base_worktree_names_are_unique_when_the_clock_read_repeats() {
1586        const N: usize = 64;
1587        const SAME_TICK: u128 = 1_788_187_156_297_209_000;
1588
1589        let barrier = std::sync::Barrier::new(N);
1590        let names = std::sync::Mutex::new(Vec::with_capacity(N));
1591        std::thread::scope(|scope| {
1592            for _ in 0..N {
1593                let barrier = &barrier;
1594                let names = &names;
1595                scope.spawn(move || {
1596                    barrier.wait();
1597                    names
1598                        .lock()
1599                        .expect("names lock")
1600                        .push(base_worktree_name(SAME_TICK));
1601                });
1602            }
1603        });
1604
1605        let mut names = names.into_inner().expect("names lock");
1606        assert_eq!(names.len(), N);
1607        names.sort();
1608        names.dedup();
1609        assert_eq!(names.len(), N, "base worktree names collided");
1610    }
1611
1612    /// The pid stays the first segment so the CLI orphan sweep keeps parsing it.
1613    #[test]
1614    fn base_worktree_path_keeps_the_pid_as_the_first_segment() {
1615        let path = base_worktree_path().expect("path should build");
1616        let name = path
1617            .file_name()
1618            .and_then(|name| name.to_str())
1619            .expect("worktree name should be utf-8");
1620        let pid = name
1621            .strip_prefix("fallow-audit-base-")
1622            .and_then(|rest| rest.split('-').next())
1623            .expect("pid segment should be present");
1624        assert_eq!(pid, std::process::id().to_string());
1625    }
1626
1627    /// A subdirectory analysis root only materializes its own subtree (plus
1628    /// top-level files). Without this, a sparse checkout of one subdirectory
1629    /// of a large monorepo materializes the whole monorepo, and on a blobless
1630    /// partial clone each out-of-cone blob triggers a lazy promisor fetch that
1631    /// presents as `fallow audit` hanging to the CI timeout (issue #2615).
1632    #[test]
1633    fn detached_worktree_from_a_subdir_skips_sibling_subtrees() {
1634        let temp = tempfile::tempdir().expect("temp dir");
1635        let repo = temp.path().join("repo");
1636        init_repo(&repo);
1637        fs::create_dir_all(repo.join("sub")).expect("create sub dir");
1638        fs::create_dir_all(repo.join("big")).expect("create big dir");
1639        fs::write(repo.join("sub/a.ts"), "export const a = 1;\n").expect("write sub file");
1640        fs::write(repo.join("big/b.ts"), "export const b = 1;\n").expect("write big file");
1641        fs::write(repo.join("top.ts"), "export const top = 1;\n").expect("write top file");
1642        commit_all(&repo, "initial");
1643
1644        let destination = temp.path().join("base");
1645        create_detached_base_worktree(&repo.join("sub"), &destination, "HEAD")
1646            .expect("base worktree should be created");
1647
1648        assert!(
1649            destination.join("sub/a.ts").is_file(),
1650            "the requested subtree must be materialized"
1651        );
1652        assert!(
1653            destination.join("top.ts").is_file(),
1654            "top-level files shape subdir discovery and stay materialized"
1655        );
1656        assert!(
1657            !destination.join("big/b.ts").exists(),
1658            "sibling subtrees must not be materialized: {}",
1659            destination.join("big/b.ts").display()
1660        );
1661
1662        remove_registered_worktree(&repo, &destination);
1663        let _ = fs::remove_dir_all(&destination);
1664    }
1665
1666    /// A repository-root run on a sparse checkout materializes the cone, not
1667    /// the whole monorepo. This is the `actions/checkout` sparse-checkout
1668    /// shape from issue #2615: cone mode lists the sparse directory, and the
1669    /// blobless partial clone has no out-of-cone blobs locally.
1670    #[test]
1671    fn detached_worktree_at_the_root_respects_the_sparse_cone() {
1672        let temp = tempfile::tempdir().expect("temp dir");
1673        let repo = temp.path().join("repo");
1674        init_repo(&repo);
1675        fs::create_dir_all(repo.join("sub")).expect("create sub dir");
1676        fs::create_dir_all(repo.join("big")).expect("create big dir");
1677        fs::write(repo.join("sub/a.ts"), "export const a = 1;\n").expect("write sub file");
1678        fs::write(repo.join("big/b.ts"), "export const b = 1;\n").expect("write big file");
1679        commit_all(&repo, "initial");
1680        git(&repo, &["sparse-checkout", "init", "--cone"]);
1681        git(&repo, &["sparse-checkout", "set", "sub"]);
1682
1683        let destination = temp.path().join("base");
1684        create_detached_base_worktree(&repo, &destination, "HEAD")
1685            .expect("base worktree should be created");
1686
1687        assert!(
1688            destination.join("sub/a.ts").is_file(),
1689            "the sparse cone must be materialized"
1690        );
1691        assert!(
1692            !destination.join("big/b.ts").exists(),
1693            "paths outside the sparse cone must not be materialized: {}",
1694            destination.join("big/b.ts").display()
1695        );
1696
1697        remove_registered_worktree(&repo, &destination);
1698        let _ = fs::remove_dir_all(&destination);
1699    }
1700
1701    /// Pure scope unit coverage: subdir runs keep their subtree plus top-level
1702    /// and ancestor ignore files; root sparse runs keep the cone; full clones
1703    /// keep everything.
1704    #[test]
1705    fn materialization_scope_filters_to_the_needed_working_set() {
1706        let subdir = MaterializationScope {
1707            subdir_prefix: Some("apps/web".to_string()),
1708            sparse_dirs: None,
1709        };
1710        assert!(subdir.should_materialize(Path::new("apps/web/a.ts")));
1711        assert!(subdir.should_materialize(Path::new("top.ts")));
1712        assert!(subdir.should_materialize(Path::new(".gitignore")));
1713        assert!(subdir.should_materialize(Path::new("apps/.gitignore")));
1714        assert!(!subdir.should_materialize(Path::new("apps/other/b.ts")));
1715        assert!(!subdir.should_materialize(Path::new("apps/.gitignore.bak")));
1716
1717        let sparse = MaterializationScope {
1718            subdir_prefix: None,
1719            sparse_dirs: Some(vec!["apps/web".to_string()]),
1720        };
1721        assert!(sparse.should_materialize(Path::new("apps/web/a.ts")));
1722        assert!(sparse.should_materialize(Path::new("top.ts")));
1723        assert!(!sparse.should_materialize(Path::new("apps/other/b.ts")));
1724
1725        let full = MaterializationScope {
1726            subdir_prefix: None,
1727            sparse_dirs: None,
1728        };
1729        assert!(full.should_materialize(Path::new("apps/other/b.ts")));
1730    }
1731
1732    #[test]
1733    fn default_workspace_ref_skips_projects_without_workspaces() {
1734        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[]).is_none());
1735    }
1736
1737    #[test]
1738    fn default_workspace_ref_skips_non_git_workspace_projects() {
1739        let workspace = WorkspaceInfo {
1740            root: PathBuf::from("/repo/packages/app"),
1741            name: "app".to_owned(),
1742            is_internal_dependency: false,
1743        };
1744
1745        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[workspace]).is_none());
1746    }
1747
1748    #[test]
1749    fn current_user_identities_empty_when_git_config_is_unavailable() {
1750        assert!(current_user_identities(Path::new("/repo")).is_empty());
1751    }
1752
1753    #[test]
1754    fn short_head_sha_omits_git_line_ending() {
1755        let temp = tempfile::tempdir().expect("temp dir");
1756        let repo = temp.path().join("repo");
1757        init_repo(&repo);
1758        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1759        commit_all(&repo, "initial");
1760
1761        let sha = short_head_sha(&repo).expect("HEAD sha");
1762        assert_eq!(sha, sha.trim());
1763        assert!(!sha.is_empty());
1764    }
1765
1766    #[test]
1767    fn short_head_sha_is_absent_outside_a_git_repo() {
1768        let temp = tempfile::tempdir().expect("temp dir");
1769
1770        assert_eq!(short_head_sha(temp.path()), None);
1771    }
1772
1773    /// Regression for issue #2699: the detected ref is handed straight back to
1774    /// git as a diff target, so it must carry no line ending. Without the
1775    /// trimmed probe contract the upstream is `origin/main\n`, the merge-base
1776    /// call against it fails, and the detection degrades to the tip branch with
1777    /// an unusable ref.
1778    #[test]
1779    fn auto_detect_audit_base_ref_omits_git_line_endings_for_the_upstream_merge_base() {
1780        let temp = tempfile::tempdir().expect("temp dir");
1781        let repo = temp.path().join("repo");
1782        init_repo(&repo);
1783        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1784        commit_all(&repo, "initial");
1785        let fork_point = git(&repo, &["rev-parse", "HEAD"]);
1786        git(&repo, &["remote", "add", "origin", &repo.to_string_lossy()]);
1787        git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1788        git(&repo, &["checkout", "-b", "feature"]);
1789        git(
1790            &repo,
1791            &["branch", "--set-upstream-to=origin/main", "feature"],
1792        );
1793        fs::write(repo.join("feature.txt"), "my change\n").expect("write feature file");
1794        commit_all(&repo, "feature");
1795
1796        let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1797
1798        assert_eq!(detected.git_ref, fork_point);
1799        assert_eq!(
1800            detected.description.as_deref(),
1801            Some("merge-base with origin/main")
1802        );
1803        assert!(crate::validate::validate_git_ref(&detected.git_ref).is_ok());
1804    }
1805
1806    /// Regression for issue #2699 on the remote-default branch of the
1807    /// detection, where the line ending survives `strip_prefix` and reappears
1808    /// inside the composed `origin/<branch>` ref.
1809    #[test]
1810    fn auto_detect_audit_base_ref_omits_git_line_endings_for_the_remote_default() {
1811        let temp = tempfile::tempdir().expect("temp dir");
1812        let repo = temp.path().join("repo");
1813        init_repo(&repo);
1814        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1815        commit_all(&repo, "initial");
1816        let fork_point = git(&repo, &["rev-parse", "HEAD"]);
1817        git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1818        git(
1819            &repo,
1820            &[
1821                "symbolic-ref",
1822                "refs/remotes/origin/HEAD",
1823                "refs/remotes/origin/main",
1824            ],
1825        );
1826
1827        let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1828
1829        assert_eq!(detected.git_ref, fork_point);
1830        assert_eq!(
1831            detected.description.as_deref(),
1832            Some("merge-base with origin/main")
1833        );
1834        assert!(crate::validate::validate_git_ref(&detected.git_ref).is_ok());
1835    }
1836
1837    #[test]
1838    fn auto_detect_audit_base_ref_resolves_origin_default_to_merge_base() {
1839        let temp = tempfile::tempdir().expect("temp dir");
1840        let repo = seeded_repo(temp.path());
1841        let head = git(&repo, &["rev-parse", "HEAD"]);
1842        git(&repo, &["branch", "trunk"]);
1843        git(&repo, &["update-ref", "refs/remotes/origin/trunk", "trunk"]);
1844        git(
1845            &repo,
1846            &[
1847                "symbolic-ref",
1848                "refs/remotes/origin/HEAD",
1849                "refs/remotes/origin/trunk",
1850            ],
1851        );
1852
1853        let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1854
1855        // trunk == HEAD, so the merge-base is HEAD's own SHA. The bare branch
1856        // name `trunk` is never returned: it would resolve to a local ref.
1857        assert_eq!(detected.git_ref, head);
1858        assert_eq!(
1859            detected.description.as_deref(),
1860            Some("merge-base with origin/trunk")
1861        );
1862    }
1863
1864    /// Regression for issue #1168: a worktree checkout whose local `main` is
1865    /// stale relative to a fresh `origin/main`. The base must be the fork point
1866    /// (merge-base with `origin/main`), NOT the stale local-`main` commit that
1867    /// the old bare-name resolution diffed against.
1868    #[test]
1869    fn auto_detect_audit_base_ref_ignores_stale_local_main() {
1870        let temp = tempfile::tempdir().expect("temp dir");
1871        let repo = seeded_repo(temp.path());
1872        let stale = git(&repo, &["rev-parse", "HEAD"]);
1873
1874        git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1875        git(
1876            &repo,
1877            &[
1878                "symbolic-ref",
1879                "refs/remotes/origin/HEAD",
1880                "refs/remotes/origin/main",
1881            ],
1882        );
1883        let fork_point = commit_file(&repo, "teammate.txt", "merged work\n");
1884        git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1885
1886        // Cut a feature branch from the fresh origin tip using the raw SHA (no
1887        // upstream tracking), then leave local `main` behind at the stale commit.
1888        git(&repo, &["checkout", "-b", "feature", &fork_point]);
1889        commit_file(&repo, "feature.txt", "my change\n");
1890        git(&repo, &["branch", "-f", "main", &stale]);
1891
1892        let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1893
1894        assert_eq!(
1895            detected.git_ref, fork_point,
1896            "base must be the fork point (origin/main), not stale local main"
1897        );
1898        assert_eq!(
1899            detected.description.as_deref(),
1900            Some("merge-base with origin/main")
1901        );
1902    }
1903
1904    #[test]
1905    fn auto_detect_audit_base_ref_prefers_configured_upstream() {
1906        let temp = tempfile::tempdir().expect("temp dir");
1907        let repo = seeded_repo(temp.path());
1908        let fork_point = git(&repo, &["rev-parse", "HEAD"]);
1909        // Configure `origin` so refs/remotes/origin/* are recognized as
1910        // tracking refs and `--set-upstream-to` is accepted.
1911        git(&repo, &["remote", "add", "origin", &repo.to_string_lossy()]);
1912        git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
1913        git(&repo, &["checkout", "-b", "feature"]);
1914        git(
1915            &repo,
1916            &["branch", "--set-upstream-to=origin/main", "feature"],
1917        );
1918        commit_file(&repo, "feature.txt", "my change\n");
1919
1920        let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1921
1922        assert_eq!(detected.git_ref, fork_point);
1923        assert_eq!(
1924            detected.description.as_deref(),
1925            Some("merge-base with origin/main")
1926        );
1927    }
1928
1929    #[test]
1930    fn auto_detect_audit_base_ref_falls_back_to_local_main_without_remote() {
1931        let temp = tempfile::tempdir().expect("temp dir");
1932        let repo = seeded_repo(temp.path());
1933
1934        let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1935
1936        assert_eq!(detected.git_ref, "main");
1937        assert_eq!(detected.description.as_deref(), Some("local main"));
1938    }
1939
1940    #[test]
1941    fn auto_detect_audit_base_ref_falls_back_to_local_master_without_remote() {
1942        let temp = tempfile::tempdir().expect("temp dir");
1943        let repo = temp.path().join("repo");
1944        fs::create_dir_all(&repo).expect("create repo");
1945        git(&repo, &["init", "-b", "master"]);
1946        git(&repo, &["config", "user.name", "Test User"]);
1947        git(&repo, &["config", "user.email", "test@example.com"]);
1948        git(&repo, &["config", "commit.gpgsign", "false"]);
1949        fs::write(repo.join("README.md"), "seed\n").expect("write seed");
1950        commit_all(&repo, "initial");
1951
1952        let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1953
1954        assert_eq!(detected.git_ref, "master");
1955        assert_eq!(detected.description.as_deref(), Some("local master"));
1956    }
1957
1958    #[test]
1959    fn auto_detect_audit_base_ref_returns_none_outside_git_repo() {
1960        let temp = tempfile::tempdir().expect("temp dir");
1961
1962        assert!(auto_detect_audit_base_ref(temp.path()).is_none());
1963    }
1964
1965    /// When the remote default shares no history with HEAD (the merge-base
1966    /// failure a shallow clone also hits), auto-detect falls back to the
1967    /// remote-tracking ref tip rather than failing the detection. That tip is
1968    /// the only branch that returns a ref git composed rather than printed, so
1969    /// it also pins the trimming for issue #2699.
1970    #[test]
1971    fn auto_detect_audit_base_ref_falls_back_to_remote_tip_without_common_ancestor() {
1972        let temp = tempfile::tempdir().expect("temp dir");
1973        let repo = seeded_repo(temp.path());
1974        git(&repo, &["checkout", "--orphan", "unrelated"]);
1975        let unrelated = commit_file(&repo, "unrelated.txt", "no shared history\n");
1976        git(
1977            &repo,
1978            &["update-ref", "refs/remotes/origin/main", &unrelated],
1979        );
1980        git(
1981            &repo,
1982            &[
1983                "symbolic-ref",
1984                "refs/remotes/origin/HEAD",
1985                "refs/remotes/origin/main",
1986            ],
1987        );
1988        git(&repo, &["checkout", "main"]);
1989
1990        let detected = auto_detect_audit_base_ref(&repo).expect("base is detected");
1991
1992        assert_eq!(detected.git_ref, "origin/main");
1993        assert_eq!(detected.description.as_deref(), Some("origin/main (tip)"));
1994    }
1995
1996    /// The repository top level is compared as a path prefix, so a line ending
1997    /// on it makes every subdirectory root fall back to the whole base
1998    /// worktree instead of the matching subdirectory (issue #2699).
1999    #[test]
2000    fn base_analysis_root_preserves_repo_subdirectory_roots() {
2001        let temp = tempfile::tempdir().expect("temp dir");
2002        let repo = temp.path().join("repo");
2003        init_repo(&repo);
2004        let app_root = repo.join("apps").join("mobile");
2005        fs::create_dir_all(&app_root).expect("create app root");
2006        let base_worktree = temp.path().join("base-worktree");
2007
2008        assert_eq!(
2009            base_analysis_root(&app_root, &base_worktree),
2010            base_worktree.join("apps").join("mobile")
2011        );
2012    }
2013
2014    /// A caller can spell the analysis root through a symbolic link, and git
2015    /// reports the resolved top level. The two path spaces must meet, or the
2016    /// prefix comparison fails and the base snapshot covers the whole base
2017    /// worktree while the head snapshot stays scoped (issue #2740).
2018    #[cfg(unix)]
2019    #[test]
2020    fn base_analysis_root_maps_a_symlinked_root_spelling() {
2021        let temp = tempfile::tempdir().expect("temp dir");
2022        let real_parent = temp.path().join("real");
2023        let repo = real_parent.join("repo");
2024        init_repo(&repo);
2025        let app_root = repo.join("apps").join("mobile");
2026        fs::create_dir_all(&app_root).expect("create app root");
2027        let linked_parent = temp.path().join("linked");
2028        std::os::unix::fs::symlink(&real_parent, &linked_parent).expect("link the parent");
2029        let base_worktree = temp.path().join("base-worktree");
2030
2031        let linked_app_root = linked_parent.join("repo").join("apps").join("mobile");
2032        assert_eq!(
2033            base_analysis_root(&linked_app_root, &base_worktree),
2034            base_worktree.join("apps").join("mobile")
2035        );
2036    }
2037
2038    #[test]
2039    fn remap_cache_dir_moves_a_cache_under_the_root_into_the_base_worktree() {
2040        let temp = tempfile::tempdir().expect("temp dir");
2041        let root = temp.path().join("repo");
2042        let base = temp.path().join("fallow-base");
2043        let cache_dir = root.join(".cache").join("fallow");
2044
2045        assert_eq!(
2046            remap_cache_dir_for_base_worktree(&root, &base, &cache_dir),
2047            base.join(".cache").join("fallow")
2048        );
2049    }
2050
2051    #[test]
2052    fn remap_cache_dir_keeps_an_absolute_cache_outside_the_root() {
2053        let temp = tempfile::tempdir().expect("temp dir");
2054        let root = temp.path().join("repo");
2055        let base = temp.path().join("fallow-base");
2056        let cache_dir = temp.path().join("shared").join("fallow-cache");
2057
2058        assert_eq!(
2059            remap_cache_dir_for_base_worktree(&root, &base, &cache_dir),
2060            cache_dir
2061        );
2062    }
2063
2064    #[test]
2065    fn remap_cache_dir_keeps_a_relative_cache() {
2066        let temp = tempfile::tempdir().expect("temp dir");
2067        let root = temp.path().join("repo");
2068        let base = temp.path().join("fallow-base");
2069        let cache_dir = Path::new(".fallow").join("cache");
2070
2071        assert_eq!(
2072            remap_cache_dir_for_base_worktree(&root, &base, &cache_dir),
2073            cache_dir
2074        );
2075    }
2076
2077    /// The root can be spelled through a symbolic link while the cache path
2078    /// is the real path. The two spellings must meet, or the base snapshot
2079    /// writes its cache into the head tree (issue #2758).
2080    #[cfg(unix)]
2081    #[test]
2082    fn remap_cache_dir_maps_a_symlinked_root_spelling() {
2083        let temp = tempfile::tempdir().expect("temp dir");
2084        let real_root = temp.path().join("real");
2085        fs::create_dir_all(&real_root).expect("create real root");
2086        let linked_root = temp.path().join("linked");
2087        std::os::unix::fs::symlink(&real_root, &linked_root).expect("link the root");
2088        let base = temp.path().join("fallow-base");
2089        let real_cache = dunce::canonicalize(&real_root)
2090            .expect("canonical root")
2091            .join(".fallow");
2092
2093        assert_eq!(
2094            remap_cache_dir_for_base_worktree(&linked_root, &base, &real_cache),
2095            base.join(".fallow")
2096        );
2097    }
2098
2099    #[test]
2100    fn head_sha_is_none_before_the_first_commit() {
2101        let temp = tempfile::tempdir().expect("temp dir");
2102        let repo = temp.path().join("repo");
2103        init_repo(&repo);
2104
2105        assert_eq!(head_sha(&repo).expect("git starts"), None);
2106    }
2107
2108    #[test]
2109    fn head_sha_returns_the_full_sha() {
2110        let temp = tempfile::tempdir().expect("temp dir");
2111        let repo = seeded_repo(temp.path());
2112
2113        let sha = head_sha(&repo).expect("git starts").expect("HEAD resolves");
2114        assert_eq!(sha, git(&repo, &["rev-parse", "HEAD"]));
2115    }
2116
2117    /// Auditing a package added on the branch is the ordinary case where the
2118    /// remapped root is absent from the base worktree. Consumers need that
2119    /// reported rather than validating the joined path and refusing the call
2120    /// (issue #2699).
2121    #[test]
2122    fn resolve_base_analysis_root_reports_a_root_absent_from_the_base() {
2123        let temp = tempfile::tempdir().expect("temp dir");
2124        let repo = temp.path().join("repo");
2125        init_repo(&repo);
2126        let existing_root = repo.join("apps").join("mobile");
2127        fs::create_dir_all(&existing_root).expect("create existing root");
2128        let new_root = repo.join("apps").join("new");
2129        fs::create_dir_all(&new_root).expect("create new root");
2130
2131        let base_worktree = temp.path().join("base-worktree");
2132        fs::create_dir_all(base_worktree.join("apps").join("mobile"))
2133            .expect("create base subdirectory");
2134
2135        assert_eq!(
2136            resolve_base_analysis_root(&existing_root, &base_worktree),
2137            BaseAnalysisRoot::Present(base_worktree.join("apps").join("mobile"))
2138        );
2139        assert_eq!(
2140            resolve_base_analysis_root(&new_root, &base_worktree),
2141            BaseAnalysisRoot::NewInHead(base_worktree.join("apps").join("new"))
2142        );
2143    }
2144
2145    #[cfg(unix)]
2146    #[test]
2147    fn temporary_base_worktree_does_not_run_post_checkout_hook() {
2148        let temp = tempfile::tempdir().expect("temp dir");
2149        let repo = temp.path().join("repo");
2150        init_repo(&repo);
2151        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
2152        commit_all(&repo, "initial");
2153
2154        let sentinel = temp.path().join("post-checkout-ran");
2155        write_executable(
2156            &repo.join(".git/hooks/post-checkout"),
2157            &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
2158        );
2159
2160        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
2161            .expect("temporary worktree should be created");
2162
2163        assert_eq!(
2164            fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
2165            "committed\n"
2166        );
2167        assert!(
2168            !sentinel.exists(),
2169            "creating a base view must not execute post-checkout hooks"
2170        );
2171    }
2172
2173    #[cfg(unix)]
2174    #[test]
2175    fn temporary_base_worktree_does_not_run_post_index_change_hook() {
2176        let temp = tempfile::tempdir().expect("temp dir");
2177        let repo = temp.path().join("repo");
2178        init_repo(&repo);
2179        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
2180        commit_all(&repo, "initial");
2181
2182        let sentinel = temp.path().join("post-index-change-ran");
2183        write_executable(
2184            &repo.join(".git/hooks/post-index-change"),
2185            &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
2186        );
2187
2188        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
2189            .expect("temporary worktree should be created");
2190
2191        assert_eq!(
2192            fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
2193            "committed\n"
2194        );
2195        assert!(
2196            !sentinel.exists(),
2197            "creating a base view must not execute post-index-change hooks"
2198        );
2199    }
2200
2201    #[cfg(unix)]
2202    #[test]
2203    fn temporary_base_worktree_does_not_run_smudge_filter() {
2204        let temp = tempfile::tempdir().expect("temp dir");
2205        let repo = temp.path().join("repo");
2206        init_repo(&repo);
2207        fs::write(
2208            repo.join(".gitattributes"),
2209            "filtered.txt filter=sentinel\n",
2210        )
2211        .expect("write attributes");
2212        fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
2213        commit_all(&repo, "initial");
2214
2215        let sentinel = temp.path().join("smudge-ran");
2216        let filter = temp.path().join("smudge-filter.sh");
2217        write_executable(
2218            &filter,
2219            &format!(
2220                "#!/bin/sh\nprintf ran > '{}'\ncat >/dev/null\nprintf 'smudged bytes\\n'\n",
2221                sentinel.display()
2222            ),
2223        );
2224        git(
2225            &repo,
2226            &[
2227                "config",
2228                "filter.sentinel.smudge",
2229                filter.to_str().expect("filter path is UTF-8"),
2230            ],
2231        );
2232
2233        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
2234            .expect("temporary worktree should be created");
2235
2236        assert_eq!(
2237            fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
2238            b"committed raw bytes\n"
2239        );
2240        assert!(
2241            !sentinel.exists(),
2242            "creating a base view must not execute smudge filters"
2243        );
2244    }
2245
2246    #[cfg(unix)]
2247    #[test]
2248    fn temporary_base_worktree_does_not_start_process_filter() {
2249        let temp = tempfile::tempdir().expect("temp dir");
2250        let repo = temp.path().join("repo");
2251        init_repo(&repo);
2252        fs::write(
2253            repo.join(".gitattributes"),
2254            "filtered.txt filter=sentinel\n",
2255        )
2256        .expect("write attributes");
2257        fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
2258        commit_all(&repo, "initial");
2259
2260        let sentinel = temp.path().join("process-filter-ran");
2261        let filter = temp.path().join("process-filter.sh");
2262        write_executable(
2263            &filter,
2264            &format!("#!/bin/sh\nprintf ran > '{}'\nexit 1\n", sentinel.display()),
2265        );
2266        git(
2267            &repo,
2268            &[
2269                "config",
2270                "filter.sentinel.process",
2271                filter.to_str().expect("filter path is UTF-8"),
2272            ],
2273        );
2274
2275        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
2276            .expect("temporary worktree should be created");
2277
2278        assert_eq!(
2279            fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
2280            b"committed raw bytes\n"
2281        );
2282        assert!(
2283            !sentinel.exists(),
2284            "creating a base view must not start process filters"
2285        );
2286    }
2287
2288    #[test]
2289    fn failed_registration_does_not_remove_existing_worktree() {
2290        let temp = tempfile::tempdir().expect("temp dir");
2291        let repo = temp.path().join("repo");
2292        init_repo(&repo);
2293        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
2294        commit_all(&repo, "initial");
2295        let destination = temp.path().join("base");
2296
2297        create_detached_base_worktree(&repo, &destination, "HEAD")
2298            .expect("first worktree should be created");
2299        let second = create_detached_base_worktree(&repo, &destination, "HEAD");
2300
2301        assert!(second.is_err(), "duplicate destination must fail");
2302        assert!(
2303            destination.join("tracked.txt").is_file(),
2304            "failed registration must not remove the existing worktree"
2305        );
2306        assert_eq!(git(&destination, &["rev-parse", "HEAD"]).len(), 40);
2307
2308        remove_registered_worktree(&repo, &destination);
2309        let _ = fs::remove_dir_all(destination);
2310    }
2311
2312    #[cfg(unix)]
2313    #[test]
2314    fn temporary_base_worktree_preserves_modes_symlinks_and_gitlinks() {
2315        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
2316
2317        let temp = tempfile::tempdir().expect("temp dir");
2318        let repo = temp.path().join("repo");
2319        init_repo(&repo);
2320        fs::write(repo.join("regular.txt"), "regular\n").expect("write regular file");
2321        let executable = repo.join("run.sh");
2322        fs::write(&executable, "#!/bin/sh\nexit 0\n").expect("write executable");
2323        let mut permissions = fs::metadata(&executable)
2324            .expect("executable metadata")
2325            .permissions();
2326        permissions.set_mode(0o755);
2327        fs::set_permissions(&executable, permissions).expect("set executable mode");
2328        std::os::unix::fs::symlink("regular.txt", repo.join("regular-link"))
2329            .expect("create symlink");
2330        commit_all(&repo, "files");
2331
2332        let gitlink_commit = git(&repo, &["rev-parse", "HEAD"]);
2333        git(
2334            &repo,
2335            &[
2336                "update-index",
2337                "--add",
2338                "--cacheinfo",
2339                &format!("160000,{gitlink_commit},vendor/submodule"),
2340            ],
2341        );
2342        git(&repo, &["commit", "-m", "gitlink"]);
2343
2344        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
2345            .expect("temporary worktree should be created");
2346        let regular_mode = fs::metadata(worktree.path().join("regular.txt"))
2347            .expect("regular metadata")
2348            .mode();
2349        let executable_mode = fs::metadata(worktree.path().join("run.sh"))
2350            .expect("executable metadata")
2351            .mode();
2352
2353        assert_eq!(regular_mode & 0o111, 0);
2354        assert_ne!(executable_mode & 0o111, 0);
2355        assert_eq!(
2356            fs::read_link(worktree.path().join("regular-link")).expect("read symlink"),
2357            PathBuf::from("regular.txt")
2358        );
2359        let gitlink = worktree.path().join("vendor/submodule");
2360        assert!(gitlink.is_dir(), "gitlink must materialize as a directory");
2361        assert!(
2362            fs::read_dir(gitlink)
2363                .expect("read gitlink directory")
2364                .next()
2365                .is_none(),
2366            "an uninitialized gitlink directory must remain empty"
2367        );
2368        assert!(
2369            git(
2370                worktree.path(),
2371                &["ls-files", "--stage", "vendor/submodule"]
2372            )
2373            .starts_with(&format!("160000 {gitlink_commit} 0\t")),
2374            "the linked worktree index must retain the gitlink object id"
2375        );
2376
2377        let path = worktree.path().to_path_buf();
2378        drop(worktree);
2379        assert!(!path.exists(), "temporary worktree must clean up on drop");
2380    }
2381
2382    #[test]
2383    fn materialized_tree_paths_reject_traversal_and_git_admin_aliases() {
2384        for path in [
2385            Path::new("../escape"),
2386            Path::new("/absolute"),
2387            Path::new(".git/config"),
2388            Path::new("nested/.GIT/config"),
2389            Path::new("nested/.git. /config"),
2390            Path::new("nested/git~1/config"),
2391        ] {
2392            assert!(
2393                validate_materialized_path(path).is_err(),
2394                "unsafe path should be rejected: {}",
2395                path.display()
2396            );
2397        }
2398        assert!(validate_materialized_path(Path::new("src/.github/file.ts")).is_ok());
2399    }
2400
2401    #[cfg(unix)]
2402    #[test]
2403    fn parent_directory_creation_refuses_symlink_traversal() {
2404        let temp = tempfile::tempdir().expect("temp dir");
2405        let root = temp.path().join("root");
2406        let outside = temp.path().join("outside");
2407        fs::create_dir(&root).expect("create root");
2408        fs::create_dir(&outside).expect("create outside");
2409        std::os::unix::fs::symlink(&outside, root.join("link")).expect("create parent symlink");
2410
2411        let result = create_safe_parent_directories(&root, Path::new("link/escaped.txt"));
2412
2413        assert!(result.is_err(), "symlink parent must be rejected");
2414        assert!(!outside.join("escaped.txt").exists());
2415    }
2416
2417    #[test]
2418    fn audit_context_fingerprint_tracks_bounded_lockfiles_and_markers() {
2419        let temp = tempfile::tempdir().expect("temp dir");
2420        let root = temp.path();
2421        fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 9\n").expect("lockfile");
2422        fs::create_dir(root.join("node_modules")).expect("node_modules");
2423        fs::write(
2424            root.join("node_modules/.modules.yaml"),
2425            "layoutVersion: 5\n",
2426        )
2427        .expect("node marker");
2428
2429        let first = audit_materialized_context_fingerprint(root);
2430        let unchanged = audit_materialized_context_fingerprint(root);
2431        assert_eq!(
2432            first, unchanged,
2433            "unchanged context must preserve a warm key"
2434        );
2435
2436        fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 10\n").expect("mutate lockfile");
2437        let lock_changed = audit_materialized_context_fingerprint(root);
2438        assert_ne!(
2439            first, lock_changed,
2440            "lockfile content must invalidate the key"
2441        );
2442
2443        fs::write(
2444            root.join("node_modules/.modules.yaml"),
2445            "layoutVersion: 6\n",
2446        )
2447        .expect("mutate node marker");
2448        let marker_changed = audit_materialized_context_fingerprint(root);
2449        assert_ne!(
2450            lock_changed, marker_changed,
2451            "bounded dependency markers must invalidate the key"
2452        );
2453
2454        fs::create_dir(root.join(".nuxt")).expect("nuxt context");
2455        fs::write(root.join(".nuxt/imports.d.ts"), "export {}\n").expect("nuxt marker");
2456        assert_ne!(
2457            marker_changed,
2458            audit_materialized_context_fingerprint(root),
2459            "missing and materialized generated context must differ"
2460        );
2461    }
2462
2463    #[test]
2464    fn audit_context_fingerprint_tracks_nested_workspace_generated_roots() {
2465        let temp = tempfile::tempdir().expect("temp dir");
2466        let root = temp.path();
2467        fs::write(
2468            root.join("package.json"),
2469            r#"{"private":true,"workspaces":["packages/*"]}"#,
2470        )
2471        .expect("root package");
2472        let nuxt = root.join("packages/nuxt-app");
2473        let astro = root.join("packages/astro-app");
2474        fs::create_dir_all(nuxt.join(".nuxt")).expect("nested nuxt context");
2475        fs::create_dir_all(astro.join(".astro")).expect("nested astro context");
2476        fs::write(nuxt.join("package.json"), r#"{"name":"nuxt-app"}"#).expect("nuxt package");
2477        fs::write(astro.join("package.json"), r#"{"name":"astro-app"}"#).expect("astro package");
2478        fs::write(nuxt.join(".nuxt/imports.d.ts"), "export {};\n").expect("nuxt marker");
2479        fs::write(astro.join(".astro/types.d.ts"), "export {};\n").expect("astro marker");
2480
2481        let first = audit_materialized_context_fingerprint(root);
2482        assert!(
2483            first
2484                .directories
2485                .iter()
2486                .any(|directory| directory.name == "packages/nuxt-app/.nuxt")
2487        );
2488        assert!(
2489            first
2490                .directories
2491                .iter()
2492                .any(|directory| directory.name == "packages/astro-app/.astro")
2493        );
2494
2495        fs::write(
2496            nuxt.join(".nuxt/imports.d.ts"),
2497            "export type Changed = true;\n",
2498        )
2499        .expect("mutate nuxt marker");
2500        assert_ne!(
2501            first,
2502            audit_materialized_context_fingerprint(root),
2503            "nested workspace marker changes must invalidate the audit context"
2504        );
2505    }
2506
2507    #[cfg(unix)]
2508    #[test]
2509    fn materialize_base_context_symlinks_nested_workspace_generated_roots() {
2510        let host = tempfile::tempdir().expect("host");
2511        let worktree = tempfile::tempdir().expect("worktree");
2512        fs::write(
2513            host.path().join("package.json"),
2514            r#"{"private":true,"workspaces":["packages/*"]}"#,
2515        )
2516        .expect("root package");
2517
2518        for (workspace, generated, marker) in [
2519            ("nuxt-app", ".nuxt", "imports.d.ts"),
2520            ("astro-app", ".astro", "types.d.ts"),
2521        ] {
2522            let host_workspace = host.path().join("packages").join(workspace);
2523            let worktree_workspace = worktree.path().join("packages").join(workspace);
2524            fs::create_dir_all(host_workspace.join(generated)).expect("host generated context");
2525            fs::create_dir_all(&worktree_workspace).expect("worktree workspace");
2526            fs::write(
2527                host_workspace.join("package.json"),
2528                format!(r#"{{"name":"{workspace}"}}"#),
2529            )
2530            .expect("workspace package");
2531            fs::write(host_workspace.join(generated).join(marker), "export {};\n")
2532                .expect("generated marker");
2533        }
2534
2535        materialize_base_dependency_context(host.path(), worktree.path());
2536
2537        for (workspace, generated, marker) in [
2538            ("nuxt-app", ".nuxt", "imports.d.ts"),
2539            ("astro-app", ".astro", "types.d.ts"),
2540        ] {
2541            let mirrored = worktree
2542                .path()
2543                .join("packages")
2544                .join(workspace)
2545                .join(generated);
2546            assert!(
2547                fs::symlink_metadata(&mirrored)
2548                    .expect("mirrored generated root")
2549                    .file_type()
2550                    .is_symlink(),
2551                "{workspace}/{generated} must reuse the host generated root"
2552            );
2553            assert!(mirrored.join(marker).is_file());
2554        }
2555    }
2556
2557    #[cfg(unix)]
2558    #[test]
2559    fn materialize_base_context_resolves_symlinked_source_directories() {
2560        let host = tempfile::tempdir().expect("host");
2561        let targets = tempfile::tempdir().expect("targets");
2562        let worktree = tempfile::tempdir().expect("worktree");
2563
2564        for (kind, marker) in [
2565            ("node_modules", ".modules.yaml"),
2566            (".nuxt", "imports.d.ts"),
2567            (".astro", "types.d.ts"),
2568        ] {
2569            let target = targets.path().join(kind);
2570            fs::create_dir(&target).expect("source target");
2571            fs::write(target.join(marker), "generated context\n").expect("context marker");
2572            std::os::unix::fs::symlink(&target, host.path().join(kind))
2573                .expect("source directory symlink");
2574        }
2575
2576        materialize_base_dependency_context(host.path(), worktree.path());
2577
2578        let fingerprint = audit_materialized_context_fingerprint(host.path());
2579        for kind in AUDIT_MATERIALIZED_CONTEXT_DIRS {
2580            let target = dunce::canonicalize(targets.path().join(kind)).expect("canonical target");
2581            let mirrored = worktree.path().join(kind);
2582            assert_eq!(
2583                fs::read_link(&mirrored).expect("materialized symlink"),
2584                target,
2585                "{kind} must link directly to the validated canonical target"
2586            );
2587            let directory = fingerprint
2588                .directories
2589                .iter()
2590                .find(|directory| directory.name == *kind)
2591                .expect("fingerprinted context directory");
2592            assert_eq!(directory.state, AuditContextPathState::Present);
2593            assert!(directory.markers.iter().any(|marker| {
2594                matches!(marker.state, AuditContextPathState::Present)
2595                    && marker.content_hash.is_some()
2596            }));
2597        }
2598    }
2599
2600    #[cfg(unix)]
2601    #[test]
2602    fn materialize_base_context_refuses_symlinked_workspace_parent() {
2603        let host = tempfile::tempdir().expect("host");
2604        let worktree = tempfile::tempdir().expect("worktree");
2605        let outside = tempfile::tempdir().expect("outside");
2606        fs::write(
2607            host.path().join("package.json"),
2608            r#"{"private":true,"workspaces":["packages/*"]}"#,
2609        )
2610        .expect("root package");
2611        let host_workspace = host.path().join("packages/app");
2612        fs::create_dir_all(host_workspace.join(".nuxt")).expect("host generated context");
2613        fs::write(host_workspace.join("package.json"), r#"{"name":"app"}"#)
2614            .expect("workspace package");
2615        fs::write(host_workspace.join(".nuxt/imports.d.ts"), "export {};\n")
2616            .expect("generated marker");
2617
2618        let outside_workspace = outside.path().join("app");
2619        fs::create_dir_all(&outside_workspace).expect("outside workspace");
2620        let outside_generated = outside_workspace.join(".nuxt");
2621        std::os::unix::fs::symlink("missing-target", &outside_generated)
2622            .expect("outside sentinel symlink");
2623        std::os::unix::fs::symlink(outside.path(), worktree.path().join("packages"))
2624            .expect("hostile workspace parent symlink");
2625
2626        materialize_base_dependency_context(host.path(), worktree.path());
2627
2628        assert_eq!(
2629            fs::read_link(&outside_generated).expect("sentinel symlink must survive"),
2630            PathBuf::from("missing-target")
2631        );
2632        assert!(
2633            !outside.path().join(".nuxt").exists(),
2634            "materialization must not create generated context outside the worktree"
2635        );
2636    }
2637
2638    #[test]
2639    fn audit_context_fingerprint_rejects_oversized_files_without_reading_them() {
2640        let temp = tempfile::tempdir().expect("temp dir");
2641        let path = temp.path().join("pnpm-lock.yaml");
2642        let file = File::create(&path).expect("oversized file");
2643        file.set_len(AUDIT_CONTEXT_FILE_MAX_BYTES.saturating_add(1))
2644            .expect("set oversized length");
2645
2646        let fingerprint = fingerprint_context_file_at(&path, "pnpm-lock.yaml");
2647
2648        assert_eq!(
2649            fingerprint.state,
2650            AuditContextPathState::Unreadable(CONTEXT_OVERSIZED_FILE_STATE.to_string())
2651        );
2652        assert!(fingerprint.source.is_some());
2653        assert!(fingerprint.content_hash.is_none());
2654    }
2655
2656    #[cfg(unix)]
2657    #[test]
2658    fn audit_context_fingerprint_rejects_symlinked_files_without_following_them() {
2659        let temp = tempfile::tempdir().expect("temp dir");
2660        let target = temp.path().join("target-lock.yaml");
2661        let link = temp.path().join("pnpm-lock.yaml");
2662        fs::write(&target, "secret target contents\n").expect("target file");
2663        std::os::unix::fs::symlink(&target, &link).expect("lockfile symlink");
2664
2665        let fingerprint = fingerprint_context_file_at(&link, "pnpm-lock.yaml");
2666
2667        assert_eq!(
2668            fingerprint.state,
2669            AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
2670        );
2671        assert!(fingerprint.content_hash.is_none());
2672    }
2673
2674    #[cfg(unix)]
2675    #[test]
2676    fn audit_context_fingerprint_does_not_follow_symlink_swapped_before_open() {
2677        let temp = tempfile::tempdir().expect("temp dir");
2678        let path = temp.path().join("pnpm-lock.yaml");
2679        let target = temp.path().join("target-lock.yaml");
2680        fs::write(&path, "original contents\n").expect("original file");
2681        fs::write(&target, "secret target contents\n").expect("target file");
2682
2683        let fingerprint = fingerprint_context_file_at_with_hooks(
2684            &path,
2685            "pnpm-lock.yaml",
2686            || {
2687                fs::remove_file(&path).expect("remove original");
2688                std::os::unix::fs::symlink(&target, &path).expect("replacement symlink");
2689            },
2690            || {},
2691        );
2692
2693        assert_eq!(
2694            fingerprint.state,
2695            AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
2696        );
2697        assert!(fingerprint.content_hash.is_none());
2698    }
2699
2700    #[test]
2701    fn audit_context_fingerprint_rejects_file_changed_during_read() {
2702        let temp = tempfile::tempdir().expect("temp dir");
2703        let path = temp.path().join("pnpm-lock.yaml");
2704        fs::write(&path, "original contents\n").expect("original file");
2705
2706        let fingerprint = fingerprint_context_file_at_with_hooks(
2707            &path,
2708            "pnpm-lock.yaml",
2709            || {},
2710            || {
2711                OpenOptions::new()
2712                    .write(true)
2713                    .open(&path)
2714                    .expect("open replacement")
2715                    .set_len(1)
2716                    .expect("truncate replacement");
2717            },
2718        );
2719
2720        assert_eq!(
2721            fingerprint.state,
2722            AuditContextPathState::Unreadable(CONTEXT_CHANGED_DURING_READ_STATE.to_string())
2723        );
2724        assert!(fingerprint.content_hash.is_none());
2725    }
2726
2727    #[cfg(unix)]
2728    #[test]
2729    fn unix_context_open_does_not_block_on_fifo() {
2730        let temp = tempfile::tempdir().expect("temp dir");
2731        let fifo = temp.path().join("pnpm-lock.yaml");
2732        let status = std::process::Command::new("mkfifo")
2733            .arg(&fifo)
2734            .status()
2735            .expect("run mkfifo");
2736        assert!(status.success(), "mkfifo must create the test pipe");
2737
2738        let fallback_fifo = fifo.clone();
2739        let fallback_writer = std::thread::spawn(move || {
2740            std::thread::sleep(std::time::Duration::from_secs(1));
2741            OpenOptions::new()
2742                .read(true)
2743                .write(true)
2744                .open(fallback_fifo)
2745                .expect("open fallback FIFO writer")
2746        });
2747        let started = std::time::Instant::now();
2748        let fingerprint = fingerprint_context_file_at(&fifo, "pnpm-lock.yaml");
2749        let elapsed = started.elapsed();
2750
2751        assert!(
2752            elapsed < std::time::Duration::from_millis(500),
2753            "nonblocking FIFO open took {elapsed:?}"
2754        );
2755        assert_eq!(
2756            fingerprint.state,
2757            AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
2758        );
2759        assert!(fingerprint.content_hash.is_none());
2760        drop(fallback_writer.join().expect("fallback writer"));
2761    }
2762
2763    #[cfg(unix)]
2764    #[test]
2765    fn audit_context_fingerprint_rejects_special_files_without_opening_them() {
2766        use std::os::unix::net::UnixListener;
2767
2768        let temp = tempfile::tempdir().expect("temp dir");
2769        let socket = temp.path().join("pnpm-lock.yaml");
2770        let _listener = UnixListener::bind(&socket).expect("unix socket");
2771
2772        let fingerprint = fingerprint_context_file_at(&socket, "pnpm-lock.yaml");
2773
2774        assert_eq!(
2775            fingerprint.state,
2776            AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
2777        );
2778        assert!(fingerprint.content_hash.is_none());
2779    }
2780}