Skip to main content

wt/git/
commit.rs

1//! Reading commit metadata for display via `gix` (spec §4): short hash
2//! (honoring `core.abbrev`), subject, author, and timestamp.
3
4use gix::ObjectId;
5
6use crate::error::{Error, Result};
7
8/// Tip-commit metadata read from the object database.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub(crate) struct CommitInfo {
11    /// Short commit hash (the full hex truncated to the abbreviation length).
12    pub(crate) hash: String,
13    /// Commit subject (first line of the message).
14    pub(crate) subject: String,
15    /// Author name.
16    pub(crate) author: String,
17    /// Author timestamp as Unix seconds.
18    pub(crate) timestamp_unix: i64,
19}
20
21/// The short-hash abbreviation length, honoring `core.abbrev` when set to a
22/// number, otherwise defaulting to 7 (spec §7 "Display conventions").
23pub(crate) fn abbrev_len(repo: &gix::Repository) -> usize {
24    repo.config_snapshot()
25        .integer("core.abbrev")
26        .and_then(|n| usize::try_from(n).ok())
27        .filter(|n| (4..=64).contains(n))
28        .unwrap_or(7)
29}
30
31/// Reads commit metadata for the object named by `oid_hex` (a full hex OID).
32pub(crate) fn commit_info(
33    repo: &gix::Repository,
34    oid_hex: &str,
35    abbrev: usize,
36) -> Result<CommitInfo> {
37    let id = ObjectId::from_hex(oid_hex.as_bytes())
38        .map_err(|e| Error::operation(format!("invalid object id {oid_hex:?}: {e}")))?;
39    let commit = repo
40        .find_object(id)
41        .map_err(|e| Error::operation(format!("cannot read object {oid_hex}: {e}")))?
42        .try_into_commit()
43        .map_err(|e| Error::operation(format!("object {oid_hex} is not a commit: {e}")))?;
44
45    let message = commit
46        .message()
47        .map_err(|e| Error::operation(format!("cannot decode commit message: {e}")))?;
48    let subject = message.summary().to_string();
49
50    let author = commit
51        .author()
52        .map_err(|e| Error::operation(format!("cannot decode commit author: {e}")))?;
53    let name = author.name.to_string();
54    let timestamp_unix = author.seconds();
55
56    let len = abbrev.clamp(4, oid_hex.len());
57    Ok(CommitInfo {
58        hash: oid_hex[..len].to_string(),
59        subject,
60        author: name,
61        timestamp_unix,
62    })
63}
64
65/// Reads up to `max` recent commits starting at `start_hex` (newest first) by
66/// walking ancestry via `gix` (spec §4 reads). Best-effort: an invalid start or
67/// an unreadable commit simply truncates the result.
68pub(crate) fn recent_commits(
69    repo: &gix::Repository,
70    start_hex: &str,
71    abbrev: usize,
72    max: usize,
73) -> Vec<CommitInfo> {
74    let Ok(id) = ObjectId::from_hex(start_hex.as_bytes()) else {
75        return Vec::new();
76    };
77    let Ok(walk) = repo.rev_walk([id]).all() else {
78        return Vec::new();
79    };
80    walk.take(max)
81        .filter_map(std::result::Result::ok)
82        .filter_map(|info| commit_info(repo, &info.id.to_string(), abbrev).ok())
83        .collect()
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::git::discover::Repo;
90    use crate::testutil::TestRepo;
91
92    fn head_oid(repo: &TestRepo) -> String {
93        repo.git(&["rev-parse", "HEAD"]).trim().to_string()
94    }
95
96    #[test]
97    fn reads_subject_author_and_short_hash() {
98        let repo = TestRepo::init();
99        let oid = head_oid(&repo);
100        let r = Repo::discover(repo.root()).unwrap();
101        let info = commit_info(r.gix(), &oid, 7).unwrap();
102        assert_eq!(info.hash.len(), 7);
103        assert!(oid.starts_with(&info.hash));
104        assert_eq!(info.subject, "init");
105        assert_eq!(info.author, "wt Test");
106        assert!(info.timestamp_unix > 1_600_000_000);
107    }
108
109    #[test]
110    fn subject_is_first_line_only() {
111        let repo = TestRepo::init();
112        repo.write("f.txt", "x\n");
113        repo.git(&["add", "-A"]);
114        repo.git(&["commit", "-q", "-m", "summary line\n\nbody text"]);
115        let oid = head_oid(&repo);
116        let r = Repo::discover(repo.root()).unwrap();
117        let info = commit_info(r.gix(), &oid, 10).unwrap();
118        assert_eq!(info.subject, "summary line");
119        assert_eq!(info.hash.len(), 10);
120    }
121
122    #[test]
123    fn abbrev_len_defaults_to_seven() {
124        let repo = TestRepo::init();
125        // "auto" (git's default) is not an integer, so it falls through to 7
126        // regardless of any host-global core.abbrev value.
127        repo.git(&["config", "core.abbrev", "auto"]);
128        let r = Repo::discover(repo.root()).unwrap();
129        assert_eq!(abbrev_len(r.gix()), 7);
130        repo.git(&["config", "core.abbrev", "12"]);
131        let r2 = Repo::discover(repo.root()).unwrap();
132        assert_eq!(abbrev_len(r2.gix()), 12);
133    }
134
135    #[test]
136    fn invalid_oid_errors() {
137        let repo = TestRepo::init();
138        let r = Repo::discover(repo.root()).unwrap();
139        assert!(commit_info(r.gix(), "not-hex", 7).is_err());
140    }
141
142    #[test]
143    fn recent_commits_walks_newest_first_and_caps() {
144        let repo = TestRepo::init(); // one commit: "init"
145        repo.write("a.txt", "1\n");
146        repo.commit_all("second");
147        repo.write("b.txt", "2\n");
148        repo.commit_all("third");
149        let oid = head_oid(&repo);
150        let r = Repo::discover(repo.root()).unwrap();
151        let commits = recent_commits(r.gix(), &oid, 7, 5);
152        assert_eq!(commits.len(), 3);
153        assert_eq!(commits[0].subject, "third"); // newest first
154        assert_eq!(commits[2].subject, "init");
155        // The cap is honored.
156        assert_eq!(recent_commits(r.gix(), &oid, 7, 2).len(), 2);
157        // An invalid start yields nothing.
158        assert!(recent_commits(r.gix(), "not-hex", 7, 5).is_empty());
159    }
160}