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