Skip to main content

affected_core/
git.rs

1use anyhow::{Context, Result};
2use git2::Repository;
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use tracing::debug;
6
7pub struct GitDiff {
8    pub changed_files: Vec<PathBuf>,
9    pub repo_root: PathBuf,
10}
11
12/// Compute changed files between a base ref and HEAD (plus uncommitted changes).
13pub fn changed_files(repo_path: &Path, base_ref: &str) -> Result<GitDiff> {
14    let repo = Repository::discover(repo_path).context("Not a git repository")?;
15
16    let repo_root = repo
17        .workdir()
18        .context("Bare repositories are not supported")?
19        .to_path_buf();
20
21    debug!("Found git repository at {}", repo_root.display());
22
23    let base_obj = repo
24        .revparse_single(base_ref)
25        .with_context(|| format!("Could not resolve base ref '{base_ref}'"))?;
26
27    debug!("Resolved base ref '{}' to {}", base_ref, base_obj.id());
28
29    let base_tree = base_obj
30        .peel_to_tree()
31        .with_context(|| format!("Could not peel '{base_ref}' to a tree"))?;
32
33    let head_ref = repo.head().context("Could not get HEAD")?;
34    let head_tree = head_ref
35        .peel_to_tree()
36        .context("Could not peel HEAD to a tree")?;
37
38    let mut files = HashSet::new();
39
40    // Committed changes: base..HEAD
41    let diff_committed = repo
42        .diff_tree_to_tree(Some(&base_tree), Some(&head_tree), None)
43        .context("Failed to diff base..HEAD")?;
44
45    for delta in diff_committed.deltas() {
46        if let Some(p) = delta.new_file().path() {
47            files.insert(p.to_path_buf());
48        }
49        if let Some(p) = delta.old_file().path() {
50            files.insert(p.to_path_buf());
51        }
52    }
53
54    // Uncommitted changes: HEAD vs working tree + index
55    let diff_uncommitted = repo
56        .diff_tree_to_workdir_with_index(Some(&head_tree), None)
57        .context("Failed to diff HEAD vs working tree")?;
58
59    for delta in diff_uncommitted.deltas() {
60        if let Some(p) = delta.new_file().path() {
61            files.insert(p.to_path_buf());
62        }
63        if let Some(p) = delta.old_file().path() {
64            files.insert(p.to_path_buf());
65        }
66    }
67
68    let mut changed_files: Vec<PathBuf> = files.into_iter().collect();
69    changed_files.sort();
70
71    debug!("Detected {} changed files", changed_files.len());
72
73    Ok(GitDiff {
74        changed_files,
75        repo_root,
76    })
77}
78
79/// Compute the merge-base between HEAD and the given branch.
80/// Returns the commit SHA as a string. Used when `--merge-base` is passed.
81pub fn merge_base(repo_path: &Path, branch: &str) -> Result<String> {
82    let repo = Repository::discover(repo_path).context("Not a git repository")?;
83
84    debug!("Computing merge-base between HEAD and '{}'", branch);
85
86    let head_oid = repo
87        .head()
88        .context("Could not get HEAD")?
89        .target()
90        .context("HEAD is not a direct reference")?;
91
92    let branch_obj = repo
93        .revparse_single(branch)
94        .with_context(|| format!("Could not resolve branch '{branch}'"))?;
95    let branch_oid = branch_obj.id();
96
97    let merge_base_oid = repo
98        .merge_base(head_oid, branch_oid)
99        .with_context(|| format!("Could not find merge-base between HEAD and '{branch}'"))?;
100
101    let sha = merge_base_oid.to_string();
102    debug!("Merge-base resolved to {}", sha);
103
104    Ok(sha)
105}