1- //! Running git.
1+ //! Git integration: running the ` git` CLI and git value types .
22
33use std:: path:: { Path , PathBuf } ;
44use std:: process:: Command ;
@@ -7,6 +7,98 @@ use bstr::{BStr, BString, ByteSlice};
77
88use crate :: process:: { CapturedStreams as _, CommandExt as _} ;
99
10+ /// A git object identifier: a full 40-char SHA-1 or 64-char SHA-256 hex
11+ /// digest, normalized to lowercase.
12+ #[ derive( Debug , Clone , PartialEq , Eq , Hash ) ]
13+ pub struct GitSha ( String ) ;
14+
15+ /// A string that is not a valid [`GitSha`].
16+ #[ derive( Debug , thiserror:: Error , PartialEq , Eq ) ]
17+ pub enum GitShaError {
18+ /// The digest is not 40 (SHA-1) or 64 (SHA-256) hex characters long.
19+ #[ error( "git sha must be 40 or 64 hex chars, got {0}" ) ]
20+ BadLength ( usize ) ,
21+ /// The digest contains a non-hex character.
22+ #[ error( "git sha contains a non-hex character" ) ]
23+ NotHex ,
24+ }
25+
26+ impl GitSha {
27+ /// The all-zero SHA-1 null oid (`0000…`, 40 chars) git uses to mean
28+ /// "no commit".
29+ #[ must_use]
30+ pub fn zero ( ) -> Self {
31+ Self ( "0" . repeat ( 40 ) )
32+ }
33+
34+ /// The digest as a lowercase hex string.
35+ #[ must_use]
36+ pub fn as_str ( & self ) -> & str {
37+ & self . 0
38+ }
39+
40+ /// Whether this is the all-zero null oid git uses to mean "no commit".
41+ #[ must_use]
42+ pub fn is_zero ( & self ) -> bool {
43+ self . 0 . bytes ( ) . all ( |b| b == b'0' )
44+ }
45+ }
46+
47+ impl std:: str:: FromStr for GitSha {
48+ type Err = GitShaError ;
49+
50+ fn from_str ( s : & str ) -> Result < Self , Self :: Err > {
51+ if s. len ( ) != 40 && s. len ( ) != 64 {
52+ return Err ( GitShaError :: BadLength ( s. len ( ) ) ) ;
53+ }
54+ if !s. bytes ( ) . all ( |b| b. is_ascii_hexdigit ( ) ) {
55+ return Err ( GitShaError :: NotHex ) ;
56+ }
57+ Ok ( Self ( s. to_ascii_lowercase ( ) ) )
58+ }
59+ }
60+
61+ impl TryFrom < & str > for GitSha {
62+ type Error = GitShaError ;
63+
64+ fn try_from ( s : & str ) -> Result < Self , Self :: Error > {
65+ s. parse ( )
66+ }
67+ }
68+
69+ impl TryFrom < String > for GitSha {
70+ type Error = GitShaError ;
71+
72+ fn try_from ( s : String ) -> Result < Self , Self :: Error > {
73+ s. parse ( )
74+ }
75+ }
76+
77+ impl std:: fmt:: Display for GitSha {
78+ fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
79+ f. write_str ( & self . 0 )
80+ }
81+ }
82+
83+ impl AsRef < str > for GitSha {
84+ fn as_ref ( & self ) -> & str {
85+ & self . 0
86+ }
87+ }
88+
89+ impl serde:: Serialize for GitSha {
90+ fn serialize < S : serde:: Serializer > ( & self , serializer : S ) -> Result < S :: Ok , S :: Error > {
91+ serializer. serialize_str ( & self . 0 )
92+ }
93+ }
94+
95+ impl < ' de > serde:: Deserialize < ' de > for GitSha {
96+ fn deserialize < D : serde:: Deserializer < ' de > > ( deserializer : D ) -> Result < Self , D :: Error > {
97+ let s = String :: deserialize ( deserializer) ?;
98+ s. parse ( ) . map_err ( serde:: de:: Error :: custom)
99+ }
100+ }
101+
10102/// A path that is not a git repository.
11103#[ derive( Debug , thiserror:: Error ) ]
12104#[ error( "`{path}` is not a git repository" ) ]
@@ -95,11 +187,12 @@ impl<'r, 'g, 'bin> GitBranch<'r, 'g, 'bin> {
95187 self . name . as_bstr ( )
96188 }
97189
98- /// The commit the branch points at, as a hex object id. `None` if git fails.
190+ /// The commit the branch points at. `None` if git fails or its output is
191+ /// not a valid object id.
99192 #[ tracing:: instrument( skip( self ) ) ]
100- pub fn head_commit ( & self ) -> Option < BString > {
193+ pub fn head_commit ( & self ) -> Option < GitSha > {
101194 let name = self . name . to_str ( ) . ok ( ) ?;
102- self . repo . run ( & [ "rev-parse" , name] )
195+ self . repo . run ( & [ "rev-parse" , name] ) ? . to_str ( ) . ok ( ) ? . parse ( ) . ok ( )
103196 }
104197}
105198
@@ -182,6 +275,66 @@ mod tests {
182275 use super :: * ;
183276 use rstest:: rstest;
184277
278+ #[ rstest]
279+ #[ case:: sha1_lower(
280+ "0123456789abcdef0123456789abcdef01234567" ,
281+ "0123456789abcdef0123456789abcdef01234567"
282+ ) ]
283+ #[ case:: sha1_upper(
284+ "0123456789ABCDEF0123456789ABCDEF01234567" ,
285+ "0123456789abcdef0123456789abcdef01234567"
286+ ) ]
287+ #[ case:: sha256(
288+ "ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789" ,
289+ "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
290+ ) ]
291+ fn parses_and_lowercases ( #[ case] input : & str , #[ case] expected : & str ) {
292+ let sha: GitSha = input. parse ( ) . unwrap ( ) ;
293+ assert_eq ! ( sha. as_str( ) , expected) ;
294+ }
295+
296+ #[ rstest]
297+ #[ case:: empty( 0 ) ]
298+ #[ case:: short( 39 ) ]
299+ #[ case:: between( 41 ) ]
300+ #[ case:: over( 65 ) ]
301+ fn rejects_wrong_length ( #[ case] len : usize ) {
302+ assert_eq ! ( "a" . repeat( len) . parse:: <GitSha >( ) , Err ( GitShaError :: BadLength ( len) ) ) ;
303+ }
304+
305+ #[ rstest]
306+ fn rejects_non_hex ( ) {
307+ let with_g = format ! ( "{}g" , "a" . repeat( 39 ) ) ;
308+ assert_eq ! ( with_g. parse:: <GitSha >( ) , Err ( GitShaError :: NotHex ) ) ;
309+ }
310+
311+ #[ rstest]
312+ #[ case:: sha1( 40 ) ]
313+ #[ case:: sha256( 64 ) ]
314+ fn zeros_are_the_null_oid ( #[ case] len : usize ) {
315+ let sha: GitSha = "0" . repeat ( len) . parse ( ) . unwrap ( ) ;
316+ assert ! ( sha. is_zero( ) ) ;
317+ }
318+
319+ #[ rstest]
320+ fn non_zero_is_not_the_null_oid ( ) {
321+ let sha: GitSha = "0123456789abcdef0123456789abcdef01234567" . parse ( ) . unwrap ( ) ;
322+ assert ! ( !sha. is_zero( ) ) ;
323+ }
324+
325+ #[ rstest]
326+ fn serde_round_trips_as_a_bare_string ( ) {
327+ let sha: GitSha = "0123456789abcdef0123456789abcdef01234567" . parse ( ) . unwrap ( ) ;
328+ let json = serde_json:: to_string ( & sha) . unwrap ( ) ;
329+ assert_eq ! ( json, "\" 0123456789abcdef0123456789abcdef01234567\" " ) ;
330+ assert_eq ! ( serde_json:: from_str:: <GitSha >( & json) . unwrap( ) , sha) ;
331+ }
332+
333+ #[ rstest]
334+ fn deserialize_rejects_an_invalid_digest ( ) {
335+ assert ! ( serde_json:: from_str:: <GitSha >( "\" not-a-sha\" " ) . is_err( ) ) ;
336+ }
337+
185338 #[ rstest]
186339 #[ case:: https( "https://github.com/acme/web.git" , Some ( "acme/web" ) ) ]
187340 #[ case:: https_no_suffix( "https://github.com/acme/web" , Some ( "acme/web" ) ) ]
@@ -251,7 +404,7 @@ mod tests {
251404
252405 let branch = repo. current_branch ( ) . unwrap ( ) ;
253406 assert_eq ! ( branch. name( ) , "main" ) ;
254- assert_eq ! ( branch. head_commit( ) . unwrap( ) . len( ) , 40 ) ;
407+ assert_eq ! ( branch. head_commit( ) . unwrap( ) . as_str ( ) . len( ) , 40 ) ;
255408
256409 let remote = repo. remote ( "origin" ) . unwrap ( ) ;
257410 assert_eq ! ( remote. name( ) , "origin" ) ;
0 commit comments