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::time::SystemTime;
8
9use fallow_config::WorkspaceInfo;
10use fallow_types::audit_cache::{
11    AuditContextDirectoryFingerprint, AuditContextFileFingerprint, AuditContextPathState,
12    AuditMaterializedContextFingerprint,
13};
14use fallow_types::source_fingerprint::SourceFingerprint;
15use xxhash_rust::xxh3::xxh3_64;
16
17use crate::{EngineError, EngineResult};
18
19const RAW_MATERIALIZATION_MARKER: &str = "fallow-raw-materialized-v1";
20
21/// Host directories shared with detached audit base views.
22pub const AUDIT_MATERIALIZED_CONTEXT_DIRS: &[&str] = &["node_modules", ".nuxt", ".astro"];
23const AUDIT_WORKSPACE_GENERATED_CONTEXT_DIRS: &[&str] = &[".nuxt", ".astro"];
24
25const AUDIT_LOCKFILES: &[&str] = &[
26    "package-lock.json",
27    "npm-shrinkwrap.json",
28    "pnpm-lock.yaml",
29    "yarn.lock",
30    "bun.lock",
31    "bun.lockb",
32];
33
34const NODE_MODULES_MARKERS: &[&str] = &[".package-lock.json", ".modules.yaml", ".yarn-state.yml"];
35const NUXT_MARKERS: &[&str] = &[
36    "tsconfig.json",
37    "tsconfig.app.json",
38    "imports.d.ts",
39    "components.d.ts",
40    "types/nitro-routes.d.ts",
41    "types/nitro-imports.d.ts",
42];
43const ASTRO_MARKERS: &[&str] = &["types.d.ts", "content.d.ts", "env.d.ts"];
44const AUDIT_CONTEXT_FILE_MAX_BYTES: u64 = 16 * 1024 * 1024;
45const CONTEXT_SYMLINK_STATE: &str = "symlink";
46const CONTEXT_SPECIAL_FILE_STATE: &str = "not_regular_file";
47const CONTEXT_OVERSIZED_FILE_STATE: &str = "file_too_large";
48const CONTEXT_PARENT_UNAVAILABLE_STATE: &str = "parent_context_unavailable";
49const CONTEXT_CHANGED_DURING_READ_STATE: &str = "changed_during_read";
50
51#[cfg(any(target_os = "linux", target_os = "android"))]
52const UNIX_CONTEXT_OPEN_FLAGS: i32 = 0x0002_0800;
53#[cfg(any(
54    target_os = "macos",
55    target_os = "ios",
56    target_os = "freebsd",
57    target_os = "dragonfly",
58    target_os = "openbsd",
59    target_os = "netbsd"
60))]
61const UNIX_CONTEXT_OPEN_FLAGS: i32 = 0x0104;
62
63#[cfg(windows)]
64const WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
65#[cfg(windows)]
66const WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
67
68/// Resolved base ref for changed-code audit.
69#[derive(Debug, Clone)]
70pub struct ResolvedAuditBase {
71    /// Git ref or SHA used for comparison.
72    pub git_ref: String,
73    /// Human-readable source of the resolved ref.
74    pub description: Option<String>,
75}
76
77/// Temporary detached worktree for comparing audit results against a base ref.
78#[derive(Debug)]
79pub struct TemporaryBaseWorktree {
80    repo_root: PathBuf,
81    path: PathBuf,
82}
83
84impl TemporaryBaseWorktree {
85    /// Create a detached base worktree for `base_ref`.
86    ///
87    /// # Errors
88    ///
89    /// Returns an engine error when the temp path cannot be generated, `git`
90    /// cannot be started, or the worktree cannot be created.
91    pub fn create(repo_root: &Path, base_ref: &str) -> EngineResult<Self> {
92        let path = base_worktree_path()?;
93        create_detached_base_worktree(repo_root, &path, base_ref)?;
94        materialize_base_dependency_context(repo_root, &path);
95        Ok(Self {
96            repo_root: repo_root.to_path_buf(),
97            path,
98        })
99    }
100
101    /// Path to the detached worktree.
102    #[must_use]
103    pub fn path(&self) -> &Path {
104        &self.path
105    }
106}
107
108/// Share dependency and generated context from the host checkout with a base view.
109pub fn materialize_base_dependency_context(repo_root: &Path, worktree_path: &Path) {
110    for slot in audit_materialized_context_slots(repo_root) {
111        let Ok(source) = canonical_context_directory(&slot.source) else {
112            continue;
113        };
114
115        if validate_materialized_path(&slot.relative).is_err()
116            || create_safe_parent_directories(worktree_path, &slot.relative).is_err()
117        {
118            continue;
119        }
120        let destination = worktree_path.join(&slot.relative);
121        match fs::symlink_metadata(&destination) {
122            Ok(metadata) if metadata.file_type().is_dir() => continue,
123            Ok(metadata) if metadata.file_type().is_symlink() => {
124                if fs::remove_file(&destination).is_err() {
125                    continue;
126                }
127            }
128            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
129            Ok(_) | Err(_) => continue,
130        }
131
132        let _ = symlink_dependency_dir(&source, &destination);
133    }
134}
135
136/// Build a bounded fingerprint of the host context materialized into a base view.
137#[must_use]
138pub fn audit_materialized_context_fingerprint(root: &Path) -> AuditMaterializedContextFingerprint {
139    let lockfiles = AUDIT_LOCKFILES
140        .iter()
141        .map(|name| fingerprint_context_file(root, &root.join(name)))
142        .collect();
143    let directories = audit_materialized_context_slots(root)
144        .iter()
145        .map(fingerprint_context_directory)
146        .collect();
147    AuditMaterializedContextFingerprint {
148        lockfiles,
149        directories,
150    }
151}
152
153#[derive(Debug)]
154struct AuditMaterializedContextSlot {
155    kind: &'static str,
156    relative: PathBuf,
157    source: PathBuf,
158}
159
160fn audit_materialized_context_slots(root: &Path) -> Vec<AuditMaterializedContextSlot> {
161    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
162    let mut slots = AUDIT_MATERIALIZED_CONTEXT_DIRS
163        .iter()
164        .map(|&kind| AuditMaterializedContextSlot {
165            kind,
166            relative: PathBuf::from(kind),
167            source: canonical_root.join(kind),
168        })
169        .collect::<Vec<_>>();
170
171    for workspace in crate::discover::discover_workspace_packages(root) {
172        let Ok(canonical_workspace) = dunce::canonicalize(&workspace.root) else {
173            continue;
174        };
175        let Ok(relative_workspace) = canonical_workspace.strip_prefix(&canonical_root) else {
176            continue;
177        };
178        if relative_workspace.as_os_str().is_empty() {
179            continue;
180        }
181        for &kind in AUDIT_WORKSPACE_GENERATED_CONTEXT_DIRS {
182            slots.push(AuditMaterializedContextSlot {
183                kind,
184                relative: relative_workspace.join(kind),
185                source: canonical_workspace.join(kind),
186            });
187        }
188    }
189
190    slots.sort_by(|left, right| left.relative.cmp(&right.relative));
191    slots.dedup_by(|left, right| left.relative == right.relative);
192    slots
193}
194
195fn fingerprint_context_directory(
196    slot: &AuditMaterializedContextSlot,
197) -> AuditContextDirectoryFingerprint {
198    let path = &slot.source;
199    let (state, canonical_path, source) = match canonical_context_directory(path) {
200        Ok(canonical_path) => {
201            let source = fs::symlink_metadata(&canonical_path)
202                .ok()
203                .as_ref()
204                .map(SourceFingerprint::from_metadata);
205            (AuditContextPathState::Present, Some(canonical_path), source)
206        }
207        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
208            (AuditContextPathState::Missing, None, None)
209        }
210        Err(error) => (
211            AuditContextPathState::Unreadable(error.kind().to_string()),
212            None,
213            fs::symlink_metadata(path)
214                .ok()
215                .as_ref()
216                .map(SourceFingerprint::from_metadata),
217        ),
218    };
219    let canonical_path_display = canonical_path
220        .as_ref()
221        .map(|path| path.to_string_lossy().replace('\\', "/"));
222    let markers = context_markers(slot.kind)
223        .iter()
224        .map(|marker| {
225            let relative = slot
226                .relative
227                .join(marker)
228                .to_string_lossy()
229                .replace('\\', "/");
230            canonical_path.as_ref().map_or_else(
231                || unavailable_context_file(&relative, &state),
232                |path| fingerprint_context_file_at(&path.join(marker), &relative),
233            )
234        })
235        .collect();
236    AuditContextDirectoryFingerprint {
237        name: slot.relative.to_string_lossy().replace('\\', "/"),
238        state,
239        canonical_path: canonical_path_display,
240        source,
241        markers,
242    }
243}
244
245fn canonical_context_directory(path: &Path) -> std::io::Result<PathBuf> {
246    let canonical_path = dunce::canonicalize(path)?;
247    match fs::symlink_metadata(&canonical_path) {
248        Ok(metadata) if metadata.file_type().is_dir() => Ok(canonical_path),
249        Ok(_) => Err(std::io::Error::new(
250            std::io::ErrorKind::InvalidInput,
251            CONTEXT_SPECIAL_FILE_STATE,
252        )),
253        Err(error) => Err(error),
254    }
255}
256
257fn context_markers(name: &str) -> &'static [&'static str] {
258    match name {
259        "node_modules" => NODE_MODULES_MARKERS,
260        ".nuxt" => NUXT_MARKERS,
261        ".astro" => ASTRO_MARKERS,
262        _ => &[],
263    }
264}
265
266fn fingerprint_context_file(root: &Path, path: &Path) -> AuditContextFileFingerprint {
267    let relative = path
268        .strip_prefix(root)
269        .unwrap_or(path)
270        .to_string_lossy()
271        .replace('\\', "/");
272    fingerprint_context_file_at(path, &relative)
273}
274
275fn fingerprint_context_file_at(path: &Path, relative: &str) -> AuditContextFileFingerprint {
276    fingerprint_context_file_at_with_hooks(path, relative, || {}, || {})
277}
278
279fn fingerprint_context_file_at_with_hooks(
280    path: &Path,
281    relative: &str,
282    before_open: impl FnOnce(),
283    after_read: impl FnOnce(),
284) -> AuditContextFileFingerprint {
285    before_open();
286    let mut file = match open_context_file(path) {
287        Ok(file) => file,
288        Err(error) => return classify_unopened_context_file(path, relative, error.kind()),
289    };
290    let opened_metadata = match file.metadata() {
291        Ok(metadata) if context_handle_is_regular_file(&metadata) => metadata,
292        Ok(metadata) => {
293            return unreadable_context_file(
294                relative,
295                CONTEXT_SPECIAL_FILE_STATE,
296                Some(SourceFingerprint::from_metadata(&metadata)),
297            );
298        }
299        Err(error) => return unreadable_context_file(relative, error.kind().to_string(), None),
300    };
301    let source = Some(SourceFingerprint::from_metadata(&opened_metadata));
302    if opened_metadata.len() > AUDIT_CONTEXT_FILE_MAX_BYTES {
303        return unreadable_context_file(relative, CONTEXT_OVERSIZED_FILE_STATE, source);
304    }
305
306    let capacity = usize::try_from(opened_metadata.len()).unwrap_or(0);
307    let mut bytes = Vec::with_capacity(capacity);
308    let read_limit = AUDIT_CONTEXT_FILE_MAX_BYTES.saturating_add(1);
309    if let Err(error) = (&mut file).take(read_limit).read_to_end(&mut bytes) {
310        return unreadable_context_file(relative, error.kind().to_string(), source);
311    }
312    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > AUDIT_CONTEXT_FILE_MAX_BYTES {
313        return unreadable_context_file(relative, CONTEXT_OVERSIZED_FILE_STATE, source);
314    }
315    after_read();
316    let final_metadata = match file.metadata() {
317        Ok(metadata) => metadata,
318        Err(error) => return unreadable_context_file(relative, error.kind().to_string(), source),
319    };
320    if SourceFingerprint::from_metadata(&opened_metadata)
321        != SourceFingerprint::from_metadata(&final_metadata)
322    {
323        return unreadable_context_file(relative, CONTEXT_CHANGED_DURING_READ_STATE, source);
324    }
325
326    AuditContextFileFingerprint {
327        path: relative.to_string(),
328        state: AuditContextPathState::Present,
329        source,
330        content_hash: Some(format!("{:016x}", xxh3_64(&bytes))),
331    }
332}
333
334#[expect(
335    clippy::filetype_is_file,
336    reason = "failed atomic opens are classified conservatively without treating arbitrary non-directories as readable files"
337)]
338fn classify_unopened_context_file(
339    path: &Path,
340    relative: &str,
341    open_error: std::io::ErrorKind,
342) -> AuditContextFileFingerprint {
343    match fs::symlink_metadata(path) {
344        Ok(metadata) if metadata.file_type().is_symlink() => unreadable_context_file(
345            relative,
346            CONTEXT_SYMLINK_STATE,
347            Some(SourceFingerprint::from_metadata(&metadata)),
348        ),
349        Ok(metadata) if !metadata.file_type().is_file() => unreadable_context_file(
350            relative,
351            CONTEXT_SPECIAL_FILE_STATE,
352            Some(SourceFingerprint::from_metadata(&metadata)),
353        ),
354        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
355            missing_context_file(relative)
356        }
357        Ok(metadata) => unreadable_context_file(
358            relative,
359            open_error.to_string(),
360            Some(SourceFingerprint::from_metadata(&metadata)),
361        ),
362        Err(_) => unreadable_context_file(relative, open_error.to_string(), None),
363    }
364}
365
366fn open_context_file(path: &Path) -> std::io::Result<File> {
367    let mut options = OpenOptions::new();
368    options.read(true);
369
370    #[cfg(any(
371        target_os = "linux",
372        target_os = "android",
373        target_os = "macos",
374        target_os = "ios",
375        target_os = "freebsd",
376        target_os = "dragonfly",
377        target_os = "openbsd",
378        target_os = "netbsd"
379    ))]
380    {
381        use std::os::unix::fs::OpenOptionsExt as _;
382
383        options.custom_flags(UNIX_CONTEXT_OPEN_FLAGS);
384    }
385    #[cfg(all(
386        unix,
387        not(any(
388            target_os = "linux",
389            target_os = "android",
390            target_os = "macos",
391            target_os = "ios",
392            target_os = "freebsd",
393            target_os = "dragonfly",
394            target_os = "openbsd",
395            target_os = "netbsd"
396        ))
397    ))]
398    {
399        return Err(std::io::Error::new(
400            std::io::ErrorKind::Unsupported,
401            "atomic no-follow context reads are unavailable on this Unix target",
402        ));
403    }
404    #[cfg(windows)]
405    {
406        use std::os::windows::fs::OpenOptionsExt as _;
407
408        options.custom_flags(WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT);
409    }
410
411    options.open(path)
412}
413
414#[cfg(windows)]
415#[expect(
416    clippy::filetype_is_file,
417    reason = "security boundary intentionally accepts regular files only and rejects reparse points, directories, and special files"
418)]
419fn context_handle_is_regular_file(metadata: &fs::Metadata) -> bool {
420    use std::os::windows::fs::MetadataExt as _;
421
422    metadata.file_type().is_file()
423        && metadata.file_attributes() & WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT == 0
424}
425
426#[cfg(not(windows))]
427#[expect(
428    clippy::filetype_is_file,
429    reason = "security boundary intentionally accepts regular files only and rejects directories, sockets, devices, and pipes"
430)]
431fn context_handle_is_regular_file(metadata: &fs::Metadata) -> bool {
432    metadata.file_type().is_file()
433}
434
435fn missing_context_file(relative: &str) -> AuditContextFileFingerprint {
436    AuditContextFileFingerprint {
437        path: relative.to_string(),
438        state: AuditContextPathState::Missing,
439        source: None,
440        content_hash: None,
441    }
442}
443
444fn unreadable_context_file(
445    relative: &str,
446    reason: impl Into<String>,
447    source: Option<SourceFingerprint>,
448) -> AuditContextFileFingerprint {
449    AuditContextFileFingerprint {
450        path: relative.to_string(),
451        state: AuditContextPathState::Unreadable(reason.into()),
452        source,
453        content_hash: None,
454    }
455}
456
457fn unavailable_context_file(
458    relative: &str,
459    parent_state: &AuditContextPathState,
460) -> AuditContextFileFingerprint {
461    if matches!(parent_state, AuditContextPathState::Missing) {
462        missing_context_file(relative)
463    } else {
464        unreadable_context_file(relative, CONTEXT_PARENT_UNAVAILABLE_STATE, None)
465    }
466}
467
468#[cfg(unix)]
469fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
470    std::os::unix::fs::symlink(source, destination)
471}
472
473#[cfg(windows)]
474fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
475    std::os::windows::fs::symlink_dir(source, destination)
476}
477
478/// Register a detached worktree without checking files out, then materialize
479/// the committed tree directly from Git objects.
480///
481/// This deliberately avoids Git's checkout pipeline. No checkout hook,
482/// smudge filter, process filter, line-ending conversion, or working-tree
483/// encoding is invoked. Regular files contain the raw committed blob bytes.
484///
485/// # Errors
486///
487/// Returns an engine error when the destination is not absolute, Git cannot
488/// create the administrative worktree, the tree contains an unsafe path, or
489/// an object cannot be materialized. A failed materialization removes both
490/// the worktree registration and its partial directory.
491pub fn create_detached_base_worktree(
492    repo_root: &Path,
493    destination: &Path,
494    base_ref: &str,
495) -> EngineResult<()> {
496    if !destination.is_absolute() {
497        return Err(EngineError::new(format!(
498            "base worktree destination must be absolute: {}",
499            destination.display()
500        )));
501    }
502
503    register_no_checkout_worktree(repo_root, destination, base_ref)?;
504    let result = make_worktree_root_private(destination)
505        .and_then(|()| resolve_registered_commit(destination, base_ref))
506        .and_then(|commit| {
507            populate_worktree_index(destination, &commit)?;
508            materialize_committed_tree(repo_root, destination, &commit)
509        })
510        .and_then(|()| write_raw_materialization_marker(destination));
511    if let Err(error) = result {
512        remove_registered_worktree(repo_root, destination);
513        let _ = fs::remove_dir_all(destination);
514        return Err(error);
515    }
516    Ok(())
517}
518
519#[cfg(unix)]
520fn make_worktree_root_private(destination: &Path) -> EngineResult<()> {
521    use std::os::unix::fs::PermissionsExt as _;
522
523    fs::set_permissions(destination, fs::Permissions::from_mode(0o700)).map_err(|error| {
524        EngineError::new(format!(
525            "could not make base worktree private at `{}`: {error}",
526            destination.display()
527        ))
528    })
529}
530
531#[cfg(not(unix))]
532#[expect(
533    clippy::unnecessary_wraps,
534    reason = "shared cross-platform signature; Unix applies privacy permissions"
535)]
536fn make_worktree_root_private(_destination: &Path) -> EngineResult<()> {
537    Ok(())
538}
539
540/// Return whether a linked worktree was completely materialized by the raw
541/// object path used by [`create_detached_base_worktree`].
542///
543/// Reusable audit caches created by older versions have no marker and must be
544/// rebuilt once so smudged or checkout-generated contents are not reused.
545#[must_use]
546pub fn detached_base_worktree_is_raw_materialized(worktree_root: &Path) -> bool {
547    raw_materialization_marker_path(worktree_root).is_ok_and(|path| path.is_file())
548}
549
550fn write_raw_materialization_marker(worktree_root: &Path) -> EngineResult<()> {
551    let marker = raw_materialization_marker_path(worktree_root)?;
552    fs::write(&marker, b"raw-v1\n").map_err(|error| {
553        EngineError::new(format!(
554            "could not record raw base-worktree materialization at `{}`: {error}",
555            marker.display()
556        ))
557    })
558}
559
560fn raw_materialization_marker_path(worktree_root: &Path) -> EngineResult<PathBuf> {
561    let marker = run_git(
562        worktree_root,
563        &["rev-parse", "--git-path", RAW_MATERIALIZATION_MARKER],
564    )
565    .ok_or_else(|| EngineError::new("could not resolve base-worktree materialization marker"))?;
566    let marker = PathBuf::from(marker.trim());
567    if marker.is_absolute() {
568        Ok(marker)
569    } else {
570        Ok(worktree_root.join(marker))
571    }
572}
573
574fn register_no_checkout_worktree(
575    repo_root: &Path,
576    destination: &Path,
577    base_ref: &str,
578) -> EngineResult<()> {
579    let mut command = git_command(repo_root);
580    command.args([
581        "worktree",
582        "add",
583        "--detach",
584        "--quiet",
585        "--no-checkout",
586        "--",
587    ]);
588    command.arg(destination).arg(base_ref);
589    let output = command.output().map_err(|error| {
590        EngineError::new(format!(
591            "could not create a temporary worktree for base ref `{base_ref}`: {error}"
592        ))
593    })?;
594    if !output.status.success() {
595        return Err(EngineError::new(format!(
596            "could not create a temporary worktree for base ref `{base_ref}`: {}",
597            String::from_utf8_lossy(&output.stderr).trim()
598        )));
599    }
600    Ok(())
601}
602
603fn resolve_registered_commit(destination: &Path, base_ref: &str) -> EngineResult<String> {
604    run_git(destination, &["rev-parse", "--verify", "HEAD^{commit}"])
605        .map(|commit| commit.trim().to_owned())
606        .ok_or_else(|| {
607            EngineError::new(format!(
608                "could not resolve the commit for base ref `{base_ref}` after creating the worktree"
609            ))
610        })
611}
612
613fn populate_worktree_index(destination: &Path, commit: &str) -> EngineResult<()> {
614    let disabled_hooks_path = destination.join(".fallow-disabled-git-hooks");
615    let output = git_command(destination)
616        .env("GIT_CONFIG_COUNT", "2")
617        .env("GIT_CONFIG_KEY_0", "core.hooksPath")
618        .env("GIT_CONFIG_VALUE_0", disabled_hooks_path)
619        .env("GIT_CONFIG_KEY_1", "core.fsmonitor")
620        .env("GIT_CONFIG_VALUE_1", "false")
621        .args(["read-tree", "--reset", commit])
622        .output()
623        .map_err(|error| {
624            EngineError::new(format!("could not populate base worktree index: {error}"))
625        })?;
626    if !output.status.success() {
627        return Err(EngineError::new(format!(
628            "could not populate base worktree index: {}",
629            String::from_utf8_lossy(&output.stderr).trim()
630        )));
631    }
632    Ok(())
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq)]
636enum TreeEntryKind {
637    Regular,
638    Executable,
639    Symlink,
640    Gitlink,
641}
642
643#[derive(Debug)]
644struct TreeEntry {
645    kind: TreeEntryKind,
646    object_id: String,
647    path: PathBuf,
648}
649
650fn materialize_committed_tree(
651    repo_root: &Path,
652    destination: &Path,
653    commit: &str,
654) -> EngineResult<()> {
655    let entries = committed_tree_entries(repo_root, commit)?;
656    let mut blobs = BatchBlobReader::spawn(repo_root)?;
657    let mut symlinks = Vec::new();
658
659    for entry in entries {
660        create_safe_parent_directories(destination, &entry.path)?;
661        let output_path = destination.join(&entry.path);
662        match entry.kind {
663            TreeEntryKind::Regular | TreeEntryKind::Executable => {
664                let mut file = OpenOptions::new()
665                    .create_new(true)
666                    .write(true)
667                    .open(&output_path)
668                    .map_err(|error| materialization_error(&entry.path, error))?;
669                blobs.copy_blob(&entry.object_id, &entry.path, &mut file)?;
670                set_regular_file_mode(&output_path, entry.kind == TreeEntryKind::Executable)?;
671            }
672            TreeEntryKind::Symlink => {
673                let target = blobs.read_blob(&entry.object_id, &entry.path)?;
674                symlinks.push((entry.path, target));
675            }
676            TreeEntryKind::Gitlink => {
677                fs::create_dir(&output_path)
678                    .map_err(|error| materialization_error(&entry.path, error))?;
679            }
680        }
681    }
682
683    blobs.finish()?;
684    for (path, target) in symlinks {
685        create_safe_parent_directories(destination, &path)?;
686        create_materialized_symlink(&destination.join(&path), &target)
687            .map_err(|error| materialization_error(&path, error))?;
688    }
689    Ok(())
690}
691
692fn committed_tree_entries(repo_root: &Path, commit: &str) -> EngineResult<Vec<TreeEntry>> {
693    let output = git_command(repo_root)
694        .args(["ls-tree", "-r", "-z", "--full-tree", commit])
695        .output()
696        .map_err(|error| EngineError::new(format!("could not read base commit tree: {error}")))?;
697    if !output.status.success() {
698        return Err(EngineError::new(format!(
699            "could not read base commit tree: {}",
700            String::from_utf8_lossy(&output.stderr).trim()
701        )));
702    }
703
704    output
705        .stdout
706        .split(|byte| *byte == 0)
707        .filter(|record| !record.is_empty())
708        .map(parse_tree_entry)
709        .collect()
710}
711
712fn parse_tree_entry(record: &[u8]) -> EngineResult<TreeEntry> {
713    let tab = record
714        .iter()
715        .position(|byte| *byte == b'\t')
716        .ok_or_else(|| EngineError::new("could not parse base commit tree entry without a path"))?;
717    let header = std::str::from_utf8(&record[..tab])
718        .map_err(|error| EngineError::new(format!("invalid Git tree header: {error}")))?;
719    let mut fields = header.split_ascii_whitespace();
720    let mode = fields.next().unwrap_or_default();
721    let object_type = fields.next().unwrap_or_default();
722    let object_id = fields.next().unwrap_or_default();
723    if fields.next().is_some() || object_id.is_empty() {
724        return Err(EngineError::new(format!(
725            "could not parse Git tree header `{header}`"
726        )));
727    }
728    let kind = match (mode, object_type) {
729        ("100644", "blob") => TreeEntryKind::Regular,
730        ("100755", "blob") => TreeEntryKind::Executable,
731        ("120000", "blob") => TreeEntryKind::Symlink,
732        ("160000", "commit") => TreeEntryKind::Gitlink,
733        _ => {
734            return Err(EngineError::new(format!(
735                "unsupported Git tree entry mode `{mode}` and type `{object_type}`"
736            )));
737        }
738    };
739    let path = git_path_from_bytes(&record[tab + 1..])?;
740    validate_materialized_path(&path)?;
741    Ok(TreeEntry {
742        kind,
743        object_id: object_id.to_owned(),
744        path,
745    })
746}
747
748#[cfg(unix)]
749#[expect(
750    clippy::unnecessary_wraps,
751    reason = "shared cross-platform signature; non-Unix path decoding is fallible"
752)]
753fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
754    use std::os::unix::ffi::OsStringExt as _;
755
756    Ok(std::ffi::OsString::from_vec(bytes.to_vec()).into())
757}
758
759#[cfg(not(unix))]
760fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
761    String::from_utf8(bytes.to_vec())
762        .map(PathBuf::from)
763        .map_err(|error| EngineError::new(format!("Git tree path is not valid UTF-8: {error}")))
764}
765
766fn validate_materialized_path(path: &Path) -> EngineResult<()> {
767    if path.as_os_str().is_empty() || path.is_absolute() {
768        return Err(unsafe_tree_path(path));
769    }
770
771    let mut saw_component = false;
772    for component in path.components() {
773        let Component::Normal(segment) = component else {
774            return Err(unsafe_tree_path(path));
775        };
776        saw_component = true;
777        if segment.to_str().is_some_and(is_git_admin_alias) {
778            return Err(unsafe_tree_path(path));
779        }
780    }
781    if !saw_component {
782        return Err(unsafe_tree_path(path));
783    }
784    Ok(())
785}
786
787fn is_git_admin_alias(segment: &str) -> bool {
788    let normalized = segment.trim_end_matches([' ', '.']).to_ascii_lowercase();
789    normalized == ".git" || normalized == "git~1"
790}
791
792fn unsafe_tree_path(path: &Path) -> EngineError {
793    EngineError::new(format!(
794        "refusing to materialize unsafe Git tree path `{}`",
795        path.display()
796    ))
797}
798
799fn create_safe_parent_directories(root: &Path, relative: &Path) -> EngineResult<()> {
800    let Some(parent) = relative.parent() else {
801        return Ok(());
802    };
803    let mut current = root.to_path_buf();
804    for component in parent.components() {
805        let Component::Normal(segment) = component else {
806            return Err(unsafe_tree_path(relative));
807        };
808        current.push(segment);
809        match fs::symlink_metadata(&current) {
810            Ok(metadata) if metadata.file_type().is_dir() => {}
811            Ok(_) => {
812                return Err(EngineError::new(format!(
813                    "refusing to materialize through non-directory path `{}`",
814                    current.display()
815                )));
816            }
817            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
818                fs::create_dir(&current).map_err(|error| materialization_error(relative, error))?;
819            }
820            Err(error) => return Err(materialization_error(relative, error)),
821        }
822    }
823    Ok(())
824}
825
826fn materialization_error(path: &Path, error: impl std::fmt::Display) -> EngineError {
827    EngineError::new(format!(
828        "could not materialize base commit path `{}`: {error}",
829        path.display()
830    ))
831}
832
833#[cfg(unix)]
834fn set_regular_file_mode(path: &Path, executable: bool) -> EngineResult<()> {
835    use std::os::unix::fs::PermissionsExt as _;
836
837    let mode = if executable { 0o755 } else { 0o644 };
838    let permissions = fs::Permissions::from_mode(mode);
839    fs::set_permissions(path, permissions).map_err(|error| materialization_error(path, error))
840}
841
842#[cfg(not(unix))]
843#[expect(
844    clippy::unnecessary_wraps,
845    reason = "shared cross-platform signature; Unix permission updates are fallible"
846)]
847fn set_regular_file_mode(_path: &Path, _executable: bool) -> EngineResult<()> {
848    Ok(())
849}
850
851#[cfg(unix)]
852fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
853    use std::os::unix::ffi::OsStringExt as _;
854
855    std::os::unix::fs::symlink(std::ffi::OsString::from_vec(target.to_vec()), path)
856}
857
858#[cfg(windows)]
859fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
860    let target_path = PathBuf::from(String::from_utf8_lossy(target).into_owned());
861    let resolved_target = path
862        .parent()
863        .map_or_else(|| target_path.clone(), |parent| parent.join(&target_path));
864    let result = if resolved_target.is_dir() {
865        std::os::windows::fs::symlink_dir(&target_path, path)
866    } else {
867        std::os::windows::fs::symlink_file(&target_path, path)
868    };
869    result.or_else(|_| fs::write(path, target))
870}
871
872#[cfg(not(any(unix, windows)))]
873fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
874    fs::write(path, target)
875}
876
877struct BatchBlobReader {
878    child: Option<Child>,
879    stdin: Option<ChildStdin>,
880    stdout: BufReader<ChildStdout>,
881}
882
883impl BatchBlobReader {
884    fn spawn(repo_root: &Path) -> EngineResult<Self> {
885        let mut command = git_command(repo_root);
886        command
887            .args(["cat-file", "--batch"])
888            .stdin(Stdio::piped())
889            .stdout(Stdio::piped())
890            .stderr(Stdio::null());
891        let mut child = command.spawn().map_err(|error| {
892            EngineError::new(format!("could not start Git object reader: {error}"))
893        })?;
894        let stdin = child
895            .stdin
896            .take()
897            .ok_or_else(|| EngineError::new("Git object reader has no stdin pipe"))?;
898        let stdout = child
899            .stdout
900            .take()
901            .ok_or_else(|| EngineError::new("Git object reader has no stdout pipe"))?;
902        Ok(Self {
903            child: Some(child),
904            stdin: Some(stdin),
905            stdout: BufReader::new(stdout),
906        })
907    }
908
909    fn copy_blob(&mut self, object_id: &str, path: &Path, target: &mut File) -> EngineResult<()> {
910        let size = self.request_blob(object_id, path)?;
911        let copied = std::io::copy(&mut self.stdout.by_ref().take(size), target)
912            .map_err(|error| materialization_error(path, error))?;
913        if copied != size {
914            return Err(EngineError::new(format!(
915                "Git object reader returned {copied} of {size} bytes for `{}`",
916                path.display()
917            )));
918        }
919        self.consume_blob_terminator(path)
920    }
921
922    fn read_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<Vec<u8>> {
923        let size = self.request_blob(object_id, path)?;
924        let size = usize::try_from(size).map_err(|error| materialization_error(path, error))?;
925        let mut bytes = vec![0; size];
926        self.stdout
927            .read_exact(&mut bytes)
928            .map_err(|error| materialization_error(path, error))?;
929        self.consume_blob_terminator(path)?;
930        Ok(bytes)
931    }
932
933    fn request_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<u64> {
934        let stdin = self
935            .stdin
936            .as_mut()
937            .ok_or_else(|| EngineError::new("Git object reader stdin is closed"))?;
938        writeln!(stdin, "{object_id}").map_err(|error| materialization_error(path, error))?;
939        stdin
940            .flush()
941            .map_err(|error| materialization_error(path, error))?;
942
943        let mut header = Vec::new();
944        self.stdout
945            .read_until(b'\n', &mut header)
946            .map_err(|error| materialization_error(path, error))?;
947        let header = std::str::from_utf8(&header)
948            .map_err(|error| materialization_error(path, error))?
949            .trim_end();
950        let mut fields = header.split_ascii_whitespace();
951        let returned_id = fields.next().unwrap_or_default();
952        let object_type = fields.next().unwrap_or_default();
953        let size = fields.next().unwrap_or_default();
954        if returned_id != object_id || object_type != "blob" || fields.next().is_some() {
955            return Err(EngineError::new(format!(
956                "unexpected Git object response `{header}` for `{}`",
957                path.display()
958            )));
959        }
960        size.parse::<u64>()
961            .map_err(|error| materialization_error(path, error))
962    }
963
964    fn consume_blob_terminator(&mut self, path: &Path) -> EngineResult<()> {
965        let mut terminator = [0; 1];
966        self.stdout
967            .read_exact(&mut terminator)
968            .map_err(|error| materialization_error(path, error))?;
969        if terminator != *b"\n" {
970            return Err(EngineError::new(format!(
971                "Git object response for `{}` had no terminator",
972                path.display()
973            )));
974        }
975        Ok(())
976    }
977
978    fn finish(mut self) -> EngineResult<()> {
979        self.stdin.take();
980        let status = self
981            .child
982            .take()
983            .ok_or_else(|| EngineError::new("Git object reader is already closed"))?
984            .wait()
985            .map_err(|error| {
986                EngineError::new(format!("could not wait for Git object reader: {error}"))
987            })?;
988        if !status.success() {
989            return Err(EngineError::new(format!(
990                "Git object reader exited with status {status}"
991            )));
992        }
993        Ok(())
994    }
995}
996
997impl Drop for BatchBlobReader {
998    fn drop(&mut self) {
999        self.stdin.take();
1000        if let Some(mut child) = self.child.take() {
1001            let _ = child.kill();
1002            let _ = child.wait();
1003        }
1004    }
1005}
1006
1007fn remove_registered_worktree(repo_root: &Path, destination: &Path) {
1008    let _ = git_command(repo_root)
1009        .args(["worktree", "remove", "--force"])
1010        .arg(destination)
1011        .output();
1012}
1013
1014impl Drop for TemporaryBaseWorktree {
1015    fn drop(&mut self) {
1016        let mut command = git_command(&self.repo_root);
1017        command
1018            .arg("worktree")
1019            .arg("remove")
1020            .arg("--force")
1021            .arg(&self.path);
1022        let _ = command.output();
1023        let _ = std::fs::remove_dir_all(&self.path);
1024    }
1025}
1026
1027/// Resolve the analysis root inside a detached base worktree.
1028#[must_use]
1029pub fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
1030    let Some(git_root) = git_toplevel(current_root) else {
1031        return base_worktree_root.to_path_buf();
1032    };
1033    let current_root =
1034        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
1035    match current_root.strip_prefix(&git_root) {
1036        Ok(relative) => base_worktree_root.join(relative),
1037        Err(_) => base_worktree_root.to_path_buf(),
1038    }
1039}
1040
1041/// Auto-detect the base ref used by changed-code audit.
1042#[must_use]
1043pub fn auto_detect_audit_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
1044    if let Some(upstream) = git_upstream_ref(root) {
1045        if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
1046            return Some(ResolvedAuditBase {
1047                git_ref: sha,
1048                description: Some(format!("merge-base with {upstream}")),
1049            });
1050        }
1051        return Some(ResolvedAuditBase {
1052            description: Some(format!("{upstream} (tip)")),
1053            git_ref: upstream,
1054        });
1055    }
1056
1057    if let Some(remote_ref) = detect_remote_default_ref(root) {
1058        if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
1059            return Some(ResolvedAuditBase {
1060                git_ref: sha,
1061                description: Some(format!("merge-base with {remote_ref}")),
1062            });
1063        }
1064        return Some(ResolvedAuditBase {
1065            description: Some(format!("{remote_ref} (tip)")),
1066            git_ref: remote_ref,
1067        });
1068    }
1069
1070    for candidate in ["main", "master"] {
1071        if git_ref_exists(root, candidate) {
1072            return Some(ResolvedAuditBase {
1073                git_ref: candidate.to_string(),
1074                description: Some(format!("local {candidate}")),
1075            });
1076        }
1077    }
1078
1079    None
1080}
1081
1082/// Short SHA for the current HEAD.
1083#[must_use]
1084pub fn short_head_sha(root: &Path) -> Option<String> {
1085    run_git(root, &["rev-parse", "--short", "HEAD"])
1086}
1087
1088/// Resolve a concrete `--changed-workspaces` ref for project-level next steps.
1089///
1090/// Returns `None` when the project has no workspaces, is not a git repository,
1091/// or has no resolvable remote default branch.
1092#[must_use]
1093pub fn default_workspace_ref(root: &Path) -> Option<String> {
1094    let workspaces = crate::discover::discover_workspace_packages(root);
1095    default_workspace_ref_for_workspaces(root, &workspaces)
1096}
1097
1098/// Resolve a concrete `--changed-workspaces` ref using existing workspace data.
1099#[must_use]
1100pub fn default_workspace_ref_for_workspaces(
1101    root: &Path,
1102    workspaces: &[WorkspaceInfo],
1103) -> Option<String> {
1104    if workspaces.is_empty() || !crate::churn::is_git_repo(root) {
1105        return None;
1106    }
1107    if let Some(reference) = run_git(
1108        root,
1109        &[
1110            "symbolic-ref",
1111            "--quiet",
1112            "--short",
1113            "refs/remotes/origin/HEAD",
1114        ],
1115    ) {
1116        let reference = reference.trim();
1117        if !reference.is_empty() {
1118            return Some(reference.to_owned());
1119        }
1120    }
1121    ["origin/main", "origin/master"]
1122        .into_iter()
1123        .find(|candidate| git_ref_exists(root, candidate))
1124        .map(str::to_owned)
1125}
1126
1127/// Git identities for the current user in forms useful for self-routing.
1128///
1129/// Includes `user.email`, its local-part handle, a GitHub no-reply unwrapped
1130/// handle when applicable, and `user.name`. Missing config values are ignored.
1131#[must_use]
1132pub fn current_user_identities(root: &Path) -> Vec<String> {
1133    let mut ids = Vec::new();
1134    if let Some(email) = read_git_config(root, "user.email") {
1135        if let Some((local, _)) = email.split_once('@') {
1136            ids.push(local.rsplit('+').next().unwrap_or(local).to_owned());
1137        }
1138        ids.push(email);
1139    }
1140    if let Some(name) = read_git_config(root, "user.name") {
1141        ids.push(name);
1142    }
1143    ids
1144}
1145
1146fn read_git_config(root: &Path, key: &str) -> Option<String> {
1147    let value = run_git(root, &["config", "--get", key])?;
1148    let trimmed = value.trim();
1149    (!trimmed.is_empty()).then(|| trimmed.to_owned())
1150}
1151
1152fn git_ref_exists(root: &Path, reference: &str) -> bool {
1153    run_git(root, &["rev-parse", "--verify", "--quiet", reference]).is_some()
1154}
1155
1156fn git_toplevel(root: &Path) -> Option<PathBuf> {
1157    run_git(root, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
1158}
1159
1160fn git_upstream_ref(root: &Path) -> Option<String> {
1161    run_git(
1162        root,
1163        &[
1164            "rev-parse",
1165            "--abbrev-ref",
1166            "--symbolic-full-name",
1167            "@{upstream}",
1168        ],
1169    )
1170}
1171
1172fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
1173    run_git(root, &["merge-base", a, b])
1174}
1175
1176fn detect_remote_default_ref(root: &Path) -> Option<String> {
1177    if let Some(full_ref) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
1178        && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
1179    {
1180        return Some(format!("origin/{branch}"));
1181    }
1182    ["origin/main", "origin/master"]
1183        .into_iter()
1184        .find(|candidate| git_ref_exists(root, candidate))
1185        .map(str::to_string)
1186}
1187
1188fn base_worktree_path() -> EngineResult<PathBuf> {
1189    let nanos = SystemTime::now()
1190        .duration_since(SystemTime::UNIX_EPOCH)
1191        .map_err(|err| EngineError::new(format!("system clock before unix epoch: {err}")))?
1192        .as_nanos();
1193    Ok(std::env::temp_dir().join(format!("fallow-audit-base-{}-{nanos}", std::process::id())))
1194}
1195
1196#[expect(
1197    clippy::disallowed_methods,
1198    reason = "canonical engine-owned git spawn wrapper for repository refs"
1199)]
1200fn git_command(root: &Path) -> Command {
1201    let mut command = Command::new("git");
1202    crate::changed_files::clear_ambient_git_env(&mut command);
1203    command.arg("-C").arg(root);
1204    command
1205}
1206
1207fn run_git(root: &Path, args: &[&str]) -> Option<String> {
1208    let output = git_command(root).args(args).output().ok()?;
1209    if !output.status.success() {
1210        return None;
1211    }
1212    String::from_utf8(output.stdout).ok()
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217    use std::fs;
1218    use std::path::PathBuf;
1219    use std::process::Command;
1220
1221    use super::*;
1222
1223    fn git(root: &Path, args: &[&str]) -> String {
1224        let output = Command::new("git")
1225            .args(args)
1226            .current_dir(root)
1227            .env_remove("GIT_DIR")
1228            .env_remove("GIT_WORK_TREE")
1229            .output()
1230            .expect("git command starts");
1231        assert!(
1232            output.status.success(),
1233            "git {args:?} failed: {}",
1234            String::from_utf8_lossy(&output.stderr)
1235        );
1236        String::from_utf8_lossy(&output.stdout).trim().to_owned()
1237    }
1238
1239    fn init_repo(root: &Path) {
1240        fs::create_dir_all(root).expect("create repo");
1241        git(root, &["init", "-b", "main"]);
1242        git(root, &["config", "user.name", "Test User"]);
1243        git(root, &["config", "user.email", "test@example.com"]);
1244        git(root, &["config", "commit.gpgsign", "false"]);
1245    }
1246
1247    fn commit_all(root: &Path, message: &str) {
1248        git(root, &["add", "."]);
1249        git(root, &["commit", "-m", message]);
1250    }
1251
1252    #[cfg(unix)]
1253    fn write_executable(path: &Path, source: &str) {
1254        use std::os::unix::fs::PermissionsExt as _;
1255
1256        fs::write(path, source).expect("write executable");
1257        let mut permissions = fs::metadata(path)
1258            .expect("executable metadata")
1259            .permissions();
1260        permissions.set_mode(0o755);
1261        fs::set_permissions(path, permissions).expect("set executable mode");
1262    }
1263
1264    #[test]
1265    fn default_workspace_ref_skips_projects_without_workspaces() {
1266        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[]).is_none());
1267    }
1268
1269    #[test]
1270    fn default_workspace_ref_skips_non_git_workspace_projects() {
1271        let workspace = WorkspaceInfo {
1272            root: PathBuf::from("/repo/packages/app"),
1273            name: "app".to_owned(),
1274            is_internal_dependency: false,
1275        };
1276
1277        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[workspace]).is_none());
1278    }
1279
1280    #[test]
1281    fn current_user_identities_empty_when_git_config_is_unavailable() {
1282        assert!(current_user_identities(Path::new("/repo")).is_empty());
1283    }
1284
1285    #[cfg(unix)]
1286    #[test]
1287    fn temporary_base_worktree_does_not_run_post_checkout_hook() {
1288        let temp = tempfile::tempdir().expect("temp dir");
1289        let repo = temp.path().join("repo");
1290        init_repo(&repo);
1291        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1292        commit_all(&repo, "initial");
1293
1294        let sentinel = temp.path().join("post-checkout-ran");
1295        write_executable(
1296            &repo.join(".git/hooks/post-checkout"),
1297            &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
1298        );
1299
1300        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1301            .expect("temporary worktree should be created");
1302
1303        assert_eq!(
1304            fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
1305            "committed\n"
1306        );
1307        assert!(
1308            !sentinel.exists(),
1309            "creating a base view must not execute post-checkout hooks"
1310        );
1311    }
1312
1313    #[cfg(unix)]
1314    #[test]
1315    fn temporary_base_worktree_does_not_run_post_index_change_hook() {
1316        let temp = tempfile::tempdir().expect("temp dir");
1317        let repo = temp.path().join("repo");
1318        init_repo(&repo);
1319        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1320        commit_all(&repo, "initial");
1321
1322        let sentinel = temp.path().join("post-index-change-ran");
1323        write_executable(
1324            &repo.join(".git/hooks/post-index-change"),
1325            &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
1326        );
1327
1328        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1329            .expect("temporary worktree should be created");
1330
1331        assert_eq!(
1332            fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
1333            "committed\n"
1334        );
1335        assert!(
1336            !sentinel.exists(),
1337            "creating a base view must not execute post-index-change hooks"
1338        );
1339    }
1340
1341    #[cfg(unix)]
1342    #[test]
1343    fn temporary_base_worktree_does_not_run_smudge_filter() {
1344        let temp = tempfile::tempdir().expect("temp dir");
1345        let repo = temp.path().join("repo");
1346        init_repo(&repo);
1347        fs::write(
1348            repo.join(".gitattributes"),
1349            "filtered.txt filter=sentinel\n",
1350        )
1351        .expect("write attributes");
1352        fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
1353        commit_all(&repo, "initial");
1354
1355        let sentinel = temp.path().join("smudge-ran");
1356        let filter = temp.path().join("smudge-filter.sh");
1357        write_executable(
1358            &filter,
1359            &format!(
1360                "#!/bin/sh\nprintf ran > '{}'\ncat >/dev/null\nprintf 'smudged bytes\\n'\n",
1361                sentinel.display()
1362            ),
1363        );
1364        git(
1365            &repo,
1366            &[
1367                "config",
1368                "filter.sentinel.smudge",
1369                filter.to_str().expect("filter path is UTF-8"),
1370            ],
1371        );
1372
1373        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1374            .expect("temporary worktree should be created");
1375
1376        assert_eq!(
1377            fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
1378            b"committed raw bytes\n"
1379        );
1380        assert!(
1381            !sentinel.exists(),
1382            "creating a base view must not execute smudge filters"
1383        );
1384    }
1385
1386    #[cfg(unix)]
1387    #[test]
1388    fn temporary_base_worktree_does_not_start_process_filter() {
1389        let temp = tempfile::tempdir().expect("temp dir");
1390        let repo = temp.path().join("repo");
1391        init_repo(&repo);
1392        fs::write(
1393            repo.join(".gitattributes"),
1394            "filtered.txt filter=sentinel\n",
1395        )
1396        .expect("write attributes");
1397        fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
1398        commit_all(&repo, "initial");
1399
1400        let sentinel = temp.path().join("process-filter-ran");
1401        let filter = temp.path().join("process-filter.sh");
1402        write_executable(
1403            &filter,
1404            &format!("#!/bin/sh\nprintf ran > '{}'\nexit 1\n", sentinel.display()),
1405        );
1406        git(
1407            &repo,
1408            &[
1409                "config",
1410                "filter.sentinel.process",
1411                filter.to_str().expect("filter path is UTF-8"),
1412            ],
1413        );
1414
1415        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1416            .expect("temporary worktree should be created");
1417
1418        assert_eq!(
1419            fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
1420            b"committed raw bytes\n"
1421        );
1422        assert!(
1423            !sentinel.exists(),
1424            "creating a base view must not start process filters"
1425        );
1426    }
1427
1428    #[test]
1429    fn failed_registration_does_not_remove_existing_worktree() {
1430        let temp = tempfile::tempdir().expect("temp dir");
1431        let repo = temp.path().join("repo");
1432        init_repo(&repo);
1433        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1434        commit_all(&repo, "initial");
1435        let destination = temp.path().join("base");
1436
1437        create_detached_base_worktree(&repo, &destination, "HEAD")
1438            .expect("first worktree should be created");
1439        let second = create_detached_base_worktree(&repo, &destination, "HEAD");
1440
1441        assert!(second.is_err(), "duplicate destination must fail");
1442        assert!(
1443            destination.join("tracked.txt").is_file(),
1444            "failed registration must not remove the existing worktree"
1445        );
1446        assert_eq!(git(&destination, &["rev-parse", "HEAD"]).len(), 40);
1447
1448        remove_registered_worktree(&repo, &destination);
1449        let _ = fs::remove_dir_all(destination);
1450    }
1451
1452    #[cfg(unix)]
1453    #[test]
1454    fn temporary_base_worktree_preserves_modes_symlinks_and_gitlinks() {
1455        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1456
1457        let temp = tempfile::tempdir().expect("temp dir");
1458        let repo = temp.path().join("repo");
1459        init_repo(&repo);
1460        fs::write(repo.join("regular.txt"), "regular\n").expect("write regular file");
1461        let executable = repo.join("run.sh");
1462        fs::write(&executable, "#!/bin/sh\nexit 0\n").expect("write executable");
1463        let mut permissions = fs::metadata(&executable)
1464            .expect("executable metadata")
1465            .permissions();
1466        permissions.set_mode(0o755);
1467        fs::set_permissions(&executable, permissions).expect("set executable mode");
1468        std::os::unix::fs::symlink("regular.txt", repo.join("regular-link"))
1469            .expect("create symlink");
1470        commit_all(&repo, "files");
1471
1472        let gitlink_commit = git(&repo, &["rev-parse", "HEAD"]);
1473        git(
1474            &repo,
1475            &[
1476                "update-index",
1477                "--add",
1478                "--cacheinfo",
1479                &format!("160000,{gitlink_commit},vendor/submodule"),
1480            ],
1481        );
1482        git(&repo, &["commit", "-m", "gitlink"]);
1483
1484        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1485            .expect("temporary worktree should be created");
1486        let regular_mode = fs::metadata(worktree.path().join("regular.txt"))
1487            .expect("regular metadata")
1488            .mode();
1489        let executable_mode = fs::metadata(worktree.path().join("run.sh"))
1490            .expect("executable metadata")
1491            .mode();
1492
1493        assert_eq!(regular_mode & 0o111, 0);
1494        assert_ne!(executable_mode & 0o111, 0);
1495        assert_eq!(
1496            fs::read_link(worktree.path().join("regular-link")).expect("read symlink"),
1497            PathBuf::from("regular.txt")
1498        );
1499        let gitlink = worktree.path().join("vendor/submodule");
1500        assert!(gitlink.is_dir(), "gitlink must materialize as a directory");
1501        assert!(
1502            fs::read_dir(gitlink)
1503                .expect("read gitlink directory")
1504                .next()
1505                .is_none(),
1506            "an uninitialized gitlink directory must remain empty"
1507        );
1508        assert!(
1509            git(
1510                worktree.path(),
1511                &["ls-files", "--stage", "vendor/submodule"]
1512            )
1513            .starts_with(&format!("160000 {gitlink_commit} 0\t")),
1514            "the linked worktree index must retain the gitlink object id"
1515        );
1516
1517        let path = worktree.path().to_path_buf();
1518        drop(worktree);
1519        assert!(!path.exists(), "temporary worktree must clean up on drop");
1520    }
1521
1522    #[test]
1523    fn materialized_tree_paths_reject_traversal_and_git_admin_aliases() {
1524        for path in [
1525            Path::new("../escape"),
1526            Path::new("/absolute"),
1527            Path::new(".git/config"),
1528            Path::new("nested/.GIT/config"),
1529            Path::new("nested/.git. /config"),
1530            Path::new("nested/git~1/config"),
1531        ] {
1532            assert!(
1533                validate_materialized_path(path).is_err(),
1534                "unsafe path should be rejected: {}",
1535                path.display()
1536            );
1537        }
1538        assert!(validate_materialized_path(Path::new("src/.github/file.ts")).is_ok());
1539    }
1540
1541    #[cfg(unix)]
1542    #[test]
1543    fn parent_directory_creation_refuses_symlink_traversal() {
1544        let temp = tempfile::tempdir().expect("temp dir");
1545        let root = temp.path().join("root");
1546        let outside = temp.path().join("outside");
1547        fs::create_dir(&root).expect("create root");
1548        fs::create_dir(&outside).expect("create outside");
1549        std::os::unix::fs::symlink(&outside, root.join("link")).expect("create parent symlink");
1550
1551        let result = create_safe_parent_directories(&root, Path::new("link/escaped.txt"));
1552
1553        assert!(result.is_err(), "symlink parent must be rejected");
1554        assert!(!outside.join("escaped.txt").exists());
1555    }
1556
1557    #[test]
1558    fn audit_context_fingerprint_tracks_bounded_lockfiles_and_markers() {
1559        let temp = tempfile::tempdir().expect("temp dir");
1560        let root = temp.path();
1561        fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 9\n").expect("lockfile");
1562        fs::create_dir(root.join("node_modules")).expect("node_modules");
1563        fs::write(
1564            root.join("node_modules/.modules.yaml"),
1565            "layoutVersion: 5\n",
1566        )
1567        .expect("node marker");
1568
1569        let first = audit_materialized_context_fingerprint(root);
1570        let unchanged = audit_materialized_context_fingerprint(root);
1571        assert_eq!(
1572            first, unchanged,
1573            "unchanged context must preserve a warm key"
1574        );
1575
1576        fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 10\n").expect("mutate lockfile");
1577        let lock_changed = audit_materialized_context_fingerprint(root);
1578        assert_ne!(
1579            first, lock_changed,
1580            "lockfile content must invalidate the key"
1581        );
1582
1583        fs::write(
1584            root.join("node_modules/.modules.yaml"),
1585            "layoutVersion: 6\n",
1586        )
1587        .expect("mutate node marker");
1588        let marker_changed = audit_materialized_context_fingerprint(root);
1589        assert_ne!(
1590            lock_changed, marker_changed,
1591            "bounded dependency markers must invalidate the key"
1592        );
1593
1594        fs::create_dir(root.join(".nuxt")).expect("nuxt context");
1595        fs::write(root.join(".nuxt/imports.d.ts"), "export {}\n").expect("nuxt marker");
1596        assert_ne!(
1597            marker_changed,
1598            audit_materialized_context_fingerprint(root),
1599            "missing and materialized generated context must differ"
1600        );
1601    }
1602
1603    #[test]
1604    fn audit_context_fingerprint_tracks_nested_workspace_generated_roots() {
1605        let temp = tempfile::tempdir().expect("temp dir");
1606        let root = temp.path();
1607        fs::write(
1608            root.join("package.json"),
1609            r#"{"private":true,"workspaces":["packages/*"]}"#,
1610        )
1611        .expect("root package");
1612        let nuxt = root.join("packages/nuxt-app");
1613        let astro = root.join("packages/astro-app");
1614        fs::create_dir_all(nuxt.join(".nuxt")).expect("nested nuxt context");
1615        fs::create_dir_all(astro.join(".astro")).expect("nested astro context");
1616        fs::write(nuxt.join("package.json"), r#"{"name":"nuxt-app"}"#).expect("nuxt package");
1617        fs::write(astro.join("package.json"), r#"{"name":"astro-app"}"#).expect("astro package");
1618        fs::write(nuxt.join(".nuxt/imports.d.ts"), "export {};\n").expect("nuxt marker");
1619        fs::write(astro.join(".astro/types.d.ts"), "export {};\n").expect("astro marker");
1620
1621        let first = audit_materialized_context_fingerprint(root);
1622        assert!(
1623            first
1624                .directories
1625                .iter()
1626                .any(|directory| directory.name == "packages/nuxt-app/.nuxt")
1627        );
1628        assert!(
1629            first
1630                .directories
1631                .iter()
1632                .any(|directory| directory.name == "packages/astro-app/.astro")
1633        );
1634
1635        fs::write(
1636            nuxt.join(".nuxt/imports.d.ts"),
1637            "export type Changed = true;\n",
1638        )
1639        .expect("mutate nuxt marker");
1640        assert_ne!(
1641            first,
1642            audit_materialized_context_fingerprint(root),
1643            "nested workspace marker changes must invalidate the audit context"
1644        );
1645    }
1646
1647    #[cfg(unix)]
1648    #[test]
1649    fn materialize_base_context_symlinks_nested_workspace_generated_roots() {
1650        let host = tempfile::tempdir().expect("host");
1651        let worktree = tempfile::tempdir().expect("worktree");
1652        fs::write(
1653            host.path().join("package.json"),
1654            r#"{"private":true,"workspaces":["packages/*"]}"#,
1655        )
1656        .expect("root package");
1657
1658        for (workspace, generated, marker) in [
1659            ("nuxt-app", ".nuxt", "imports.d.ts"),
1660            ("astro-app", ".astro", "types.d.ts"),
1661        ] {
1662            let host_workspace = host.path().join("packages").join(workspace);
1663            let worktree_workspace = worktree.path().join("packages").join(workspace);
1664            fs::create_dir_all(host_workspace.join(generated)).expect("host generated context");
1665            fs::create_dir_all(&worktree_workspace).expect("worktree workspace");
1666            fs::write(
1667                host_workspace.join("package.json"),
1668                format!(r#"{{"name":"{workspace}"}}"#),
1669            )
1670            .expect("workspace package");
1671            fs::write(host_workspace.join(generated).join(marker), "export {};\n")
1672                .expect("generated marker");
1673        }
1674
1675        materialize_base_dependency_context(host.path(), worktree.path());
1676
1677        for (workspace, generated, marker) in [
1678            ("nuxt-app", ".nuxt", "imports.d.ts"),
1679            ("astro-app", ".astro", "types.d.ts"),
1680        ] {
1681            let mirrored = worktree
1682                .path()
1683                .join("packages")
1684                .join(workspace)
1685                .join(generated);
1686            assert!(
1687                fs::symlink_metadata(&mirrored)
1688                    .expect("mirrored generated root")
1689                    .file_type()
1690                    .is_symlink(),
1691                "{workspace}/{generated} must reuse the host generated root"
1692            );
1693            assert!(mirrored.join(marker).is_file());
1694        }
1695    }
1696
1697    #[cfg(unix)]
1698    #[test]
1699    fn materialize_base_context_resolves_symlinked_source_directories() {
1700        let host = tempfile::tempdir().expect("host");
1701        let targets = tempfile::tempdir().expect("targets");
1702        let worktree = tempfile::tempdir().expect("worktree");
1703
1704        for (kind, marker) in [
1705            ("node_modules", ".modules.yaml"),
1706            (".nuxt", "imports.d.ts"),
1707            (".astro", "types.d.ts"),
1708        ] {
1709            let target = targets.path().join(kind);
1710            fs::create_dir(&target).expect("source target");
1711            fs::write(target.join(marker), "generated context\n").expect("context marker");
1712            std::os::unix::fs::symlink(&target, host.path().join(kind))
1713                .expect("source directory symlink");
1714        }
1715
1716        materialize_base_dependency_context(host.path(), worktree.path());
1717
1718        let fingerprint = audit_materialized_context_fingerprint(host.path());
1719        for kind in AUDIT_MATERIALIZED_CONTEXT_DIRS {
1720            let target = dunce::canonicalize(targets.path().join(kind)).expect("canonical target");
1721            let mirrored = worktree.path().join(kind);
1722            assert_eq!(
1723                fs::read_link(&mirrored).expect("materialized symlink"),
1724                target,
1725                "{kind} must link directly to the validated canonical target"
1726            );
1727            let directory = fingerprint
1728                .directories
1729                .iter()
1730                .find(|directory| directory.name == *kind)
1731                .expect("fingerprinted context directory");
1732            assert_eq!(directory.state, AuditContextPathState::Present);
1733            assert!(directory.markers.iter().any(|marker| {
1734                matches!(marker.state, AuditContextPathState::Present)
1735                    && marker.content_hash.is_some()
1736            }));
1737        }
1738    }
1739
1740    #[cfg(unix)]
1741    #[test]
1742    fn materialize_base_context_refuses_symlinked_workspace_parent() {
1743        let host = tempfile::tempdir().expect("host");
1744        let worktree = tempfile::tempdir().expect("worktree");
1745        let outside = tempfile::tempdir().expect("outside");
1746        fs::write(
1747            host.path().join("package.json"),
1748            r#"{"private":true,"workspaces":["packages/*"]}"#,
1749        )
1750        .expect("root package");
1751        let host_workspace = host.path().join("packages/app");
1752        fs::create_dir_all(host_workspace.join(".nuxt")).expect("host generated context");
1753        fs::write(host_workspace.join("package.json"), r#"{"name":"app"}"#)
1754            .expect("workspace package");
1755        fs::write(host_workspace.join(".nuxt/imports.d.ts"), "export {};\n")
1756            .expect("generated marker");
1757
1758        let outside_workspace = outside.path().join("app");
1759        fs::create_dir_all(&outside_workspace).expect("outside workspace");
1760        let outside_generated = outside_workspace.join(".nuxt");
1761        std::os::unix::fs::symlink("missing-target", &outside_generated)
1762            .expect("outside sentinel symlink");
1763        std::os::unix::fs::symlink(outside.path(), worktree.path().join("packages"))
1764            .expect("hostile workspace parent symlink");
1765
1766        materialize_base_dependency_context(host.path(), worktree.path());
1767
1768        assert_eq!(
1769            fs::read_link(&outside_generated).expect("sentinel symlink must survive"),
1770            PathBuf::from("missing-target")
1771        );
1772        assert!(
1773            !outside.path().join(".nuxt").exists(),
1774            "materialization must not create generated context outside the worktree"
1775        );
1776    }
1777
1778    #[test]
1779    fn audit_context_fingerprint_rejects_oversized_files_without_reading_them() {
1780        let temp = tempfile::tempdir().expect("temp dir");
1781        let path = temp.path().join("pnpm-lock.yaml");
1782        let file = File::create(&path).expect("oversized file");
1783        file.set_len(AUDIT_CONTEXT_FILE_MAX_BYTES.saturating_add(1))
1784            .expect("set oversized length");
1785
1786        let fingerprint = fingerprint_context_file_at(&path, "pnpm-lock.yaml");
1787
1788        assert_eq!(
1789            fingerprint.state,
1790            AuditContextPathState::Unreadable(CONTEXT_OVERSIZED_FILE_STATE.to_string())
1791        );
1792        assert!(fingerprint.source.is_some());
1793        assert!(fingerprint.content_hash.is_none());
1794    }
1795
1796    #[cfg(unix)]
1797    #[test]
1798    fn audit_context_fingerprint_rejects_symlinked_files_without_following_them() {
1799        let temp = tempfile::tempdir().expect("temp dir");
1800        let target = temp.path().join("target-lock.yaml");
1801        let link = temp.path().join("pnpm-lock.yaml");
1802        fs::write(&target, "secret target contents\n").expect("target file");
1803        std::os::unix::fs::symlink(&target, &link).expect("lockfile symlink");
1804
1805        let fingerprint = fingerprint_context_file_at(&link, "pnpm-lock.yaml");
1806
1807        assert_eq!(
1808            fingerprint.state,
1809            AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
1810        );
1811        assert!(fingerprint.content_hash.is_none());
1812    }
1813
1814    #[cfg(unix)]
1815    #[test]
1816    fn audit_context_fingerprint_does_not_follow_symlink_swapped_before_open() {
1817        let temp = tempfile::tempdir().expect("temp dir");
1818        let path = temp.path().join("pnpm-lock.yaml");
1819        let target = temp.path().join("target-lock.yaml");
1820        fs::write(&path, "original contents\n").expect("original file");
1821        fs::write(&target, "secret target contents\n").expect("target file");
1822
1823        let fingerprint = fingerprint_context_file_at_with_hooks(
1824            &path,
1825            "pnpm-lock.yaml",
1826            || {
1827                fs::remove_file(&path).expect("remove original");
1828                std::os::unix::fs::symlink(&target, &path).expect("replacement symlink");
1829            },
1830            || {},
1831        );
1832
1833        assert_eq!(
1834            fingerprint.state,
1835            AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
1836        );
1837        assert!(fingerprint.content_hash.is_none());
1838    }
1839
1840    #[test]
1841    fn audit_context_fingerprint_rejects_file_changed_during_read() {
1842        let temp = tempfile::tempdir().expect("temp dir");
1843        let path = temp.path().join("pnpm-lock.yaml");
1844        fs::write(&path, "original contents\n").expect("original file");
1845
1846        let fingerprint = fingerprint_context_file_at_with_hooks(
1847            &path,
1848            "pnpm-lock.yaml",
1849            || {},
1850            || {
1851                OpenOptions::new()
1852                    .write(true)
1853                    .open(&path)
1854                    .expect("open replacement")
1855                    .set_len(1)
1856                    .expect("truncate replacement");
1857            },
1858        );
1859
1860        assert_eq!(
1861            fingerprint.state,
1862            AuditContextPathState::Unreadable(CONTEXT_CHANGED_DURING_READ_STATE.to_string())
1863        );
1864        assert!(fingerprint.content_hash.is_none());
1865    }
1866
1867    #[cfg(unix)]
1868    #[test]
1869    fn unix_context_open_does_not_block_on_fifo() {
1870        let temp = tempfile::tempdir().expect("temp dir");
1871        let fifo = temp.path().join("pnpm-lock.yaml");
1872        let status = Command::new("mkfifo")
1873            .arg(&fifo)
1874            .status()
1875            .expect("run mkfifo");
1876        assert!(status.success(), "mkfifo must create the test pipe");
1877
1878        let fallback_fifo = fifo.clone();
1879        let fallback_writer = std::thread::spawn(move || {
1880            std::thread::sleep(std::time::Duration::from_secs(1));
1881            OpenOptions::new()
1882                .read(true)
1883                .write(true)
1884                .open(fallback_fifo)
1885                .expect("open fallback FIFO writer")
1886        });
1887        let started = std::time::Instant::now();
1888        let fingerprint = fingerprint_context_file_at(&fifo, "pnpm-lock.yaml");
1889        let elapsed = started.elapsed();
1890
1891        assert!(
1892            elapsed < std::time::Duration::from_millis(500),
1893            "nonblocking FIFO open took {elapsed:?}"
1894        );
1895        assert_eq!(
1896            fingerprint.state,
1897            AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
1898        );
1899        assert!(fingerprint.content_hash.is_none());
1900        drop(fallback_writer.join().expect("fallback writer"));
1901    }
1902
1903    #[cfg(unix)]
1904    #[test]
1905    fn audit_context_fingerprint_rejects_special_files_without_opening_them() {
1906        use std::os::unix::net::UnixListener;
1907
1908        let temp = tempfile::tempdir().expect("temp dir");
1909        let socket = temp.path().join("pnpm-lock.yaml");
1910        let _listener = UnixListener::bind(&socket).expect("unix socket");
1911
1912        let fingerprint = fingerprint_context_file_at(&socket, "pnpm-lock.yaml");
1913
1914        assert_eq!(
1915            fingerprint.state,
1916            AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
1917        );
1918        assert!(fingerprint.content_hash.is_none());
1919    }
1920}