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