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