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