Fast implementation of Git in pure Go
1package furgit
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12func TestTreeWrite(t *testing.T) {
13 repoPath, cleanup := setupTestRepo(t)
14 defer cleanup()
15
16 blobData := []byte("file content")
17 blobHash := gitHashObject(t, repoPath, "blob", blobData)
18
19 repo, err := OpenRepository(repoPath)
20 if err != nil {
21 t.Fatalf("OpenRepository failed: %v", err)
22 }
23 defer func() { _ = repo.Close() }()
24
25 blobHashObj, _ := repo.ParseHash(blobHash)
26 tree := &Tree{
27 Entries: []TreeEntry{
28 {Mode: 0o100644, Name: []byte("file.txt"), ID: blobHashObj},
29 },
30 }
31
32 treeHash, err := repo.WriteLooseObject(tree)
33 if err != nil {
34 t.Fatalf("WriteLooseObject failed: %v", err)
35 }
36
37 gitType := string(gitCatFile(t, repoPath, "-t", treeHash.String()))
38 if gitType != "tree" {
39 t.Errorf("git type: got %q, want %q", gitType, "tree")
40 }
41
42 gitLsTree := gitCmd(t, repoPath, "ls-tree", treeHash.String())
43 if !strings.Contains(gitLsTree, "file.txt") {
44 t.Errorf("git ls-tree doesn't contain file.txt: %s", gitLsTree)
45 }
46 if !strings.Contains(gitLsTree, blobHash) {
47 t.Errorf("git ls-tree doesn't contain blob hash: %s", gitLsTree)
48 }
49}
50
51func TestTreeRead(t *testing.T) {
52 repoPath, cleanup := setupTestRepo(t)
53 defer cleanup()
54
55 workDir, cleanupWork := setupWorkDir(t)
56 defer cleanupWork()
57
58 err := os.WriteFile(filepath.Join(workDir, "a.txt"), []byte("content a"), 0o644)
59 if err != nil {
60 t.Fatalf("failed to write a.txt: %v", err)
61 }
62 err = os.WriteFile(filepath.Join(workDir, "b.txt"), []byte("content b"), 0o644)
63 if err != nil {
64 t.Fatalf("failed to write b.txt: %v", err)
65 }
66 err = os.WriteFile(filepath.Join(workDir, "c.txt"), []byte("content c"), 0o644)
67 if err != nil {
68 t.Fatalf("failed to write c.txt: %v", err)
69 }
70
71 gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".")
72 treeHash := gitCmd(t, repoPath, "--work-tree="+workDir, "write-tree")
73
74 repo, err := OpenRepository(repoPath)
75 if err != nil {
76 t.Fatalf("OpenRepository failed: %v", err)
77 }
78 defer func() { _ = repo.Close() }()
79
80 hash, _ := repo.ParseHash(treeHash)
81 obj, err := repo.ReadObject(hash)
82 if err != nil {
83 t.Fatalf("ReadObject failed: %v", err)
84 }
85
86 tree, ok := obj.(*StoredTree)
87 if !ok {
88 t.Fatalf("expected *StoredTree, got %T", obj)
89 }
90
91 if len(tree.Entries) != 3 {
92 t.Fatalf("entries count: got %d, want 3", len(tree.Entries))
93 }
94
95 expectedNames := []string{"a.txt", "b.txt", "c.txt"}
96 for i, expected := range expectedNames {
97 if string(tree.Entries[i].Name) != expected {
98 t.Errorf("entry[%d] name: got %q, want %q", i, tree.Entries[i].Name, expected)
99 }
100 }
101
102 if tree.ObjectType() != ObjectTypeTree {
103 t.Errorf("ObjectType(): got %d, want %d", tree.ObjectType(), ObjectTypeTree)
104 }
105}
106
107func TestTreeEntry(t *testing.T) {
108 repoPath, cleanup := setupTestRepo(t)
109 defer cleanup()
110
111 workDir, cleanupWork := setupWorkDir(t)
112 defer cleanupWork()
113
114 err := os.WriteFile(filepath.Join(workDir, "a.txt"), []byte("content a"), 0o644)
115 if err != nil {
116 t.Fatalf("failed to write a.txt: %v", err)
117 }
118 err = os.WriteFile(filepath.Join(workDir, "b.txt"), []byte("content b"), 0o644)
119 if err != nil {
120 t.Fatalf("failed to write b.txt: %v", err)
121 }
122 err = os.WriteFile(filepath.Join(workDir, "c.txt"), []byte("content c"), 0o644)
123 if err != nil {
124 t.Fatalf("failed to write c.txt: %v", err)
125 }
126
127 gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".")
128 treeHash := gitCmd(t, repoPath, "--work-tree="+workDir, "write-tree")
129
130 repo, err := OpenRepository(repoPath)
131 if err != nil {
132 t.Fatalf("OpenRepository failed: %v", err)
133 }
134 defer func() { _ = repo.Close() }()
135
136 hash, _ := repo.ParseHash(treeHash)
137 obj, _ := repo.ReadObject(hash)
138 tree := obj.(*StoredTree)
139
140 entry := tree.Entry([]byte("b.txt"))
141 if entry == nil {
142 t.Fatal("Entry returned nil for existing entry")
143 }
144 if !bytes.Equal(entry.Name, []byte("b.txt")) {
145 t.Errorf("entry name: got %q, want %q", entry.Name, "b.txt")
146 }
147
148 notFound := tree.Entry([]byte("notfound.txt"))
149 if notFound != nil {
150 t.Error("Entry returned non-nil for non-existing entry")
151 }
152}
153
154func TestTreeEntryRecursive(t *testing.T) {
155 repoPath, cleanup := setupTestRepo(t)
156 defer cleanup()
157
158 workDir, cleanupWork := setupWorkDir(t)
159 defer cleanupWork()
160
161 err := os.MkdirAll(filepath.Join(workDir, "dir"), 0o755)
162 if err != nil {
163 t.Fatalf("failed to create dir: %v", err)
164 }
165 err = os.WriteFile(filepath.Join(workDir, "file1.txt"), []byte("file1"), 0o644)
166 if err != nil {
167 t.Fatalf("failed to write file1.txt: %v", err)
168 }
169 err = os.WriteFile(filepath.Join(workDir, "file2.txt"), []byte("file2"), 0o644)
170 if err != nil {
171 t.Fatalf("failed to write file2.txt: %v", err)
172 }
173 err = os.WriteFile(filepath.Join(workDir, "dir", "nested.txt"), []byte("nested"), 0o644)
174 if err != nil {
175 t.Fatalf("failed to write dir/nested.txt: %v", err)
176 }
177
178 gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".")
179 treeHash := gitCmd(t, repoPath, "--work-tree="+workDir, "write-tree")
180
181 repo, err := OpenRepository(repoPath)
182 if err != nil {
183 t.Fatalf("OpenRepository failed: %v", err)
184 }
185 defer func() { _ = repo.Close() }()
186
187 hash, _ := repo.ParseHash(treeHash)
188 obj, _ := repo.ReadObject(hash)
189 tree := obj.(*StoredTree)
190
191 entry, err := tree.EntryRecursive(repo, [][]byte{[]byte("file1.txt")})
192 if err != nil {
193 t.Fatalf("EntryRecursive file1.txt failed: %v", err)
194 }
195 if !bytes.Equal(entry.Name, []byte("file1.txt")) {
196 t.Errorf("entry name: got %q, want %q", entry.Name, "file1.txt")
197 }
198
199 gitShow := string(gitCatFile(t, repoPath, "blob", entry.ID.String()))
200 if gitShow != "file1" {
201 t.Errorf("file1 content from git: got %q, want %q", gitShow, "file1")
202 }
203
204 nestedEntry, err := tree.EntryRecursive(repo, [][]byte{[]byte("dir"), []byte("nested.txt")})
205 if err != nil {
206 t.Fatalf("EntryRecursive dir/nested.txt failed: %v", err)
207 }
208 if !bytes.Equal(nestedEntry.Name, []byte("nested.txt")) {
209 t.Errorf("nested entry name: got %q, want %q", nestedEntry.Name, "nested.txt")
210 }
211
212 gitShowNested := string(gitCatFile(t, repoPath, "blob", nestedEntry.ID.String()))
213 if gitShowNested != "nested" {
214 t.Errorf("nested content from git: got %q, want %q", gitShowNested, "nested")
215 }
216
217 _, err = tree.EntryRecursive(repo, [][]byte{[]byte("nonexistent.txt")})
218 if err == nil {
219 t.Error("expected error for nonexistent path")
220 }
221
222 _, err = tree.EntryRecursive(repo, [][]byte{})
223 if err == nil {
224 t.Error("expected error for empty path")
225 }
226}
227
228func TestTreeLarge(t *testing.T) {
229 if testing.Short() {
230 t.Skip("skipping large tree test in short mode")
231 }
232
233 repoPath, cleanup := setupTestRepo(t)
234 defer cleanup()
235
236 gitCmd(t, repoPath, "config", "gc.auto", "0")
237
238 workDir, cleanupWork := setupWorkDir(t)
239 defer cleanupWork()
240
241 numFiles := 1000
242 for i := 0; i < numFiles; i++ {
243 filename := filepath.Join(workDir, fmt.Sprintf("file%04d.txt", i))
244 content := fmt.Sprintf("Content for file %d\n", i)
245 err := os.WriteFile(filename, []byte(content), 0o644)
246 if err != nil {
247 t.Fatalf("failed to write %s: %v", filename, err)
248 }
249 }
250
251 gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".")
252 treeHash := gitCmd(t, repoPath, "--work-tree="+workDir, "write-tree")
253
254 repo, err := OpenRepository(repoPath)
255 if err != nil {
256 t.Fatalf("OpenRepository failed: %v", err)
257 }
258 defer func() { _ = repo.Close() }()
259
260 hash, _ := repo.ParseHash(treeHash)
261 obj, _ := repo.ReadObject(hash)
262 tree := obj.(*StoredTree)
263
264 if len(tree.Entries) != numFiles {
265 t.Errorf("tree entries: got %d, want %d", len(tree.Entries), numFiles)
266 }
267
268 gitCount := gitCmd(t, repoPath, "ls-tree", treeHash)
269 gitLines := strings.Count(gitCount, "\n") + 1
270 if len(tree.Entries) != gitLines {
271 t.Errorf("furgit found %d entries, git found %d", len(tree.Entries), gitLines)
272 }
273
274 for i := 0; i < 10; i++ {
275 idx := i * (numFiles / 10)
276 expectedName := fmt.Sprintf("file%04d.txt", idx)
277 entry := tree.Entry([]byte(expectedName))
278 if entry == nil {
279 t.Errorf("expected to find entry %s", expectedName)
280 continue
281 }
282
283 blobObj, _ := repo.ReadObject(entry.ID)
284 blob := blobObj.(*StoredBlob)
285
286 expectedContent := fmt.Sprintf("Content for file %d\n", idx)
287 if string(blob.Data) != expectedContent {
288 t.Errorf("blob %s: got %q, want %q", expectedName, blob.Data, expectedContent)
289 }
290
291 gitData := gitCatFile(t, repoPath, "blob", entry.ID.String())
292 if !bytes.Equal(blob.Data, gitData) {
293 t.Errorf("blob %s: furgit data doesn't match git data", expectedName)
294 }
295 }
296}
297
298func TestTreeInsertEntry(t *testing.T) {
299 tree := &Tree{
300 Entries: []TreeEntry{
301 {Mode: FileModeRegular, Name: []byte("alpha"), ID: Hash{}},
302 {Mode: FileModeRegular, Name: []byte("gamma"), ID: Hash{}},
303 },
304 }
305
306 if err := tree.InsertEntry(TreeEntry{Mode: FileModeRegular, Name: []byte("beta"), ID: Hash{}}); err != nil {
307 t.Fatalf("InsertEntry failed: %v", err)
308 }
309 if len(tree.Entries) != 3 {
310 t.Fatalf("entries count: got %d, want 3", len(tree.Entries))
311 }
312 if string(tree.Entries[1].Name) != "beta" {
313 t.Fatalf("inserted order mismatch: got %q, want %q", tree.Entries[1].Name, "beta")
314 }
315
316 if err := tree.InsertEntry(TreeEntry{Mode: FileModeRegular, Name: []byte("beta"), ID: Hash{}}); err == nil {
317 t.Fatal("expected duplicate insert error")
318 }
319
320 var nilTree *Tree
321 if err := nilTree.InsertEntry(TreeEntry{Mode: FileModeRegular, Name: []byte("x"), ID: Hash{}}); err == nil {
322 t.Fatal("expected error for nil tree")
323 }
324}
325
326func TestTreeRemoveEntry(t *testing.T) {
327 tree := &Tree{
328 Entries: []TreeEntry{
329 {Mode: FileModeRegular, Name: []byte("alpha"), ID: Hash{}},
330 {Mode: FileModeRegular, Name: []byte("beta"), ID: Hash{}},
331 {Mode: FileModeRegular, Name: []byte("gamma"), ID: Hash{}},
332 },
333 }
334
335 if err := tree.RemoveEntry([]byte("beta")); err != nil {
336 t.Fatalf("RemoveEntry failed: %v", err)
337 }
338 if len(tree.Entries) != 2 {
339 t.Fatalf("entries count: got %d, want 2", len(tree.Entries))
340 }
341 if string(tree.Entries[0].Name) != "alpha" || string(tree.Entries[1].Name) != "gamma" {
342 t.Fatalf("remove order mismatch: got %q, %q", tree.Entries[0].Name, tree.Entries[1].Name)
343 }
344
345 if err := tree.RemoveEntry([]byte("beta")); err == nil {
346 t.Fatal("expected ErrNotFound for missing entry")
347 }
348
349 var nilTree *Tree
350 if err := nilTree.RemoveEntry([]byte("alpha")); err == nil {
351 t.Fatal("expected error for nil tree")
352 }
353}
354
355func TestTreeEntryNameCompare(t *testing.T) {
356 t.Parallel()
357
358 tests := []struct {
359 name string
360 entryName []byte
361 entryMode FileMode
362 searchName []byte
363 searchIsTree bool
364 want int
365 }{
366 {
367 name: "equal file names",
368 entryName: []byte("alpha"),
369 entryMode: FileModeRegular,
370 searchName: []byte("alpha"),
371 want: 0,
372 },
373 {
374 name: "equal tree names",
375 entryName: []byte("dir"),
376 entryMode: FileModeDir,
377 searchName: []byte("dir"),
378 searchIsTree: true,
379 want: 0,
380 },
381 {
382 name: "lexicographic less",
383 entryName: []byte("alpha"),
384 entryMode: FileModeRegular,
385 searchName: []byte("beta"),
386 want: -1,
387 },
388 {
389 name: "lexicographic greater",
390 entryName: []byte("gamma"),
391 entryMode: FileModeRegular,
392 searchName: []byte("beta"),
393 want: 1,
394 },
395 {
396 name: "file sorts before same-name dir",
397 entryName: []byte("same"),
398 entryMode: FileModeRegular,
399 searchName: []byte("same"),
400 searchIsTree: true,
401 want: -1,
402 },
403 {
404 name: "dir sorts after same-name file",
405 entryName: []byte("same"),
406 entryMode: FileModeDir,
407 searchName: []byte("same"),
408 searchIsTree: false,
409 want: 1,
410 },
411 {
412 name: "dir sorts before longer file",
413 entryName: []byte("a"),
414 entryMode: FileModeDir,
415 searchName: []byte("ab"),
416 searchIsTree: false,
417 want: -1,
418 },
419 {
420 name: "file sorts before longer file",
421 entryName: []byte("a"),
422 entryMode: FileModeRegular,
423 searchName: []byte("ab"),
424 want: -1,
425 },
426 {
427 name: "search tree compares after exact file name",
428 entryName: []byte("a"),
429 entryMode: FileModeRegular,
430 searchName: []byte("a"),
431 searchIsTree: true,
432 want: -1,
433 },
434 {
435 name: "entry tree compares after exact search file",
436 entryName: []byte("a"),
437 entryMode: FileModeDir,
438 searchName: []byte("a"),
439 searchIsTree: false,
440 want: 1,
441 },
442 {
443 name: "slash impact mid-compare",
444 entryName: []byte("a"),
445 entryMode: FileModeDir,
446 searchName: []byte("a0"),
447 searchIsTree: false,
448 want: -1,
449 },
450 {
451 name: "file sorts after same prefix dir",
452 entryName: []byte("a0"),
453 entryMode: FileModeRegular,
454 searchName: []byte("a"),
455 searchIsTree: true,
456 want: 1,
457 },
458 }
459
460 for _, tt := range tests {
461 tt := tt
462 t.Run(tt.name, func(t *testing.T) {
463 got := TreeEntryNameCompare(tt.entryName, tt.entryMode, tt.searchName, tt.searchIsTree)
464 if got < 0 {
465 got = -1
466 } else if got > 0 {
467 got = 1
468 }
469 if got != tt.want {
470 t.Fatalf("compare(%q,%v,%q,%v) = %d, want %d", tt.entryName, tt.entryMode, tt.searchName, tt.searchIsTree, got, tt.want)
471 }
472 })
473 }
474}