Skip to main content

gitversion_rs/git/
mod.rs

1//! Pure-Rust repository access layer built on gix (gitoxide).
2//!
3//! Corresponds to the original `GitVersion.LibGit2Sharp`, providing the minimum graph
4//! operations needed for version calculation: tag collection, commit walking, merge-base, and uncommitted changes.
5
6use anyhow::{Context, Result};
7use chrono::{DateTime, FixedOffset, TimeZone};
8use gix::ObjectId;
9use rust_i18n::t;
10use std::collections::HashSet;
11use std::path::Path;
12
13/// Summary of a single commit.
14#[derive(Debug, Clone)]
15pub struct CommitInfo {
16    pub sha: String,
17    pub short_sha: String,
18    pub message: String,
19    pub when: DateTime<FixedOffset>,
20    pub parent_count: usize,
21    /// List of parent commit SHAs.
22    pub parents: Vec<String>,
23}
24
25/// A candidate version tag.
26#[derive(Debug, Clone)]
27pub struct TagInfo {
28    pub name: String,
29    /// The commit SHA the tag points to (peeled for annotated tags).
30    pub target_sha: String,
31    pub when: DateTime<FixedOffset>,
32}
33
34/// Repository wrapper.
35pub struct GitRepo {
36    repo: gix::Repository,
37}
38
39fn gix_time_to_chrono(t: gix::date::Time) -> DateTime<FixedOffset> {
40    let offset =
41        FixedOffset::east_opt(t.offset).unwrap_or_else(|| FixedOffset::east_opt(0).unwrap());
42    offset
43        .timestamp_opt(t.seconds, 0)
44        .single()
45        .unwrap_or_else(|| offset.timestamp_opt(0, 0).unwrap())
46}
47
48impl GitRepo {
49    /// Discover and open the repository by searching `path` and its parents for `.git`.
50    pub fn discover(path: &Path) -> Result<Self> {
51        let repo =
52            gix::discover(path).with_context(|| t!("git.repo_not_found", path = path.display()))?;
53        Ok(Self { repo })
54    }
55
56    /// Root of the repository working tree.
57    pub fn workdir(&self) -> Option<&Path> {
58        self.repo.workdir()
59    }
60
61    /// Path to the `.git` directory (used to compute the cache location).
62    pub fn git_dir(&self) -> &Path {
63        self.repo.git_dir()
64    }
65
66    /// Canonical ref name of HEAD (or the short SHA when detached).
67    pub fn head_ref_name(&self) -> String {
68        match self.repo.head_name() {
69            Ok(Some(name)) => name.as_bstr().to_string(),
70            _ => self
71                .head_commit()
72                .map(|c| c.short_sha)
73                .unwrap_or_else(|_| "HEAD".into()),
74        }
75    }
76
77    /// Sorted list of `"<name> <target_sha>"` for every ref. Used as the refs snapshot in the cache key.
78    pub fn refs_snapshot(&self) -> Result<Vec<String>> {
79        let mut out = Vec::new();
80        if let Ok(platform) = self.repo.references() {
81            if let Ok(iter) = platform.all() {
82                for reference in iter.flatten() {
83                    let name = reference.name().as_bstr().to_string();
84                    let target = reference
85                        .clone()
86                        .into_fully_peeled_id()
87                        .map(|id| id.to_string())
88                        .unwrap_or_default();
89                    out.push(format!("{name} {target}"));
90                }
91            }
92        }
93        out.sort();
94        Ok(out)
95    }
96
97    fn commit_info(commit: &gix::Commit<'_>) -> Result<CommitInfo> {
98        let sha = commit.id().to_string();
99        let when = gix_time_to_chrono(commit.time()?);
100        let message = commit
101            .message_raw()
102            .map(|m| m.to_string())
103            .unwrap_or_default();
104        let parents: Vec<String> = commit.parent_ids().map(|id| id.to_string()).collect();
105        Ok(CommitInfo {
106            short_sha: sha[..7.min(sha.len())].to_string(),
107            sha,
108            message,
109            when,
110            parent_count: parents.len(),
111            parents,
112        })
113    }
114
115    /// The commit that HEAD points to.
116    pub fn head_commit(&self) -> Result<CommitInfo> {
117        let commit = self
118            .repo
119            .head_commit()
120            .with_context(|| t!("git.head_read").to_string())?;
121        Self::commit_info(&commit)
122    }
123
124    /// Friendly name of the currently checked-out branch.
125    ///
126    /// When HEAD is detached, mirrors the original GitVersion (`GitVersionContextFactory`)
127    /// `GetBranchesContainingCommit(...).OnlyOrDefault()` logic: if a branch has HEAD as its
128    /// tip (direct match), that branch is used; otherwise the first branch that contains HEAD
129    /// as a reachable ancestor is used. Exactly one match returns its name; zero or multiple
130    /// matches return `(no branch)`. (When CI checks out a tag as detached HEAD, HEAD may not
131    /// be the tip of main, so tip-only matching is insufficient.)
132    pub fn current_branch_name(&self) -> Result<String> {
133        if let Some(name) = self.repo.head_name()? {
134            Ok(name.shorten().to_string())
135        } else {
136            let head_sha = self.repo.head_commit()?.id().to_string();
137            let containing = self.branches_containing(&head_sha);
138            if containing.len() == 1 {
139                Ok(containing.into_iter().next().unwrap())
140            } else {
141                Ok("(no branch)".to_string())
142            }
143        }
144    }
145
146    /// Local branch names (shorthand) whose tip is the given SHA.
147    fn local_branches_at(&self, sha: &str) -> Vec<String> {
148        let mut out = Vec::new();
149        if let Ok(platform) = self.repo.references() {
150            if let Ok(branches) = platform.local_branches() {
151                for reference in branches.flatten() {
152                    if let Ok(id) = reference.clone().into_fully_peeled_id() {
153                        if id.to_string() == sha {
154                            out.push(reference.name().shorten().to_string());
155                        }
156                    }
157                }
158            }
159        }
160        out
161    }
162
163    /// Mirrors the original `GetBranchesContainingCommit`: returns local branches whose tip is
164    /// HEAD (direct match); if none, returns local branches that contain HEAD as a reachable
165    /// ancestor. Only local branches are considered (matching the original's tracked-branch priority).
166    fn branches_containing(&self, head_sha: &str) -> Vec<String> {
167        let direct = self.local_branches_at(head_sha);
168        if !direct.is_empty() {
169            return direct;
170        }
171        let mut out = Vec::new();
172        if let Ok(platform) = self.repo.references() {
173            if let Ok(branches) = platform.local_branches() {
174                for reference in branches.flatten() {
175                    if let Ok(id) = reference.clone().into_fully_peeled_id() {
176                        let tip = id.to_string();
177                        // HEAD is an ancestor of this branch tip → the branch contains HEAD.
178                        if self.is_ancestor_of(head_sha, &tip).unwrap_or(false) {
179                            out.push(reference.name().shorten().to_string());
180                        }
181                    }
182                }
183            }
184        }
185        out
186    }
187
188    /// Resolve a spec (branch/tag/SHA) to a commit ObjectId.
189    fn resolve(&self, spec: &str) -> Option<ObjectId> {
190        let id = self.repo.rev_parse_single(spec).ok()?;
191        let commit = id.object().ok()?.try_into_commit().ok()?;
192        Some(commit.id)
193    }
194
195    /// Collect all tags together with the commits they point to.
196    pub fn tags(&self) -> Result<Vec<TagInfo>> {
197        let mut out = Vec::new();
198        let platform = self.repo.references()?;
199        for reference in platform.tags()?.flatten() {
200            let name = reference.name().shorten().to_string();
201            if let Ok(id) = reference.clone().into_fully_peeled_id() {
202                let commit = id.object().ok().and_then(|o| o.try_into_commit().ok());
203                if let Some(commit) = commit {
204                    if let Ok(time) = commit.time() {
205                        out.push(TagInfo {
206                            name,
207                            target_sha: commit.id().to_string(),
208                            when: gix_time_to_chrono(time),
209                        });
210                    }
211                }
212            }
213        }
214        Ok(out)
215    }
216
217    /// Shorthand names of all local and remote branches.
218    pub fn branch_names(&self) -> Result<Vec<String>> {
219        let mut out = Vec::new();
220        let platform = self.repo.references()?;
221        for reference in platform.local_branches()?.flatten() {
222            out.push(reference.name().shorten().to_string());
223        }
224        for reference in self.repo.references()?.remote_branches()?.flatten() {
225            out.push(reference.name().shorten().to_string());
226        }
227        Ok(out)
228    }
229
230    /// Returns reachable commits from `from` (exclusive) to `to` (inclusive), newest first.
231    /// When `from` is `None`, returns all ancestors of `to`.
232    pub fn commits_between(&self, from: Option<&str>, to: &str) -> Result<Vec<CommitInfo>> {
233        let to_oid = self
234            .resolve(to)
235            .with_context(|| t!("git.commit_not_found", commit = to))?;
236
237        let mut platform = self.repo.rev_walk([to_oid]);
238        if let Some(f) = from {
239            if let Some(f_oid) = self.resolve(f) {
240                platform = platform.with_hidden([f_oid]);
241            }
242        }
243
244        let mut out = Vec::new();
245        for info in platform.all()? {
246            let info = info?;
247            if let Ok(commit) = self.repo.find_commit(info.id) {
248                out.push(Self::commit_info(&commit)?);
249            }
250        }
251        Ok(out)
252    }
253
254    /// Returns commits from `from` (exclusive) to `to` (inclusive) following **first parents only**,
255    /// newest first. Used for Mainline trunk traversal.
256    pub fn first_parent_between(&self, from: Option<&str>, to: &str) -> Result<Vec<CommitInfo>> {
257        let to_oid = self
258            .resolve(to)
259            .with_context(|| t!("git.commit_not_found", commit = to))?;
260        let mut platform = self.repo.rev_walk([to_oid]).first_parent_only();
261        if let Some(f) = from {
262            if let Some(f_oid) = self.resolve(f) {
263                platform = platform.with_hidden([f_oid]);
264            }
265        }
266        let mut out = Vec::new();
267        for info in platform.all()? {
268            let info = info?;
269            if let Ok(commit) = self.repo.find_commit(info.id) {
270                out.push(Self::commit_info(&commit)?);
271            }
272        }
273        Ok(out)
274    }
275
276    /// Merge-base of two commits.
277    pub fn merge_base(&self, a: &str, b: &str) -> Result<Option<String>> {
278        let (oid_a, oid_b) = match (self.resolve(a), self.resolve(b)) {
279            (Some(x), Some(y)) => (x, y),
280            _ => return Ok(None),
281        };
282        match self.repo.merge_base(oid_a, oid_b) {
283            Ok(base) => Ok(Some(base.to_string())),
284            Err(_) => Ok(None),
285        }
286    }
287
288    /// Returns true if the given commit is reachable from HEAD (i.e. is an ancestor).
289    pub fn is_ancestor_of_head(&self, sha: &str) -> Result<bool> {
290        let head = self.head_commit()?;
291        self.is_ancestor_of(sha, &head.sha)
292    }
293
294    /// Returns true if `ancestor` is an ancestor of (or identical to) `descendant`.
295    pub fn is_ancestor_of(&self, ancestor: &str, descendant: &str) -> Result<bool> {
296        let (a, d) = match (self.resolve(ancestor), self.resolve(descendant)) {
297            (Some(a), Some(d)) => (a, d),
298            _ => return Ok(false),
299        };
300        if a == d {
301            return Ok(true);
302        }
303        match self.repo.merge_base(a, d) {
304            Ok(base) => Ok(base.detach() == a),
305            Err(_) => Ok(false),
306        }
307    }
308
309    /// File paths changed by a commit relative to its first parent.
310    /// Returns an empty vec for root commits or when the diff cannot be obtained.
311    pub fn changed_paths_for_commit(&self, sha: &str) -> Vec<String> {
312        (|| -> Option<Vec<String>> {
313            let oid = self.resolve(sha)?;
314            let commit = self.repo.find_commit(oid).ok()?;
315            let new_tree = commit.tree().ok()?;
316            let parent = commit
317                .parent_ids()
318                .next()
319                .and_then(|pid| self.repo.find_commit(pid).ok())?;
320            let old_tree = parent.tree().ok()?;
321
322            let mut paths: Vec<String> = Vec::new();
323            let mut platform = old_tree.changes().ok()?;
324            // track_path: enables the location() field.
325            // track_rewrites(None): disables rename tracking → no blob access needed.
326            platform.options(|o| {
327                o.track_path();
328                o.track_rewrites(None);
329            });
330            let _ = platform.for_each_to_obtain_tree(&new_tree, |change| {
331                paths.push(change.location().to_string());
332                Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Continue(()))
333            });
334            Some(paths)
335        })()
336        .unwrap_or_default()
337    }
338
339    /// Resolve a spec (branch/tag/SHA) to a `CommitInfo`.
340    pub fn commit_info_of(&self, spec: &str) -> Option<CommitInfo> {
341        let id = self.resolve(spec)?;
342        let commit = self.repo.find_commit(id).ok()?;
343        Self::commit_info(&commit).ok()
344    }
345
346    /// Sorted list of shorthand local branch names.
347    pub fn local_branch_names(&self) -> Result<Vec<String>> {
348        let mut out = Vec::new();
349        let platform = self.repo.references()?;
350        for reference in platform.local_branches()?.flatten() {
351            out.push(reference.name().shorten().to_string());
352        }
353        out.sort();
354        Ok(out)
355    }
356
357    /// Create a lightweight tag on the specified commit (defaults to HEAD).
358    pub fn create_tag(&self, name: &str, target_spec: Option<&str>) -> Result<()> {
359        let target = match target_spec {
360            Some(s) => self
361                .resolve(s)
362                .with_context(|| t!("git.target_commit_not_found").to_string())?,
363            None => self.repo.head_commit()?.id,
364        };
365        self.repo
366            .reference(
367                format!("refs/tags/{name}"),
368                target,
369                gix::refs::transaction::PreviousValue::MustNotExist,
370                format!("gitversion: create tag {name}"),
371            )
372            .with_context(|| t!("git.tag_create_failed", name = name))?;
373        Ok(())
374    }
375
376    /// Create a branch ref on the specified commit (defaults to HEAD). Does not touch the working tree.
377    pub fn create_branch(&self, name: &str, target_spec: Option<&str>) -> Result<()> {
378        let target = match target_spec {
379            Some(s) => self
380                .resolve(s)
381                .with_context(|| t!("git.target_commit_not_found").to_string())?,
382            None => self.repo.head_commit()?.id,
383        };
384        self.repo
385            .reference(
386                format!("refs/heads/{name}"),
387                target,
388                gix::refs::transaction::PreviousValue::MustNotExist,
389                format!("gitversion: create branch {name}"),
390            )
391            .with_context(|| t!("git.branch_create_failed", name = name))?;
392        Ok(())
393    }
394
395    /// Delete the on-disk cache directory (`<.git>/gitversion_cache`).
396    pub fn clear_cache(&self) -> Result<usize> {
397        let dir = self.git_dir().join("gitversion_cache");
398        if !dir.exists() {
399            return Ok(0);
400        }
401        let count = std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0);
402        std::fs::remove_dir_all(&dir)
403            .with_context(|| t!("git.cache_clear_failed", path = dir.display()))?;
404        Ok(count)
405    }
406
407    /// Number of uncommitted changes in the working tree.
408    pub fn uncommitted_changes(&self) -> Result<i64> {
409        // The original GitVersion counts the diff between the HEAD tree and (index + working dir),
410        // including untracked (added) files. gix's index-worktree status covers both untracked
411        // and modified tracked files, so we count that.
412        let status = match self.repo.status(gix::progress::Discard) {
413            Ok(s) => s,
414            Err(_) => return Ok(0),
415        };
416        let iter = match status.into_index_worktree_iter(Vec::new()) {
417            Ok(it) => it,
418            Err(_) => return Ok(0),
419        };
420        Ok(iter.flatten().count() as i64)
421    }
422
423    /// Names of tags directly attached to the given commit.
424    pub fn tags_on_commit(&self, sha: &str) -> Result<HashSet<String>> {
425        Ok(self
426            .tags()?
427            .into_iter()
428            .filter(|t| t.target_sha == sha)
429            .map(|t| t.name)
430            .collect())
431    }
432}