Skip to main content

opal/
git.rs

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}