Skip to main content

sley_object/
commit_create.rs

1//! Commit object authoring: the [`CommitCreate`] request shape and its
2//! serialization.
3//!
4//! Sunk out of sley-sequencer so commit body building (parent-format
5//! validation, header serialization, folded `gpgsig` headers) lives next to
6//! [`Commit`] itself. The odb write seam stays on sequencer because
7//! `ObjectWriter` is an sley-odb trait and sley-odb depends on this crate.
8
9use sley_core::{GitError, ObjectFormat, ObjectId, Result};
10
11use crate::{Commit, EncodedObject, ObjectType};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct CommitCreate {
15    pub tree: ObjectId,
16    pub parents: Vec<ObjectId>,
17    pub author: Vec<u8>,
18    pub committer: Vec<u8>,
19    pub message: Vec<u8>,
20    /// `encoding` header value (`i18n.commitEncoding`); `None`/UTF-8 omits it.
21    pub encoding: Option<Vec<u8>>,
22    pub signature: Option<Vec<u8>>,
23}
24
25/// Validate and serialize a commit request into an encodable object: parent /
26/// tree formats must agree, the canonical commit body is written, and an
27/// optional detached signature is folded under git's `gpgsig`
28/// (`gpgsig-sha256`) continuation-header convention.
29pub fn encode_commit_object(commit: CommitCreate) -> Result<EncodedObject> {
30    let format = commit.tree.format();
31    for parent in &commit.parents {
32        if parent.format() != format {
33            return Err(GitError::InvalidObjectId(format!(
34                "parent {parent} uses {}, tree uses {}",
35                parent.format().name(),
36                format.name()
37            )));
38        }
39    }
40    let signature = commit.signature;
41    let commit = Commit {
42        tree: commit.tree,
43        parents: commit.parents,
44        author: commit.author,
45        committer: commit.committer,
46        encoding: commit.encoding,
47        message: commit.message,
48    };
49    let mut body = commit.write();
50    if let Some(signature) = signature {
51        body = commit_body_with_signature(format, &body, &signature);
52    }
53    Ok(EncodedObject::new(ObjectType::Commit, body))
54}
55
56fn commit_body_with_signature(format: ObjectFormat, body: &[u8], signature: &[u8]) -> Vec<u8> {
57    let Some(split) = body.windows(2).position(|window| window == b"\n\n") else {
58        return body.to_vec();
59    };
60    let mut out = Vec::with_capacity(body.len() + signature.len() + signature.len() / 70 + 16);
61    out.extend_from_slice(&body[..split]);
62    out.push(b'\n');
63    out.extend_from_slice(match format {
64        ObjectFormat::Sha1 => b"gpgsig ",
65        ObjectFormat::Sha256 => b"gpgsig-sha256 ",
66    });
67    append_folded_signature(&mut out, signature);
68    out.extend_from_slice(&body[split + 1..]);
69    out
70}
71
72fn append_folded_signature(out: &mut Vec<u8>, signature: &[u8]) {
73    let mut first = true;
74    let mut lines = signature.split(|byte| *byte == b'\n').peekable();
75    while let Some(line) = lines.next() {
76        if line.is_empty() && lines.peek().is_none() && signature.ends_with(b"\n") {
77            continue;
78        }
79        if !first {
80            out.push(b' ');
81        }
82        out.extend_from_slice(line);
83        out.push(b'\n');
84        first = false;
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::format_commit_identity;
92
93    fn sha1(hex: &str) -> ObjectId {
94        ObjectId::from_hex(ObjectFormat::Sha1, hex).expect("test operation should succeed")
95    }
96
97    fn sample(signature: Option<Vec<u8>>) -> CommitCreate {
98        let identity =
99            format_commit_identity("Example User", "example@example.invalid", "@0 +0000")
100                .expect("test operation should succeed");
101        CommitCreate {
102            tree: sha1("4b825dc642cb6eb9a060e54bf8d69288fbee4904"),
103            parents: Vec::new(),
104            author: identity.clone(),
105            committer: identity,
106            message: b"initial subject\n".to_vec(),
107            encoding: None,
108            signature,
109        }
110    }
111
112    #[test]
113    fn unsigned_encoding_matches_the_known_commit_bytes() {
114        let object = encode_commit_object(sample(None)).expect("test operation should succeed");
115        assert_eq!(object.object_type, ObjectType::Commit);
116        let text = String::from_utf8_lossy(&object.body);
117        assert_eq!(
118            text,
119            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
120             author Example User <example@example.invalid> 0 +0000\n\
121             committer Example User <example@example.invalid> 0 +0000\n\
122             \n\
123             initial subject\n"
124        );
125    }
126
127    #[test]
128    fn signatures_fold_into_continuation_header_lines() {
129        let signature = b"-----BEGIN PGP SIGNATURE-----\nfirst line\nsecond line\n-----END PGP SIGNATURE-----\n".to_vec();
130        let object =
131            encode_commit_object(sample(Some(signature))).expect("test operation should succeed");
132        let text = String::from_utf8_lossy(&object.body);
133        assert!(text.contains(
134            "gpgsig -----BEGIN PGP SIGNATURE-----\n \
135             first line\n \
136             second line\n \
137             -----END PGP SIGNATURE-----\n"
138        ));
139    }
140
141    #[test]
142    fn parent_format_mismatch_is_rejected_before_writing() {
143        let mut commit = sample(None);
144        commit.parents.push(ObjectId::null(ObjectFormat::Sha256));
145        let err = match encode_commit_object(commit) {
146            Err(err) => err,
147            Ok(_) => panic!("expected parent format mismatch to be rejected"),
148        };
149        assert!(err.to_string().contains("uses sha256, tree uses sha1"));
150    }
151}