detached b1a375f

fix(commit): read tree and parents the way git does (GO-005)

The tree must be the first header, else ErrCorrupt. Only the parent lines
that directly follow it are parents; later tree and parent lines are
ordinary headers and are ignored, as in git log --format=%P. The object path
now agrees with the commit-graph path. Conformance tests against hostile
commits written with hash-object --literally, and fuzz seeds.
commit
b1a375f22bbe2324dc83d057a14a032aaa8f5226
author
brice · · 2026-10-10 22:15 UTC
parent
f0b398e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +129 -24
M commit.go +35 -18
@@ -63,16 +63,25 @@ func (r *Repo) ReadCommit(id ObjectID, c *Commit) error {
6363 }
6464
6565 // ParseCommit parses the content of a commit object into c. The fields of c
66-// point into data, which must stay unchanged while they are used. It returns
67-// ErrCorrupt if the commit has no valid tree or a malformed parent.
66+// point into data, which must stay unchanged while they are used.
67+//
68+// It reads headers the way Git does: the first line must be the tree, the
69+// parent lines are the ones that immediately follow it, and any tree or
70+// parent line further down is an ordinary, ignored header. It returns
71+// ErrCorrupt if the first line is not a valid tree or a parent is malformed.
6872 func ParseCommit(data []byte, c *Commit) error {
6973 c.ID = ObjectID{}
7074 c.Parents = c.Parents[:0]
7175 c.Author, c.Committer = Signature{}, Signature{}
7276 c.Encoding, c.Message = nil, nil
73- haveTree := false
77+ const (
78+ stTree = iota
79+ stParents
80+ stRest
81+ )
82+ state := stTree
7483 for len(data) > 0 {
75- if data[0] == '\n' {
84+ if data[0] == '\n' && state != stTree {
7685 c.Message = data[1:]
7786 break
7887 }
@@ -82,23 +91,31 @@ func ParseCommit(data []byte, c *Commit) error {
8291 } else {
8392 line, data = data, nil
8493 }
85- if line[0] == ' ' {
86- continue // continuation of a multi-line header such as gpgsig
87- }
8894 key, val, _ := bytes.Cut(line, []byte{' '})
89- switch string(key) {
90- case "tree":
95+ switch state {
96+ case stTree:
9197 id, ok := parseIDBytes(val)
92- if !ok || haveTree {
93- return corrupt("commit: bad tree header")
98+ if string(key) != "tree" || !ok {
99+ return corrupt("commit: the first header is not a valid tree")
94100 }
95- c.Tree, haveTree = id, true
96- case "parent":
97- id, ok := parseIDBytes(val)
98- if !ok {
99- return corrupt("commit: bad parent header")
101+ c.Tree = id
102+ state = stParents
103+ continue
104+ case stParents:
105+ if string(key) == "parent" {
106+ id, ok := parseIDBytes(val)
107+ if !ok {
108+ return corrupt("commit: bad parent header")
109+ }
110+ c.Parents = append(c.Parents, id)
111+ continue
100112 }
101- c.Parents = append(c.Parents, id)
113+ state = stRest
114+ }
115+ if line[0] == ' ' {
116+ continue // continuation of a multi-line header such as gpgsig
117+ }
118+ switch string(key) {
102119 case "author":
103120 c.Author = parseSignature(val)
104121 case "committer":
@@ -107,7 +124,7 @@ func ParseCommit(data []byte, c *Commit) error {
107124 c.Encoding = val
108125 }
109126 }
110- if !haveTree {
127+ if state == stTree {
111128 return corrupt("commit: no tree")
112129 }
113130 return nil
@@ -63,16 +63,25 @@ func (r *Repo) ReadCommit(id ObjectID, c *Commit) error {
63 }63 }
64 64
65 // ParseCommit parses the content of a commit object into c. The fields of c65 // ParseCommit parses the content of a commit object into c. The fields of c
66-// point into data, which must stay unchanged while they are used. It returns66+// point into data, which must stay unchanged while they are used.
67-// ErrCorrupt if the commit has no valid tree or a malformed parent.67+//
68+// It reads headers the way Git does: the first line must be the tree, the
69+// parent lines are the ones that immediately follow it, and any tree or
70+// parent line further down is an ordinary, ignored header. It returns
71+// ErrCorrupt if the first line is not a valid tree or a parent is malformed.
68 func ParseCommit(data []byte, c *Commit) error {72 func ParseCommit(data []byte, c *Commit) error {
69 c.ID = ObjectID{}73 c.ID = ObjectID{}
70 c.Parents = c.Parents[:0]74 c.Parents = c.Parents[:0]
71 c.Author, c.Committer = Signature{}, Signature{}75 c.Author, c.Committer = Signature{}, Signature{}
72 c.Encoding, c.Message = nil, nil76 c.Encoding, c.Message = nil, nil
73- haveTree := false77+ const (
78+ stTree = iota
79+ stParents
80+ stRest
81+ )
82+ state := stTree
74 for len(data) > 0 {83 for len(data) > 0 {
75- if data[0] == '\n' {84+ if data[0] == '\n' && state != stTree {
76 c.Message = data[1:]85 c.Message = data[1:]
77 break86 break
78 }87 }
@@ -82,23 +91,31 @@ func ParseCommit(data []byte, c *Commit) error {
82 } else {91 } else {
83 line, data = data, nil92 line, data = data, nil
84 }93 }
85- if line[0] == ' ' {
86- continue // continuation of a multi-line header such as gpgsig
87- }
88 key, val, _ := bytes.Cut(line, []byte{' '})94 key, val, _ := bytes.Cut(line, []byte{' '})
89- switch string(key) {95+ switch state {
90- case "tree":96+ case stTree:
91 id, ok := parseIDBytes(val)97 id, ok := parseIDBytes(val)
92- if !ok || haveTree {98+ if string(key) != "tree" || !ok {
93- return corrupt("commit: bad tree header")99+ return corrupt("commit: the first header is not a valid tree")
94 }100 }
95- c.Tree, haveTree = id, true101+ c.Tree = id
96- case "parent":102+ state = stParents
97- id, ok := parseIDBytes(val)103+ continue
98- if !ok {104+ case stParents:
99- return corrupt("commit: bad parent header")105+ if string(key) == "parent" {
106+ id, ok := parseIDBytes(val)
107+ if !ok {
108+ return corrupt("commit: bad parent header")
109+ }
110+ c.Parents = append(c.Parents, id)
111+ continue
100 }112 }
101- c.Parents = append(c.Parents, id)113+ state = stRest
114+ }
115+ if line[0] == ' ' {
116+ continue // continuation of a multi-line header such as gpgsig
117+ }
118+ switch string(key) {
102 case "author":119 case "author":
103 c.Author = parseSignature(val)120 c.Author = parseSignature(val)
104 case "committer":121 case "committer":
@@ -107,7 +124,7 @@ func ParseCommit(data []byte, c *Commit) error {
107 c.Encoding = val124 c.Encoding = val
108 }125 }
109 }126 }
110- if !haveTree {127+ if state == stTree {
111 return corrupt("commit: no tree")128 return corrupt("commit: no tree")
112 }129 }
113 return nil130 return nil
A commit_hostile_test.go +68 -0
@@ -0,0 +1,68 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "errors"
7+ "strings"
8+ "testing"
9+)
10+
11+// Headers after the first line are read the way Git reads them: the tree
12+// must come first, the parents follow it directly, and later tree or parent
13+// lines are ordinary headers that nothing uses.
14+func TestParseCommitHostileHeadersMatchGit(t *testing.T) {
15+ f := newFx(t)
16+ f.write("a", []byte("a"), 0o644)
17+ p1 := f.commit("one")
18+ f.write("a", []byte("b"), 0o644)
19+ p2 := f.commit("two")
20+ tree := f.git("rev-parse", "HEAD^{tree}")
21+ sig := "author A <a@x> 1 +0000\ncommitter C <c@x> 2 +0000\n"
22+ cases := []struct {
23+ name, body string
24+ corrupt bool
25+ }{
26+ {"second tree", "tree " + tree + "\ntree " + p1 + "\nparent " + p1 + "\n" + sig + "\nmsg\n", false},
27+ {"parents then tree", "tree " + tree + "\nparent " + p1 + "\nparent " + p2 + "\ntree " + p1 + "\n" + sig + "\nmsg\n", false},
28+ {"parent after author", "tree " + tree + "\nparent " + p1 + "\n" + sig + "parent " + p2 + "\n\nmsg\n", false},
29+ {"parent after other header", "tree " + tree + "\n" + "author A <a@x> 1 +0000\nparent " + p1 + "\ncommitter C <c@x> 2 +0000\n\nmsg\n", false},
30+ {"bad parent after author", "tree " + tree + "\n" + sig + "parent zzz\n\nmsg\n", false},
31+ {"tree after author", "author A <a@x> 1 +0000\ntree " + tree + "\n\nmsg\n", true},
32+ {"parent before tree", "parent " + p1 + "\ntree " + tree + "\n" + sig + "\nmsg\n", true},
33+ {"no tree", sig + "\nmsg\n", true},
34+ {"bad parent", "tree " + tree + "\nparent zzz\n" + sig + "\nmsg\n", true},
35+ {"parent with junk", "tree " + tree + "\nparent " + p1 + " junk\n" + sig + "\nmsg\n", true},
36+ }
37+ r := f.open()
38+ for _, c := range cases {
39+ t.Run(c.name, func(t *testing.T) {
40+ id := mustID(t, f.gitIn([]byte(c.body), "hash-object", "-t", "commit", "--literally", "-w", "--stdin"))
41+ var got Commit
42+ err := r.ReadCommit(id, &got)
43+ want, gerr := f.gitErr("log", "-1", "--format=%P %T", id.String())
44+ if c.corrupt {
45+ if gerr == nil {
46+ t.Fatalf("git accepts it: %q", want)
47+ }
48+ if !errors.Is(err, ErrCorrupt) {
49+ t.Fatalf("ReadCommit = %v, want ErrCorrupt", err)
50+ }
51+ return
52+ }
53+ if gerr != nil {
54+ t.Fatalf("git rejects it: %v", gerr)
55+ }
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ var ps []string
60+ for _, p := range got.Parents {
61+ ps = append(ps, p.String())
62+ }
63+ if have := strings.TrimSpace(strings.Join(ps, " ") + " " + got.Tree.String()); have != strings.TrimSpace(want) {
64+ t.Errorf("parents and tree = %q, git says %q", have, strings.TrimSpace(want))
65+ }
66+ })
67+ }
68+}
@@ -0,0 +1,68 @@
1+// SPDX-License-Identifier: MIT
2+
3+package git
4+
5+import (
6+ "errors"
7+ "strings"
8+ "testing"
9+)
10+
11+// Headers after the first line are read the way Git reads them: the tree
12+// must come first, the parents follow it directly, and later tree or parent
13+// lines are ordinary headers that nothing uses.
14+func TestParseCommitHostileHeadersMatchGit(t *testing.T) {
15+ f := newFx(t)
16+ f.write("a", []byte("a"), 0o644)
17+ p1 := f.commit("one")
18+ f.write("a", []byte("b"), 0o644)
19+ p2 := f.commit("two")
20+ tree := f.git("rev-parse", "HEAD^{tree}")
21+ sig := "author A <a@x> 1 +0000\ncommitter C <c@x> 2 +0000\n"
22+ cases := []struct {
23+ name, body string
24+ corrupt bool
25+ }{
26+ {"second tree", "tree " + tree + "\ntree " + p1 + "\nparent " + p1 + "\n" + sig + "\nmsg\n", false},
27+ {"parents then tree", "tree " + tree + "\nparent " + p1 + "\nparent " + p2 + "\ntree " + p1 + "\n" + sig + "\nmsg\n", false},
28+ {"parent after author", "tree " + tree + "\nparent " + p1 + "\n" + sig + "parent " + p2 + "\n\nmsg\n", false},
29+ {"parent after other header", "tree " + tree + "\n" + "author A <a@x> 1 +0000\nparent " + p1 + "\ncommitter C <c@x> 2 +0000\n\nmsg\n", false},
30+ {"bad parent after author", "tree " + tree + "\n" + sig + "parent zzz\n\nmsg\n", false},
31+ {"tree after author", "author A <a@x> 1 +0000\ntree " + tree + "\n\nmsg\n", true},
32+ {"parent before tree", "parent " + p1 + "\ntree " + tree + "\n" + sig + "\nmsg\n", true},
33+ {"no tree", sig + "\nmsg\n", true},
34+ {"bad parent", "tree " + tree + "\nparent zzz\n" + sig + "\nmsg\n", true},
35+ {"parent with junk", "tree " + tree + "\nparent " + p1 + " junk\n" + sig + "\nmsg\n", true},
36+ }
37+ r := f.open()
38+ for _, c := range cases {
39+ t.Run(c.name, func(t *testing.T) {
40+ id := mustID(t, f.gitIn([]byte(c.body), "hash-object", "-t", "commit", "--literally", "-w", "--stdin"))
41+ var got Commit
42+ err := r.ReadCommit(id, &got)
43+ want, gerr := f.gitErr("log", "-1", "--format=%P %T", id.String())
44+ if c.corrupt {
45+ if gerr == nil {
46+ t.Fatalf("git accepts it: %q", want)
47+ }
48+ if !errors.Is(err, ErrCorrupt) {
49+ t.Fatalf("ReadCommit = %v, want ErrCorrupt", err)
50+ }
51+ return
52+ }
53+ if gerr != nil {
54+ t.Fatalf("git rejects it: %v", gerr)
55+ }
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ var ps []string
60+ for _, p := range got.Parents {
61+ ps = append(ps, p.String())
62+ }
63+ if have := strings.TrimSpace(strings.Join(ps, " ") + " " + got.Tree.String()); have != strings.TrimSpace(want) {
64+ t.Errorf("parents and tree = %q, git says %q", have, strings.TrimSpace(want))
65+ }
66+ })
67+ }
68+}
M fuzz_test.go +17 -0
@@ -93,6 +93,9 @@ func FuzzParseCommit(f *testing.F) {
9393 f.Add([]byte("tree " + tree))
9494 f.Add([]byte("tree " + tree + "\nauthor <> 99999999999999999999999 +9999\n\n"))
9595 f.Add([]byte("tree\n\n"))
96+ f.Add([]byte("tree " + tree + "\ntree " + par + "\nparent " + par + "\n\n"))
97+ f.Add([]byte("tree " + tree + "\nparent " + par + "\nauthor A <a@x> 1 +0000\nparent " + tree + "\n\nm"))
98+ f.Add([]byte("parent " + par + "\ntree " + tree + "\n\n"))
9699 f.Add([]byte{})
97100 f.Add([]byte("\n\n\n"))
98101 var c Commit
@@ -102,6 +105,20 @@ func FuzzParseCommit(f *testing.F) {
102105 if err == nil && len(c.Message) > len(data) {
103106 t.Fatal("message longer than input")
104107 }
108+ if err == nil && !bytes.HasPrefix(data, []byte("tree ")) {
109+ t.Fatal("accepted a commit whose first header is not the tree")
110+ }
111+ if err == nil && len(c.Parents) > 0 {
112+ // The parents are the lines that follow the tree directly.
113+ rest := data[bytes.IndexByte(data, '\n')+1:]
114+ for _, p := range c.Parents {
115+ want := append([]byte("parent "), []byte(p.String())...)
116+ if len(rest) < len(want) || !bytes.EqualFold(rest[:len(want)], want) {
117+ t.Fatalf("parent %s is not on the line after the tree", p)
118+ }
119+ rest = rest[bytes.IndexByte(rest, '\n')+1:]
120+ }
121+ }
105122 var tg Tag
106123 okOrCorrupt(t, ParseTag(data, &tg))
107124 _ = parseSignature(data)
@@ -93,6 +93,9 @@ func FuzzParseCommit(f *testing.F) {
93 f.Add([]byte("tree " + tree))93 f.Add([]byte("tree " + tree))
94 f.Add([]byte("tree " + tree + "\nauthor <> 99999999999999999999999 +9999\n\n"))94 f.Add([]byte("tree " + tree + "\nauthor <> 99999999999999999999999 +9999\n\n"))
95 f.Add([]byte("tree\n\n"))95 f.Add([]byte("tree\n\n"))
96+ f.Add([]byte("tree " + tree + "\ntree " + par + "\nparent " + par + "\n\n"))
97+ f.Add([]byte("tree " + tree + "\nparent " + par + "\nauthor A <a@x> 1 +0000\nparent " + tree + "\n\nm"))
98+ f.Add([]byte("parent " + par + "\ntree " + tree + "\n\n"))
96 f.Add([]byte{})99 f.Add([]byte{})
97 f.Add([]byte("\n\n\n"))100 f.Add([]byte("\n\n\n"))
98 var c Commit101 var c Commit
@@ -102,6 +105,20 @@ func FuzzParseCommit(f *testing.F) {
102 if err == nil && len(c.Message) > len(data) {105 if err == nil && len(c.Message) > len(data) {
103 t.Fatal("message longer than input")106 t.Fatal("message longer than input")
104 }107 }
108+ if err == nil && !bytes.HasPrefix(data, []byte("tree ")) {
109+ t.Fatal("accepted a commit whose first header is not the tree")
110+ }
111+ if err == nil && len(c.Parents) > 0 {
112+ // The parents are the lines that follow the tree directly.
113+ rest := data[bytes.IndexByte(data, '\n')+1:]
114+ for _, p := range c.Parents {
115+ want := append([]byte("parent "), []byte(p.String())...)
116+ if len(rest) < len(want) || !bytes.EqualFold(rest[:len(want)], want) {
117+ t.Fatalf("parent %s is not on the line after the tree", p)
118+ }
119+ rest = rest[bytes.IndexByte(rest, '\n')+1:]
120+ }
121+ }
105 var tg Tag122 var tg Tag
106 okOrCorrupt(t, ParseTag(data, &tg))123 okOrCorrupt(t, ParseTag(data, &tg))
107 _ = parseSignature(data)124 _ = parseSignature(data)
M parse_test.go +9 -6
@@ -223,12 +223,15 @@ func TestParseCommitEdgeCases(t *testing.T) {
223223 }
224224 }
225225 for name, d := range map[string]string{
226- "empty": "",
227- "no tree": "author A <a@x> 1 +0000\n\nmsg",
228- "bad tree": "tree xyz\n\n",
229- "short tree": "tree " + tree[:39] + "\n\n",
230- "bad parent": "tree " + tree + "\nparent zz\n\n",
231- "double tree": "tree " + tree + "\ntree " + tree + "\n\n",
226+ "empty": "",
227+ "no tree": "author A <a@x> 1 +0000\n\nmsg",
228+ "bad tree": "tree xyz\n\n",
229+ "short tree": "tree " + tree[:39] + "\n\n",
230+ "bad parent": "tree " + tree + "\nparent zz\n\n",
231+ "tree second": "author A <a@x> 1 +0000\ntree " + tree + "\n\n",
232+ "parent first": "parent " + tree + "\ntree " + tree + "\n\n",
233+ "blank first": "\ntree " + tree + "\n",
234+ "bad parent after": "tree " + tree + "\nparent " + tree + "\nparent x\n\n",
232235 } {
233236 if err := ParseCommit([]byte(d), &c); !errors.Is(err, ErrCorrupt) {
234237 t.Errorf("%s: %v", name, err)
@@ -223,12 +223,15 @@ func TestParseCommitEdgeCases(t *testing.T) {
223 }223 }
224 }224 }
225 for name, d := range map[string]string{225 for name, d := range map[string]string{
226- "empty": "",226+ "empty": "",
227- "no tree": "author A <a@x> 1 +0000\n\nmsg",227+ "no tree": "author A <a@x> 1 +0000\n\nmsg",
228- "bad tree": "tree xyz\n\n",228+ "bad tree": "tree xyz\n\n",
229- "short tree": "tree " + tree[:39] + "\n\n",229+ "short tree": "tree " + tree[:39] + "\n\n",
230- "bad parent": "tree " + tree + "\nparent zz\n\n",230+ "bad parent": "tree " + tree + "\nparent zz\n\n",
231- "double tree": "tree " + tree + "\ntree " + tree + "\n\n",231+ "tree second": "author A <a@x> 1 +0000\ntree " + tree + "\n\n",
232+ "parent first": "parent " + tree + "\ntree " + tree + "\n\n",
233+ "blank first": "\ntree " + tree + "\n",
234+ "bad parent after": "tree " + tree + "\nparent " + tree + "\nparent x\n\n",
232 } {235 } {
233 if err := ParseCommit([]byte(d), &c); !errors.Is(err, ErrCorrupt) {236 if err := ParseCommit([]byte(d), &c); !errors.Is(err, ErrCorrupt) {
234 t.Errorf("%s: %v", name, err)237 t.Errorf("%s: %v", name, err)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 2

↑↓ moveenter openesc close