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;
10
11use crate::{EngineError, EngineResult};
12
13const RAW_MATERIALIZATION_MARKER: &str = "fallow-raw-materialized-v1";
14
15/// Resolved base ref for changed-code audit.
16#[derive(Debug, Clone)]
17pub struct ResolvedAuditBase {
18    /// Git ref or SHA used for comparison.
19    pub git_ref: String,
20    /// Human-readable source of the resolved ref.
21    pub description: Option<String>,
22}
23
24/// Temporary detached worktree for comparing audit results against a base ref.
25#[derive(Debug)]
26pub struct TemporaryBaseWorktree {
27    repo_root: PathBuf,
28    path: PathBuf,
29}
30
31impl TemporaryBaseWorktree {
32    /// Create a detached base worktree for `base_ref`.
33    ///
34    /// # Errors
35    ///
36    /// Returns an engine error when the temp path cannot be generated, `git`
37    /// cannot be started, or the worktree cannot be created.
38    pub fn create(repo_root: &Path, base_ref: &str) -> EngineResult<Self> {
39        let path = base_worktree_path()?;
40        create_detached_base_worktree(repo_root, &path, base_ref)?;
41        Ok(Self {
42            repo_root: repo_root.to_path_buf(),
43            path,
44        })
45    }
46
47    /// Path to the detached worktree.
48    #[must_use]
49    pub fn path(&self) -> &Path {
50        &self.path
51    }
52}
53
54/// Register a detached worktree without checking files out, then materialize
55/// the committed tree directly from Git objects.
56///
57/// This deliberately avoids Git's checkout pipeline. No checkout hook,
58/// smudge filter, process filter, line-ending conversion, or working-tree
59/// encoding is invoked. Regular files contain the raw committed blob bytes.
60///
61/// # Errors
62///
63/// Returns an engine error when the destination is not absolute, Git cannot
64/// create the administrative worktree, the tree contains an unsafe path, or
65/// an object cannot be materialized. A failed materialization removes both
66/// the worktree registration and its partial directory.
67pub fn create_detached_base_worktree(
68    repo_root: &Path,
69    destination: &Path,
70    base_ref: &str,
71) -> EngineResult<()> {
72    if !destination.is_absolute() {
73        return Err(EngineError::new(format!(
74            "base worktree destination must be absolute: {}",
75            destination.display()
76        )));
77    }
78
79    register_no_checkout_worktree(repo_root, destination, base_ref)?;
80    let result = resolve_registered_commit(destination, base_ref)
81        .and_then(|commit| {
82            populate_worktree_index(destination, &commit)?;
83            materialize_committed_tree(repo_root, destination, &commit)
84        })
85        .and_then(|()| write_raw_materialization_marker(destination));
86    if let Err(error) = result {
87        remove_registered_worktree(repo_root, destination);
88        let _ = fs::remove_dir_all(destination);
89        return Err(error);
90    }
91    Ok(())
92}
93
94/// Return whether a linked worktree was completely materialized by the raw
95/// object path used by [`create_detached_base_worktree`].
96///
97/// Reusable audit caches created by older versions have no marker and must be
98/// rebuilt once so smudged or checkout-generated contents are not reused.
99#[must_use]
100pub fn detached_base_worktree_is_raw_materialized(worktree_root: &Path) -> bool {
101    raw_materialization_marker_path(worktree_root).is_ok_and(|path| path.is_file())
102}
103
104fn write_raw_materialization_marker(worktree_root: &Path) -> EngineResult<()> {
105    let marker = raw_materialization_marker_path(worktree_root)?;
106    fs::write(&marker, b"raw-v1\n").map_err(|error| {
107        EngineError::new(format!(
108            "could not record raw base-worktree materialization at `{}`: {error}",
109            marker.display()
110        ))
111    })
112}
113
114fn raw_materialization_marker_path(worktree_root: &Path) -> EngineResult<PathBuf> {
115    let marker = run_git(
116        worktree_root,
117        &["rev-parse", "--git-path", RAW_MATERIALIZATION_MARKER],
118    )
119    .ok_or_else(|| EngineError::new("could not resolve base-worktree materialization marker"))?;
120    let marker = PathBuf::from(marker.trim());
121    if marker.is_absolute() {
122        Ok(marker)
123    } else {
124        Ok(worktree_root.join(marker))
125    }
126}
127
128fn register_no_checkout_worktree(
129    repo_root: &Path,
130    destination: &Path,
131    base_ref: &str,
132) -> EngineResult<()> {
133    let mut command = git_command(repo_root);
134    command.args([
135        "worktree",
136        "add",
137        "--detach",
138        "--quiet",
139        "--no-checkout",
140        "--",
141    ]);
142    command.arg(destination).arg(base_ref);
143    let output = command.output().map_err(|error| {
144        EngineError::new(format!(
145            "could not create a temporary worktree for base ref `{base_ref}`: {error}"
146        ))
147    })?;
148    if !output.status.success() {
149        return Err(EngineError::new(format!(
150            "could not create a temporary worktree for base ref `{base_ref}`: {}",
151            String::from_utf8_lossy(&output.stderr).trim()
152        )));
153    }
154    Ok(())
155}
156
157fn resolve_registered_commit(destination: &Path, base_ref: &str) -> EngineResult<String> {
158    run_git(destination, &["rev-parse", "--verify", "HEAD^{commit}"])
159        .map(|commit| commit.trim().to_owned())
160        .ok_or_else(|| {
161            EngineError::new(format!(
162                "could not resolve the commit for base ref `{base_ref}` after creating the worktree"
163            ))
164        })
165}
166
167fn populate_worktree_index(destination: &Path, commit: &str) -> EngineResult<()> {
168    let disabled_hooks_path = destination.join(".fallow-disabled-git-hooks");
169    let output = git_command(destination)
170        .env("GIT_CONFIG_COUNT", "2")
171        .env("GIT_CONFIG_KEY_0", "core.hooksPath")
172        .env("GIT_CONFIG_VALUE_0", disabled_hooks_path)
173        .env("GIT_CONFIG_KEY_1", "core.fsmonitor")
174        .env("GIT_CONFIG_VALUE_1", "false")
175        .args(["read-tree", "--reset", commit])
176        .output()
177        .map_err(|error| {
178            EngineError::new(format!("could not populate base worktree index: {error}"))
179        })?;
180    if !output.status.success() {
181        return Err(EngineError::new(format!(
182            "could not populate base worktree index: {}",
183            String::from_utf8_lossy(&output.stderr).trim()
184        )));
185    }
186    Ok(())
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190enum TreeEntryKind {
191    Regular,
192    Executable,
193    Symlink,
194    Gitlink,
195}
196
197#[derive(Debug)]
198struct TreeEntry {
199    kind: TreeEntryKind,
200    object_id: String,
201    path: PathBuf,
202}
203
204fn materialize_committed_tree(
205    repo_root: &Path,
206    destination: &Path,
207    commit: &str,
208) -> EngineResult<()> {
209    let entries = committed_tree_entries(repo_root, commit)?;
210    let mut blobs = BatchBlobReader::spawn(repo_root)?;
211    let mut symlinks = Vec::new();
212
213    for entry in entries {
214        create_safe_parent_directories(destination, &entry.path)?;
215        let output_path = destination.join(&entry.path);
216        match entry.kind {
217            TreeEntryKind::Regular | TreeEntryKind::Executable => {
218                let mut file = OpenOptions::new()
219                    .create_new(true)
220                    .write(true)
221                    .open(&output_path)
222                    .map_err(|error| materialization_error(&entry.path, error))?;
223                blobs.copy_blob(&entry.object_id, &entry.path, &mut file)?;
224                set_regular_file_mode(&output_path, entry.kind == TreeEntryKind::Executable)?;
225            }
226            TreeEntryKind::Symlink => {
227                let target = blobs.read_blob(&entry.object_id, &entry.path)?;
228                symlinks.push((entry.path, target));
229            }
230            TreeEntryKind::Gitlink => {
231                fs::create_dir(&output_path)
232                    .map_err(|error| materialization_error(&entry.path, error))?;
233            }
234        }
235    }
236
237    blobs.finish()?;
238    for (path, target) in symlinks {
239        create_safe_parent_directories(destination, &path)?;
240        create_materialized_symlink(&destination.join(&path), &target)
241            .map_err(|error| materialization_error(&path, error))?;
242    }
243    Ok(())
244}
245
246fn committed_tree_entries(repo_root: &Path, commit: &str) -> EngineResult<Vec<TreeEntry>> {
247    let output = git_command(repo_root)
248        .args(["ls-tree", "-r", "-z", "--full-tree", commit])
249        .output()
250        .map_err(|error| EngineError::new(format!("could not read base commit tree: {error}")))?;
251    if !output.status.success() {
252        return Err(EngineError::new(format!(
253            "could not read base commit tree: {}",
254            String::from_utf8_lossy(&output.stderr).trim()
255        )));
256    }
257
258    output
259        .stdout
260        .split(|byte| *byte == 0)
261        .filter(|record| !record.is_empty())
262        .map(parse_tree_entry)
263        .collect()
264}
265
266fn parse_tree_entry(record: &[u8]) -> EngineResult<TreeEntry> {
267    let tab = record
268        .iter()
269        .position(|byte| *byte == b'\t')
270        .ok_or_else(|| EngineError::new("could not parse base commit tree entry without a path"))?;
271    let header = std::str::from_utf8(&record[..tab])
272        .map_err(|error| EngineError::new(format!("invalid Git tree header: {error}")))?;
273    let mut fields = header.split_ascii_whitespace();
274    let mode = fields.next().unwrap_or_default();
275    let object_type = fields.next().unwrap_or_default();
276    let object_id = fields.next().unwrap_or_default();
277    if fields.next().is_some() || object_id.is_empty() {
278        return Err(EngineError::new(format!(
279            "could not parse Git tree header `{header}`"
280        )));
281    }
282    let kind = match (mode, object_type) {
283        ("100644", "blob") => TreeEntryKind::Regular,
284        ("100755", "blob") => TreeEntryKind::Executable,
285        ("120000", "blob") => TreeEntryKind::Symlink,
286        ("160000", "commit") => TreeEntryKind::Gitlink,
287        _ => {
288            return Err(EngineError::new(format!(
289                "unsupported Git tree entry mode `{mode}` and type `{object_type}`"
290            )));
291        }
292    };
293    let path = git_path_from_bytes(&record[tab + 1..])?;
294    validate_materialized_path(&path)?;
295    Ok(TreeEntry {
296        kind,
297        object_id: object_id.to_owned(),
298        path,
299    })
300}
301
302#[cfg(unix)]
303#[expect(
304    clippy::unnecessary_wraps,
305    reason = "shared cross-platform signature; non-Unix path decoding is fallible"
306)]
307fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
308    use std::os::unix::ffi::OsStringExt as _;
309
310    Ok(std::ffi::OsString::from_vec(bytes.to_vec()).into())
311}
312
313#[cfg(not(unix))]
314fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
315    String::from_utf8(bytes.to_vec())
316        .map(PathBuf::from)
317        .map_err(|error| EngineError::new(format!("Git tree path is not valid UTF-8: {error}")))
318}
319
320fn validate_materialized_path(path: &Path) -> EngineResult<()> {
321    if path.as_os_str().is_empty() || path.is_absolute() {
322        return Err(unsafe_tree_path(path));
323    }
324
325    let mut saw_component = false;
326    for component in path.components() {
327        let Component::Normal(segment) = component else {
328            return Err(unsafe_tree_path(path));
329        };
330        saw_component = true;
331        if segment.to_str().is_some_and(is_git_admin_alias) {
332            return Err(unsafe_tree_path(path));
333        }
334    }
335    if !saw_component {
336        return Err(unsafe_tree_path(path));
337    }
338    Ok(())
339}
340
341fn is_git_admin_alias(segment: &str) -> bool {
342    let normalized = segment.trim_end_matches([' ', '.']).to_ascii_lowercase();
343    normalized == ".git" || normalized == "git~1"
344}
345
346fn unsafe_tree_path(path: &Path) -> EngineError {
347    EngineError::new(format!(
348        "refusing to materialize unsafe Git tree path `{}`",
349        path.display()
350    ))
351}
352
353fn create_safe_parent_directories(root: &Path, relative: &Path) -> EngineResult<()> {
354    let Some(parent) = relative.parent() else {
355        return Ok(());
356    };
357    let mut current = root.to_path_buf();
358    for component in parent.components() {
359        let Component::Normal(segment) = component else {
360            return Err(unsafe_tree_path(relative));
361        };
362        current.push(segment);
363        match fs::symlink_metadata(&current) {
364            Ok(metadata) if metadata.file_type().is_dir() => {}
365            Ok(_) => {
366                return Err(EngineError::new(format!(
367                    "refusing to materialize through non-directory path `{}`",
368                    current.display()
369                )));
370            }
371            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
372                fs::create_dir(&current).map_err(|error| materialization_error(relative, error))?;
373            }
374            Err(error) => return Err(materialization_error(relative, error)),
375        }
376    }
377    Ok(())
378}
379
380fn materialization_error(path: &Path, error: impl std::fmt::Display) -> EngineError {
381    EngineError::new(format!(
382        "could not materialize base commit path `{}`: {error}",
383        path.display()
384    ))
385}
386
387#[cfg(unix)]
388fn set_regular_file_mode(path: &Path, executable: bool) -> EngineResult<()> {
389    use std::os::unix::fs::PermissionsExt as _;
390
391    let mode = if executable { 0o755 } else { 0o644 };
392    let permissions = fs::Permissions::from_mode(mode);
393    fs::set_permissions(path, permissions).map_err(|error| materialization_error(path, error))
394}
395
396#[cfg(not(unix))]
397#[expect(
398    clippy::unnecessary_wraps,
399    reason = "shared cross-platform signature; Unix permission updates are fallible"
400)]
401fn set_regular_file_mode(_path: &Path, _executable: bool) -> EngineResult<()> {
402    Ok(())
403}
404
405#[cfg(unix)]
406fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
407    use std::os::unix::ffi::OsStringExt as _;
408
409    std::os::unix::fs::symlink(std::ffi::OsString::from_vec(target.to_vec()), path)
410}
411
412#[cfg(windows)]
413fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
414    let target_path = PathBuf::from(String::from_utf8_lossy(target).into_owned());
415    let resolved_target = path
416        .parent()
417        .map_or_else(|| target_path.clone(), |parent| parent.join(&target_path));
418    let result = if resolved_target.is_dir() {
419        std::os::windows::fs::symlink_dir(&target_path, path)
420    } else {
421        std::os::windows::fs::symlink_file(&target_path, path)
422    };
423    result.or_else(|_| fs::write(path, target))
424}
425
426#[cfg(not(any(unix, windows)))]
427fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
428    fs::write(path, target)
429}
430
431struct BatchBlobReader {
432    child: Option<Child>,
433    stdin: Option<ChildStdin>,
434    stdout: BufReader<ChildStdout>,
435}
436
437impl BatchBlobReader {
438    fn spawn(repo_root: &Path) -> EngineResult<Self> {
439        let mut command = git_command(repo_root);
440        command
441            .args(["cat-file", "--batch"])
442            .stdin(Stdio::piped())
443            .stdout(Stdio::piped())
444            .stderr(Stdio::null());
445        let mut child = command.spawn().map_err(|error| {
446            EngineError::new(format!("could not start Git object reader: {error}"))
447        })?;
448        let stdin = child
449            .stdin
450            .take()
451            .ok_or_else(|| EngineError::new("Git object reader has no stdin pipe"))?;
452        let stdout = child
453            .stdout
454            .take()
455            .ok_or_else(|| EngineError::new("Git object reader has no stdout pipe"))?;
456        Ok(Self {
457            child: Some(child),
458            stdin: Some(stdin),
459            stdout: BufReader::new(stdout),
460        })
461    }
462
463    fn copy_blob(&mut self, object_id: &str, path: &Path, target: &mut File) -> EngineResult<()> {
464        let size = self.request_blob(object_id, path)?;
465        let copied = std::io::copy(&mut self.stdout.by_ref().take(size), target)
466            .map_err(|error| materialization_error(path, error))?;
467        if copied != size {
468            return Err(EngineError::new(format!(
469                "Git object reader returned {copied} of {size} bytes for `{}`",
470                path.display()
471            )));
472        }
473        self.consume_blob_terminator(path)
474    }
475
476    fn read_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<Vec<u8>> {
477        let size = self.request_blob(object_id, path)?;
478        let size = usize::try_from(size).map_err(|error| materialization_error(path, error))?;
479        let mut bytes = vec![0; size];
480        self.stdout
481            .read_exact(&mut bytes)
482            .map_err(|error| materialization_error(path, error))?;
483        self.consume_blob_terminator(path)?;
484        Ok(bytes)
485    }
486
487    fn request_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<u64> {
488        let stdin = self
489            .stdin
490            .as_mut()
491            .ok_or_else(|| EngineError::new("Git object reader stdin is closed"))?;
492        writeln!(stdin, "{object_id}").map_err(|error| materialization_error(path, error))?;
493        stdin
494            .flush()
495            .map_err(|error| materialization_error(path, error))?;
496
497        let mut header = Vec::new();
498        self.stdout
499            .read_until(b'\n', &mut header)
500            .map_err(|error| materialization_error(path, error))?;
501        let header = std::str::from_utf8(&header)
502            .map_err(|error| materialization_error(path, error))?
503            .trim_end();
504        let mut fields = header.split_ascii_whitespace();
505        let returned_id = fields.next().unwrap_or_default();
506        let object_type = fields.next().unwrap_or_default();
507        let size = fields.next().unwrap_or_default();
508        if returned_id != object_id || object_type != "blob" || fields.next().is_some() {
509            return Err(EngineError::new(format!(
510                "unexpected Git object response `{header}` for `{}`",
511                path.display()
512            )));
513        }
514        size.parse::<u64>()
515            .map_err(|error| materialization_error(path, error))
516    }
517
518    fn consume_blob_terminator(&mut self, path: &Path) -> EngineResult<()> {
519        let mut terminator = [0; 1];
520        self.stdout
521            .read_exact(&mut terminator)
522            .map_err(|error| materialization_error(path, error))?;
523        if terminator != [b'\n'] {
524            return Err(EngineError::new(format!(
525                "Git object response for `{}` had no terminator",
526                path.display()
527            )));
528        }
529        Ok(())
530    }
531
532    fn finish(mut self) -> EngineResult<()> {
533        self.stdin.take();
534        let status = self
535            .child
536            .take()
537            .ok_or_else(|| EngineError::new("Git object reader is already closed"))?
538            .wait()
539            .map_err(|error| {
540                EngineError::new(format!("could not wait for Git object reader: {error}"))
541            })?;
542        if !status.success() {
543            return Err(EngineError::new(format!(
544                "Git object reader exited with status {status}"
545            )));
546        }
547        Ok(())
548    }
549}
550
551impl Drop for BatchBlobReader {
552    fn drop(&mut self) {
553        self.stdin.take();
554        if let Some(mut child) = self.child.take() {
555            let _ = child.kill();
556            let _ = child.wait();
557        }
558    }
559}
560
561fn remove_registered_worktree(repo_root: &Path, destination: &Path) {
562    let _ = git_command(repo_root)
563        .args(["worktree", "remove", "--force"])
564        .arg(destination)
565        .output();
566}
567
568impl Drop for TemporaryBaseWorktree {
569    fn drop(&mut self) {
570        let mut command = git_command(&self.repo_root);
571        command
572            .arg("worktree")
573            .arg("remove")
574            .arg("--force")
575            .arg(&self.path);
576        let _ = command.output();
577        let _ = std::fs::remove_dir_all(&self.path);
578    }
579}
580
581/// Resolve the analysis root inside a detached base worktree.
582#[must_use]
583pub fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
584    let Some(git_root) = git_toplevel(current_root) else {
585        return base_worktree_root.to_path_buf();
586    };
587    let current_root =
588        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
589    match current_root.strip_prefix(&git_root) {
590        Ok(relative) => base_worktree_root.join(relative),
591        Err(_) => base_worktree_root.to_path_buf(),
592    }
593}
594
595/// Auto-detect the base ref used by changed-code audit.
596#[must_use]
597pub fn auto_detect_audit_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
598    if let Some(upstream) = git_upstream_ref(root) {
599        if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
600            return Some(ResolvedAuditBase {
601                git_ref: sha,
602                description: Some(format!("merge-base with {upstream}")),
603            });
604        }
605        return Some(ResolvedAuditBase {
606            description: Some(format!("{upstream} (tip)")),
607            git_ref: upstream,
608        });
609    }
610
611    if let Some(remote_ref) = detect_remote_default_ref(root) {
612        if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
613            return Some(ResolvedAuditBase {
614                git_ref: sha,
615                description: Some(format!("merge-base with {remote_ref}")),
616            });
617        }
618        return Some(ResolvedAuditBase {
619            description: Some(format!("{remote_ref} (tip)")),
620            git_ref: remote_ref,
621        });
622    }
623
624    for candidate in ["main", "master"] {
625        if git_ref_exists(root, candidate) {
626            return Some(ResolvedAuditBase {
627                git_ref: candidate.to_string(),
628                description: Some(format!("local {candidate}")),
629            });
630        }
631    }
632
633    None
634}
635
636/// Short SHA for the current HEAD.
637#[must_use]
638pub fn short_head_sha(root: &Path) -> Option<String> {
639    run_git(root, &["rev-parse", "--short", "HEAD"])
640}
641
642/// Resolve a concrete `--changed-workspaces` ref for project-level next steps.
643///
644/// Returns `None` when the project has no workspaces, is not a git repository,
645/// or has no resolvable remote default branch.
646#[must_use]
647pub fn default_workspace_ref(root: &Path) -> Option<String> {
648    let workspaces = crate::discover::discover_workspace_packages(root);
649    default_workspace_ref_for_workspaces(root, &workspaces)
650}
651
652/// Resolve a concrete `--changed-workspaces` ref using existing workspace data.
653#[must_use]
654pub fn default_workspace_ref_for_workspaces(
655    root: &Path,
656    workspaces: &[WorkspaceInfo],
657) -> Option<String> {
658    if workspaces.is_empty() || !crate::churn::is_git_repo(root) {
659        return None;
660    }
661    if let Some(reference) = run_git(
662        root,
663        &[
664            "symbolic-ref",
665            "--quiet",
666            "--short",
667            "refs/remotes/origin/HEAD",
668        ],
669    ) {
670        let reference = reference.trim();
671        if !reference.is_empty() {
672            return Some(reference.to_owned());
673        }
674    }
675    ["origin/main", "origin/master"]
676        .into_iter()
677        .find(|candidate| git_ref_exists(root, candidate))
678        .map(str::to_owned)
679}
680
681/// Git identities for the current user in forms useful for self-routing.
682///
683/// Includes `user.email`, its local-part handle, a GitHub no-reply unwrapped
684/// handle when applicable, and `user.name`. Missing config values are ignored.
685#[must_use]
686pub fn current_user_identities(root: &Path) -> Vec<String> {
687    let mut ids = Vec::new();
688    if let Some(email) = read_git_config(root, "user.email") {
689        if let Some((local, _)) = email.split_once('@') {
690            ids.push(local.rsplit('+').next().unwrap_or(local).to_owned());
691        }
692        ids.push(email);
693    }
694    if let Some(name) = read_git_config(root, "user.name") {
695        ids.push(name);
696    }
697    ids
698}
699
700fn read_git_config(root: &Path, key: &str) -> Option<String> {
701    let value = run_git(root, &["config", "--get", key])?;
702    let trimmed = value.trim();
703    (!trimmed.is_empty()).then(|| trimmed.to_owned())
704}
705
706fn git_ref_exists(root: &Path, reference: &str) -> bool {
707    run_git(root, &["rev-parse", "--verify", "--quiet", reference]).is_some()
708}
709
710fn git_toplevel(root: &Path) -> Option<PathBuf> {
711    run_git(root, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
712}
713
714fn git_upstream_ref(root: &Path) -> Option<String> {
715    run_git(
716        root,
717        &[
718            "rev-parse",
719            "--abbrev-ref",
720            "--symbolic-full-name",
721            "@{upstream}",
722        ],
723    )
724}
725
726fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
727    run_git(root, &["merge-base", a, b])
728}
729
730fn detect_remote_default_ref(root: &Path) -> Option<String> {
731    if let Some(full_ref) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
732        && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
733    {
734        return Some(format!("origin/{branch}"));
735    }
736    ["origin/main", "origin/master"]
737        .into_iter()
738        .find(|candidate| git_ref_exists(root, candidate))
739        .map(str::to_string)
740}
741
742fn base_worktree_path() -> EngineResult<PathBuf> {
743    let nanos = SystemTime::now()
744        .duration_since(SystemTime::UNIX_EPOCH)
745        .map_err(|err| EngineError::new(format!("system clock before unix epoch: {err}")))?
746        .as_nanos();
747    Ok(std::env::temp_dir().join(format!("fallow-audit-base-{}-{nanos}", std::process::id())))
748}
749
750#[expect(
751    clippy::disallowed_methods,
752    reason = "canonical engine-owned git spawn wrapper for repository refs"
753)]
754fn git_command(root: &Path) -> Command {
755    let mut command = Command::new("git");
756    crate::changed_files::clear_ambient_git_env(&mut command);
757    command.arg("-C").arg(root);
758    command
759}
760
761fn run_git(root: &Path, args: &[&str]) -> Option<String> {
762    let output = git_command(root).args(args).output().ok()?;
763    if !output.status.success() {
764        return None;
765    }
766    String::from_utf8(output.stdout).ok()
767}
768
769#[cfg(test)]
770mod tests {
771    use std::fs;
772    use std::path::PathBuf;
773    use std::process::Command;
774
775    use super::*;
776
777    fn git(root: &Path, args: &[&str]) -> String {
778        let output = Command::new("git")
779            .args(args)
780            .current_dir(root)
781            .env_remove("GIT_DIR")
782            .env_remove("GIT_WORK_TREE")
783            .output()
784            .expect("git command starts");
785        assert!(
786            output.status.success(),
787            "git {args:?} failed: {}",
788            String::from_utf8_lossy(&output.stderr)
789        );
790        String::from_utf8_lossy(&output.stdout).trim().to_owned()
791    }
792
793    fn init_repo(root: &Path) {
794        fs::create_dir_all(root).expect("create repo");
795        git(root, &["init", "-b", "main"]);
796        git(root, &["config", "user.name", "Test User"]);
797        git(root, &["config", "user.email", "test@example.com"]);
798        git(root, &["config", "commit.gpgsign", "false"]);
799    }
800
801    fn commit_all(root: &Path, message: &str) {
802        git(root, &["add", "."]);
803        git(root, &["commit", "-m", message]);
804    }
805
806    #[cfg(unix)]
807    fn write_executable(path: &Path, source: &str) {
808        use std::os::unix::fs::PermissionsExt as _;
809
810        fs::write(path, source).expect("write executable");
811        let mut permissions = fs::metadata(path)
812            .expect("executable metadata")
813            .permissions();
814        permissions.set_mode(0o755);
815        fs::set_permissions(path, permissions).expect("set executable mode");
816    }
817
818    #[test]
819    fn default_workspace_ref_skips_projects_without_workspaces() {
820        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[]).is_none());
821    }
822
823    #[test]
824    fn default_workspace_ref_skips_non_git_workspace_projects() {
825        let workspace = WorkspaceInfo {
826            root: PathBuf::from("/repo/packages/app"),
827            name: "app".to_owned(),
828            is_internal_dependency: false,
829        };
830
831        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[workspace]).is_none());
832    }
833
834    #[test]
835    fn current_user_identities_empty_when_git_config_is_unavailable() {
836        assert!(current_user_identities(Path::new("/repo")).is_empty());
837    }
838
839    #[cfg(unix)]
840    #[test]
841    fn temporary_base_worktree_does_not_run_post_checkout_hook() {
842        let temp = tempfile::tempdir().expect("temp dir");
843        let repo = temp.path().join("repo");
844        init_repo(&repo);
845        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
846        commit_all(&repo, "initial");
847
848        let sentinel = temp.path().join("post-checkout-ran");
849        write_executable(
850            &repo.join(".git/hooks/post-checkout"),
851            &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
852        );
853
854        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
855            .expect("temporary worktree should be created");
856
857        assert_eq!(
858            fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
859            "committed\n"
860        );
861        assert!(
862            !sentinel.exists(),
863            "creating a base view must not execute post-checkout hooks"
864        );
865    }
866
867    #[cfg(unix)]
868    #[test]
869    fn temporary_base_worktree_does_not_run_post_index_change_hook() {
870        let temp = tempfile::tempdir().expect("temp dir");
871        let repo = temp.path().join("repo");
872        init_repo(&repo);
873        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
874        commit_all(&repo, "initial");
875
876        let sentinel = temp.path().join("post-index-change-ran");
877        write_executable(
878            &repo.join(".git/hooks/post-index-change"),
879            &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
880        );
881
882        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
883            .expect("temporary worktree should be created");
884
885        assert_eq!(
886            fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
887            "committed\n"
888        );
889        assert!(
890            !sentinel.exists(),
891            "creating a base view must not execute post-index-change hooks"
892        );
893    }
894
895    #[cfg(unix)]
896    #[test]
897    fn temporary_base_worktree_does_not_run_smudge_filter() {
898        let temp = tempfile::tempdir().expect("temp dir");
899        let repo = temp.path().join("repo");
900        init_repo(&repo);
901        fs::write(
902            repo.join(".gitattributes"),
903            "filtered.txt filter=sentinel\n",
904        )
905        .expect("write attributes");
906        fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
907        commit_all(&repo, "initial");
908
909        let sentinel = temp.path().join("smudge-ran");
910        let filter = temp.path().join("smudge-filter.sh");
911        write_executable(
912            &filter,
913            &format!(
914                "#!/bin/sh\nprintf ran > '{}'\ncat >/dev/null\nprintf 'smudged bytes\\n'\n",
915                sentinel.display()
916            ),
917        );
918        git(
919            &repo,
920            &[
921                "config",
922                "filter.sentinel.smudge",
923                filter.to_str().expect("filter path is UTF-8"),
924            ],
925        );
926
927        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
928            .expect("temporary worktree should be created");
929
930        assert_eq!(
931            fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
932            b"committed raw bytes\n"
933        );
934        assert!(
935            !sentinel.exists(),
936            "creating a base view must not execute smudge filters"
937        );
938    }
939
940    #[cfg(unix)]
941    #[test]
942    fn temporary_base_worktree_does_not_start_process_filter() {
943        let temp = tempfile::tempdir().expect("temp dir");
944        let repo = temp.path().join("repo");
945        init_repo(&repo);
946        fs::write(
947            repo.join(".gitattributes"),
948            "filtered.txt filter=sentinel\n",
949        )
950        .expect("write attributes");
951        fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
952        commit_all(&repo, "initial");
953
954        let sentinel = temp.path().join("process-filter-ran");
955        let filter = temp.path().join("process-filter.sh");
956        write_executable(
957            &filter,
958            &format!("#!/bin/sh\nprintf ran > '{}'\nexit 1\n", sentinel.display()),
959        );
960        git(
961            &repo,
962            &[
963                "config",
964                "filter.sentinel.process",
965                filter.to_str().expect("filter path is UTF-8"),
966            ],
967        );
968
969        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
970            .expect("temporary worktree should be created");
971
972        assert_eq!(
973            fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
974            b"committed raw bytes\n"
975        );
976        assert!(
977            !sentinel.exists(),
978            "creating a base view must not start process filters"
979        );
980    }
981
982    #[test]
983    fn failed_registration_does_not_remove_existing_worktree() {
984        let temp = tempfile::tempdir().expect("temp dir");
985        let repo = temp.path().join("repo");
986        init_repo(&repo);
987        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
988        commit_all(&repo, "initial");
989        let destination = temp.path().join("base");
990
991        create_detached_base_worktree(&repo, &destination, "HEAD")
992            .expect("first worktree should be created");
993        let second = create_detached_base_worktree(&repo, &destination, "HEAD");
994
995        assert!(second.is_err(), "duplicate destination must fail");
996        assert!(
997            destination.join("tracked.txt").is_file(),
998            "failed registration must not remove the existing worktree"
999        );
1000        assert_eq!(git(&destination, &["rev-parse", "HEAD"]).len(), 40);
1001
1002        remove_registered_worktree(&repo, &destination);
1003        let _ = fs::remove_dir_all(destination);
1004    }
1005
1006    #[cfg(unix)]
1007    #[test]
1008    fn temporary_base_worktree_preserves_modes_symlinks_and_gitlinks() {
1009        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1010
1011        let temp = tempfile::tempdir().expect("temp dir");
1012        let repo = temp.path().join("repo");
1013        init_repo(&repo);
1014        fs::write(repo.join("regular.txt"), "regular\n").expect("write regular file");
1015        let executable = repo.join("run.sh");
1016        fs::write(&executable, "#!/bin/sh\nexit 0\n").expect("write executable");
1017        let mut permissions = fs::metadata(&executable)
1018            .expect("executable metadata")
1019            .permissions();
1020        permissions.set_mode(0o755);
1021        fs::set_permissions(&executable, permissions).expect("set executable mode");
1022        std::os::unix::fs::symlink("regular.txt", repo.join("regular-link"))
1023            .expect("create symlink");
1024        commit_all(&repo, "files");
1025
1026        let gitlink_commit = git(&repo, &["rev-parse", "HEAD"]);
1027        git(
1028            &repo,
1029            &[
1030                "update-index",
1031                "--add",
1032                "--cacheinfo",
1033                &format!("160000,{gitlink_commit},vendor/submodule"),
1034            ],
1035        );
1036        git(&repo, &["commit", "-m", "gitlink"]);
1037
1038        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1039            .expect("temporary worktree should be created");
1040        let regular_mode = fs::metadata(worktree.path().join("regular.txt"))
1041            .expect("regular metadata")
1042            .mode();
1043        let executable_mode = fs::metadata(worktree.path().join("run.sh"))
1044            .expect("executable metadata")
1045            .mode();
1046
1047        assert_eq!(regular_mode & 0o111, 0);
1048        assert_ne!(executable_mode & 0o111, 0);
1049        assert_eq!(
1050            fs::read_link(worktree.path().join("regular-link")).expect("read symlink"),
1051            PathBuf::from("regular.txt")
1052        );
1053        let gitlink = worktree.path().join("vendor/submodule");
1054        assert!(gitlink.is_dir(), "gitlink must materialize as a directory");
1055        assert!(
1056            fs::read_dir(gitlink)
1057                .expect("read gitlink directory")
1058                .next()
1059                .is_none(),
1060            "an uninitialized gitlink directory must remain empty"
1061        );
1062        assert!(
1063            git(
1064                worktree.path(),
1065                &["ls-files", "--stage", "vendor/submodule"]
1066            )
1067            .starts_with(&format!("160000 {gitlink_commit} 0\t")),
1068            "the linked worktree index must retain the gitlink object id"
1069        );
1070
1071        let path = worktree.path().to_path_buf();
1072        drop(worktree);
1073        assert!(!path.exists(), "temporary worktree must clean up on drop");
1074    }
1075
1076    #[test]
1077    fn materialized_tree_paths_reject_traversal_and_git_admin_aliases() {
1078        for path in [
1079            Path::new("../escape"),
1080            Path::new("/absolute"),
1081            Path::new(".git/config"),
1082            Path::new("nested/.GIT/config"),
1083            Path::new("nested/.git. /config"),
1084            Path::new("nested/git~1/config"),
1085        ] {
1086            assert!(
1087                validate_materialized_path(path).is_err(),
1088                "unsafe path should be rejected: {}",
1089                path.display()
1090            );
1091        }
1092        assert!(validate_materialized_path(Path::new("src/.github/file.ts")).is_ok());
1093    }
1094
1095    #[cfg(unix)]
1096    #[test]
1097    fn parent_directory_creation_refuses_symlink_traversal() {
1098        let temp = tempfile::tempdir().expect("temp dir");
1099        let root = temp.path().join("root");
1100        let outside = temp.path().join("outside");
1101        fs::create_dir(&root).expect("create root");
1102        fs::create_dir(&outside).expect("create outside");
1103        std::os::unix::fs::symlink(&outside, root.join("link")).expect("create parent symlink");
1104
1105        let result = create_safe_parent_directories(&root, Path::new("link/escaped.txt"));
1106
1107        assert!(result.is_err(), "symlink parent must be rejected");
1108        assert!(!outside.join("escaped.txt").exists());
1109    }
1110}