diff options
| author | 2025-11-16 00:00:00 +0000 | |
|---|---|---|
| committer | 2025-11-16 00:00:00 +0000 | |
| commit | bad0f9715556a470d0de2a22c7040181e3a033ba (patch) | |
| tree | 21463072ce5bc85682a887ce0cae26d833941af3 | |
| parent | EntryRecursive should return ErrNotFound instead of nil, nil (diff) | |
| signature | ||
Use actual git for tests and enhance Head
| -rw-r--r-- | .build.yml | 6 | ||||
| -rw-r--r-- | config/config_test.go | 500 | ||||
| -rw-r--r-- | errors_test.go | 17 | ||||
| -rw-r--r-- | hash_sha1_test.go | 9 | ||||
| -rw-r--r-- | hash_sha256_test.go | 9 | ||||
| -rw-r--r-- | hash_test.go | 79 | ||||
| -rw-r--r-- | hybrid_test.go | 271 | ||||
| -rw-r--r-- | ident_test.go | 123 | ||||
| -rw-r--r-- | obj_blob_test.go | 120 | ||||
| -rw-r--r-- | obj_commit_test.go | 188 | ||||
| -rw-r--r-- | obj_tag_test.go | 191 | ||||
| -rw-r--r-- | obj_test.go | 52 | ||||
| -rw-r--r-- | obj_tree.go | 20 | ||||
| -rw-r--r-- | obj_tree_test.go | 296 | ||||
| -rw-r--r-- | objects_test.go | 212 | ||||
| -rw-r--r-- | pack_test.go | 330 | ||||
| -rw-r--r-- | refs.go | 48 | ||||
| -rw-r--r-- | refs_test.go | 161 | ||||
| -rw-r--r-- | repo_current_test.go | 90 | ||||
| -rw-r--r-- | repo_test.go | 537 | ||||
| -rw-r--r-- | testutil_sha1_test.go | 29 | ||||
| -rw-r--r-- | testutil_sha256_test.go | 29 | ||||
| -rw-r--r-- | testutil_test.go | 67 |
23 files changed, 2105 insertions, 1279 deletions
@@ -8,10 +8,10 @@ tasks: go build - test-sha256: | cd furgit - go test -v + go test -v ./... - test-sha1: | cd furgit - go test -v -tags sha1 + go test -v -tags sha1 ./... - lint: | cd furgit - golangci-lint run . + golangci-lint run ./... diff --git a/config/config_test.go b/config/config_test.go index f863c230..4296535f 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,365 +1,323 @@ package config import ( + "os" + "os/exec" + "path/filepath" "strings" "testing" ) -func TestParseConfigSimple(t *testing.T) { - input := ` -[core] - repositoryformatversion = 0 - filemode = true - bare = false - -[user] - name = Alice Example - email = alice@example.com -` - cfg, err := ParseConfig(strings.NewReader(input)) +func setupTestRepo(t *testing.T) (string, func()) { + t.Helper() + tempDir, err := os.MkdirTemp("", "furgit-config-test-*") if err != nil { - t.Fatalf("ParseConfig error: %v", err) - } - - if got := cfg.Get("core", "", "repositoryformatversion"); got != "0" { - t.Errorf("core.repositoryformatversion = %q, want %q", got, "0") + t.Fatalf("failed to create temp dir: %v", err) } - if got := cfg.Get("core", "", "filemode"); got != "true" { - t.Errorf("core.filemode = %q, want %q", got, "true") + cleanup := func() { + _ = os.RemoveAll(tempDir) } - if got := cfg.Get("user", "", "name"); got != "Alice Example" { - t.Errorf("user.name = %q, want %q", got, "Alice Example") - } - if got := cfg.Get("user", "", "email"); got != "alice@example.com" { - t.Errorf("user.email = %q, want %q", got, "alice@example.com") - } -} -func TestParseConfigSubsection(t *testing.T) { - input := ` -[remote "origin"] - url = https://villosa.example.org/group1/group2//repos/repo - fetch = +refs/heads/*:refs/remotes/origin/* -` - cfg, err := ParseConfig(strings.NewReader(input)) - if err != nil { - t.Fatalf("ParseConfig error: %v", err) + cmd := exec.Command("git", "init", "--object-format=sha256", "--bare", tempDir) + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if output, err := cmd.CombinedOutput(); err != nil { + cleanup() + t.Fatalf("failed to init git repo: %v\n%s", err, output) } - if got := cfg.Get("remote", "origin", "url"); got != "https://villosa.example.org/group1/group2//repos/repo" { - t.Errorf("remote.origin.url = %q, want %q", got, "https://villosa.example.org/group1/group2//repos/repo") - } - if got := cfg.Get("remote", "origin", "fetch"); got != "+refs/heads/*:refs/remotes/origin/*" { - t.Errorf("remote.origin.fetch = %q, want %q", got, "+refs/heads/*:refs/remotes/origin/*") + return tempDir, cleanup +} + +func gitConfig(t *testing.T, dir string, args ...string) { + t.Helper() + fullArgs := append([]string{"config"}, args...) + cmd := exec.Command("git", fullArgs...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git config %v failed: %v\n%s", args, err, output) } } -func TestParseConfigCaseInsensitive(t *testing.T) { - input := ` -[Core] - FileMode = true -` - cfg, err := ParseConfig(strings.NewReader(input)) +func gitConfigGet(t *testing.T, dir, key string) string { + t.Helper() + cmd := exec.Command("git", "config", "--get", key) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + output, err := cmd.CombinedOutput() if err != nil { - t.Fatalf("ParseConfig error: %v", err) + return "" } + return strings.TrimSpace(string(output)) +} - if got := cfg.Get("core", "", "filemode"); got != "true" { - t.Errorf("core.filemode = %q, want %q", got, "true") - } - if got := cfg.Get("CORE", "", "FILEMODE"); got != "true" { - t.Errorf("CORE.FILEMODE = %q, want %q", got, "true") +func TestConfigAgainstGit(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + gitConfig(t, repoPath, "core.bare", "true") + gitConfig(t, repoPath, "core.filemode", "false") + gitConfig(t, repoPath, "user.name", "John Doe") + gitConfig(t, repoPath, "user.email", "john@example.com") + + cfgFile, err := os.Open(filepath.Join(repoPath, "config")) + if err != nil { + t.Fatalf("failed to open config: %v", err) } -} + defer func() { _ = cfgFile.Close() }() -func TestParseConfigBooleanKeys(t *testing.T) { - input := ` -[core] - bare - ignorecase -` - cfg, err := ParseConfig(strings.NewReader(input)) + cfg, err := ParseConfig(cfgFile) if err != nil { - t.Fatalf("ParseConfig error: %v", err) + t.Fatalf("ParseConfig failed: %v", err) } if got := cfg.Get("core", "", "bare"); got != "true" { - t.Errorf("core.bare = %q, want %q", got, "true") - } - if got := cfg.Get("core", "", "ignorecase"); got != "true" { - t.Errorf("core.ignorecase = %q, want %q", got, "true") + t.Errorf("core.bare: got %q, want %q", got, "true") } -} - -func TestParseConfigQuotedValues(t *testing.T) { - input := ` -[user] - name = "Bob Smith" - comment = "Has a \"quoted\" word" -` - cfg, err := ParseConfig(strings.NewReader(input)) - if err != nil { - t.Fatalf("ParseConfig error: %v", err) + if got := cfg.Get("core", "", "filemode"); got != "false" { + t.Errorf("core.filemode: got %q, want %q", got, "false") } - - if got := cfg.Get("user", "", "name"); got != "Bob Smith" { - t.Errorf("user.name = %q, want %q", got, "Bob Smith") + if got := cfg.Get("user", "", "name"); got != "John Doe" { + t.Errorf("user.name: got %q, want %q", got, "John Doe") } - if got := cfg.Get("user", "", "comment"); got != `Has a "quoted" word` { - t.Errorf("user.comment = %q, want %q", got, `Has a "quoted" word`) + if got := cfg.Get("user", "", "email"); got != "john@example.com" { + t.Errorf("user.email: got %q, want %q", got, "john@example.com") } } -func TestParseConfigEscapeSequences(t *testing.T) { - input := ` -[test] - newline = "line1\nline2" - tab = "col1\tcol2" - backslash = "path\\to\\file" -` - cfg, err := ParseConfig(strings.NewReader(input)) +func TestConfigSubsectionAgainstGit(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + gitConfig(t, repoPath, "remote.origin.url", "https://example.com/repo.git") + gitConfig(t, repoPath, "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") + + cfgFile, err := os.Open(filepath.Join(repoPath, "config")) if err != nil { - t.Fatalf("ParseConfig error: %v", err) + t.Fatalf("failed to open config: %v", err) } + defer func() { _ = cfgFile.Close() }() - if got := cfg.Get("test", "", "newline"); got != "line1\nline2" { - t.Errorf("test.newline = %q, want %q", got, "line1\nline2") + cfg, err := ParseConfig(cfgFile) + if err != nil { + t.Fatalf("ParseConfig failed: %v", err) } - if got := cfg.Get("test", "", "tab"); got != "col1\tcol2" { - t.Errorf("test.tab = %q, want %q", got, "col1\tcol2") + + if got := cfg.Get("remote", "origin", "url"); got != "https://example.com/repo.git" { + t.Errorf("remote.origin.url: got %q, want %q", got, "https://example.com/repo.git") } - if got := cfg.Get("test", "", "backslash"); got != "path\\to\\file" { - t.Errorf("test.backslash = %q, want %q", got, "path\\to\\file") + if got := cfg.Get("remote", "origin", "fetch"); got != "+refs/heads/*:refs/remotes/origin/*" { + t.Errorf("remote.origin.fetch: got %q, want %q", got, "+refs/heads/*:refs/remotes/origin/*") } } -func TestParseConfigComments(t *testing.T) { - input := ` -# This is a comment -; This is also a comment -[core] - # Comment in section - bare = false # inline comment - filemode = true ; another inline comment -` - cfg, err := ParseConfig(strings.NewReader(input)) - if err != nil { - t.Fatalf("ParseConfig error: %v", err) - } +func TestConfigMultiValueAgainstGit(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() - if got := cfg.Get("core", "", "bare"); got != "false" { - t.Errorf("core.bare = %q, want %q", got, "false") - } - if got := cfg.Get("core", "", "filemode"); got != "true" { - t.Errorf("core.filemode = %q, want %q", got, "true") + gitConfig(t, repoPath, "--add", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main") + gitConfig(t, repoPath, "--add", "remote.origin.fetch", "+refs/heads/dev:refs/remotes/origin/dev") + gitConfig(t, repoPath, "--add", "remote.origin.fetch", "+refs/tags/*:refs/tags/*") + + cfgFile, err := os.Open(filepath.Join(repoPath, "config")) + if err != nil { + t.Fatalf("failed to open config: %v", err) } -} + defer func() { _ = cfgFile.Close() }() -func TestParseConfigMultipleValues(t *testing.T) { - input := ` -[remote "origin"] - fetch = +refs/heads/main:refs/remotes/origin/main - fetch = +refs/heads/dev:refs/remotes/origin/dev -` - cfg, err := ParseConfig(strings.NewReader(input)) + cfg, err := ParseConfig(cfgFile) if err != nil { - t.Fatalf("ParseConfig error: %v", err) + t.Fatalf("ParseConfig failed: %v", err) } - values := cfg.GetAll("remote", "origin", "fetch") - if len(values) != 2 { - t.Fatalf("expected 2 values, got %d", len(values)) + fetches := cfg.GetAll("remote", "origin", "fetch") + if len(fetches) != 3 { + t.Fatalf("expected 3 fetch values, got %d", len(fetches)) } - if values[0] != "+refs/heads/main:refs/remotes/origin/main" { - t.Errorf("fetch[0] = %q", values[0]) + + expected := []string{ + "+refs/heads/main:refs/remotes/origin/main", + "+refs/heads/dev:refs/remotes/origin/dev", + "+refs/tags/*:refs/tags/*", } - if values[1] != "+refs/heads/dev:refs/remotes/origin/dev" { - t.Errorf("fetch[1] = %q", values[1]) + for i, want := range expected { + if fetches[i] != want { + t.Errorf("fetch[%d]: got %q, want %q", i, fetches[i], want) + } } } -func TestParseConfigSubsectionWithEscapes(t *testing.T) { - input := ` -[branch "feature/my-branch"] - remote = origin - merge = refs/heads/feature/my-branch -` - cfg, err := ParseConfig(strings.NewReader(input)) - if err != nil { - t.Fatalf("ParseConfig error: %v", err) - } +func TestConfigCaseInsensitiveAgainstGit(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + gitConfig(t, repoPath, "Core.Bare", "true") + gitConfig(t, repoPath, "CORE.FileMode", "false") + + gitVerifyBare := gitConfigGet(t, repoPath, "core.bare") + gitVerifyFilemode := gitConfigGet(t, repoPath, "core.filemode") - if got := cfg.Get("branch", "feature/my-branch", "remote"); got != "origin" { - t.Errorf("branch.feature/my-branch.remote = %q, want %q", got, "origin") + cfgFile, err := os.Open(filepath.Join(repoPath, "config")) + if err != nil { + t.Fatalf("failed to open config: %v", err) } -} + defer func() { _ = cfgFile.Close() }() -func TestParseConfigEmptyValue(t *testing.T) { - input := ` -[core] - empty = - whitespace = -` - cfg, err := ParseConfig(strings.NewReader(input)) + cfg, err := ParseConfig(cfgFile) if err != nil { - t.Fatalf("ParseConfig error: %v", err) + t.Fatalf("ParseConfig failed: %v", err) } - if got := cfg.Get("core", "", "empty"); got != "" { - t.Errorf("core.empty = %q, want empty string", got) + if got := cfg.Get("core", "", "bare"); got != gitVerifyBare { + t.Errorf("core.bare: got %q, want %q (from git)", got, gitVerifyBare) + } + if got := cfg.Get("CORE", "", "BARE"); got != gitVerifyBare { + t.Errorf("CORE.BARE: got %q, want %q (from git)", got, gitVerifyBare) } - if got := cfg.Get("core", "", "whitespace"); got != "" { - t.Errorf("core.whitespace = %q, want empty string", got) + if got := cfg.Get("core", "", "filemode"); got != gitVerifyFilemode { + t.Errorf("core.filemode: got %q, want %q (from git)", got, gitVerifyFilemode) } } -func TestParseConfigInvalidInputs(t *testing.T) { - cases := []struct { - name string - input string - }{ - { - name: "key before section", - input: "key = value\n", - }, - { - name: "invalid section no closing bracket", - input: "[section\n", - }, - { - name: "invalid escape in value", - input: "[test]\nkey = \"invalid\\x\"\n", - }, - { - name: "unclosed quote in value", - input: "[test]\nkey = \"unclosed\n", - }, - { - name: "unclosed quote in subsection", - input: "[section \"unclosed]\n", - }, - } +func TestConfigBooleanAgainstGit(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, err := ParseConfig(strings.NewReader(tc.input)) - if err == nil { - t.Errorf("expected error for %q, got nil", tc.name) - } - }) - } -} + gitConfig(t, repoPath, "test.flag1", "true") + gitConfig(t, repoPath, "test.flag2", "false") + gitConfig(t, repoPath, "test.flag3", "yes") + gitConfig(t, repoPath, "test.flag4", "no") -func TestParseConfigEntries(t *testing.T) { - input := ` -[core] - bare = false -[user] - name = Alice -` - cfg, err := ParseConfig(strings.NewReader(input)) + cfgFile, err := os.Open(filepath.Join(repoPath, "config")) if err != nil { - t.Fatalf("ParseConfig error: %v", err) + t.Fatalf("failed to open config: %v", err) } + defer func() { _ = cfgFile.Close() }() - entries := cfg.Entries() - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) + cfg, err := ParseConfig(cfgFile) + if err != nil { + t.Fatalf("ParseConfig failed: %v", err) } - if entries[0].Section != "core" || entries[0].Key != "bare" || entries[0].Value != "false" { - t.Errorf("entry[0] = %+v", entries[0]) + tests := []struct { + key string + want string + }{ + {"flag1", gitConfigGet(t, repoPath, "test.flag1")}, + {"flag2", gitConfigGet(t, repoPath, "test.flag2")}, + {"flag3", gitConfigGet(t, repoPath, "test.flag3")}, + {"flag4", gitConfigGet(t, repoPath, "test.flag4")}, } - if entries[1].Section != "user" || entries[1].Key != "name" || entries[1].Value != "Alice" { - t.Errorf("entry[1] = %+v", entries[1]) + + for _, tt := range tests { + if got := cfg.Get("test", "", tt.key); got != tt.want { + t.Errorf("test.%s: got %q, want %q (from git)", tt.key, got, tt.want) + } } } -func TestParseConfigGetNotFound(t *testing.T) { - input := ` -[core] - bare = false -` - cfg, err := ParseConfig(strings.NewReader(input)) - if err != nil { - t.Fatalf("ParseConfig error: %v", err) - } +func TestConfigComplexValuesAgainstGit(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + gitConfig(t, repoPath, "test.spaced", "value with spaces") + gitConfig(t, repoPath, "test.special", "value=with=equals") + gitConfig(t, repoPath, "test.path", "/path/to/something") + gitConfig(t, repoPath, "test.number", "12345") - if got := cfg.Get("nonexistent", "", "key"); got != "" { - t.Errorf("expected empty string for nonexistent key, got %q", got) + cfgFile, err := os.Open(filepath.Join(repoPath, "config")) + if err != nil { + t.Fatalf("failed to open config: %v", err) } -} + defer func() { _ = cfgFile.Close() }() -func TestParseConfigComplexSubsection(t *testing.T) { - input := ` -[url "https://villosa.example.org/"] - insteadOf = gh: -` - cfg, err := ParseConfig(strings.NewReader(input)) + cfg, err := ParseConfig(cfgFile) if err != nil { - t.Fatalf("ParseConfig error: %v", err) + t.Fatalf("ParseConfig failed: %v", err) } - if got := cfg.Get("url", "https://villosa.example.org/", "insteadof"); got != "gh:" { - t.Errorf("url.https://villosa.example.org/.insteadof = %q, want %q", got, "gh:") + tests := []string{"spaced", "special", "path", "number"} + for _, key := range tests { + want := gitConfigGet(t, repoPath, "test."+key) + if got := cfg.Get("test", "", key); got != want { + t.Errorf("test.%s: got %q, want %q (from git)", key, got, want) + } } } -func TestParseConfigBooleanKeyWithInlineComment(t *testing.T) { - input := ` -[core] - bare ; this is a comment - filemode # another comment -` - cfg, err := ParseConfig(strings.NewReader(input)) - if err != nil { - t.Fatalf("ParseConfig error: %v", err) - } +func TestConfigEntriesAgainstGit(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() - if got := cfg.Get("core", "", "bare"); got != "true" { - t.Errorf("core.bare = %q, want %q", got, "true") - } - if got := cfg.Get("core", "", "filemode"); got != "true" { - t.Errorf("core.filemode = %q, want %q", got, "true") + gitConfig(t, repoPath, "core.bare", "true") + gitConfig(t, repoPath, "core.filemode", "false") + gitConfig(t, repoPath, "user.name", "Test User") + + cfgFile, err := os.Open(filepath.Join(repoPath, "config")) + if err != nil { + t.Fatalf("failed to open config: %v", err) } -} + defer func() { _ = cfgFile.Close() }() -func TestParseConfigLineContinuation(t *testing.T) { - input := `[section] - # Quoted value with line continuation - quoted = "line1\ -line2\ -line3" - - # Unquoted value with line continuation - unquoted = one\ -two\ -three -` - cfg, err := ParseConfig(strings.NewReader(input)) + cfg, err := ParseConfig(cfgFile) if err != nil { - t.Fatalf("ParseConfig error: %v", err) + t.Fatalf("ParseConfig failed: %v", err) } - if got := cfg.Get("section", "", "quoted"); got != "line1line2line3" { - t.Errorf("section.quoted = %q, want %q", got, "line1line2line3") + entries := cfg.Entries() + if len(entries) < 3 { + t.Errorf("expected at least 3 entries, got %d", len(entries)) } - if got := cfg.Get("section", "", "unquoted"); got != "onetwothree" { - t.Errorf("section.unquoted = %q, want %q", got, "onetwothree") + + found := make(map[string]bool) + for _, entry := range entries { + key := entry.Section + "." + entry.Key + if entry.Subsection != "" { + key = entry.Section + "." + entry.Subsection + "." + entry.Key + } + found[key] = true + + gitValue := gitConfigGet(t, repoPath, key) + if entry.Value != gitValue { + t.Errorf("entry %s: got value %q, git has %q", key, entry.Value, gitValue) + } } } -func TestParseConfigDOSLineEndings(t *testing.T) { - input := "[core]\r\n\tbare = true\r\n\tfilemode = false\r\n" - cfg, err := ParseConfig(strings.NewReader(input)) - if err != nil { - t.Fatalf("ParseConfig error: %v", err) +func TestConfigErrorCases(t *testing.T) { + tests := []struct { + name string + config string + }{ + { + name: "key before section", + config: "bare = true", + }, + { + name: "invalid section character", + config: "[core/invalid]", + }, + { + name: "unterminated section", + config: "[core", + }, + { + name: "unterminated quote", + config: "[core]\n\tbare = \"true", + }, + { + name: "invalid escape", + config: "[core]\n\tvalue = \"test\\x\"", + }, } - if got := cfg.Get("core", "", "bare"); got != "true" { - t.Errorf("core.bare = %q, want %q", got, "true") - } - if got := cfg.Get("core", "", "filemode"); got != "false" { - t.Errorf("core.filemode = %q, want %q", got, "false") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := strings.NewReader(tt.config) + _, err := ParseConfig(r) + if err == nil { + t.Errorf("expected error for %s", tt.name) + } + }) } } diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 00000000..29803f97 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,17 @@ +package furgit + +import ( + "testing" +) + +func TestErrors(t *testing.T) { + if ErrInvalidObject == nil { + t.Error("ErrInvalidObject should not be nil") + } + if ErrInvalidRef == nil { + t.Error("ErrInvalidRef should not be nil") + } + if ErrNotFound == nil { + t.Error("ErrNotFound should not be nil") + } +} diff --git a/hash_sha1_test.go b/hash_sha1_test.go deleted file mode 100644 index 9f3137b9..00000000 --- a/hash_sha1_test.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build sha1 - -package furgit - -import ( - "crypto/sha1" -) - -const testHashSize = sha1.Size diff --git a/hash_sha256_test.go b/hash_sha256_test.go deleted file mode 100644 index 0b735f0a..00000000 --- a/hash_sha256_test.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !sha1 - -package furgit - -import ( - "crypto/sha256" -) - -const testHashSize = sha256.Size diff --git a/hash_test.go b/hash_test.go index 212e5ef2..dab1d49e 100644 --- a/hash_test.go +++ b/hash_test.go @@ -1,44 +1,75 @@ package furgit import ( - "strings" "testing" ) -func TestParseHashValidAndInvalid(t *testing.T) { - pattern := "0123456789abcdef" - repeats := (testHashSize*2 + len(pattern) - 1) / len(pattern) - hexStr := strings.Repeat(pattern, repeats)[:testHashSize*2] +func TestHashParse(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() - repo := &Repository{hashSize: testHashSize} - id, err := repo.ParseHash(hexStr) + repo, err := OpenRepository(repoPath) if err != nil { - t.Fatalf("ParseHash returned error: %v", err) + t.Fatalf("OpenRepository failed: %v", err) } + defer func() { + _ = repo.Close() + }() - if got := id.String(); got != hexStr { - t.Fatalf("unexpected String result: %q", got) + var validHash string + var expectedSize int + if repo.hashSize == 32 { + validHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + expectedSize = 32 + } else { + validHash = "0123456789abcdef0123456789abcdef01234567" + expectedSize = 20 } - if _, err := repo.ParseHash("abcd"); err == nil { - t.Fatal("expected error for short hash") + hash, err := repo.ParseHash(validHash) + if err != nil { + t.Fatalf("ParseHash failed: %v", err) + } + if hash.String() != validHash { + t.Errorf("String(): got %q, want %q", hash.String(), validHash) + } + if hash.Size() != expectedSize { + t.Errorf("Size(): got %d, want %d", hash.Size(), expectedSize) } - badHex := strings.Repeat("z", testHashSize*2) - if _, err := repo.ParseHash(badHex); err == nil { - t.Fatal("expected error for non-hex input") + hashBytes := hash.Bytes() + if len(hashBytes) != expectedSize { + t.Errorf("Bytes() length: got %d, want %d", len(hashBytes), expectedSize) } } -func TestHashBytesCopiesUnderlyingData(t *testing.T) { - var id Hash - for i := range id.data { - id.data[i] = byte(i) +func TestHashParseErrors(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + tests := []struct { + name string + hash string + }{ + {"invalid chars", "invalid"}, + {"wrong length", "0123456789abcdef"}, + {"non-hex", "0123456789abcdefg123456789abcdef0123456789abcdef0123456789abcdef"}, } - id.size = testHashSize - orig := id.Bytes() - orig[0] ^= 0xff - if id.data[0] == orig[0] { - t.Fatal("Bytes should return a copy") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := repo.ParseHash(tt.hash) + if err == nil { + t.Errorf("expected error for %s", tt.name) + } + }) } } diff --git a/hybrid_test.go b/hybrid_test.go new file mode 100644 index 00000000..083605d8 --- /dev/null +++ b/hybrid_test.go @@ -0,0 +1,271 @@ +package furgit + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestTreeNestedDeep(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + depth := 50 + currentDir := workDir + for i := 0; i < depth; i++ { + currentDir = filepath.Join(currentDir, fmt.Sprintf("level%d", i)) + err := os.MkdirAll(currentDir, 0o755) + if err != nil { + t.Fatalf("failed to create directory %s: %v", currentDir, err) + } + } + err := os.WriteFile(filepath.Join(currentDir, "deep.txt"), []byte("deep content"), 0o644) + if err != nil { + t.Fatalf("failed to create deep.txt: %v", err) + } + + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + treeHash := gitCmd(t, repoPath, "--work-tree="+workDir, "write-tree") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + hash, _ := repo.ParseHash(treeHash) + obj, _ := repo.ReadObject(hash) + tree := obj.(*StoredTree) + + path := make([][]byte, depth+1) + for i := 0; i < depth; i++ { + path[i] = []byte(fmt.Sprintf("level%d", i)) + } + path[depth] = []byte("deep.txt") + + entry, err := tree.EntryRecursive(repo, path) + if err != nil { + t.Fatalf("EntryRecursive failed for deep path: %v", err) + } + + blobObj, _ := repo.ReadObject(entry.ID) + blob := blobObj.(*StoredBlob) + + if !bytes.Equal(blob.Data, []byte("deep content")) { + t.Errorf("deep file content: got %q, want %q", blob.Data, "deep content") + } +} + +func TestTreeMixedModes(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "normal.txt"), []byte("normal"), 0o644) + if err != nil { + t.Fatalf("failed to create normal.txt: %v", err) + } + err = os.WriteFile(filepath.Join(workDir, "executable.sh"), []byte("#!/bin/sh\necho test"), 0o755) + if err != nil { + t.Fatalf("failed to create executable.sh: %v", err) + } + err = os.Symlink("normal.txt", filepath.Join(workDir, "link.txt")) + if err != nil { + t.Fatalf("failed to create symlink: %v", err) + } + + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + treeHash := gitCmd(t, repoPath, "--work-tree="+workDir, "write-tree") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + hash, _ := repo.ParseHash(treeHash) + obj, _ := repo.ReadObject(hash) + tree := obj.(*StoredTree) + + modes := make(map[string]FileMode) + for _, entry := range tree.Entries { + modes[string(entry.Name)] = entry.Mode + } + + if modes["normal.txt"] != 0o100644 { + t.Errorf("normal.txt mode: got %o, want %o", modes["normal.txt"], 0o100644) + } + if modes["executable.sh"] != 0o100755 { + t.Errorf("executable.sh mode: got %o, want %o", modes["executable.sh"], 0o100755) + } + if modes["link.txt"] != 0o120000 { + t.Errorf("link.txt mode: got %o, want %o", modes["link.txt"], 0o120000) + } +} + +func TestCommitChain(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + numCommits := 100 + var commits []string + + for i := 0; i < numCommits; i++ { + filename := filepath.Join(workDir, fmt.Sprintf("file%d.txt", i)) + err := os.WriteFile(filename, []byte(fmt.Sprintf("content %d", i)), 0o644) + if err != nil { + t.Fatalf("failed to create %s: %v", filename, err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", fmt.Sprintf("Commit %d", i)) + commitHash := gitCmd(t, repoPath, "rev-parse", "HEAD") + commits = append(commits, commitHash) + } + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + hash, _ := repo.ParseHash(commits[len(commits)-1]) + for i := numCommits - 1; i >= 0; i-- { + obj, err := repo.ReadObject(hash) + if err != nil { + t.Fatalf("failed to read commit %d: %v", i, err) + } + + commit, ok := obj.(*StoredCommit) + if !ok { + t.Fatalf("expected *StoredCommit at %d, got %T", i, obj) + } + + expectedMsg := fmt.Sprintf("Commit %d\n", i) + if !bytes.Equal(commit.Message, []byte(expectedMsg)) { + t.Errorf("commit %d message: got %q, want %q", i, commit.Message, expectedMsg) + } + + if i > 0 { + if len(commit.Parents) != 1 { + t.Fatalf("commit %d should have 1 parent, got %d", i, len(commit.Parents)) + } + hash = commit.Parents[0] + } else { + if len(commit.Parents) != 0 { + t.Errorf("first commit should have 0 parents, got %d", len(commit.Parents)) + } + } + } +} + +func TestMultipleTags(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("content"), 0o644) + if err != nil { + t.Fatalf("failed to create file.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Tagged commit") + commitHash := gitCmd(t, repoPath, "rev-parse", "HEAD") + + tags := []string{"v1.0.0", "v1.0.1", "v1.1.0", "v2.0.0"} + for _, tagName := range tags { + gitCmd(t, repoPath, "tag", "-a", "-m", fmt.Sprintf("Release %s", tagName), tagName, commitHash) + } + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + for _, tagName := range tags { + tagHash := gitCmd(t, repoPath, "rev-parse", tagName) + hash, _ := repo.ParseHash(tagHash) + obj, err := repo.ReadObject(hash) + if err != nil { + t.Errorf("failed to read tag %s: %v", tagName, err) + continue + } + + tag, ok := obj.(*StoredTag) + if !ok { + t.Errorf("tag %s: expected *StoredTag, got %T", tagName, obj) + continue + } + + if !bytes.Equal(tag.Name, []byte(tagName)) { + t.Errorf("tag name: got %q, want %q", tag.Name, tagName) + } + } +} + +func TestPackfileAfterMultipleRepacks(t *testing.T) { + if testing.Short() { + t.Skip("skipping multiple repack test in short mode") + } + + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + gitCmd(t, repoPath, "config", "gc.auto", "0") + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + for i := 0; i < 5; i++ { + err := os.WriteFile(filepath.Join(workDir, fmt.Sprintf("file%d.txt", i)), []byte(fmt.Sprintf("content %d", i)), 0o644) + if err != nil { + t.Fatalf("failed to create file%d.txt: %v", i, err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", fmt.Sprintf("Commit %d", i)) + gitCmd(t, repoPath, "repack", "-d") + } + + gitCmd(t, repoPath, "repack", "-a", "-d") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + headHash := gitCmd(t, repoPath, "rev-parse", "HEAD") + hash, _ := repo.ParseHash(headHash) + + obj, err := repo.ReadObject(hash) + if err != nil { + t.Fatalf("failed to read HEAD from final packfile: %v", err) + } + + commit := obj.(*StoredCommit) + if !bytes.Contains(commit.Message, []byte("Commit 4")) { + t.Errorf("HEAD commit message incorrect: got %q", commit.Message) + } +} diff --git a/ident_test.go b/ident_test.go index 76e1fb04..a3d3d03e 100644 --- a/ident_test.go +++ b/ident_test.go @@ -1,72 +1,73 @@ package furgit import ( - "strings" + "bytes" "testing" ) -func TestParseIdentRoundTrip(t *testing.T) { - line := []byte("Alice Example <alice@example.com> 1700000000 -0700") - id, err := parseIdent(line) - if err != nil { - t.Fatalf("parseIdent error: %v", err) +func TestIdentSerialize(t *testing.T) { + tests := []struct { + name string + ident Ident + }{ + { + name: "positive offset", + ident: Ident{ + Name: []byte("John Doe"), + Email: []byte("john@example.org"), + WhenUnix: 1234567890, + OffsetMinutes: 120, + }, + }, + { + name: "negative offset", + ident: Ident{ + Name: []byte("Jane Smith"), + Email: []byte("jane@example.org"), + WhenUnix: 9876543210, + OffsetMinutes: -300, + }, + }, + { + name: "zero offset", + ident: Ident{ + Name: []byte("UTC User"), + Email: []byte("utc@example.org"), + WhenUnix: 1000000000, + OffsetMinutes: 0, + }, + }, } - if got := string(id.Email); got != "alice@example.com" { - t.Fatalf("email mismatch: %q", got) - } - ids, err := id.Serialize() - if err != nil { - t.Fatalf("Serialize error: %v", err) - } - serialized := string(ids) - if !strings.Contains(serialized, "alice@example.com") { - t.Fatalf("Serialize missing email: %q", serialized) - } - when := id.When() - if when.Unix() != 1700000000 { - t.Fatalf("When unix mismatch: %d", when.Unix()) - } - if _, offset := when.Zone(); offset != -7*3600 { - t.Fatalf("When offset mismatch: %d", offset) - } -} -func TestParseIdentInvalidInputs(t *testing.T) { - cases := []string{ - "MissingEmail 1700000000 +0000", - "Name <email> notanumber +0000", - "Name <email> 1700000000 123", - } - for _, tc := range cases { - if _, err := parseIdent([]byte(tc)); err == nil { - t.Fatalf("expected error for %q", tc) - } - } -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + serialized, err := tt.ident.Serialize() + if err != nil { + t.Fatalf("Serialize failed: %v", err) + } -func TestIdentSerializeUsesCanonicalSpacing(t *testing.T) { - id := Ident{ - Name: []byte("Bob"), - Email: []byte("bob@example.com"), - WhenUnix: 1000, - OffsetMinutes: 90, - } - ids, err := id.Serialize() - if err != nil { - t.Fatalf("Serialize error: %v", err) - } - got := string(ids) - if !strings.Contains(got, "Bob <bob@example.com>") { - t.Fatalf("unexpected serialize output: %q", got) - } - if !strings.HasSuffix(got, "+0130") { - t.Fatalf("expected timezone in +0130 form: %q", got) - } - loc := id.When() - if loc.Unix() != 1000 { - t.Fatalf("When unix mismatch: %d", loc.Unix()) - } - if _, offset := loc.Zone(); offset != 90*60 { - t.Fatalf("When offset mismatch: %d", offset) + parsed, err := parseIdent(serialized) + if err != nil { + t.Fatalf("parseIdent failed: %v", err) + } + + if !bytes.HasPrefix(parsed.Name, tt.ident.Name) { + t.Errorf("name: got %q, want prefix %q", parsed.Name, tt.ident.Name) + } + if !bytes.Equal(parsed.Email, tt.ident.Email) { + t.Errorf("email: got %q, want %q", parsed.Email, tt.ident.Email) + } + if parsed.WhenUnix != tt.ident.WhenUnix { + t.Errorf("whenUnix: got %d, want %d", parsed.WhenUnix, tt.ident.WhenUnix) + } + if parsed.OffsetMinutes != tt.ident.OffsetMinutes { + t.Errorf("offsetMinutes: got %d, want %d", parsed.OffsetMinutes, tt.ident.OffsetMinutes) + } + + when := tt.ident.When() + if when.Unix() != tt.ident.WhenUnix { + t.Errorf("When().Unix(): got %d, want %d", when.Unix(), tt.ident.WhenUnix) + } + }) } } diff --git a/obj_blob_test.go b/obj_blob_test.go new file mode 100644 index 00000000..72ef0a23 --- /dev/null +++ b/obj_blob_test.go @@ -0,0 +1,120 @@ +package furgit + +import ( + "bytes" + "fmt" + "testing" +) + +func TestBlobRead(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + testData := []byte("Hello, Furgit!\nThis is test blob data.") + gitHash := gitHashObject(t, repoPath, "blob", testData) + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + hash, _ := repo.ParseHash(gitHash) + obj, err := repo.ReadObject(hash) + if err != nil { + t.Fatalf("ReadObject failed: %v", err) + } + + blob, ok := obj.(*StoredBlob) + if !ok { + t.Fatalf("expected *StoredBlob, got %T", obj) + } + + if !bytes.Equal(blob.Data, testData) { + t.Errorf("Data mismatch: got %q, want %q", blob.Data, testData) + } + if blob.Hash() != hash { + t.Errorf("Hash(): got %s, want %s", blob.Hash(), hash) + } + if blob.ObjectType() != ObjectTypeBlob { + t.Errorf("ObjectType(): got %d, want %d", blob.ObjectType(), ObjectTypeBlob) + } + + gitData := gitCatFile(t, repoPath, "blob", gitHash) + if !bytes.Equal(blob.Data, gitData) { + t.Error("furgit data doesn't match git data") + } +} + +func TestBlobWrite(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + testData := []byte("Test data written by furgit") + blob := &Blob{Data: testData} + + hash, err := repo.WriteLooseObject(blob) + if err != nil { + t.Fatalf("WriteLooseObject failed: %v", err) + } + + gitType := string(gitCatFile(t, repoPath, "-t", hash.String())) + if gitType != "blob" { + t.Errorf("git type: got %q, want %q", gitType, "blob") + } + + gitData := gitCatFile(t, repoPath, "blob", hash.String()) + if !bytes.Equal(gitData, testData) { + t.Error("git data doesn't match written data") + } + + gitSize := string(gitCatFile(t, repoPath, "-s", hash.String())) + if gitSize != fmt.Sprintf("%d", len(testData)) { + t.Errorf("git size: got %s, want %d", gitSize, len(testData)) + } +} + +func TestBlobRoundtrip(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + testData := []byte("Roundtrip test data") + blob := &Blob{Data: testData} + + hash, err := repo.WriteLooseObject(blob) + if err != nil { + t.Fatalf("WriteLooseObject failed: %v", err) + } + + obj, err := repo.ReadObject(hash) + if err != nil { + t.Fatalf("ReadObject failed: %v", err) + } + + readBlob, ok := obj.(*StoredBlob) + if !ok { + t.Fatalf("expected *StoredBlob, got %T", obj) + } + + if !bytes.Equal(readBlob.Data, testData) { + t.Error("roundtrip data mismatch") + } +} diff --git a/obj_commit_test.go b/obj_commit_test.go new file mode 100644 index 00000000..939385d5 --- /dev/null +++ b/obj_commit_test.go @@ -0,0 +1,188 @@ +package furgit + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func TestCommitWrite(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + blobHash := gitHashObject(t, repoPath, "blob", []byte("content")) + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + blobHashObj, _ := repo.ParseHash(blobHash) + tree := &Tree{ + Entries: []TreeEntry{ + {Mode: 0o100644, Name: []byte("file.txt"), ID: blobHashObj}, + }, + } + treeHash, _ := repo.WriteLooseObject(tree) + + whenUnix := time.Date(2023, 11, 16, 12, 0, 0, 0, time.UTC).Unix() + commit := &Commit{ + Tree: treeHash, + Author: Ident{ + Name: []byte("Test Author"), + Email: []byte("test@example.org"), + WhenUnix: whenUnix, + OffsetMinutes: 0, + }, + Committer: Ident{ + Name: []byte("Test Committer"), + Email: []byte("committer@example.org"), + WhenUnix: whenUnix, + OffsetMinutes: 0, + }, + Message: []byte("Initial commit\n"), + } + + commitHash, err := repo.WriteLooseObject(commit) + if err != nil { + t.Fatalf("WriteLooseObject failed: %v", err) + } + + gitType := string(gitCatFile(t, repoPath, "-t", commitHash.String())) + if gitType != "commit" { + t.Errorf("git type: got %q, want %q", gitType, "commit") + } + + readObj, err := repo.ReadObject(commitHash) + if err != nil { + t.Fatalf("ReadObject failed after write: %v", err) + } + readCommit, ok := readObj.(*StoredCommit) + if !ok { + t.Fatalf("expected *StoredCommit, got %T", readObj) + } + + if !bytes.HasPrefix(readCommit.Author.Name, []byte("Test Author")) { + t.Errorf("author name: got %q, want prefix %q", readCommit.Author.Name, "Test Author") + } + if !bytes.Equal(readCommit.Message, []byte("Initial commit\n")) { + t.Errorf("message: got %q, want %q", readCommit.Message, "Initial commit\n") + } +} + +func TestCommitRead(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("content"), 0o644) + if err != nil { + t.Fatalf("failed to write file.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Test commit") + commitHash := gitCmd(t, repoPath, "rev-parse", "HEAD") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + hash, _ := repo.ParseHash(commitHash) + obj, err := repo.ReadObject(hash) + if err != nil { + t.Fatalf("ReadObject failed: %v", err) + } + + commit, ok := obj.(*StoredCommit) + if !ok { + t.Fatalf("expected *StoredCommit, got %T", obj) + } + + if !bytes.HasPrefix(commit.Author.Name, []byte("Test Author")) { + t.Errorf("author name: got %q", commit.Author.Name) + } + if !bytes.Equal(commit.Author.Email, []byte("test@example.org")) { + t.Errorf("author email: got %q", commit.Author.Email) + } + if !bytes.Equal(commit.Message, []byte("Test commit\n")) { + t.Errorf("message: got %q", commit.Message) + } + if commit.ObjectType() != ObjectTypeCommit { + t.Errorf("ObjectType(): got %d, want %d", commit.ObjectType(), ObjectTypeCommit) + } +} + +func TestCommitWithParents(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "file1.txt"), []byte("content1"), 0o644) + if err != nil { + t.Fatalf("failed to write file1.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "First commit") + parent1Hash := gitCmd(t, repoPath, "rev-parse", "HEAD") + + err = os.WriteFile(filepath.Join(workDir, "file2.txt"), []byte("content2"), 0o644) + if err != nil { + t.Fatalf("failed to write file2.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Second commit") + parent2Hash := gitCmd(t, repoPath, "rev-parse", "HEAD") + + err = os.WriteFile(filepath.Join(workDir, "file3.txt"), []byte("content3"), 0o644) + if err != nil { + t.Fatalf("failed to write file3.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + treeHash := gitCmd(t, repoPath, "--work-tree="+workDir, "write-tree") + + mergeCommitData := fmt.Sprintf("tree %s\nparent %s\nparent %s\nauthor Test Author <test@example.org> 1234567890 +0000\ncommitter Test Committer <committer@example.org> 1234567890 +0000\n\nMerge commit\n", + treeHash, parent1Hash, parent2Hash) + + cmd := gitHashObject(t, repoPath, "commit", []byte(mergeCommitData)) + mergeHash := cmd + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { + _ = repo.Close() + }() + + hash, _ := repo.ParseHash(mergeHash) + obj, _ := repo.ReadObject(hash) + commit := obj.(*StoredCommit) + + if len(commit.Parents) != 2 { + t.Fatalf("parents count: got %d, want 2", len(commit.Parents)) + } + + p1, _ := repo.ParseHash(parent1Hash) + p2, _ := repo.ParseHash(parent2Hash) + + if commit.Parents[0] != p1 { + t.Errorf("parent[0]: got %s, want %s", commit.Parents[0], parent1Hash) + } + if commit.Parents[1] != p2 { + t.Errorf("parent[1]: got %s, want %s", commit.Parents[1], parent2Hash) + } +} diff --git a/obj_tag_test.go b/obj_tag_test.go new file mode 100644 index 00000000..6b3c8368 --- /dev/null +++ b/obj_tag_test.go @@ -0,0 +1,191 @@ +package furgit + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" +) + +func TestTagWrite(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("content"), 0o644) + if err != nil { + t.Fatalf("failed to write file.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Tagged commit") + commitHash := gitCmd(t, repoPath, "rev-parse", "HEAD") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + commitHashObj, _ := repo.ParseHash(commitHash) + + whenUnix := time.Now().Unix() + tag := &Tag{ + Target: commitHashObj, + TargetType: ObjectTypeCommit, + Name: []byte("v2.0.0"), + Tagger: &Ident{ + Name: []byte("Tagger Name"), + Email: []byte("tagger@test.org"), + WhenUnix: whenUnix, + OffsetMinutes: 120, + }, + Message: []byte("Release version 2.0.0\n"), + } + + tagHash, err := repo.WriteLooseObject(tag) + if err != nil { + t.Fatalf("WriteLooseObject failed: %v", err) + } + + gitType := string(gitCatFile(t, repoPath, "-t", tagHash.String())) + if gitType != "tag" { + t.Errorf("git type: got %q, want %q", gitType, "tag") + } + + readObj, err := repo.ReadObject(tagHash) + if err != nil { + t.Fatalf("ReadObject failed after write: %v", err) + } + readTag, ok := readObj.(*StoredTag) + if !ok { + t.Fatalf("expected *StoredTag, got %T", readObj) + } + + if !bytes.Equal(readTag.Name, []byte("v2.0.0")) { + t.Errorf("tag name: got %q, want %q", readTag.Name, "v2.0.0") + } + if !bytes.HasPrefix(readTag.Tagger.Name, []byte("Tagger Name")) { + t.Errorf("tagger name: got %q, want prefix %q", readTag.Tagger.Name, "Tagger Name") + } + if !bytes.Equal(readTag.Message, []byte("Release version 2.0.0\n")) { + t.Errorf("message: got %q, want %q", readTag.Message, "Release version 2.0.0\n") + } + + if tag.ObjectType() != ObjectTypeTag { + t.Errorf("ObjectType(): got %d, want %d", tag.ObjectType(), ObjectTypeTag) + } +} + +func TestTagRead(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("content"), 0o644) + if err != nil { + t.Fatalf("failed to write file.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Commit for tag") + commitHash := gitCmd(t, repoPath, "rev-parse", "HEAD") + + gitCmd(t, repoPath, "tag", "-a", "-m", "Tag message", "v1.0.0", commitHash) + tagHash := gitCmd(t, repoPath, "rev-parse", "v1.0.0") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + hash, _ := repo.ParseHash(tagHash) + obj, err := repo.ReadObject(hash) + if err != nil { + t.Fatalf("ReadObject failed: %v", err) + } + + tag, ok := obj.(*StoredTag) + if !ok { + t.Fatalf("expected *StoredTag, got %T", obj) + } + + if !bytes.Equal(tag.Name, []byte("v1.0.0")) { + t.Errorf("name: got %q, want %q", tag.Name, "v1.0.0") + } + if tag.TargetType != ObjectTypeCommit { + t.Errorf("target type: got %d, want %d", tag.TargetType, ObjectTypeCommit) + } + if tag.Target.String() != commitHash { + t.Errorf("target: got %s, want %s", tag.Target, commitHash) + } +} + +func TestTagRoundtrip(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "file.txt"), []byte("content"), 0o644) + if err != nil { + t.Fatalf("failed to write file.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Commit") + commitHash := gitCmd(t, repoPath, "rev-parse", "HEAD") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + commitHashObj, _ := repo.ParseHash(commitHash) + + tag := &Tag{ + Target: commitHashObj, + TargetType: ObjectTypeCommit, + Name: []byte("v3.0.0"), + Tagger: &Ident{ + Name: []byte("Test Tagger"), + Email: []byte("tagger@example.org"), + WhenUnix: 123456789, + OffsetMinutes: 0, + }, + Message: []byte("Tag message\n"), + } + + tagHash, err := repo.WriteLooseObject(tag) + if err != nil { + t.Fatalf("WriteLooseObject failed: %v", err) + } + + obj, err := repo.ReadObject(tagHash) + if err != nil { + t.Fatalf("ReadObject failed: %v", err) + } + + readTag, ok := obj.(*StoredTag) + if !ok { + t.Fatalf("expected *StoredTag, got %T", obj) + } + + if !bytes.Equal(readTag.Name, tag.Name) { + t.Errorf("name: got %q, want %q", readTag.Name, tag.Name) + } + if readTag.Target != tag.Target { + t.Errorf("target: got %s, want %s", readTag.Target, tag.Target) + } + if readTag.TargetType != tag.TargetType { + t.Errorf("target type: got %d, want %d", readTag.TargetType, tag.TargetType) + } + if !bytes.Equal(readTag.Message, tag.Message) { + t.Errorf("message: got %q, want %q", readTag.Message, tag.Message) + } +} diff --git a/obj_test.go b/obj_test.go new file mode 100644 index 00000000..124127a5 --- /dev/null +++ b/obj_test.go @@ -0,0 +1,52 @@ +package furgit + +import ( + "fmt" + "testing" +) + +func TestObjectTypeSize(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + testData := []byte("Test data for size check") + gitHash := gitHashObject(t, repoPath, "blob", testData) + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + hash, _ := repo.ParseHash(gitHash) + ty, size, err := repo.ReadObjectTypeSize(hash) + if err != nil { + t.Fatalf("ReadObjectTypeSize failed: %v", err) + } + + if ty != ObjectTypeBlob { + t.Errorf("type: got %d, want %d", ty, ObjectTypeBlob) + } + + gitSize := string(gitCatFile(t, repoPath, "-s", gitHash)) + if size != int64(len(testData)) || gitSize != fmt.Sprintf("%d", size) { + t.Errorf("size mismatch: furgit=%d git=%s expected=%d", size, gitSize, len(testData)) + } +} + +func TestReadObjectInvalid(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + invalidHash, _ := repo.ParseHash("0000000000000000000000000000000000000000000000000000000000000000") + _, err = repo.ReadObject(invalidHash) + if err == nil { + t.Error("expected error for invalid object") + } +} diff --git a/obj_tree.go b/obj_tree.go index 06d88692..be4accb5 100644 --- a/obj_tree.go +++ b/obj_tree.go @@ -23,9 +23,25 @@ func (sTree *StoredTree) Hash() Hash { return sTree.hash } +// FileMode represents the mode of a file in a Git tree. +type FileMode uint32 + +const ( + // FileModeDir represents a directory (tree) in a Git tree. + FileModeDir FileMode = 0o40000 + // FileModeRegular represents a regular file (blob) in a Git tree. + FileModeRegular FileMode = 0o100644 + // FileModeExecutable represents an executable file (blob) in a Git tree. + FileModeExecutable FileMode = 0o100755 + // FileModeSymlink represents a symbolic link (blob) in a Git tree. + FileModeSymlink FileMode = 0o120000 + // FileModeGitlink represents a Git link (submodule) in a Git tree. + FileModeGitlink FileMode = 0o160000 +) + // TreeEntry represents a single entry in a Git tree. type TreeEntry struct { - Mode uint32 + Mode FileMode Name []byte ID Hash } @@ -71,7 +87,7 @@ func parseTree(id Hash, body []byte, repo *Repository) (*StoredTree, error) { } entry := TreeEntry{ - Mode: uint32(mode), + Mode: FileMode(mode), Name: append([]byte(nil), nameBytes...), ID: child, } diff --git a/obj_tree_test.go b/obj_tree_test.go new file mode 100644 index 00000000..3314a1b4 --- /dev/null +++ b/obj_tree_test.go @@ -0,0 +1,296 @@ +package furgit + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTreeWrite(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + blobData := []byte("file content") + blobHash := gitHashObject(t, repoPath, "blob", blobData) + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + blobHashObj, _ := repo.ParseHash(blobHash) + tree := &Tree{ + Entries: []TreeEntry{ + {Mode: 0o100644, Name: []byte("file.txt"), ID: blobHashObj}, + }, + } + + treeHash, err := repo.WriteLooseObject(tree) + if err != nil { + t.Fatalf("WriteLooseObject failed: %v", err) + } + + gitType := string(gitCatFile(t, repoPath, "-t", treeHash.String())) + if gitType != "tree" { + t.Errorf("git type: got %q, want %q", gitType, "tree") + } + + gitLsTree := gitCmd(t, repoPath, "ls-tree", treeHash.String()) + if !strings.Contains(gitLsTree, "file.txt") { + t.Errorf("git ls-tree doesn't contain file.txt: %s", gitLsTree) + } + if !strings.Contains(gitLsTree, blobHash) { + t.Errorf("git ls-tree doesn't contain blob hash: %s", gitLsTree) + } +} + +func TestTreeRead(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "a.txt"), []byte("content a"), 0o644) + if err != nil { + t.Fatalf("failed to write a.txt: %v", err) + } + err = os.WriteFile(filepath.Join(workDir, "b.txt"), []byte("content b"), 0o644) + if err != nil { + t.Fatalf("failed to write b.txt: %v", err) + } + err = os.WriteFile(filepath.Join(workDir, "c.txt"), []byte("content c"), 0o644) + if err != nil { + t.Fatalf("failed to write c.txt: %v", err) + } + + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + treeHash := gitCmd(t, repoPath, "--work-tree="+workDir, "write-tree") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + hash, _ := repo.ParseHash(treeHash) + obj, err := repo.ReadObject(hash) + if err != nil { + t.Fatalf("ReadObject failed: %v", err) + } + + tree, ok := obj.(*StoredTree) + if !ok { + t.Fatalf("expected *StoredTree, got %T", obj) + } + + if len(tree.Entries) != 3 { + t.Fatalf("entries count: got %d, want 3", len(tree.Entries)) + } + + expectedNames := []string{"a.txt", "b.txt", "c.txt"} + for i, expected := range expectedNames { + if string(tree.Entries[i].Name) != expected { + t.Errorf("entry[%d] name: got %q, want %q", i, tree.Entries[i].Name, expected) + } + } + + if tree.ObjectType() != ObjectTypeTree { + t.Errorf("ObjectType(): got %d, want %d", tree.ObjectType(), ObjectTypeTree) + } +} + +func TestTreeEntry(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "a.txt"), []byte("content a"), 0o644) + if err != nil { + t.Fatalf("failed to write a.txt: %v", err) + } + err = os.WriteFile(filepath.Join(workDir, "b.txt"), []byte("content b"), 0o644) + if err != nil { + t.Fatalf("failed to write b.txt: %v", err) + } + err = os.WriteFile(filepath.Join(workDir, "c.txt"), []byte("content c"), 0o644) + if err != nil { + t.Fatalf("failed to write c.txt: %v", err) + } + + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + treeHash := gitCmd(t, repoPath, "--work-tree="+workDir, "write-tree") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + hash, _ := repo.ParseHash(treeHash) + obj, _ := repo.ReadObject(hash) + tree := obj.(*StoredTree) + + entry := tree.Entry([]byte("b.txt")) + if entry == nil { + t.Fatal("Entry returned nil for existing entry") + } + if !bytes.Equal(entry.Name, []byte("b.txt")) { + t.Errorf("entry name: got %q, want %q", entry.Name, "b.txt") + } + + notFound := tree.Entry([]byte("notfound.txt")) + if notFound != nil { + t.Error("Entry returned non-nil for non-existing entry") + } +} + +func TestTreeEntryRecursive(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.MkdirAll(filepath.Join(workDir, "dir"), 0o755) + if err != nil { + t.Fatalf("failed to create dir: %v", err) + } + err = os.WriteFile(filepath.Join(workDir, "file1.txt"), []byte("file1"), 0o644) + if err != nil { + t.Fatalf("failed to write file1.txt: %v", err) + } + err = os.WriteFile(filepath.Join(workDir, "file2.txt"), []byte("file2"), 0o644) + if err != nil { + t.Fatalf("failed to write file2.txt: %v", err) + } + err = os.WriteFile(filepath.Join(workDir, "dir", "nested.txt"), []byte("nested"), 0o644) + if err != nil { + t.Fatalf("failed to write dir/nested.txt: %v", err) + } + + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + treeHash := gitCmd(t, repoPath, "--work-tree="+workDir, "write-tree") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + hash, _ := repo.ParseHash(treeHash) + obj, _ := repo.ReadObject(hash) + tree := obj.(*StoredTree) + + entry, err := tree.EntryRecursive(repo, [][]byte{[]byte("file1.txt")}) + if err != nil { + t.Fatalf("EntryRecursive file1.txt failed: %v", err) + } + if !bytes.Equal(entry.Name, []byte("file1.txt")) { + t.Errorf("entry name: got %q, want %q", entry.Name, "file1.txt") + } + + gitShow := string(gitCatFile(t, repoPath, "blob", entry.ID.String())) + if gitShow != "file1" { + t.Errorf("file1 content from git: got %q, want %q", gitShow, "file1") + } + + nestedEntry, err := tree.EntryRecursive(repo, [][]byte{[]byte("dir"), []byte("nested.txt")}) + if err != nil { + t.Fatalf("EntryRecursive dir/nested.txt failed: %v", err) + } + if !bytes.Equal(nestedEntry.Name, []byte("nested.txt")) { + t.Errorf("nested entry name: got %q, want %q", nestedEntry.Name, "nested.txt") + } + + gitShowNested := string(gitCatFile(t, repoPath, "blob", nestedEntry.ID.String())) + if gitShowNested != "nested" { + t.Errorf("nested content from git: got %q, want %q", gitShowNested, "nested") + } + + _, err = tree.EntryRecursive(repo, [][]byte{[]byte("nonexistent.txt")}) + if err == nil { + t.Error("expected error for nonexistent path") + } + + _, err = tree.EntryRecursive(repo, [][]byte{}) + if err == nil { + t.Error("expected error for empty path") + } +} + +func TestTreeLarge(t *testing.T) { + if testing.Short() { + t.Skip("skipping large tree test in short mode") + } + + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + gitCmd(t, repoPath, "config", "gc.auto", "0") + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + numFiles := 1000 + for i := 0; i < numFiles; i++ { + filename := filepath.Join(workDir, fmt.Sprintf("file%04d.txt", i)) + content := fmt.Sprintf("Content for file %d\n", i) + err := os.WriteFile(filename, []byte(content), 0o644) + if err != nil { + t.Fatalf("failed to write %s: %v", filename, err) + } + } + + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + treeHash := gitCmd(t, repoPath, "--work-tree="+workDir, "write-tree") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + hash, _ := repo.ParseHash(treeHash) + obj, _ := repo.ReadObject(hash) + tree := obj.(*StoredTree) + + if len(tree.Entries) != numFiles { + t.Errorf("tree entries: got %d, want %d", len(tree.Entries), numFiles) + } + + gitCount := gitCmd(t, repoPath, "ls-tree", treeHash) + gitLines := strings.Count(gitCount, "\n") + 1 + if len(tree.Entries) != gitLines { + t.Errorf("furgit found %d entries, git found %d", len(tree.Entries), gitLines) + } + + for i := 0; i < 10; i++ { + idx := i * (numFiles / 10) + expectedName := fmt.Sprintf("file%04d.txt", idx) + entry := tree.Entry([]byte(expectedName)) + if entry == nil { + t.Errorf("expected to find entry %s", expectedName) + continue + } + + blobObj, _ := repo.ReadObject(entry.ID) + blob := blobObj.(*StoredBlob) + + expectedContent := fmt.Sprintf("Content for file %d\n", idx) + if string(blob.Data) != expectedContent { + t.Errorf("blob %s: got %q, want %q", expectedName, blob.Data, expectedContent) + } + + gitData := gitCatFile(t, repoPath, "blob", entry.ID.String()) + if !bytes.Equal(blob.Data, gitData) { + t.Errorf("blob %s: furgit data doesn't match git data", expectedName) + } + } +} diff --git a/objects_test.go b/objects_test.go deleted file mode 100644 index b191b865..00000000 --- a/objects_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package furgit - -import ( - "bytes" - "fmt" - "path/filepath" - "strings" - "testing" -) - -func testRepo(t *testing.T) *Repository { - t.Helper() - return &Repository{hashSize: testHashSize} -} - -func mustHash(t *testing.T, hex string) Hash { - repo := testRepo(t) - id, err := repo.ParseHash(hex) - if err != nil { - t.Fatalf("ParseHash failed: %v", err) - } - return id -} - -func hashWithByte(fill byte) Hash { - var h Hash - for i := 0; i < testHashSize; i++ { - h.data[i] = fill - fill++ - } - h.size = testHashSize - return h -} - -func TestLoosePathUsesExpectedLayout(t *testing.T) { - pattern := "0123456789abcdef" - repeats := (testHashSize*2 + len(pattern) - 1) / len(pattern) - hexStr := strings.Repeat(pattern, repeats)[:testHashSize*2] - id := mustHash(t, hexStr) - repo := testRepo(t) - expect := filepath.Join("objects", hexStr[:2], hexStr[2:]) - got, err := repo.loosePath(id) - if err != nil { - t.Fatalf("loosePath error: %v", err) - } - if got != expect { - t.Fatalf("unexpected loose path: %q", got) - } -} - -func TestParseBlobAndSerialize(t *testing.T) { - data := []byte("blob payload") - id := hashWithByte(0x10) - blob, err := parseBlob(id, data) - if err != nil { - t.Fatalf("parseBlob error: %v", err) - } - if !bytes.Equal(blob.Data, data) { - t.Fatalf("blob data mismatch: %q", blob.Data) - } - if blob.Hash() != id { - t.Fatalf("blob hash mismatch: %v", blob.Hash()) - } - raw, err := blob.Serialize() - if err != nil { - t.Fatalf("Serialize error: %v", err) - } - header, err := headerForType(ObjectTypeBlob, data) - if err != nil { - t.Fatalf("headerForType: %v", err) - } - want := append(append([]byte(nil), header...), data...) - if !bytes.Equal(raw, want) { - t.Fatalf("serialized blob mismatch") - } -} - -func TestParseTreeAndSerialize(t *testing.T) { - repo := testRepo(t) - entries := []TreeEntry{ - {Mode: 0100644, Name: []byte("file.txt"), ID: hashWithByte(0x20)}, - {Mode: 040000, Name: []byte("subdir"), ID: hashWithByte(0x30)}, - } - body := treeBody(&Tree{Entries: entries}) - id := hashWithByte(0x40) - tree, err := parseTree(id, body, repo) - if err != nil { - t.Fatalf("parseTree error: %v", err) - } - if len(tree.Entries) != len(entries) { - t.Fatalf("expected %d entries, got %d", len(entries), len(tree.Entries)) - } - for i := range entries { - if tree.Entries[i].Mode != entries[i].Mode || !bytes.Equal(tree.Entries[i].Name, entries[i].Name) || tree.Entries[i].ID != entries[i].ID { - t.Fatalf("entry %d mismatch", i) - } - } - serialized, err := (&Tree{Entries: entries}).Serialize() - if err != nil { - t.Fatalf("Serialize error: %v", err) - } - header, _ := headerForType(ObjectTypeTree, body) - want := append(append([]byte(nil), header...), body...) - if !bytes.Equal(serialized, want) { - t.Fatalf("serialized tree mismatch") - } -} - -func TestParseCommitWithExtraHeader(t *testing.T) { - treeID := hashWithByte(0x50) - parent := hashWithByte(0x60) - ident := Ident{ - Name: []byte("Alice"), - Email: []byte("alice@example.com"), - WhenUnix: 1700000000, - OffsetMinutes: -420, - } - var buf bytes.Buffer - fmt.Fprintf(&buf, "tree %s\n", treeID.String()) - fmt.Fprintf(&buf, "parent %s\n", parent.String()) - buf.WriteString("author ") - ids, err := ident.Serialize() - if err != nil { - t.Fatalf("Serialize error: %v", err) - } - buf.Write(ids) - buf.WriteByte('\n') - buf.WriteString("committer ") - buf.Write(ids) - buf.WriteByte('\n') - buf.WriteString("extra data\n\nMessage body\n") - repo := testRepo(t) - commit, err := parseCommit(hashWithByte(0x70), buf.Bytes(), repo) - if err != nil { - t.Fatalf("parseCommit error: %v", err) - } - if commit.Tree != treeID { - t.Fatalf("tree mismatch") - } - if len(commit.Parents) != 1 || commit.Parents[0] != parent { - t.Fatalf("parent mismatch: %+v", commit.Parents) - } - if string(commit.Message) != "Message body\n" { - t.Fatalf("message mismatch: %q", commit.Message) - } - if len(commit.ExtraHeaders) != 1 || commit.ExtraHeaders[0].Key != "extra" || !bytes.Equal(commit.ExtraHeaders[0].Value, []byte("data")) { - t.Fatalf("extra headers mismatch: %+v", commit.ExtraHeaders) - } - - roundTrip := &Commit{ - Tree: treeID, - Parents: []Hash{parent}, - Author: ident, - Committer: ident, - Message: []byte("Message body\n"), - } - raw, err := roundTrip.Serialize() - if err != nil { - t.Fatalf("Serialize error: %v", err) - } - if !strings.Contains(string(raw), "tree "+treeID.String()) { - t.Fatalf("serialized commit missing tree header") - } -} - -func TestParseTagAndSerialize(t *testing.T) { - target := hashWithByte(0x80) - tagger := &Ident{ - Name: []byte("Tagger"), - Email: []byte("tagger@example.com"), - WhenUnix: 1234, - OffsetMinutes: 0, - } - var buf bytes.Buffer - buf.WriteString("object ") - buf.WriteString(target.String()) - buf.WriteByte('\n') - buf.WriteString("type commit\n") - buf.WriteString("tag v1.0\n") - buf.WriteString("tagger ") - tgs, err := tagger.Serialize() - if err != nil { - t.Fatalf("Serialize error: %v", err) - } - buf.Write(tgs) - buf.WriteString("\n\nannotated tag\n") - body := append([]byte(nil), buf.Bytes()...) - repo := testRepo(t) - tag, err := parseTag(hashWithByte(0x90), body, repo) - if err != nil { - t.Fatalf("parseTag error: %v", err) - } - if tag.Target != target || tag.TargetType != ObjectTypeCommit { - t.Fatalf("tag target mismatch") - } - if tag.Tagger == nil { - t.Fatalf("tagger missing in body %q", string(body)) - } - if !bytes.Contains(tag.Tagger.Name, []byte("Tagger")) { - t.Fatalf("tagger name mismatch: %q", tag.Tagger.Name) - } - if string(tag.Name) != "v1.0" { - t.Fatalf("tag name mismatch: %q", tag.Name) - } - serialized, err := tag.Serialize() - if err != nil { - t.Fatalf("Serialize error: %v", err) - } - if !strings.Contains(string(serialized), "tag v1.0") { - t.Fatalf("serialized tag missing name header") - } -} diff --git a/pack_test.go b/pack_test.go index 4d6f651f..c23a9d9d 100644 --- a/pack_test.go +++ b/pack_test.go @@ -2,215 +2,221 @@ package furgit import ( "bytes" - "compress/zlib" - "encoding/binary" + "fmt" + "os" + "path/filepath" + "strings" "testing" - - "git.sr.ht/~runxiyu/furgit/internal/bufpool" ) -func compressBytes(t *testing.T, payload []byte) []byte { - var buf bytes.Buffer - zw := zlib.NewWriter(&buf) - if _, err := zw.Write(payload); err != nil { - t.Fatalf("compress write: %v", err) +func TestPackfileRead(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + gitCmd(t, repoPath, "config", "gc.auto", "0") + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "file1.txt"), []byte("content1"), 0o644) + if err != nil { + t.Fatalf("failed to write file1.txt: %v", err) } - if err := zw.Close(); err != nil { - t.Fatalf("compress close: %v", err) + err = os.WriteFile(filepath.Join(workDir, "file2.txt"), []byte("content2"), 0o644) + if err != nil { + t.Fatalf("failed to write file2.txt: %v", err) } - return buf.Bytes() -} -func TestPackSectionInflate(t *testing.T) { - payload := []byte("pack payload") - compressed := compressBytes(t, payload) - body, err := packSectionInflate(bytes.NewReader(compressed), len(payload)) + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Test commit") + commitHash := gitCmd(t, repoPath, "rev-parse", "HEAD") + + gitCmd(t, repoPath, "repack", "-a", "-d") + + repo, err := OpenRepository(repoPath) if err != nil { - t.Fatalf("packSectionInflate error: %v", err) + t.Fatalf("OpenRepository failed: %v", err) } - if got := string(body.Bytes()); got != string(payload) { - t.Fatalf("unexpected inflated data: %q", got) + defer func() { _ = repo.Close() }() + + hashObj, _ := repo.ParseHash(commitHash) + obj, err := repo.ReadObject(hashObj) + if err != nil { + t.Fatalf("ReadObject from pack failed: %v", err) } - body.Release() - body, err = packSectionInflate(bytes.NewReader(compressed), 0) + commit, ok := obj.(*StoredCommit) + if !ok { + t.Fatalf("expected *StoredCommit, got %T", obj) + } + + treeObj, err := repo.ReadObject(commit.Tree) if err != nil { - t.Fatalf("packSectionInflate streaming error: %v", err) + t.Fatalf("ReadObject tree failed: %v", err) } - if got := string(body.Bytes()); got != string(payload) { - t.Fatalf("unexpected streaming data: %q", got) + + tree, ok := treeObj.(*StoredTree) + if !ok { + t.Fatalf("expected *StoredTree, got %T", treeObj) } - body.Release() -} -func encodePackHeader(ty ObjectType, size int) []byte { - first := byte((ty & 0x7) << 4) - first |= byte(size & 0x0f) - size >>= 4 - if size == 0 { - return []byte{first} + if len(tree.Entries) != 2 { + t.Errorf("tree entries: got %d, want 2", len(tree.Entries)) } - first |= 0x80 - out := []byte{first} - for size > 0 { - b := byte(size & 0x7f) - size >>= 7 - if size != 0 { - b |= 0x80 + + gitLsTree := gitCmd(t, repoPath, "ls-tree", commit.Tree.String()) + for _, entry := range tree.Entries { + if !strings.Contains(gitLsTree, string(entry.Name)) { + t.Errorf("git ls-tree doesn't contain %s", entry.Name) } - out = append(out, b) } - return out } -func TestPackHeaderRead(t *testing.T) { - buf := encodePackHeader(ObjectTypeTree, 0x1fff) - ty, size, err := packHeaderRead(bytes.NewReader(buf)) - if err != nil { - t.Fatalf("packHeaderRead error: %v", err) - } - if ty != ObjectTypeTree || size != 0x1fff { - t.Fatalf("unexpected header decode ty=%d size=%d", ty, size) - } - if _, _, err := packHeaderRead(bytes.NewReader([]byte{0x80})); err == nil { - t.Fatal("expected error for truncated header") +func TestPackfileLarge(t *testing.T) { + if testing.Short() { + t.Skip("skipping large packfile test in short mode") } -} -func encodeVarint(value int) []byte { - var out []byte - for { - b := byte(value & 0x7f) - value >>= 7 - if value != 0 { - b |= 0x80 - } - out = append(out, b) - if value == 0 { - break + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + gitCmd(t, repoPath, "config", "gc.auto", "0") + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + numFiles := 1000 + for i := 0; i < numFiles; i++ { + filename := filepath.Join(workDir, fmt.Sprintf("file%04d.txt", i)) + content := fmt.Sprintf("Content for file %d\n", i) + err := os.WriteFile(filename, []byte(content), 0o644) + if err != nil { + t.Fatalf("failed to write %s: %v", filename, err) } } - return out -} -func TestPackVarintRead(t *testing.T) { - buf := encodeVarint(0x3456) - pos := 0 - val, err := packVarintRead(buf, &pos) + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Large commit") + commitHash := gitCmd(t, repoPath, "rev-parse", "HEAD") + + gitCmd(t, repoPath, "repack", "-a", "-d") + + repo, err := OpenRepository(repoPath) if err != nil { - t.Fatalf("packVarintRead error: %v", err) + t.Fatalf("OpenRepository failed: %v", err) } - if val != 0x3456 { - t.Fatalf("unexpected varint value: %d", val) + defer func() { _ = repo.Close() }() + + hashObj, _ := repo.ParseHash(commitHash) + obj, _ := repo.ReadObject(hashObj) + commit := obj.(*StoredCommit) + + treeObj, _ := repo.ReadObject(commit.Tree) + tree := treeObj.(*StoredTree) + + if len(tree.Entries) != numFiles { + t.Errorf("tree entries: got %d, want %d", len(tree.Entries), numFiles) } - if pos != len(buf) { - t.Fatalf("expected pos %d, got %d", len(buf), pos) + + gitCount := gitCmd(t, repoPath, "ls-tree", commit.Tree.String()) + gitLines := strings.Count(gitCount, "\n") + 1 + if len(tree.Entries) != gitLines { + t.Errorf("furgit found %d entries, git found %d", len(tree.Entries), gitLines) } - bad := []byte{0x80} - pos = 0 - if _, err := packVarintRead(bad, &pos); err == nil { - t.Fatal("expected error for unterminated varint") + + for i := 0; i < 10; i++ { + idx := i * (numFiles / 10) + expectedName := fmt.Sprintf("file%04d.txt", idx) + entry := tree.Entry([]byte(expectedName)) + if entry == nil { + t.Errorf("expected to find entry %s", expectedName) + continue + } + + blobObj, _ := repo.ReadObject(entry.ID) + blob := blobObj.(*StoredBlob) + + expectedContent := fmt.Sprintf("Content for file %d\n", idx) + if string(blob.Data) != expectedContent { + t.Errorf("blob %s: got %q, want %q", expectedName, blob.Data, expectedContent) + } + + gitData := gitCatFile(t, repoPath, "blob", entry.ID.String()) + if !bytes.Equal(blob.Data, gitData) { + t.Errorf("blob %s: furgit data doesn't match git data", expectedName) + } } } -func TestPackDeltaApply(t *testing.T) { - base := bufpool.FromOwned([]byte("abcdefghij")) - defer base.Release() - deltaBytes := []byte{0x0a, 0x0a, 0x91, 0x00, 0x03, 0x03, 'X', 'Y', 'Z', 0x91, 0x06, 0x04} - delta := bufpool.FromOwned(deltaBytes) - defer delta.Release() - out, err := packDeltaApply(base, delta) - if err != nil { - t.Fatalf("packDeltaApply error: %v", err) +func TestMultiPackIndex(t *testing.T) { + if testing.Short() { + t.Skip("skipping multi-pack-index test in short mode") } - if got := string(out.Bytes()); got != "abcXYZghij" { - t.Fatalf("unexpected delta output: %q", got) - } - out.Release() -} -func TestPackDeltaApplyMismatchedBaseSize(t *testing.T) { - base := bufpool.FromOwned([]byte("abc")) - defer base.Release() - delta := bufpool.FromOwned([]byte{0x04, 0x04}) - defer delta.Release() - if _, err := packDeltaApply(base, delta); err == nil { - t.Fatal("expected error for mismatched base size") + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + gitCmd(t, repoPath, "config", "gc.auto", "0") + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "file1.txt"), []byte("content1"), 0o644) + if err != nil { + t.Fatalf("failed to write file1.txt: %v", err) } -} + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Commit 1") + commit1Hash := gitCmd(t, repoPath, "rev-parse", "HEAD") + gitCmd(t, repoPath, "repack", "-d") -func TestPackDeltaReadOfsDistance(t *testing.T) { - dist, err := packDeltaReadOfsDistance(bytes.NewReader([]byte{0x81, 0x01})) + err = os.WriteFile(filepath.Join(workDir, "file2.txt"), []byte("content2"), 0o644) if err != nil { - t.Fatalf("packDeltaReadOfsDistance error: %v", err) + t.Fatalf("failed to write file2.txt: %v", err) } - if dist != 257 { - t.Fatalf("unexpected distance: %d", dist) + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "Commit 2") + commit2Hash := gitCmd(t, repoPath, "rev-parse", "HEAD") + gitCmd(t, repoPath, "repack", "-d") + + gitCmd(t, repoPath, "repack", "--write-midx") + + midxPath := filepath.Join(repoPath, "objects", "pack", "multi-pack-index") + if _, err := os.Stat(midxPath); os.IsNotExist(err) { + t.Fatalf("multi-pack-index file does not exist at %s", midxPath) } - if _, err := packDeltaReadOfsDistance(bytes.NewReader([]byte{})); err == nil { - t.Fatal("expected error for empty reader") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) } -} + defer func() { _ = repo.Close() }() -func TestBsearchHash(t *testing.T) { - h1 := hashWithByte(0x01) - h2 := hashWithByte(0x03) - names := append(append([]byte(nil), h1.data[:testHashSize]...), h2.data[:testHashSize]...) - idx, found := bsearchHash(names, testHashSize, 0, 2, h2) - if !found || idx != 1 { - t.Fatalf("expected to find second hash, idx=%d found=%v", idx, found) + hash1, _ := repo.ParseHash(commit1Hash) + obj1, err := repo.ReadObject(hash1) + if err != nil { + t.Fatalf("ReadObject commit1 failed: %v", err) } - _, found = bsearchHash(names, testHashSize, 0, 2, hashWithByte(0x05)) - if found { - t.Fatalf("did not expect to find unknown hash") + commit1 := obj1.(*StoredCommit) + if commit1.Hash() != hash1 { + t.Error("commit1 hash mismatch") } -} -func buildTestPackIndexBuffer(hash Hash, offset uint32) []byte { - fanout := make([]byte, 256*4) - first := int(hash.data[0]) - for i := 0; i < 256; i++ { - var val uint32 - if i >= first { - val = 1 - } - binary.BigEndian.PutUint32(fanout[i*4:], val) + if !bytes.Contains(commit1.Message, []byte("Commit 1")) { + t.Errorf("commit1 message doesn't contain 'Commit 1': got %q", commit1.Message) } - var buf bytes.Buffer - _ = binary.Write(&buf, binary.BigEndian, uint32(idxMagic)) - _ = binary.Write(&buf, binary.BigEndian, uint32(idxVersion2)) - buf.Write(fanout) - buf.Write(hash.data[:testHashSize]) - buf.Write(make([]byte, 4)) - off32 := make([]byte, 4) - binary.BigEndian.PutUint32(off32, offset) - buf.Write(off32) - buf.Write(make([]byte, 2*testHashSize)) - return buf.Bytes() -} -func TestPackIndexParse(t *testing.T) { - h := hashWithByte(0x11) - data := buildTestPackIndexBuffer(h, 0x12345678) - pi := &packIndex{repo: &Repository{hashSize: testHashSize}} - if err := pi.parse(data); err != nil { - t.Fatalf("parse error: %v", err) - } - if pi.numObjects != 1 { - t.Fatalf("expected 1 object, got %d", pi.numObjects) + hash2, _ := repo.ParseHash(commit2Hash) + obj2, err := repo.ReadObject(hash2) + if err != nil { + t.Fatalf("ReadObject commit2 failed: %v", err) } - if got, err := pi.offset(0); err != nil || got != 0x12345678 { - t.Fatalf("unexpected 32-bit offset or error: %d, %v", got, err) + commit2 := obj2.(*StoredCommit) + if commit2.Hash() != hash2 { + t.Error("commit2 hash mismatch") } -} -func TestPackIndexOffset64(t *testing.T) { - pi := &packIndex{} - pi.offset32 = make([]byte, 4) - binary.BigEndian.PutUint32(pi.offset32, 0x80000000) - pi.offset64 = make([]byte, 8) - binary.BigEndian.PutUint64(pi.offset64, 0x1_0000_0000) - if got, err := pi.offset(0); err != nil || got != 0x1_0000_0000 { - t.Fatalf("unexpected 64-bit offset or error: %d, %v", got, err) + if !bytes.Contains(commit2.Message, []byte("Commit 2")) { + t.Errorf("commit2 message doesn't contain 'Commit 2': got %q", commit2.Message) } } @@ -75,21 +75,45 @@ func (repo *Repository) resolvePackedRef(refname string) (Hash, error) { return Hash{}, ErrInvalidObject } -// ResolveHEAD reads HEAD and returns the fully qualified -// ref name it points to. -func (repo *Repository) ResolveHEAD() (string, error) { +// HeadKind represents the kind of HEAD reference. +type HeadKind int + +const ( + // The HEAD reference is invalid. + HeadKindInvalid HeadKind = iota + // The HEAD reference points to a detached commit hash. + HeadKindDetached + // The HEAD reference points to a symbolic ref. + HeadKindSymbolic +) + +// HeadRef represents a HEAD reference. +type HeadRef struct { + // Kind is the kind of HEAD reference. + Kind HeadKind + // When Kind is HeadSymbolic, Ref is the fully qualified ref name. + Ref string + // When Kind is HeadDetached, Hash is the commit hash. + Hash Hash +} + +// ResolveHead reads HEAD into a HEAD reference. +func (repo *Repository) ResolveHead() (HeadRef, error) { data, err := os.ReadFile(repo.repoPath("HEAD")) if err != nil { - return "", err + return HeadRef{Kind: HeadKindInvalid}, err } - line := strings.TrimSpace(string(data)) - const prefix = "ref: " - if strings.HasPrefix(line, prefix) { - ref := strings.TrimSpace(line[len(prefix):]) - if ref == "" { - return "", ErrInvalidRef + line := strings.TrimSuffix(string(data), "\n") + if strings.HasPrefix(line, "ref: ") { + refname := strings.TrimSpace(line[5:]) + if !strings.HasPrefix(refname, "refs/") { + return HeadRef{Kind: HeadKindSymbolic, Ref: refname}, ErrInvalidRef } - return ref, nil + return HeadRef{Kind: HeadKindSymbolic, Ref: refname}, nil + } + id, err := repo.ParseHash(line) + if err != nil { + return HeadRef{Kind: HeadKindInvalid}, err } - return "", ErrInvalidRef + return HeadRef{Kind: HeadKindDetached, Hash: id}, nil } diff --git a/refs_test.go b/refs_test.go new file mode 100644 index 00000000..c9f3da57 --- /dev/null +++ b/refs_test.go @@ -0,0 +1,161 @@ +package furgit + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveRef(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "test.txt"), []byte("content"), 0o644) + if err != nil { + t.Fatalf("Failed to write test.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "test") + commitHash := gitCmd(t, repoPath, "rev-parse", "HEAD") + gitCmd(t, repoPath, "update-ref", "refs/heads/main", commitHash) + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + hashObj, _ := repo.ParseHash(commitHash) + resolved, err := repo.ResolveRef("refs/heads/main") + if err != nil { + t.Fatalf("ResolveRef failed: %v", err) + } + + if resolved != hashObj { + t.Errorf("resolved hash: got %s, want %s", resolved, hashObj) + } + + gitRevParse := gitCmd(t, repoPath, "rev-parse", "refs/heads/main") + if resolved.String() != gitRevParse { + t.Errorf("furgit resolved %s, git resolved %s", resolved, gitRevParse) + } + + _, err = repo.ResolveRef("refs/heads/nonexistent") + if err == nil { + t.Error("expected error for nonexistent ref") + } +} + +func TestResolveHEAD(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "test.txt"), []byte("content"), 0o644) + if err != nil { + t.Fatalf("failed to write test.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "test") + commitHash := gitCmd(t, repoPath, "rev-parse", "HEAD") + gitCmd(t, repoPath, "update-ref", "refs/heads/main", commitHash) + gitCmd(t, repoPath, "symbolic-ref", "HEAD", "refs/heads/main") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + ref, err := repo.ResolveHead() + if err != nil { + t.Fatalf("ResolveHEAD failed: %v", err) + } + + switch ref.Kind { + case HeadKindSymbolic: + if ref.Ref != "refs/heads/main" { + t.Errorf("HEAD ref: got %q, want %q", ref, "refs/heads/main") + } + gitSymRef := gitCmd(t, repoPath, "symbolic-ref", "HEAD") + if ref.Ref != gitSymRef { + t.Errorf("furgit resolved %v, git resolved %s", ref, gitSymRef) + } + default: + t.Errorf("HEAD kind: got %v, want %v", ref.Kind, HeadKindSymbolic) + } + +} + +func TestPackedRefs(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() + + workDir, cleanupWork := setupWorkDir(t) + defer cleanupWork() + + err := os.WriteFile(filepath.Join(workDir, "test.txt"), []byte("content1"), 0o644) + if err != nil { + t.Fatalf("failed to write test.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "commit1") + commit1Hash := gitCmd(t, repoPath, "rev-parse", "HEAD") + + err = os.WriteFile(filepath.Join(workDir, "test2.txt"), []byte("content2"), 0o644) + if err != nil { + t.Fatalf("failed to write test2.txt: %v", err) + } + gitCmd(t, repoPath, "--work-tree="+workDir, "add", ".") + gitCmd(t, repoPath, "--work-tree="+workDir, "commit", "-m", "commit2") + commit2Hash := gitCmd(t, repoPath, "rev-parse", "HEAD") + + gitCmd(t, repoPath, "update-ref", "refs/heads/branch1", commit1Hash) + gitCmd(t, repoPath, "update-ref", "refs/heads/branch2", commit2Hash) + gitCmd(t, repoPath, "update-ref", "refs/tags/v1.0", commit1Hash) + + gitCmd(t, repoPath, "pack-refs", "--all") + + repo, err := OpenRepository(repoPath) + if err != nil { + t.Fatalf("OpenRepository failed: %v", err) + } + defer func() { _ = repo.Close() }() + + hash1, _ := repo.ParseHash(commit1Hash) + hash2, _ := repo.ParseHash(commit2Hash) + + resolved1, err := repo.ResolveRef("refs/heads/branch1") + if err != nil { + t.Fatalf("ResolveRef branch1 failed: %v", err) + } + if resolved1 != hash1 { + t.Errorf("branch1: got %s, want %s", resolved1, hash1) + } + + gitResolved1 := gitCmd(t, repoPath, "rev-parse", "refs/heads/branch1") + if resolved1.String() != gitResolved1 { + t.Errorf("furgit resolved %s, git resolved %s", resolved1, gitResolved1) + } + + resolved2, err := repo.ResolveRef("refs/heads/branch2") + if err != nil { + t.Fatalf("ResolveRef branch2 failed: %v", err) + } + if resolved2 != hash2 { + t.Errorf("branch2: got %s, want %s", resolved2, hash2) + } + + resolvedTag, err := repo.ResolveRef("refs/tags/v1.0") + if err != nil { + t.Fatalf("ResolveRef tag failed: %v", err) + } + if resolvedTag != hash1 { + t.Errorf("tag: got %s, want %s", resolvedTag, hash1) + } +} diff --git a/repo_current_test.go b/repo_current_test.go new file mode 100644 index 00000000..f5699916 --- /dev/null +++ b/repo_current_test.go @@ -0,0 +1,90 @@ +package furgit + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCurrentRepoDepthFirstEnumeration(t *testing.T) { + gitDir := filepath.Join(".git") + if _, err := os.Stat(gitDir); os.IsNotExist(err) { + t.Skip("no .git directory found in current repo") + } + + repo, err := OpenRepository(gitDir) + if err != nil { + t.Skipf("failed to open current .git directory: %v", err) + } + defer func() { _ = repo.Close() }() + + headRef, err := repo.ResolveHead() + if err != nil { + t.Fatalf("failed to resolve HEAD: %v", err) + } + + var headHash Hash + + switch headRef.Kind { + case HeadKindDetached: + headHash = headRef.Hash + case HeadKindSymbolic: + headHash, err = repo.ResolveRef(headRef.Ref) + if err != nil { + t.Fatalf("failed to resolve symbolic HEAD ref %v: %v", headRef, err) + } + default: + t.Fatalf("unexpected HEAD ref kind: %v", headRef.Kind) + } + + visited := make(map[Hash]bool) + var visitQueue []Hash + visitQueue = append(visitQueue, headHash) + + objectsRead := 0 + errors := 0 + for len(visitQueue) > 0 { + hash := visitQueue[0] + visitQueue = visitQueue[1:] + + if visited[hash] { + continue + } + visited[hash] = true + + obj, err := repo.ReadObject(hash) + if err != nil { + t.Logf("failed to read object %s: %v", hash, err) + errors++ + if errors > 10 { + t.Fatalf("too many errors (%d) reading objects", errors) + } + continue + } + objectsRead++ + + switch o := obj.(type) { + case *StoredCommit: + visitQueue = append(visitQueue, o.Tree) + visitQueue = append(visitQueue, o.Parents...) + case *StoredTree: + for _, entry := range o.Entries { + visitQueue = append(visitQueue, entry.ID) + } + case *StoredTag: + visitQueue = append(visitQueue, o.Target) + case *StoredBlob: + default: + t.Errorf("unexpected object type: %T", o) + } + } + + if objectsRead == 0 { + t.Fatal("no objects were read from the repository") + } + + t.Logf("Read %d objects from current repository HEAD (%d errors)", objectsRead, errors) + if errors > 0 { + t.Fatalf("encountered %d errors during enumeration", errors) + } +} diff --git a/repo_test.go b/repo_test.go index b1b48df4..0e9de49e 100644 --- a/repo_test.go +++ b/repo_test.go @@ -1,539 +1,48 @@ package furgit import ( - "bytes" - "crypto/sha1" - "crypto/sha256" - "encoding/binary" - "errors" - "fmt" - "math" - "os" - "path/filepath" - "sort" "testing" ) -func writeLooseBlob(t *testing.T, repo *Repository, data []byte) Hash { - blob := &Blob{Data: data} - id, err := repo.WriteLooseObject(blob) - if err != nil { - t.Fatalf("WriteLooseObject: %v", err) - } - return id -} - -func TestOpenRepositoryAndLooseRead(t *testing.T) { - root := t.TempDir() - setupRepoConfig(t, root) - repo, err := OpenRepository(root) - if err != nil { - t.Fatalf("OpenRepository error: %v", err) - } - t.Cleanup(func() { _ = repo.Close() }) - - id := writeLooseBlob(t, repo, []byte("loose blob payload")) - obj, err := repo.looseRead(id) - if err != nil { - t.Fatalf("looseRead error: %v", err) - } - blob, ok := obj.(*StoredBlob) - if !ok { - t.Fatalf("expected StoredBlob, got %T", obj) - } - if string(blob.Data) != "loose blob payload" { - t.Fatalf("blob data mismatch: %q", blob.Data) - } -} - -func TestResolveRefLooseAndPacked(t *testing.T) { - root := t.TempDir() - setupRepoConfig(t, root) - repo, err := OpenRepository(root) - if err != nil { - t.Fatalf("OpenRepository error: %v", err) - } - t.Cleanup(func() { _ = repo.Close() }) - - looseID := hashWithByte(0xa0) - loosePath := filepath.Join(root, "refs", "heads") - if err := os.MkdirAll(loosePath, 0o755); err != nil { - t.Fatalf("mkdir refs: %v", err) - } - if err := os.WriteFile(filepath.Join(loosePath, "master"), []byte(looseID.String()+"\n"), 0o644); err != nil { - t.Fatalf("write ref: %v", err) - } - id, err := repo.ResolveRef("refs/heads/master") - if err != nil || id != looseID { - t.Fatalf("ResolveRef loose mismatch (id=%v err=%v)", id, err) - } - - packedID := hashWithByte(0xb0) - packed := fmt.Sprintf("%s refs/tags/v1\n", packedID.String()) - if err := os.WriteFile(filepath.Join(root, "packed-refs"), []byte(packed), 0o644); err != nil { - t.Fatalf("write packed refs: %v", err) - } - id, err = repo.resolvePackedRef("refs/tags/v1") - if err != nil || id != packedID { - t.Fatalf("resolvePackedRef direct mismatch (id=%v err=%v)", id, err) - } - id, err = repo.ResolveRef("refs/tags/v1") - if err != nil || id != packedID { - t.Fatalf("ResolveRef packed mismatch (id=%v err=%v)", id, err) - } - - if _, err := repo.ResolveRef("refs/heads/missing"); !errors.Is(err, ErrInvalidObject) { - t.Fatalf("expected ErrInvalidObject for missing ref, got %v", err) - } -} - -func TestResolveHEAD(t *testing.T) { - root := t.TempDir() - setupRepoConfig(t, root) - repo, err := OpenRepository(root) - if err != nil { - t.Fatalf("OpenRepository error: %v", err) - } - t.Cleanup(func() { _ = repo.Close() }) - - headPath := filepath.Join(root, "HEAD") - if err := os.WriteFile(headPath, []byte("ref: refs/heads/master\n"), 0o644); err != nil { - t.Fatalf("write HEAD: %v", err) - } - ref, err := repo.ResolveHEAD() - if err != nil || ref != "refs/heads/master" { - t.Fatalf("ResolveHEAD mismatch (ref=%q err=%v)", ref, err) - } - if err := os.WriteFile(headPath, []byte("detached\n"), 0o644); err != nil { - t.Fatalf("write HEAD detached: %v", err) - } - if _, err := repo.ResolveHEAD(); err == nil { - t.Fatal("expected error for detached HEAD") - } -} - -func TestReadObjectTypeSizeLoose(t *testing.T) { - t.Parallel() - root := t.TempDir() - setupRepoConfig(t, root) - repo, err := OpenRepository(root) - if err != nil { - t.Fatalf("OpenRepository error: %v", err) - } - t.Cleanup(func() { _ = repo.Close() }) - - data := []byte("header-only read") - id := writeLooseBlob(t, repo, data) - ty, size, err := repo.ReadObjectTypeSize(id) - if err != nil { - t.Fatalf("ReadObjectTypeSize loose error: %v", err) - } - if ty != ObjectTypeBlob || size != int64(len(data)) { - t.Fatalf("unexpected loose metadata ty=%d size=%d", ty, size) - } -} - -func TestReadObjectTypeSizePackedObjects(t *testing.T) { - t.Parallel() - root := t.TempDir() - setupRepoConfig(t, root) - - objs := []testPackObject{ - {finalType: ObjectTypeBlob, body: []byte("packed base payload")}, - { - finalType: ObjectTypeBlob, - body: []byte("packed delta payload with extra bytes"), - encoding: packEncodingOfsDelta, - baseIndex: 0, - }, - } - ids := writeTestPack(t, root, "pack-basic", objs) - - repo, err := OpenRepository(root) - if err != nil { - t.Fatalf("OpenRepository error: %v", err) - } - t.Cleanup(func() { _ = repo.Close() }) - - ty, size, err := repo.ReadObjectTypeSize(ids[0]) - if err != nil { - t.Fatalf("ReadObjectTypeSize base error: %v", err) - } - if ty != ObjectTypeBlob || size != int64(len(objs[0].body)) { - t.Fatalf("unexpected base metadata ty=%d size=%d", ty, size) - } - - ty, size, err = repo.ReadObjectTypeSize(ids[1]) - if err != nil { - t.Fatalf("ReadObjectTypeSize delta error: %v", err) - } - if ty != ObjectTypeBlob || size != int64(len(objs[1].body)) { - t.Fatalf("unexpected delta metadata ty=%d size=%d", ty, size) - } -} - -func TestReadObjectTypeSizePackRefDeltaLooseBase(t *testing.T) { - t.Parallel() - root := t.TempDir() - setupRepoConfig(t, root) - - repo, err := OpenRepository(root) - if err != nil { - t.Fatalf("OpenRepository error: %v", err) - } - t.Cleanup(func() { _ = repo.Close() }) - - looseBody := []byte("loose base for ref delta") - baseID := writeLooseBlob(t, repo, looseBody) - - objs := []testPackObject{ - { - finalType: ObjectTypeBlob, - body: []byte("ref delta rewritten body"), - encoding: packEncodingRefDelta, - baseHash: baseID, - baseBody: looseBody, - }, - } - ids := writeTestPack(t, root, "pack-ref", objs) - - ty, size, err := repo.ReadObjectTypeSize(ids[0]) - if err != nil { - t.Fatalf("ReadObjectTypeSize ref delta error: %v", err) - } - if ty != ObjectTypeBlob || size != int64(len(objs[0].body)) { - t.Fatalf("unexpected ref delta metadata ty=%d size=%d", ty, size) - } -} - -func TestWriteLooseObjectAllTypes(t *testing.T) { - root := t.TempDir() - setupRepoConfig(t, root) - repo, err := OpenRepository(root) - if err != nil { - t.Fatalf("OpenRepository error: %v", err) - } - t.Cleanup(func() { _ = repo.Close() }) - - // Blob - blob := &Blob{Data: []byte("test blob data")} - blobID, err := repo.WriteLooseObject(blob) - if err != nil { - t.Fatalf("WriteLooseObject Blob error: %v", err) - } - readBlob, err := repo.ReadObject(blobID) - if err != nil { - t.Fatalf("ReadObject Blob error: %v", err) - } - if rb, ok := readBlob.(*StoredBlob); !ok { - t.Fatalf("expected StoredBlob, got %T", readBlob) - } else if string(rb.Data) != "test blob data" { - t.Fatalf("blob data mismatch: %q", rb.Data) - } - - // Tree - tree := &Tree{ - Entries: []TreeEntry{ - {Mode: 0100644, Name: []byte("file.txt"), ID: blobID}, - }, - } - treeID, err := repo.WriteLooseObject(tree) - if err != nil { - t.Fatalf("WriteLooseObject Tree error: %v", err) - } - readTree, err := repo.ReadObject(treeID) - if err != nil { - t.Fatalf("ReadObject Tree error: %v", err) - } - if rt, ok := readTree.(*StoredTree); !ok { - t.Fatalf("expected StoredTree, got %T", readTree) - } else if len(rt.Entries) != 1 { - t.Fatalf("tree entries mismatch: %d", len(rt.Entries)) - } +func TestRepositoryOpen(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() - // Commit - commit := &Commit{ - Tree: treeID, - Author: Ident{ - Name: []byte("Test Author"), - Email: []byte("test@example.com"), - WhenUnix: 1700000000, - OffsetMinutes: 0, - }, - Committer: Ident{ - Name: []byte("Test Author"), - Email: []byte("test@example.com"), - WhenUnix: 1700000000, - OffsetMinutes: 0, - }, - Message: []byte("Test commit message\n"), - } - commitID, err := repo.WriteLooseObject(commit) - if err != nil { - t.Fatalf("WriteLooseObject Commit error: %v", err) - } - readCommit, err := repo.ReadObject(commitID) + repo, err := OpenRepository(repoPath) if err != nil { - t.Fatalf("ReadObject Commit error: %v", err) - } - if rc, ok := readCommit.(*StoredCommit); !ok { - t.Fatalf("expected StoredCommit, got %T", readCommit) - } else if rc.Tree != treeID { - t.Fatalf("commit tree mismatch") + t.Fatalf("OpenRepository failed: %v", err) } + defer func() { _ = repo.Close() }() - // Tag - tag := &Tag{ - Target: commitID, - TargetType: ObjectTypeCommit, - Name: []byte("v1.0.0"), - Tagger: &Ident{ - Name: []byte("Test Tagger"), - Email: []byte("tagger@example.com"), - WhenUnix: 1700000000, - OffsetMinutes: 0, - }, - Message: []byte("Test tag message\n"), - } - tagID, err := repo.WriteLooseObject(tag) - if err != nil { - t.Fatalf("WriteLooseObject Tag error: %v", err) - } - readTag, err := repo.ReadObject(tagID) - if err != nil { - t.Fatalf("ReadObject Tag error: %v", err) + if repo.rootPath != repoPath { + t.Errorf("rootPath: got %q, want %q", repo.rootPath, repoPath) } - if rtag, ok := readTag.(*StoredTag); !ok { - t.Fatalf("expected StoredTag, got %T", readTag) - } else if rtag.Target != commitID { - t.Fatalf("tag target mismatch") + if repo.hashSize != 32 && repo.hashSize != 20 { + t.Errorf("hashSize: got %d, want 32 (SHA-256) or 20 (SHA-1)", repo.hashSize) } } -type packObjectEncoding uint8 - -const ( - packEncodingFull packObjectEncoding = iota - packEncodingOfsDelta - packEncodingRefDelta -) - -type testPackObject struct { - finalType ObjectType - body []byte - encoding packObjectEncoding - baseIndex int - baseHash Hash - baseBody []byte -} - -func writeTestPack(t *testing.T, root, name string, objs []testPackObject) []Hash { - t.Helper() - repo := &Repository{hashSize: testHashSize} - packDir := filepath.Join(root, "objects", "pack") - err := os.MkdirAll(packDir, 0o750) - if err != nil { - t.Fatalf("mkdir pack dir: %v", err) - } - - var buf bytes.Buffer - buf.Write([]byte{'P', 'A', 'C', 'K'}) - err = binary.Write(&buf, binary.BigEndian, uint32(packVersion2)) - if err != nil { - t.Fatalf("write pack version: %v", err) +func TestRepositoryOpenInvalid(t *testing.T) { + _, err := OpenRepository("/nonexistent/path") + if err == nil { + t.Fatal("expected error for nonexistent path") } - objCount := len(objs) - if objCount > math.MaxUint32 { - t.Fatalf("too many objects: %d", len(objs)) - } - count32 := uint32(objCount) //#nosec G115 - err = binary.Write(&buf, binary.BigEndian, count32) - if err != nil { - t.Fatalf("write pack count: %v", err) - } - - offsets := make([]uint64, len(objs)) - ids := make([]Hash, len(objs)) - - for i, obj := range objs { - offset := buf.Len() - if offset < 0 { - t.Fatalf("negative buffer length") - } - offsets[i] = uint64(offset) - header, err := headerForType(obj.finalType, obj.body) - if err != nil { - t.Fatalf("headerForType: %v", err) - } - raw := make([]byte, len(header)+len(obj.body)) - copy(raw, header) - copy(raw[len(header):], obj.body) - ids[i] = repo.computeRawHash(raw) - - switch obj.encoding { - case packEncodingFull: - buf.Write(encodePackHeader(obj.finalType, len(obj.body))) - buf.Write(compressBytes(t, obj.body)) - case packEncodingOfsDelta: - if obj.baseIndex < 0 || obj.baseIndex >= i { - t.Fatalf("invalid base index %d for ofs delta %d", obj.baseIndex, i) - } - buf.Write(encodePackHeader(ObjectTypeOfsDelta, len(obj.body))) - dist := offsets[i] - offsets[obj.baseIndex] - buf.Write(encodeOfsDistance(dist)) - baseBody := objs[obj.baseIndex].body - delta := buildInsertOnlyDelta(len(baseBody), obj.body) - buf.Write(compressBytes(t, delta)) - case packEncodingRefDelta: - if obj.baseHash == (Hash{}) { - t.Fatalf("ref delta %d missing base hash", i) - } - baseBody := obj.baseBody - if len(baseBody) == 0 { - t.Fatalf("ref delta %d missing base body", i) - } - buf.Write(encodePackHeader(ObjectTypeRefDelta, len(obj.body))) - buf.Write(obj.baseHash.data[:testHashSize]) - delta := buildInsertOnlyDelta(len(baseBody), obj.body) - buf.Write(compressBytes(t, delta)) - default: - t.Fatalf("unknown encoding %d", obj.encoding) - } - } - - packContent := append([]byte(nil), buf.Bytes()...) - packChecksum := repo.computeRawHash(packContent) - buf.Write(packChecksum.data[:testHashSize]) - packBytes := buf.Bytes() - - packPath := filepath.Join(packDir, name+".pack") - err = os.WriteFile(packPath, packBytes, 0o600) - if err != nil { - t.Fatalf("write pack file: %v", err) - } - - writeTestPackIndex(t, packDir, name, ids, offsets, packChecksum) - return ids } -func writeTestPackIndex(t *testing.T, packDir, name string, ids []Hash, offsets []uint64, packChecksum Hash) { - t.Helper() - repo := &Repository{hashSize: testHashSize} - type idxEntry struct { - id Hash - offset uint64 - } - entries := make([]idxEntry, len(ids)) - for i := range ids { - entries[i] = idxEntry{id: ids[i], offset: offsets[i]} - } - sort.Slice(entries, func(i, j int) bool { - return bytes.Compare(entries[i].id.data[:testHashSize], entries[j].id.data[:testHashSize]) < 0 - }) +func TestRepositoryClose(t *testing.T) { + repoPath, cleanup := setupTestRepo(t) + defer cleanup() - var buf bytes.Buffer - err := binary.Write(&buf, binary.BigEndian, uint32(idxMagic)) - if err != nil { - t.Fatalf("write idx magic: %v", err) - } - err = binary.Write(&buf, binary.BigEndian, uint32(idxVersion2)) + repo, err := OpenRepository(repoPath) if err != nil { - t.Fatalf("write idx version: %v", err) - } - - var fanout [256]uint32 - for _, entry := range entries { - first := int(entry.id.data[0]) - for i := first; i < len(fanout); i++ { - fanout[i]++ - } + t.Fatalf("OpenRepository failed: %v", err) } - for _, count := range fanout { - err = binary.Write(&buf, binary.BigEndian, count) - if err != nil { - t.Fatalf("write fanout: %v", err) - } - } - - for _, entry := range entries { - buf.Write(entry.id.data[:testHashSize]) - } - - buf.Write(make([]byte, len(entries)*4)) - for _, entry := range entries { - if entry.offset >= 0x80000000 { - t.Fatalf("offset too large for 32-bit table") - } - var word [4]byte - binary.BigEndian.PutUint32(word[:], uint32(entry.offset)) - buf.Write(word[:]) + if err := repo.Close(); err != nil { + t.Fatalf("Close failed: %v", err) } - idxData := append([]byte(nil), buf.Bytes()...) - idxChecksum := repo.computeRawHash(idxData) - buf.Write(packChecksum.data[:testHashSize]) - buf.Write(idxChecksum.data[:testHashSize]) - - idxPath := filepath.Join(packDir, name+".idx") - err = os.WriteFile(idxPath, buf.Bytes(), 0o600) - if err != nil { - t.Fatalf("write idx file: %v", err) - } -} - -func buildInsertOnlyDelta(srcLen int, dst []byte) []byte { - var buf bytes.Buffer - buf.Write(encodeVarint(srcLen)) - buf.Write(encodeVarint(len(dst))) - remaining := dst - for len(remaining) > 0 { - chunk := remaining - if len(chunk) > 127 { - chunk = remaining[:127] - } - buf.WriteByte(byte(len(chunk))) - buf.Write(chunk) - remaining = remaining[len(chunk):] - } - return buf.Bytes() -} - -func encodeOfsDistance(dist uint64) []byte { - if dist == 0 { - return []byte{0} - } - var out []byte - out = append(out, byte(dist&0x7f)) - for dist >>= 7; dist != 0; dist >>= 7 { - out = append(out, byte(((dist-1)&0x7f)|0x80)) - } - for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { - out[i], out[j] = out[j], out[i] - } - return out -} - -func setupRepoConfig(t *testing.T, root string) { - var algo string - switch testHashSize { - case sha1.Size: - algo = "sha1" - case sha256.Size: - algo = "sha256" - default: - t.Fatalf("unsupported testHashSize: %d", testHashSize) - } - - cfg := fmt.Sprintf(` -[core] - repositoryformatversion = 0 -[extensions] - objectformat = %s -`, algo) - - err := os.WriteFile(filepath.Join(root, "config"), []byte(cfg), 0o644) - if err != nil { - t.Fatalf("write config: %v", err) + if err := repo.Close(); err != nil { + t.Fatalf("second Close failed: %v", err) } } diff --git a/testutil_sha1_test.go b/testutil_sha1_test.go new file mode 100644 index 00000000..01cdc746 --- /dev/null +++ b/testutil_sha1_test.go @@ -0,0 +1,29 @@ +//go:build sha1 + +package furgit + +import ( + "os" + "os/exec" + "testing" +) + +func setupTestRepo(t *testing.T) (string, func()) { + t.Helper() + tempDir, err := os.MkdirTemp("", "furgit-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + cleanup := func() { + _ = os.RemoveAll(tempDir) + } + + cmd := exec.Command("git", "init", "--object-format=sha1", "--bare", tempDir) + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if output, err := cmd.CombinedOutput(); err != nil { + cleanup() + t.Fatalf("failed to init git repo: %v\n%s", err, output) + } + + return tempDir, cleanup +} diff --git a/testutil_sha256_test.go b/testutil_sha256_test.go new file mode 100644 index 00000000..70ed6f19 --- /dev/null +++ b/testutil_sha256_test.go @@ -0,0 +1,29 @@ +//go:build !sha1 + +package furgit + +import ( + "os" + "os/exec" + "testing" +) + +func setupTestRepo(t *testing.T) (string, func()) { + t.Helper() + tempDir, err := os.MkdirTemp("", "furgit-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + cleanup := func() { + _ = os.RemoveAll(tempDir) + } + + cmd := exec.Command("git", "init", "--object-format=sha256", "--bare", tempDir) + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if output, err := cmd.CombinedOutput(); err != nil { + cleanup() + t.Fatalf("failed to init git repo: %v\n%s", err, output) + } + + return tempDir, cleanup +} diff --git a/testutil_test.go b/testutil_test.go new file mode 100644 index 00000000..bca6db14 --- /dev/null +++ b/testutil_test.go @@ -0,0 +1,67 @@ +package furgit + +import ( + "bytes" + "os" + "os/exec" + "strings" + "testing" +) + +func setupWorkDir(t *testing.T) (string, func()) { + t.Helper() + workDir, err := os.MkdirTemp("", "furgit-work-*") + if err != nil { + t.Fatalf("failed to create work dir: %v", err) + } + return workDir, func() { _ = os.RemoveAll(workDir) } +} + +func gitCmd(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_AUTHOR_NAME=Test Author", + "GIT_AUTHOR_EMAIL=test@example.org", + "GIT_COMMITTER_NAME=Test Committer", + "GIT_COMMITTER_EMAIL=committer@example.org", + "GIT_AUTHOR_DATE=1234567890 +0000", + "GIT_COMMITTER_DATE=1234567890 +0000", + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, output) + } + return strings.TrimSpace(string(output)) +} + +func gitHashObject(t *testing.T, dir, objType string, data []byte) string { + t.Helper() + cmd := exec.Command("git", "hash-object", "-t", objType, "-w", "--stdin") + cmd.Dir = dir + cmd.Stdin = bytes.NewReader(data) + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git hash-object failed: %v\n%s", err, output) + } + return strings.TrimSpace(string(output)) +} + +func gitCatFile(t *testing.T, dir, objType, hash string) []byte { + t.Helper() + cmd := exec.Command("git", "cat-file", objType, hash) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git cat-file %s %s failed: %v\n%s", objType, hash, err, output) + } + if objType == "-t" || objType == "-s" { + return bytes.TrimSpace(output) + } + return output +} |
