steid

@jamesgill /

steid/src/domain/push_event.rs
Rust6.7 KBCode·Blame·History·Raw
1//! What one authorized push did to one ref.
2//!
3//! A ref that moved, was created, or was deleted, recorded once the push has actually
4//! landed. This is the first brick of anything that reacts to a push — webhooks today,
5//! CI eventually — and it is deliberately a *record of a fact*, never a queue: rows are
6//! written and never deleted, and nothing about delivery lives here.
7
8use std::time::SystemTime;
9
10use super::{DomainError, ObjectId, PushEventId, RefName, RepoId, UserId};
11
12/// The all-zero object id git uses for "there was nothing here".
13///
14/// A creation has it on the `before` side, a deletion on the `after` side. Kept as the
15/// SHA-1 width because that is what Steid's own repositories are; a SHA-256 repository
16/// would use the 64-character form, and both are accepted by
17/// [`ObjectId::new`](ObjectId::new), so nothing here has to choose for the caller.
18pub const ZERO_OBJECT_ID: &str = "0000000000000000000000000000000000000000";
19
20/// One ref changing, in one push.
21///
22/// One event per ref rather than one per push: a `git push --all` moves several refs at
23/// once, and a consumer wants to know which — a webhook payload names exactly one ref,
24/// the way every other forge's does.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct PushEvent {
27 pub id: PushEventId,
28 pub repo_id: RepoId,
29 /// The full ref name, e.g. `refs/heads/main`. Full rather than short because
30 /// `refs/heads/x` and `refs/tags/x` are different things that shorten alike.
31 pub ref_name: RefName,
32 /// What the ref pointed at before, or the zero id if it did not exist.
33 pub before: ObjectId,
34 /// What it points at now, or the zero id if it was deleted.
35 pub after: ObjectId,
36 /// Who pushed. `None` only for a row whose user has since been deleted — a push is
37 /// always authorized, and Steid has no anonymous writes.
38 pub pusher: Option<UserId>,
39 pub created_at: SystemTime,
40}
41
42impl PushEvent {
43 /// Records a ref change.
44 ///
45 /// Refuses an event where nothing moved. A snapshot comparison should never produce
46 /// one, and a stored row saying a ref changed from `x` to `x` would be a lie every
47 /// consumer then has to filter out.
48 pub fn new(
49 id: PushEventId,
50 repo_id: RepoId,
51 ref_name: RefName,
52 before: ObjectId,
53 after: ObjectId,
54 pusher: Option<UserId>,
55 created_at: SystemTime,
56 ) -> Result<Self, DomainError> {
57 if before == after {
58 return Err(DomainError::validation(
59 "ref",
60 "a push event records a ref that changed",
61 ));
62 }
63
64 Ok(Self {
65 id,
66 repo_id,
67 ref_name,
68 before,
69 after,
70 pusher,
71 created_at,
72 })
73 }
74
75 /// Rebuilds a row from storage without revalidating it.
76 #[allow(clippy::too_many_arguments)]
77 pub fn from_trusted(
78 id: PushEventId,
79 repo_id: RepoId,
80 ref_name: RefName,
81 before: ObjectId,
82 after: ObjectId,
83 pusher: Option<UserId>,
84 created_at: SystemTime,
85 ) -> Self {
86 Self {
87 id,
88 repo_id,
89 ref_name,
90 before,
91 after,
92 pusher,
93 created_at,
94 }
95 }
96
97 /// The zero id — "no commit on this side".
98 pub fn zero() -> ObjectId {
99 ObjectId::from_trusted(ZERO_OBJECT_ID)
100 }
101
102 /// Whether this push created the ref.
103 pub fn is_create(&self) -> bool {
104 is_zero(&self.before)
105 }
106
107 /// Whether this push deleted the ref.
108 pub fn is_delete(&self) -> bool {
109 is_zero(&self.after)
110 }
111
112 /// The ref without its namespace, for display: `main`, not `refs/heads/main`.
113 pub fn short_ref(&self) -> &str {
114 let name = self.ref_name.as_str();
115
116 name.strip_prefix("refs/heads/")
117 .or_else(|| name.strip_prefix("refs/tags/"))
118 .unwrap_or(name)
119 }
120
121 /// What kind of ref moved, for a row that has to say so in one word.
122 pub fn ref_kind(&self) -> &'static str {
123 let name = self.ref_name.as_str();
124
125 if name.starts_with("refs/tags/") {
126 "tag"
127 } else if name.starts_with("refs/heads/") {
128 "branch"
129 } else {
130 "ref"
131 }
132 }
133}
134
135/// Whether an id is the all-zeroes one, at either width.
136///
137/// Compared by content rather than against one constant, because a SHA-256 repository
138/// spells the same absence with 64 zeroes.
139pub fn is_zero(id: &ObjectId) -> bool {
140 !id.as_str().is_empty() && id.as_str().bytes().all(|byte| byte == b'0')
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 fn oid(prefix: &str) -> ObjectId {
148 ObjectId::new(format!("{prefix:0>40}")).expect("valid id")
149 }
150
151 fn event(before: ObjectId, after: ObjectId, name: &str) -> Result<PushEvent, DomainError> {
152 PushEvent::new(
153 PushEventId::generate(),
154 RepoId::generate(),
155 RefName::from_trusted(name),
156 before,
157 after,
158 Some(UserId::generate()),
159 SystemTime::UNIX_EPOCH,
160 )
161 }
162
163 #[test]
164 fn a_ref_that_did_not_move_is_not_an_event() {
165 assert!(event(oid("a"), oid("a"), "refs/heads/main").is_err());
166 }
167
168 #[test]
169 fn a_creation_has_the_zero_id_before_it() {
170 let created = event(PushEvent::zero(), oid("a"), "refs/heads/main").expect("valid");
171
172 assert!(created.is_create());
173 assert!(!created.is_delete());
174 }
175
176 #[test]
177 fn a_deletion_has_the_zero_id_after_it() {
178 let deleted = event(oid("a"), PushEvent::zero(), "refs/heads/gone").expect("valid");
179
180 assert!(deleted.is_delete());
181 assert!(!deleted.is_create());
182 }
183
184 #[test]
185 fn an_ordinary_move_is_neither() {
186 let moved = event(oid("a"), oid("b"), "refs/heads/main").expect("valid");
187
188 assert!(!moved.is_create());
189 assert!(!moved.is_delete());
190 }
191
192 #[test]
193 fn the_zero_id_is_recognised_at_both_widths() {
194 assert!(is_zero(&ObjectId::from_trusted("0".repeat(40))));
195 assert!(is_zero(&ObjectId::from_trusted("0".repeat(64))));
196 assert!(!is_zero(&oid("a")));
197 }
198
199 #[test]
200 fn a_ref_is_shortened_and_named_by_its_namespace() {
201 let branch = event(oid("a"), oid("b"), "refs/heads/feat/x").expect("valid");
202 let tag = event(oid("a"), oid("b"), "refs/tags/v1.0").expect("valid");
203 let other = event(oid("a"), oid("b"), "refs/notes/commits").expect("valid");
204
205 assert_eq!(
206 (branch.short_ref(), branch.ref_kind()),
207 ("feat/x", "branch")
208 );
209 assert_eq!((tag.short_ref(), tag.ref_kind()), ("v1.0", "tag"));
210 assert_eq!(
211 (other.short_ref(), other.ref_kind()),
212 ("refs/notes/commits", "ref")
213 );
214 }
215}