# Better commit parsing

`ef72e26`→[main](/content/gh/entireio/git-sync/commits/main/index.html)·

nodo·1mo ago·2 files·+136 added/-23 removed

## Changes

2

- internal/gitproto

- Mcommitparents.go+46/-23

- Mcommitparents_test.go+90

```
265 unmodified lines

266
267
268
269
270
271
272
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
274
286
287
288
289
290
291
292
293
294
295
296
297
276
277
278
279
280
281
282
283
284
285
298
299
300
301
302
287
303
304
305
290
291
292
293
294
295
306
307
308
309
310
311
299
312
313
314
315
316
317
318
319
320
321
322
323

265 unmodified lines

// --- Commit-header parsing ---------------------------------------------------

// parseCommitParents walks the header lines of a commit object and
// returns the hashes referenced by "parent <hash>\n" lines. Stops at
// the first empty line (end of headers) or at "author " (parents
// always appear before author in well-formed commits).
// parseCommitParents extracts parent hashes from the canonical
// position of a commit object: immediately after the single "tree"
// header, in an uninterrupted run, before any other header. Anything
// outside that run — "parent" lines that appear before "tree", after
// the first non-parent header, or in malformed shape — is ignored.
//
// This mirrors git's own parser: a malformed commit can claim extra
// "parent" lines outside the canonical position, but git treats only
// the canonical run as real parents. Matching git here keeps our
// reachability computation consistent with what a canonical reader
// will see in the same bytes.
//
// Returns nil if the first header isn't "tree <40-hex>", or if a
// parent line is malformed (wrong length / non-hex hash). In both
// cases the planner sees an empty parent set and stops walking
// rather than guessing.
func parseCommitParents(content []byte) []plumbing.Hash {
	const prefix = "parent "
	const (
		treePrefix   = "tree "
		parentPrefix = "parent "
		parentLen    = len(parentPrefix) + 40
	)

line, rest := nextLine(content)
	if !bytes.HasPrefix(line, []byte(treePrefix)) {
		return nil
	}

var parents []plumbing.Hash
	rest := content
	for len(rest) > 0 {
		nl := bytes.IndexByte(rest, '\n')
		var line []byte
		if nl < 0 {
			line = rest
			rest = nil
		} else {
			line = rest[:nl]
			rest = rest[nl+1:]
		}
		for {
			line, rest = nextLine(rest)
			if !bytes.HasPrefix(line, []byte(parentPrefix)) {
				return parents
			}
			if len(line) == 0 {
				if len(line) != parentLen {
					return parents
				}
				switch {
				case bytes.HasPrefix(line, []byte(prefix)):
					if len(line) >= len(prefix)+40 {
						parents = append(parents, plumbing.NewHash(string(line[len(prefix):len(prefix)+40])))
					}
				case bytes.HasPrefix(line, []byte("author ")):
					h := plumbing.NewHash(string(line[len(parentPrefix):]))
					if h.IsZero() {
						return parents
					}
					parents = append(parents, h)
				}
			}
		}
	}

return parents
}

// nextLine splits off the first '\n'-terminated line from content,
// returning the line (without the newline) and the remainder. If
// content has no newline, returns it whole with a nil remainder.
func nextLine(content []byte) (line, rest []byte) {
	nl := bytes.IndexByte(content, '\n')
	if nl < 0 {
		return content, nil
	}
	return content[:nl], content[nl+1:]
}
```

Minternal/gitproto/commitparents.go+46/-23

