Skip to main content

grit_lib/odb/
commit.rs

1//! Reading commit objects by id.
2//!
3//! Callers that need "the commit at this id" or "the root tree of this commit" share these
4//! [`Odb`] methods instead of reading the object, checking its kind and parsing it by hand.
5
6use crate::error::{Error, Result};
7use crate::objects::{
8    commit_through_subject_len, parse_commit, CommitData, CommitRef, ObjectId, ObjectKind,
9};
10
11use super::Odb;
12
13impl Odb {
14    /// Read `oid` and parse it as a commit.
15    ///
16    /// Annotated tags are not followed; peel first with [`Self::peel_to_commit`] when the id
17    /// may name a tag.
18    ///
19    /// # Parameters
20    ///
21    /// - `oid`: the commit to read.
22    ///
23    /// # Returns
24    ///
25    /// The owned, parsed commit headers and message.
26    ///
27    /// # Errors
28    ///
29    /// - [`Error::ObjectNotFound`] when `oid` is missing.
30    /// - [`Error::UnexpectedObjectKind`] when `oid` names a tree, blob or tag.
31    /// - [`Error::CorruptObject`] when the commit's required headers are missing or malformed.
32    /// - Any other error [`Self::read`] reports.
33    pub fn read_commit(&self, oid: &ObjectId) -> Result<CommitData> {
34        let object = self.read(oid)?;
35        expect_commit(oid, object.kind)?;
36        parse_commit(&object.data)
37    }
38
39    /// The root tree of the commit `oid`.
40    ///
41    /// Only the commit headers are decoded; the author, committer and message are not copied.
42    ///
43    /// # Parameters
44    ///
45    /// - `oid`: the commit whose tree to return.
46    ///
47    /// # Errors
48    ///
49    /// Same as [`Self::read_commit`].
50    pub fn commit_tree(&self, oid: &ObjectId) -> Result<ObjectId> {
51        let object = self.read(oid)?;
52        expect_commit(oid, object.kind)?;
53        Ok(CommitRef::parse(&object.data)?.tree())
54    }
55
56    /// The leading part of commit `oid`'s payload that one-line listings need: every header
57    /// and the message up to and including its first non-blank line.
58    ///
59    /// [`parse_commit`] and [`crate::objects::read_commit_subject_line`] give the same tree,
60    /// parents, author, committer, extra headers and subject for these bytes as for the whole
61    /// commit; only the message after the subject line is missing. A commit with no such
62    /// prefix (for example one that declares a non-UTF-8 `encoding`, or has no message) is
63    /// returned whole.
64    ///
65    /// When the commit is stored whole in a pack, inflation stops at the subject line, so a
66    /// long message body costs nothing. The skipped tail is not checked for corruption;
67    /// [`Self::read`] the commit to check all of it.
68    ///
69    /// # Parameters
70    ///
71    /// - `oid`: the commit to read.
72    ///
73    /// # Errors
74    ///
75    /// - [`Error::ObjectNotFound`] when `oid` is missing.
76    /// - [`Error::UnexpectedObjectKind`] when `oid` names a tree, blob or tag.
77    /// - Any other error [`Self::read`] reports.
78    pub fn read_commit_through_subject(&self, oid: &ObjectId) -> Result<Vec<u8>> {
79        if self.custom_primary.is_none() {
80            if let Some(data) =
81                crate::pack::pack_cache::try_read_commit_through_subject_via_single_pack_reader(
82                    &self.objects_dir,
83                    oid,
84                )?
85            {
86                return Ok(through_subject(data));
87            }
88        }
89        let object = self.read(oid)?;
90        expect_commit(oid, object.kind)?;
91        Ok(through_subject(object.data))
92    }
93}
94
95fn through_subject(mut data: Vec<u8>) -> Vec<u8> {
96    if let Some(len) = commit_through_subject_len(&data) {
97        data.truncate(len);
98    }
99    data
100}
101
102fn expect_commit(oid: &ObjectId, actual: ObjectKind) -> Result<()> {
103    if actual == ObjectKind::Commit {
104        return Ok(());
105    }
106    Err(Error::UnexpectedObjectKind {
107        oid: *oid,
108        expected: ObjectKind::Commit,
109        actual,
110    })
111}
112
113#[cfg(test)]
114mod tests {
115    #![allow(clippy::expect_used, clippy::unwrap_used)]
116
117    use super::*;
118    use tempfile::TempDir;
119
120    fn commit_with_parent(odb: &Odb, tree: ObjectId, parent: Option<ObjectId>) -> ObjectId {
121        let parent = parent.map(|p| format!("parent {p}\n")).unwrap_or_default();
122        let body = format!(
123            "tree {tree}\n{parent}author A <a@example.com> 1 +0000\ncommitter C <c@example.com> 2 +0100\n\nsubject\n\nbody\n"
124        );
125        odb.write(ObjectKind::Commit, body.as_bytes()).unwrap()
126    }
127
128    #[test]
129    fn read_commit_matches_parse_commit() {
130        let dir = TempDir::new().unwrap();
131        let odb = Odb::new(dir.path());
132        let tree = odb.write(ObjectKind::Tree, b"").unwrap();
133        let root = commit_with_parent(&odb, tree, None);
134        let child = commit_with_parent(&odb, tree, Some(root));
135
136        let read = odb.read_commit(&child).unwrap();
137        let parsed = parse_commit(&odb.read(&child).unwrap().data).unwrap();
138        assert_eq!(read, parsed);
139        assert_eq!(read.tree, tree);
140        assert_eq!(read.parents, vec![root]);
141        assert_eq!(odb.commit_tree(&child).unwrap(), tree);
142    }
143
144    fn git(dir: &std::path::Path, args: &[&str], stdin: Option<&[u8]>) -> Vec<u8> {
145        use std::io::Write as _;
146        let mut child = std::process::Command::new("git")
147            .args(args)
148            .current_dir(dir)
149            .stdin(std::process::Stdio::piped())
150            .stdout(std::process::Stdio::piped())
151            .stderr(std::process::Stdio::piped())
152            .spawn()
153            .unwrap();
154        child
155            .stdin
156            .take()
157            .unwrap()
158            .write_all(stdin.unwrap_or_default())
159            .unwrap();
160        let out = child.wait_with_output().unwrap();
161        assert!(
162            out.status.success(),
163            "git {args:?}: {}",
164            String::from_utf8_lossy(&out.stderr)
165        );
166        out.stdout
167    }
168
169    /// Commits git writes with awkward messages, each with the prefix
170    /// [`Odb::read_commit_through_subject`] must return.
171    fn git_commits_with_prefixes(dir: &std::path::Path) -> Vec<(ObjectId, Vec<u8>)> {
172        git(dir, &["init", "-q", "--bare", "."], None);
173        let tree = String::from_utf8(git(dir, &["mktree"], Some(b""))).unwrap();
174        let head = format!(
175            "tree {}\nauthor A U Thor <a@example.com> 1700000000 +0100\ncommitter C O Mitter <c@example.com> 1700000001 -0700\n",
176            tree.trim()
177        );
178        let body: String = (0..200)
179            .map(|i| format!("Body line {i} with some words.\n"))
180            .collect();
181        let signed_head = format!(
182            "{head}gpgsig -----BEGIN PGP SIGNATURE-----\n \n {}\n -----END PGP SIGNATURE-----\nchange-id kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk\n",
183            "A".repeat(64)
184        );
185        let cases: Vec<(Vec<u8>, usize)> = vec![
186            {
187                let prefix = format!("{head}\nSubject line\n");
188                (format!("{prefix}\n{body}").into_bytes(), prefix.len())
189            },
190            {
191                let prefix = format!("{head}\n\n  \n\t  Indented subject\n");
192                (
193                    format!("{prefix}second line\n\n{body}").into_bytes(),
194                    prefix.len(),
195                )
196            },
197            {
198                let prefix = format!("{signed_head}\nSigned subject\n");
199                (format!("{prefix}\n{body}").into_bytes(), prefix.len())
200            },
201            {
202                let prefix = format!("{head}\n\u{65e5}\u{672c}\n\u{a0}\nascii line\n");
203                (format!("{prefix}{body}").into_bytes(), prefix.len())
204            },
205            {
206                let mut raw = format!("{head}encoding ISO-8859-1\n\n").into_bytes();
207                raw.extend_from_slice(b"S\xe9ance\n\ncaf\xe9\n");
208                raw.extend_from_slice(body.as_bytes());
209                let len = raw.len();
210                (raw, len)
211            },
212            {
213                let raw = format!("{head}\nno trailing newline").into_bytes();
214                let len = raw.len();
215                (raw, len)
216            },
217            {
218                let raw = head.clone().into_bytes();
219                let len = raw.len();
220                (raw, len)
221            },
222        ];
223        cases
224            .into_iter()
225            .map(|(raw, prefix_len)| {
226                let hex = git(
227                    dir,
228                    &[
229                        "hash-object",
230                        "-t",
231                        "commit",
232                        "-w",
233                        "--literally",
234                        "--stdin",
235                    ],
236                    Some(&raw),
237                );
238                let oid = ObjectId::from_hex(std::str::from_utf8(&hex).unwrap().trim()).unwrap();
239                (oid, raw[..prefix_len].to_vec())
240            })
241            .collect()
242    }
243
244    fn assert_prefixes(odb: &Odb, dir: &std::path::Path, cases: &[(ObjectId, Vec<u8>)]) {
245        use crate::objects::read_commit_subject_line;
246        for (oid, expected) in cases {
247            let full = git(dir, &["cat-file", "commit", &oid.to_hex()], None);
248            let prefix = odb
249                .with_pack_read_context(|| odb.read_commit_through_subject(oid))
250                .unwrap();
251            assert_eq!(prefix, *expected, "{oid}");
252            assert!(full.starts_with(&prefix), "{oid}");
253            assert_eq!(
254                read_commit_subject_line(&prefix).unwrap(),
255                read_commit_subject_line(&full).unwrap(),
256                "{oid}"
257            );
258            let (short, whole) = (parse_commit(&prefix).unwrap(), parse_commit(&full).unwrap());
259            assert_eq!(
260                (short.tree, &short.parents, &short.author, &short.committer),
261                (whole.tree, &whole.parents, &whole.author, &whole.committer),
262                "{oid}"
263            );
264            assert_eq!(
265                (&short.encoding, &short.extra_headers),
266                (&whole.encoding, &whole.extra_headers),
267                "{oid}"
268            );
269        }
270    }
271
272    #[test]
273    fn read_commit_through_subject_matches_git_loose_and_packed() {
274        let dir = TempDir::new().unwrap();
275        let cases = git_commits_with_prefixes(dir.path());
276        let odb = Odb::new(&dir.path().join("objects"));
277        assert_prefixes(&odb, dir.path(), &cases);
278
279        git(
280            dir.path(),
281            &["update-ref", "refs/heads/a", &cases[0].0.to_hex()],
282            None,
283        );
284        for (i, (oid, _)) in cases.iter().enumerate() {
285            git(
286                dir.path(),
287                &["update-ref", &format!("refs/tags/t{i}"), &oid.to_hex()],
288                None,
289            );
290        }
291        git(dir.path(), &["repack", "-a", "-d", "-q"], None);
292        git(dir.path(), &["prune-packed"], None);
293        let odb = Odb::new(&dir.path().join("objects"));
294        assert_prefixes(&odb, dir.path(), &cases);
295
296        // Commits the pack stores whole come from the bounded pack read (deltas fall back to
297        // a whole read); git deltifies these similar commits, so only some are whole.
298        let objects = dir.path().join("objects");
299        let mut cut_short = 0;
300        for (oid, expected) in &cases {
301            let read = odb.with_pack_read_context(|| {
302                crate::pack::pack_cache::try_read_commit_through_subject_via_single_pack_reader(
303                    &objects, oid,
304                )
305            });
306            let Some(prefix) = read.unwrap() else {
307                continue;
308            };
309            let full = git(dir.path(), &["cat-file", "commit", &oid.to_hex()], None);
310            if prefix.len() < full.len() {
311                assert_eq!(prefix, *expected, "{oid}");
312                cut_short += 1;
313            } else {
314                assert_eq!(prefix, full, "{oid}");
315            }
316        }
317        assert!(cut_short > 0, "no commit was read only through its subject");
318    }
319
320    #[test]
321    fn read_commit_through_subject_rejects_other_kinds() {
322        let dir = TempDir::new().unwrap();
323        let odb = Odb::new(dir.path());
324        let blob = odb.write(ObjectKind::Blob, b"data").unwrap();
325        assert!(matches!(
326            odb.read_commit_through_subject(&blob),
327            Err(Error::UnexpectedObjectKind {
328                actual: ObjectKind::Blob,
329                ..
330            })
331        ));
332        let missing = ObjectId::from_hex("1111111111111111111111111111111111111111").unwrap();
333        assert!(matches!(
334            odb.read_commit_through_subject(&missing),
335            Err(Error::ObjectNotFound(_))
336        ));
337    }
338
339    #[test]
340    fn non_commits_are_unexpected_kinds() {
341        let dir = TempDir::new().unwrap();
342        let odb = Odb::new(dir.path());
343        let tree = odb.write(ObjectKind::Tree, b"").unwrap();
344        let blob = odb.write(ObjectKind::Blob, b"data").unwrap();
345        let commit = commit_with_parent(&odb, tree, None);
346        let tag = odb
347            .write(
348                ObjectKind::Tag,
349                format!("object {commit}\ntype commit\ntag t\n\nmsg\n").as_bytes(),
350            )
351            .unwrap();
352
353        for (oid, kind) in [
354            (tree, ObjectKind::Tree),
355            (blob, ObjectKind::Blob),
356            (tag, ObjectKind::Tag),
357        ] {
358            for err in [
359                odb.read_commit(&oid).unwrap_err(),
360                odb.commit_tree(&oid).unwrap_err(),
361            ] {
362                assert!(
363                    matches!(
364                        err,
365                        Error::UnexpectedObjectKind {
366                            oid: o,
367                            expected: ObjectKind::Commit,
368                            actual,
369                        } if o == oid && actual == kind
370                    ),
371                    "{err:?}"
372                );
373                assert_eq!(
374                    err.to_string(),
375                    format!("object {oid} is a {kind}, not a commit")
376                );
377            }
378        }
379    }
380
381    #[test]
382    fn missing_and_malformed_commits_are_errors() {
383        let dir = TempDir::new().unwrap();
384        let odb = Odb::new(dir.path());
385        let missing = ObjectId::from_hex("1111111111111111111111111111111111111111").unwrap();
386        assert!(matches!(
387            odb.read_commit(&missing),
388            Err(Error::ObjectNotFound(_))
389        ));
390        assert!(matches!(
391            odb.commit_tree(&missing),
392            Err(Error::ObjectNotFound(_))
393        ));
394
395        let no_tree = odb
396            .write(
397                ObjectKind::Commit,
398                b"author A <a@example.com> 0 +0000\n\nno tree\n",
399            )
400            .unwrap();
401        assert!(matches!(
402            odb.read_commit(&no_tree),
403            Err(Error::CorruptObject(_))
404        ));
405        assert!(matches!(
406            odb.commit_tree(&no_tree),
407            Err(Error::CorruptObject(_))
408        ));
409    }
410}