fallow_engine/
repo_refs.rs1use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::time::SystemTime;
6
7use fallow_config::WorkspaceInfo;
8
9use crate::{EngineError, EngineResult};
10
11#[derive(Debug, Clone)]
13pub struct ResolvedAuditBase {
14 pub git_ref: String,
16 pub description: Option<String>,
18}
19
20#[derive(Debug)]
22pub struct TemporaryBaseWorktree {
23 repo_root: PathBuf,
24 path: PathBuf,
25}
26
27impl TemporaryBaseWorktree {
28 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 #[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#[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#[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#[must_use]
138pub fn short_head_sha(root: &Path) -> Option<String> {
139 run_git(root, &["rev-parse", "--short", "HEAD"])
140}
141
142#[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#[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#[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}