aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Runxi Yu2025-11-16 00:00:00 +0000
committerGravatar Runxi Yu2025-11-16 00:00:00 +0000
commitbad0f9715556a470d0de2a22c7040181e3a033ba (patch)
tree21463072ce5bc85682a887ce0cae26d833941af3
parentEntryRecursive should return ErrNotFound instead of nil, nil (diff)
signature
Use actual git for tests and enhance Head
-rw-r--r--.build.yml6
-rw-r--r--config/config_test.go500
-rw-r--r--errors_test.go17
-rw-r--r--hash_sha1_test.go9
-rw-r--r--hash_sha256_test.go9
-rw-r--r--hash_test.go79
-rw-r--r--hybrid_test.go271
-rw-r--r--ident_test.go123
-rw-r--r--obj_blob_test.go120
-rw-r--r--obj_commit_test.go188
-rw-r--r--obj_tag_test.go191
-rw-r--r--obj_test.go52
-rw-r--r--obj_tree.go20
-rw-r--r--obj_tree_test.go296
-rw-r--r--objects_test.go212
-rw-r--r--pack_test.go330
-rw-r--r--refs.go48
-rw-r--r--refs_test.go161
-rw-r--r--repo_current_test.go90
-rw-r--r--repo_test.go537
-rw-r--r--testutil_sha1_test.go29
-rw-r--r--testutil_sha256_test.go29
-rw-r--r--testutil_test.go67
23 files changed, 2105 insertions, 1279 deletions
diff --git a/.build.yml b/.build.yml
index 559f1108..c00ed9c2 100644
--- a/.build.yml
+++ b/.build.yml
@@ -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)
}
}
diff --git a/refs.go b/refs.go
index b1824251..c0737563 100644
--- a/refs.go
+++ b/refs.go
@@ -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
+}