1use anyhow::{Context, Result};
2use git2::Repository;
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use tracing::debug;
6
7#[non_exhaustive]
8pub struct GitDiff {
9 pub changed_files: Vec<PathBuf>,
10 pub repo_root: PathBuf,
11}
12
13pub fn changed_files(repo_path: &Path, base_ref: &str) -> Result<GitDiff> {
15 let repo = Repository::discover(repo_path).context("Not a git repository")?;
16
17 let repo_root = repo
18 .workdir()
19 .context("Bare repositories are not supported")?
20 .to_path_buf();
21
22 debug!("Found git repository at {}", repo_root.display());
23
24 let base_obj = repo
25 .revparse_single(base_ref)
26 .with_context(|| format!("Could not resolve base ref '{base_ref}'"))?;
27
28 debug!("Resolved base ref '{}' to {}", base_ref, base_obj.id());
29
30 let base_tree = base_obj
31 .peel_to_tree()
32 .with_context(|| format!("Could not peel '{base_ref}' to a tree"))?;
33
34 let head_ref = repo.head().context("Could not get HEAD")?;
35 let head_tree = head_ref
36 .peel_to_tree()
37 .context("Could not peel HEAD to a tree")?;
38
39 let mut index = repo.index().context("Could not read index")?;
41 index
42 .read(true)
43 .context("Could not refresh index from disk")?;
44
45 let mut files = HashSet::new();
46
47 let diff_committed = repo
49 .diff_tree_to_tree(Some(&base_tree), Some(&head_tree), None)
50 .context("Failed to diff base..HEAD")?;
51
52 for delta in diff_committed.deltas() {
53 if let Some(p) = delta.new_file().path() {
54 files.insert(p.to_path_buf());
55 }
56 if let Some(p) = delta.old_file().path() {
57 files.insert(p.to_path_buf());
58 }
59 }
60
61 let diff_uncommitted = repo
63 .diff_tree_to_workdir_with_index(Some(&head_tree), None)
64 .context("Failed to diff HEAD vs working tree")?;
65
66 for delta in diff_uncommitted.deltas() {
67 if let Some(p) = delta.new_file().path() {
68 files.insert(p.to_path_buf());
69 }
70 if let Some(p) = delta.old_file().path() {
71 files.insert(p.to_path_buf());
72 }
73 }
74
75 let mut changed_files: Vec<PathBuf> = files.into_iter().collect();
76 changed_files.sort();
77
78 debug!("Detected {} changed files", changed_files.len());
79
80 Ok(GitDiff {
81 changed_files,
82 repo_root,
83 })
84}
85
86pub fn merge_base(repo_path: &Path, branch: &str) -> Result<String> {
89 let repo = Repository::discover(repo_path).context("Not a git repository")?;
90
91 debug!("Computing merge-base between HEAD and '{}'", branch);
92
93 let head_oid = repo
94 .head()
95 .context("Could not get HEAD")?
96 .target()
97 .context("HEAD is not a direct reference")?;
98
99 let branch_obj = repo
100 .revparse_single(branch)
101 .with_context(|| format!("Could not resolve branch '{branch}'"))?;
102 let branch_oid = branch_obj.id();
103
104 let merge_base_oid = repo
105 .merge_base(head_oid, branch_oid)
106 .with_context(|| format!("Could not find merge-base between HEAD and '{branch}'"))?;
107
108 let sha = merge_base_oid.to_string();
109 debug!("Merge-base resolved to {}", sha);
110
111 Ok(sha)
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use std::process::Command;
118
119 fn git(dir: &Path, args: &[&str]) -> String {
120 let output = Command::new("git")
121 .args(args)
122 .current_dir(dir)
123 .env("GIT_AUTHOR_NAME", "test")
124 .env("GIT_AUTHOR_EMAIL", "test@test.com")
125 .env("GIT_COMMITTER_NAME", "test")
126 .env("GIT_COMMITTER_EMAIL", "test@test.com")
127 .output()
128 .expect("git command failed");
129 assert!(
130 output.status.success(),
131 "git {:?} failed: {}",
132 args,
133 String::from_utf8_lossy(&output.stderr)
134 );
135 String::from_utf8_lossy(&output.stdout).trim().to_string()
136 }
137
138 fn setup_repo(dir: &Path) {
139 git(dir, &["init"]);
140 git(dir, &["config", "user.email", "test@test.com"]);
141 git(dir, &["config", "user.name", "test"]);
142 std::fs::write(dir.join("file.txt"), "initial").unwrap();
143 git(dir, &["add", "."]);
144 git(dir, &["commit", "-m", "initial"]);
145 }
146
147 #[test]
148 fn test_changed_files_committed() {
149 let dir = tempfile::tempdir().unwrap();
150 setup_repo(dir.path());
151 let base = git(dir.path(), &["rev-parse", "HEAD"]);
152
153 std::fs::write(dir.path().join("new.txt"), "new content").unwrap();
154 git(dir.path(), &["add", "."]);
155 git(dir.path(), &["commit", "-m", "add new file"]);
156
157 let diff = changed_files(dir.path(), &base).unwrap();
158 assert!(diff.changed_files.iter().any(|f| f.ends_with("new.txt")));
159 }
160
161 #[test]
162 fn test_changed_files_uncommitted() {
163 let dir = tempfile::tempdir().unwrap();
164 setup_repo(dir.path());
165 let base = git(dir.path(), &["rev-parse", "HEAD"]);
166
167 std::fs::write(dir.path().join("file.txt"), "modified content").unwrap();
169
170 let diff = changed_files(dir.path(), &base).unwrap();
171 assert!(diff.changed_files.iter().any(|f| f.ends_with("file.txt")));
172 }
173
174 #[test]
175 fn test_changed_files_invalid_ref() {
176 let dir = tempfile::tempdir().unwrap();
177 setup_repo(dir.path());
178
179 let result = changed_files(dir.path(), "nonexistent_ref_xyz");
180 assert!(result.is_err());
181 }
182
183 #[test]
184 fn test_merge_base_computation() {
185 let dir = tempfile::tempdir().unwrap();
186 setup_repo(dir.path());
187
188 let default_branch = git(dir.path(), &["branch", "--show-current"]);
190 let main_sha = git(dir.path(), &["rev-parse", "HEAD"]);
191 git(dir.path(), &["checkout", "-b", "feature"]);
192 std::fs::write(dir.path().join("feature.txt"), "feature").unwrap();
193 git(dir.path(), &["add", "."]);
194 git(dir.path(), &["commit", "-m", "feature commit"]);
195
196 let result = merge_base(dir.path(), &default_branch).unwrap();
197 assert_eq!(result, main_sha);
198 }
199
200 #[test]
201 fn test_not_a_git_repo() {
202 let dir = tempfile::tempdir().unwrap();
203 let result = changed_files(dir.path(), "HEAD");
204 assert!(result.is_err());
205 }
206}