Fast implementation of Git in pure Go
at legacy 474 lines 13 kB view raw
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}