```
2 unmodified lines

3
4
5
6
7
8
9
10
148 unmodified lines

159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252

2 unmodified lines

import (
	"bytes"
	"io"
	"reflect"
	"strings"
	"testing"
	"time"
)

148 unmodified lines

func (r readOnlyReader) Read(p []byte) (int, error) { return r.r.Read(p) }

// TestParseCommitParents_CanonicalPositionOnly locks in that we
// extract parents only from the canonical position (immediately
// after "tree", in an uninterrupted run). A malformed commit that
// puts "parent" lines elsewhere should not influence the result —
// this matches git's own parser and prevents reachability divergence
// between us and any canonical reader of the same bytes.
func TestParseCommitParents_CanonicalPositionOnly(t *testing.T) {
	t.Parallel()
	var (
		tree = strings.Repeat("a", 40)
		h1   = strings.Repeat("1", 40)
		h2   = strings.Repeat("2", 40)
		h3   = strings.Repeat("3", 40)
	)

cases := []struct {
		name string
		body string
		want []plumbing.Hash
	}{
		{
			name: "root commit (no parents)",
			body: "tree " + tree + "\nauthor X <x@e> 0 +0000\ncommitter X <x@e> 0 +0000\n\nmsg\n",
			want: nil,
		},
		{
			name: "single parent",
			body: "tree " + tree + "\nparent " + h1 + "\nauthor X <x@e> 0 +0000\n\nmsg\n",
			want: []plumbing.Hash{plumbing.NewHash(h1)},
		},
		{
			name: "merge: two parents",
			body: "tree " + tree + "\nparent " + h1 + "\nparent " + h2 + "\nauthor X <x@e> 0 +0000\n\nmsg\n",
			want: []plumbing.Hash{plumbing.NewHash(h1), plumbing.NewHash(h2)},
		},
		{
			name: "parent before tree is ignored (object malformed: tree must be first)",
			body: "parent " + h1 + "\ntree " + tree + "\nparent " + h2 + "\nauthor X <x@e> 0 +0000\n\nmsg\n",
			want: nil,
		},
		{
			name: "missing tree returns nil",
			body: "parent " + h1 + "\nauthor X <x@e> 0 +0000\n\nmsg\n",
			want: nil,
		},
		{
			name: "parent after author is ignored",
			body: "tree " + tree + "\nparent " + h1 + "\nauthor X <x@e> 0 +0000\nparent " + h2 + "\n\nmsg\n",
			want: []plumbing.Hash{plumbing.NewHash(h1)},
		},
		{
			name: "parent run interrupted by another header is truncated",
			body: "tree " + tree + "\nparent " + h1 + "\nencoding UTF-8\nparent " + h2 + "\nauthor X <x@e> 0 +0000\n\nmsg\n",
			want: []plumbing.Hash{plumbing.NewHash(h1)},
		},
		{
			name: "malformed parent line (too short) stops the run",
			body: "tree " + tree + "\nparent " + h1 + "\nparent short\nparent " + h2 + "\nauthor X <x@e> 0 +0000\n\nmsg\n",
			want: []plumbing.Hash{plumbing.NewHash(h1)},
		},
		{
			name: "non-hex parent hash stops the run",
			body: "tree " + tree + "\nparent " + h1 + "\nparent " + strings.Repeat("g", 40) + "\nparent " + h2 + "\nauthor X <x@e> 0 +0000\n\nmsg\n",
			want: []plumbing.Hash{plumbing.NewHash(h1)},
		},
		{
			name: "three parents in canonical run",
			body: "tree " + tree + "\nparent " + h1 + "\nparent " + h2 + "\nparent " + h3 + "\nauthor X <x@e> 0 +0000\n\nmsg\n",
			want: []plumbing.Hash{plumbing.NewHash(h1), plumbing.NewHash(h2), plumbing.NewHash(h3)},
		},
		{
			name: "empty body returns nil",
			body: "",
			want: nil,
		},
	}

for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got := parseCommitParents([]byte(tc.body))
			if !reflect.DeepEqual(got, tc.want) {
				t.Fatalf("parseCommitParents:\n got=%v\nwant=%v", got, tc.want)
			}
		})
	}
}

func TestExtractCommitParents_NonSeekableSpillsToDisk(t *testing.T) {
	t.Parallel()
	pack, want := buildSyntheticCommitChain(t, 30, true)
