Skip to main content

callisto_model/
commit.rs

1//! The Layer 1 commit-history contract.
2//!
3//! Severity inference needs exactly one thing from a version-control system:
4//! the list of commits reachable from `HEAD` down to some bound, scoped to a
5//! set of paths. That need is expressed here, in permissive Layer 1, as
6//! [`CommitWalker`] over [`CommitRecord`] values -- so consumers such as
7//! `callisto-conventional` depend on the *shape* of a commit walk rather than
8//! on any particular VCS implementation (native `gix`, a shelled-out `git`,
9//! or a test double).
10
11use std::path::PathBuf;
12
13use crate::{CommandError, CommitSha};
14
15/// A single commit as far as history analysis is concerned: its identity plus
16/// its message, pre-split at the first blank line the way `git log` splits
17/// `%s` from `%b`.
18///
19/// `summary` is the first line; `body` is everything after the blank line that
20/// follows it, or `None` when the message has no body. Callers that need the
21/// original raw message rejoin them with a blank line between.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct CommitRecord {
24    pub sha: CommitSha,
25    pub summary: String,
26    pub body: Option<String>,
27}
28
29/// Why a [`CommitWalker::commits_since`] call could not produce a history.
30///
31/// Deliberately narrower than any backend's own error type: it keeps only the
32/// distinctions a consumer can act on -- the underlying command failed, the
33/// requested bound does not exist, or the backend failed for some other
34/// reason it can only describe in prose. Backends map their richer errors
35/// into these variants at the boundary.
36#[derive(Clone, Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum CommitWalkError {
39    /// The walk was served by shelling out, and the subprocess itself failed
40    /// (e.g. no `git` binary on `PATH`).
41    #[error(transparent)]
42    Command(#[from] CommandError),
43
44    /// An explicitly requested `since_ref` does not resolve to a commit.
45    ///
46    /// This is always an error rather than a silent fall-through to an
47    /// unbounded walk: ignoring the caller's bound would re-surface
48    /// already-released commits into severity and changelog inference.
49    #[error("reference `{ref_name}` was not found")]
50    #[diagnostic(
51        code(E054),
52        help("Check if the reference or tag exists in local or remote Git refs.")
53    )]
54    RefNotFound { ref_name: String },
55
56    /// The backend failed for a reason with no Layer 1 equivalent -- a
57    /// repository that could not be opened, a corrupt object database, an
58    /// unparsable log stream. `message` carries the backend's own rendering.
59    #[error("commit walk failed: {message}")]
60    #[diagnostic(code(E026))]
61    Backend { message: String },
62}
63
64/// Reads commit history. The single VCS capability that severity inference
65/// requires, and therefore the only one this trait exposes.
66pub trait CommitWalker {
67    /// Lists commits reachable from `HEAD`, down to (exclusive) `since_ref`
68    /// when given, filtered to those touching at least one of `pathspecs`.
69    ///
70    /// An empty `pathspecs` slice disables path filtering and returns every
71    /// commit in the walk. Merge commits are excluded, matching
72    /// `git log --no-merges`. Results are ordered newest-first.
73    ///
74    /// `since_ref: None` requests the full history and always succeeds;
75    /// a `since_ref` that is given but does not resolve is
76    /// [`CommitWalkError::RefNotFound`], never a silent unbounded walk.
77    fn commits_since(
78        &self,
79        since_ref: Option<&str>,
80        pathspecs: &[PathBuf],
81    ) -> Result<Vec<CommitRecord>, CommitWalkError>;
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    /// Spec: the trait is object-safe. Consumers take `&dyn CommitWalker`
89    /// so that a single non-generic function body serves every backend.
90    #[test]
91    fn test_commit_walker_is_object_safe() {
92        struct Empty;
93        impl CommitWalker for Empty {
94            fn commits_since(
95                &self,
96                _since_ref: Option<&str>,
97                _pathspecs: &[PathBuf],
98            ) -> Result<Vec<CommitRecord>, CommitWalkError> {
99                Ok(Vec::new())
100            }
101        }
102
103        let walker: &dyn CommitWalker = &Empty;
104        assert!(walker.commits_since(None, &[]).unwrap().is_empty());
105    }
106
107    /// Spec: a `CommandError` converts into `CommitWalkError` transparently,
108    /// so a shelled-out backend can propagate spawn failures with `?`.
109    #[test]
110    fn test_command_error_converts_transparently() {
111        let err: CommitWalkError = CommandError::NotFound {
112            program: "git".to_string(),
113        }
114        .into();
115
116        assert!(matches!(err, CommitWalkError::Command(_)));
117        assert_eq!(
118            err.to_string(),
119            "`git` was not found; callisto requires it to be available"
120        );
121    }
122}