Skip to main content

fallow_engine/
repo_refs.rs

1//! Engine-owned repository reference probes and temporary repo views.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::time::SystemTime;
6
7use fallow_config::WorkspaceInfo;
8
9use crate::{EngineError, EngineResult};
10
11/// Resolved base ref for changed-code audit.
12#[derive(Debug, Clone)]
13pub struct ResolvedAuditBase {
14    /// Git ref or SHA used for comparison.
15    pub git_ref: String,
16    /// Human-readable source of the resolved ref.
17    pub description: Option<String>,
18}
19
20/// Temporary detached worktree for comparing audit results against a base ref.
21#[derive(Debug)]
22pub struct TemporaryBaseWorktree {
23    repo_root: PathBuf,
24    path: PathBuf,
25}
26
27impl TemporaryBaseWorktree {
28    /// Create a detached base worktree for `base_ref`.
29    ///
30    /// # Errors
31    ///
32    /// Returns an engine error when the temp path cannot be generated, `git`
33    /// cannot be started, or the worktree cannot be created.
34    pub fn create(repo_root: &Path, base_ref: &str) -> EngineResult<Self> {
35        let path = base_worktree_path()?;
36        let mut command = git_command(repo_root);
37        command
38            .arg("worktree")
39            .arg("add")
40            .arg("--detach")
41            .arg("--quiet")
42            .arg(&path)
43            .arg(base_ref);
44        let output = command.output().map_err(|err| {
45            EngineError::new(format!(
46                "could not create a temporary worktree for base ref `{base_ref}`: {err}"
47            ))
48        })?;
49        if !output.status.success() {
50            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
51            return Err(EngineError::new(format!(
52                "could not create a temporary worktree for base ref `{base_ref}`: {stderr}"
53            )));
54        }
55        Ok(Self {
56            repo_root: repo_root.to_path_buf(),
57            path,
58        })
59    }
60
61    /// Path to the detached worktree.
62    #[must_use]
63    pub fn path(&self) -> &Path {
64        &self.path
65    }
66}
67
68impl Drop for TemporaryBaseWorktree {
69    fn drop(&mut self) {
70        let mut command = git_command(&self.repo_root);
71        command
72            .arg("worktree")
73            .arg("remove")
74            .arg("--force")
75            .arg(&self.path);
76        let _ = command.output();
77        let _ = std::fs::remove_dir_all(&self.path);
78    }
79}
80
81/// Resolve the analysis root inside a detached base worktree.
82#[must_use]
83pub fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
84    let Some(git_root) = git_toplevel(current_root) else {
85        return base_worktree_root.to_path_buf();
86    };
87    let current_root =
88        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
89    match current_root.strip_prefix(&git_root) {
90        Ok(relative) => base_worktree_root.join(relative),
91        Err(_) => base_worktree_root.to_path_buf(),
92    }
93}
94
95/// Auto-detect the base ref used by changed-code audit.
96#[must_use]
97pub fn auto_detect_audit_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
98    if let Some(upstream) = git_upstream_ref(root) {
99        if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
100            return Some(ResolvedAuditBase {
101                git_ref: sha,
102                description: Some(format!("merge-base with {upstream}")),
103            });
104        }
105        return Some(ResolvedAuditBase {
106            description: Some(format!("{upstream} (tip)")),
107            git_ref: upstream,
108        });
109    }
110
111    if let Some(remote_ref) = detect_remote_default_ref(root) {
112        if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
113            return Some(ResolvedAuditBase {
114                git_ref: sha,
115                description: Some(format!("merge-base with {remote_ref}")),
116            });
117        }
118        return Some(ResolvedAuditBase {
119            description: Some(format!("{remote_ref} (tip)")),
120            git_ref: remote_ref,
121        });
122    }
123
124    for candidate in ["main", "master"] {
125        if git_ref_exists(root, candidate) {
126            return Some(ResolvedAuditBase {
127                git_ref: candidate.to_string(),
128                description: Some(format!("local {candidate}")),
129            });
130        }
131    }
132
133    None
134}
135
136/// Short SHA for the current HEAD.
137#[must_use]
138pub fn short_head_sha(root: &Path) -> Option<String> {
139    run_git(root, &["rev-parse", "--short", "HEAD"])
140}
141
142/// Resolve a concrete `--changed-workspaces` ref for project-level next steps.
143///
144/// Returns `None` when the project has no workspaces, is not a git repository,
145/// or has no resolvable remote default branch.
146#[must_use]
147pub fn default_workspace_ref(root: &Path) -> Option<String> {
148    let workspaces = crate::discover::discover_workspace_packages(root);
149    default_workspace_ref_for_workspaces(root, &workspaces)
150}
151
152/// Resolve a concrete `--changed-workspaces` ref using existing workspace data.
153#[must_use]
154pub fn default_workspace_ref_for_workspaces(
155    root: &Path,
156    workspaces: &[WorkspaceInfo],
157) -> Option<String> {
158    if workspaces.is_empty() || !crate::churn::is_git_repo(root) {
159        return None;
160    }
161    if let Some(reference) = run_git(
162        root,
163        &[
164            "symbolic-ref",
165            "--quiet",
166            "--short",
167            "refs/remotes/origin/HEAD",
168        ],
169    ) {
170        let reference = reference.trim();
171        if !reference.is_empty() {
172            return Some(reference.to_owned());
173        }
174    }
175    ["origin/main", "origin/master"]
176        .into_iter()
177        .find(|candidate| git_ref_exists(root, candidate))
178        .map(str::to_owned)
179}
180
181/// Git identities for the current user in forms useful for self-routing.
182///
183/// Includes `user.email`, its local-part handle, a GitHub no-reply unwrapped
184/// handle when applicable, and `user.name`. Missing config values are ignored.
185#[must_use]
186pub fn current_user_identities(root: &Path) -> Vec<String> {
187    let mut ids = Vec::new();
188    if let Some(email) = read_git_config(root, "user.email") {
189        if let Some((local, _)) = email.split_once('@') {
190            ids.push(local.rsplit('+').next().unwrap_or(local).to_owned());
191        }
192        ids.push(email);
193    }
194    if let Some(name) = read_git_config(root, "user.name") {
195        ids.push(name);
196    }
197    ids
198}
199
200fn read_git_config(root: &Path, key: &str) -> Option<String> {
201    let value = run_git(root, &["config", "--get", key])?;
202    let trimmed = value.trim();
203    (!trimmed.is_empty()).then(|| trimmed.to_owned())
204}
205
206fn git_ref_exists(root: &Path, reference: &str) -> bool {
207    run_git(root, &["rev-parse", "--verify", "--quiet", reference]).is_some()
208}
209
210fn git_toplevel(root: &Path) -> Option<PathBuf> {
211    run_git(root, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
212}
213
214fn git_upstream_ref(root: &Path) -> Option<String> {
215    run_git(
216        root,
217        &[
218            "rev-parse",
219            "--abbrev-ref",
220            "--symbolic-full-name",
221            "@{upstream}",
222        ],
223    )
224}
225
226fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
227    run_git(root, &["merge-base", a, b])
228}
229
230fn detect_remote_default_ref(root: &Path) -> Option<String> {
231    if let Some(full_ref) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
232        && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
233    {
234        return Some(format!("origin/{branch}"));
235    }
236    ["origin/main", "origin/master"]
237        .into_iter()
238        .find(|candidate| git_ref_exists(root, candidate))
239        .map(str::to_string)
240}
241
242fn base_worktree_path() -> EngineResult<PathBuf> {
243    let nanos = SystemTime::now()
244        .duration_since(SystemTime::UNIX_EPOCH)
245        .map_err(|err| EngineError::new(format!("system clock before unix epoch: {err}")))?
246        .as_nanos();
247    Ok(std::env::temp_dir().join(format!("fallow-audit-base-{}-{nanos}", std::process::id())))
248}
249
250#[expect(
251    clippy::disallowed_methods,
252    reason = "canonical engine-owned git spawn wrapper for repository refs"
253)]
254fn git_command(root: &Path) -> Command {
255    let mut command = Command::new("git");
256    crate::changed_files::clear_ambient_git_env(&mut command);
257    command.arg("-C").arg(root);
258    command
259}
260
261fn run_git(root: &Path, args: &[&str]) -> Option<String> {
262    let output = git_command(root).args(args).output().ok()?;
263    if !output.status.success() {
264        return None;
265    }
266    String::from_utf8(output.stdout).ok()
267}
268
269#[cfg(test)]
270mod tests {
271    use std::path::PathBuf;
272
273    use super::*;
274
275    #[test]
276    fn default_workspace_ref_skips_projects_without_workspaces() {
277        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[]).is_none());
278    }
279
280    #[test]
281    fn default_workspace_ref_skips_non_git_workspace_projects() {
282        let workspace = WorkspaceInfo {
283            root: PathBuf::from("/repo/packages/app"),
284            name: "app".to_owned(),
285            is_internal_dependency: false,
286        };
287
288        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[workspace]).is_none());
289    }
290
291    #[test]
292    fn current_user_identities_empty_when_git_config_is_unavailable() {
293        assert!(current_user_identities(Path::new("/repo")).is_empty());
294    }
295}