1use anyhow::{Context, Result, anyhow};
2use git2::{DiffOptions, Repository, Status, StatusOptions};
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6fn open_repository(workdir: &Path) -> Result<Repository> {
7 Repository::discover(workdir)
8 .with_context(|| format!("failed to open git repository from {}", workdir.display()))
9}
10
11pub fn repository_root(workdir: &Path) -> Result<PathBuf> {
12 let repo = open_repository(workdir)?;
13 if let Some(root) = repo.workdir() {
14 return Ok(root.to_path_buf());
15 }
16 repo.path()
17 .parent()
18 .map(Path::to_path_buf)
19 .ok_or_else(|| anyhow!("repository has no working directory"))
20}
21
22fn resolve_commit<'repo>(repo: &'repo Repository, spec: &str) -> Result<git2::Commit<'repo>> {
23 repo.revparse_single(spec)
24 .with_context(|| format!("failed to resolve git revision '{spec}'"))?
25 .peel_to_commit()
26 .with_context(|| format!("revision '{spec}' does not point to a commit"))
27}
28
29pub fn current_branch(workdir: &Path) -> Result<String> {
30 let repo = open_repository(workdir)?;
31 let head = repo.head().context("failed to read HEAD")?;
32 if !head.is_branch() {
33 return Err(anyhow!("HEAD is not attached to a branch"));
34 }
35 head.shorthand()
36 .map(str::to_string)
37 .ok_or_else(|| anyhow!("HEAD branch has no shorthand name"))
38}
39
40pub fn head_ref(workdir: &Path) -> Result<String> {
41 let repo = open_repository(workdir)?;
42 Ok(repo
43 .head()
44 .context("failed to read HEAD")?
45 .peel_to_commit()
46 .context("HEAD does not point to a commit")?
47 .id()
48 .to_string())
49}
50
51pub fn current_tag(workdir: &Path) -> Result<String> {
52 let repo = open_repository(workdir)?;
53 let head = repo
54 .head()
55 .context("failed to read HEAD")?
56 .peel_to_commit()
57 .context("HEAD does not point to a commit")?
58 .id();
59
60 let mut tags = Vec::new();
61 for reference in repo
62 .references_glob("refs/tags/*")
63 .context("failed to enumerate tags")?
64 {
65 let reference = reference.context("failed to read tag reference")?;
66 let Some(name) = reference.shorthand() else {
67 continue;
68 };
69 let Ok(commit) = reference.peel_to_commit() else {
70 continue;
71 };
72 if commit.id() == head {
73 tags.push(name.to_string());
74 }
75 }
76
77 tags.sort();
78 if tags.is_empty() {
79 return Err(anyhow!("no tag points at HEAD"));
80 }
81 if tags.len() > 1 {
82 return Err(anyhow!(
83 "multiple tags point at HEAD: {}; set CI_COMMIT_TAG or GIT_COMMIT_TAG explicitly",
84 tags.join(", ")
85 ));
86 }
87 Ok(tags.remove(0))
88}
89
90pub fn merge_base(workdir: &Path, base: &str, head: Option<&str>) -> Result<Option<String>> {
91 let repo = open_repository(workdir)?;
92 let Ok(base) = resolve_commit(&repo, base) else {
93 return Ok(None);
94 };
95 let Ok(head) = resolve_commit(&repo, head.unwrap_or("HEAD")) else {
96 return Ok(None);
97 };
98
99 match repo.merge_base(base.id(), head.id()) {
100 Ok(oid) => Ok(Some(oid.to_string())),
101 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
102 Err(err) => Err(err).context("failed to compute merge base"),
103 }
104}
105
106pub fn default_branch(workdir: &Path) -> Result<String> {
107 let repo = open_repository(workdir)?;
108 let reference = repo
109 .find_reference("refs/remotes/origin/HEAD")
110 .context("failed to read refs/remotes/origin/HEAD")?;
111 let target = reference
112 .symbolic_target()
113 .ok_or_else(|| anyhow!("origin HEAD is not a symbolic reference"))?;
114 target
115 .rsplit('/')
116 .next()
117 .map(str::to_string)
118 .ok_or_else(|| anyhow!("origin HEAD target has no branch segment"))
119}
120
121pub fn changed_files(
122 workdir: &Path,
123 base: Option<&str>,
124 head: Option<&str>,
125) -> Result<HashSet<String>> {
126 let repo = open_repository(workdir)?;
127 let mut opts = DiffOptions::new();
128
129 let diff = match (base, head) {
130 (Some(base), Some(head)) => {
131 let Ok(base) = resolve_commit(&repo, base) else {
132 return Ok(HashSet::new());
133 };
134 let Ok(head) = resolve_commit(&repo, head) else {
135 return Ok(HashSet::new());
136 };
137 let base_tree = base.tree().context("failed to read base tree")?;
138 let head_tree = head.tree().context("failed to read head tree")?;
139 repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))
140 .context("failed to diff git trees")?
141 }
142 (Some(base), None) => {
143 let Ok(base) = resolve_commit(&repo, base) else {
144 return Ok(HashSet::new());
145 };
146 let head = repo
147 .head()
148 .context("failed to read HEAD")?
149 .peel_to_commit()
150 .context("HEAD does not point to a commit")?;
151 let base_tree = base.tree().context("failed to read base tree")?;
152 let head_tree = head.tree().context("failed to read head tree")?;
153 repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))
154 .context("failed to diff git trees")?
155 }
156 (None, Some(head)) => {
157 let Ok(head) = resolve_commit(&repo, head) else {
158 return Ok(HashSet::new());
159 };
160 let head_tree = head.tree().context("failed to read head tree")?;
161 repo.diff_tree_to_workdir_with_index(Some(&head_tree), Some(&mut opts))
162 .context("failed to diff git tree against workdir")?
163 }
164 (None, None) => {
165 let head = repo
166 .head()
167 .context("failed to read HEAD")?
168 .peel_to_commit()
169 .context("HEAD does not point to a commit")?;
170 let Ok(parent) = head.parent(0) else {
171 return Ok(HashSet::new());
172 };
173 let parent_tree = parent.tree().context("failed to read parent tree")?;
174 let head_tree = head.tree().context("failed to read head tree")?;
175 repo.diff_tree_to_tree(Some(&parent_tree), Some(&head_tree), Some(&mut opts))
176 .context("failed to diff git trees")?
177 }
178 };
179
180 let mut paths = HashSet::new();
181 for delta in diff.deltas() {
182 if let Some(path) = delta
183 .new_file()
184 .path()
185 .or_else(|| delta.old_file().path())
186 .and_then(path_to_string)
187 {
188 paths.insert(path);
189 }
190 }
191 Ok(paths)
192}
193
194pub fn untracked_files(workdir: &Path) -> Result<Vec<String>> {
195 let repo = open_repository(workdir)?;
196 let mut opts = StatusOptions::new();
197 opts.include_untracked(true)
198 .recurse_untracked_dirs(true)
199 .include_ignored(true)
200 .recurse_ignored_dirs(true)
201 .include_unmodified(false);
202 let statuses = repo
203 .statuses(Some(&mut opts))
204 .context("failed to enumerate git status entries")?;
205
206 let mut paths = Vec::new();
207 for entry in statuses.iter() {
208 let status = entry.status();
209 if !status_intersects_untracked(status) {
210 continue;
211 }
212 if let Some(path) = entry.path() {
213 paths.push(path.to_string());
214 }
215 }
216 paths.sort();
217 paths.dedup();
218 Ok(paths)
219}
220
221fn status_intersects_untracked(status: Status) -> bool {
222 status.is_wt_new() || status.is_ignored()
223}
224
225fn path_to_string(path: &Path) -> Option<String> {
226 path.to_str().map(str::to_string)
227}
228
229#[cfg(test)]
230pub(crate) mod test_support {
231 use super::*;
232 use anyhow::Result;
233 use git2::{RepositoryInitOptions, Signature};
234 use tempfile::{TempDir, tempdir};
235
236 pub(crate) fn init_repo_with_commit_and_tag(tag: &str) -> Result<TempDir> {
237 let dir = tempdir()?;
238 let mut init = RepositoryInitOptions::new();
239 init.initial_head("main");
240 let repo = Repository::init_opts(dir.path(), &init)?;
241
242 std::fs::write(dir.path().join("README.md"), "opal\n")?;
243
244 let mut index = repo.index()?;
245 index.add_path(Path::new("README.md"))?;
246 let tree_id = index.write_tree()?;
247 let tree = repo.find_tree(tree_id)?;
248 let sig = Signature::now("Opal Tests", "opal@example.com")?;
249 let oid = repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])?;
250 let object = repo.find_object(oid, None)?;
251 repo.tag_lightweight(tag, &object, false)?;
252
253 Ok(dir)
254 }
255
256 pub(crate) fn init_repo_with_commit_and_tags(tags: &[&str]) -> Result<TempDir> {
257 let dir = tempdir()?;
258 let mut init = RepositoryInitOptions::new();
259 init.initial_head("main");
260 let repo = Repository::init_opts(dir.path(), &init)?;
261
262 std::fs::write(dir.path().join("README.md"), "opal\n")?;
263
264 let mut index = repo.index()?;
265 index.add_path(Path::new("README.md"))?;
266 let tree_id = index.write_tree()?;
267 let tree = repo.find_tree(tree_id)?;
268 let sig = Signature::now("Opal Tests", "opal@example.com")?;
269 let oid = repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])?;
270 let object = repo.find_object(oid, None)?;
271 for tag in tags {
272 repo.tag_lightweight(tag, &object, false)?;
273 }
274
275 Ok(dir)
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::{current_tag, test_support::init_repo_with_commit_and_tags, untracked_files};
282 use anyhow::Result;
283 use git2::{RepositoryInitOptions, Signature};
284 use std::path::Path;
285 use tempfile::tempdir;
286
287 #[test]
288 fn untracked_files_include_ignored_paths() -> Result<()> {
289 let dir = tempdir()?;
290 let mut init = RepositoryInitOptions::new();
291 init.initial_head("main");
292 let repo = git2::Repository::init_opts(dir.path(), &init)?;
293
294 std::fs::write(dir.path().join("README.md"), "opal\n")?;
295 std::fs::write(dir.path().join(".gitignore"), "tests-temp/\n")?;
296
297 let mut index = repo.index()?;
298 index.add_path(Path::new("README.md"))?;
299 index.add_path(Path::new(".gitignore"))?;
300 let tree_id = index.write_tree()?;
301 let tree = repo.find_tree(tree_id)?;
302 let sig = Signature::now("Opal Tests", "opal@example.com")?;
303 repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])?;
304
305 std::fs::create_dir_all(dir.path().join("tests-temp"))?;
306 std::fs::write(dir.path().join("tests-temp").join("generated.txt"), "hi")?;
307 std::fs::write(dir.path().join("scratch.txt"), "hello")?;
308
309 let files = untracked_files(dir.path())?;
310
311 assert!(files.iter().any(|path| path == "scratch.txt"));
312 assert!(files.iter().any(|path| path == "tests-temp/generated.txt"));
313 Ok(())
314 }
315
316 #[test]
317 fn current_tag_errors_when_multiple_tags_point_to_head() -> Result<()> {
318 let dir = init_repo_with_commit_and_tags(&["v0.1.2", "v0.1.3"])?;
319 let err = current_tag(dir.path()).expect_err("multiple tags should be ambiguous");
320 assert!(
321 err.to_string().contains("multiple tags point at HEAD"),
322 "unexpected error: {err:#}"
323 );
324 Ok(())
325 }
326}