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}