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