From b5974cfa453d3f9a71a1647b40ef77b76079ff65 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Wed, 25 Mar 2026 15:29:36 -0300 Subject: [PATCH] add nip34/git-natural-api, using the same approach as https://jsr.io/@fiatjaf/git-natural-api. --- go.mod | 1 - go.sum | 2 - nip34/gitnaturalapi/commits.go | 106 +++++++ nip34/gitnaturalapi/diff.go | 413 ++++++++++++++++++++++++++ nip34/gitnaturalapi/lib.go | 264 ++++++++++++++++ nip34/gitnaturalapi/lib_test.go | 289 ++++++++++++++++++ nip34/gitnaturalapi/packs.go | 154 ++++++++++ nip34/gitnaturalapi/parse_packfile.go | 307 +++++++++++++++++++ nip34/gitnaturalapi/refs.go | 120 ++++++++ nip34/gitnaturalapi/tree.go | 101 +++++++ 10 files changed, 1754 insertions(+), 3 deletions(-) create mode 100644 nip34/gitnaturalapi/commits.go create mode 100644 nip34/gitnaturalapi/diff.go create mode 100644 nip34/gitnaturalapi/lib.go create mode 100644 nip34/gitnaturalapi/lib_test.go create mode 100644 nip34/gitnaturalapi/packs.go create mode 100644 nip34/gitnaturalapi/parse_packfile.go create mode 100644 nip34/gitnaturalapi/refs.go create mode 100644 nip34/gitnaturalapi/tree.go diff --git a/go.mod b/go.mod index 4baecdc..95864b5 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,6 @@ require ( ) require ( - fiatjaf.com/lib v0.3.6 github.com/dgraph-io/ristretto/v2 v2.3.0 github.com/go-git/go-git/v5 v5.16.3 github.com/sivukhin/godjot v1.0.6 diff --git a/go.sum b/go.sum index 788c24b..b4ececd 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY= -fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8= github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc= github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw= github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc= diff --git a/nip34/gitnaturalapi/commits.go b/nip34/gitnaturalapi/commits.go new file mode 100644 index 0000000..b7439f5 --- /dev/null +++ b/nip34/gitnaturalapi/commits.go @@ -0,0 +1,106 @@ +package gitnaturalapi + +import ( + "fmt" + "strconv" + "strings" +) + +type Person struct { + Name string + Email string + Timestamp int64 + Timezone string +} + +type Commit struct { + Hash string + Tree string + Parents []string + Author Person + Committer Person + Message string +} + +func ParseCommit(data []byte, hash string) (*Commit, error) { + content := string(data) + + headerEndIndex := strings.Index(content, "\n\n") + if headerEndIndex == -1 { + return nil, fmt.Errorf("invalid commit format for %s: no message separator found", hash) + } + + header := content[:headerEndIndex] + message := content[headerEndIndex+2:] + + lines := strings.Split(header, "\n") + result := &Commit{ + Hash: hash, + Parents: []string{}, + Message: message, + } + + for _, line := range lines { + if strings.HasPrefix(line, "tree ") { + result.Tree = line[5:] + } else if strings.HasPrefix(line, "parent ") { + result.Parents = append(result.Parents, line[7:]) + } else if strings.HasPrefix(line, "author ") { + person, err := parsePerson(line[7:]) + if err != nil { + return nil, fmt.Errorf("invalid author in commit %s: %w", hash, err) + } + result.Author = person + } else if strings.HasPrefix(line, "committer ") { + person, err := parsePerson(line[10:]) + if err != nil { + return nil, fmt.Errorf("invalid committer in commit %s: %w", hash, err) + } + result.Committer = person + } + } + + if result.Tree == "" { + return nil, fmt.Errorf("invalid commit format for %s: missing tree", hash) + } + if result.Author.Name == "" { + return nil, fmt.Errorf("invalid commit format for %s: missing author", hash) + } + if result.Committer.Name == "" { + return nil, fmt.Errorf("invalid commit format for %s: missing committer", hash) + } + + return result, nil +} + +func parsePerson(line string) (Person, error) { + emailStart := strings.Index(line, " <") + if emailStart == -1 { + return Person{}, fmt.Errorf("invalid person format: %s", line) + } + name := line[:emailStart] + + emailEnd := strings.Index(line[emailStart+2:], ">") + if emailEnd == -1 { + return Person{}, fmt.Errorf("invalid person format: %s", line) + } + email := line[emailStart+2 : emailStart+2+emailEnd] + + rest := strings.TrimSpace(line[emailStart+2+emailEnd+1:]) + parts := strings.SplitN(rest, " ", 2) + if len(parts) != 2 { + return Person{}, fmt.Errorf("invalid person format: %s", line) + } + + timestamp, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return Person{}, fmt.Errorf("invalid timestamp in person: %s", parts[0]) + } + + return Person{ + Name: name, + Email: email, + Timestamp: timestamp, + Timezone: parts[1], + }, nil +} diff --git a/nip34/gitnaturalapi/diff.go b/nip34/gitnaturalapi/diff.go new file mode 100644 index 0000000..894d4fe --- /dev/null +++ b/nip34/gitnaturalapi/diff.go @@ -0,0 +1,413 @@ +package gitnaturalapi + +import ( + "strings" + "sync" +) + +type DiffLine struct { + Index int + Status string + Text string + Change string +} + +type DiffFile struct { + Path string + Status string + Content []byte + Lines []DiffLine +} + +type changedEntry struct { + newVersion TreeEntry + oldVersions []TreeEntry +} + +func GetCommitDiff(url string, commitOrRef string) ([]DiffFile, error) { + commit, err := GetSingleCommit(url, commitOrRef) + if err != nil { + return nil, err + } + + added := make(map[string]TreeEntry) + deleted := make(map[string]TreeEntry) + changed := make(map[string]*changedEntry) + unchanged := make(map[string]bool) + + for _, parent := range commit.Parents { + parentCommit, err := GetSingleCommit(url, parent) + if err != nil { + return nil, err + } + err = computeTreeDiffs(url, commit.Tree, parentCommit.Tree, "", added, deleted, changed, unchanged) + if err != nil { + return nil, err + } + } + + var diff []DiffFile + var mu sync.Mutex + var wg sync.WaitGroup + var firstErr error + + for path, entry := range changed { + p := path + e := entry + wg.Add(1) + go func() { + defer wg.Done() + curr, err := GetObject(url, e.newVersion.Hash) + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + return + } + if curr == nil { + return + } + + if len(e.oldVersions) == 0 { + return + } + + oldObj, err := GetObject(url, e.oldVersions[0].Hash) + if err != nil || oldObj == nil { + return + } + + if isBinary(curr.Data) || isBinary(oldObj.Data) { + mu.Lock() + diff = append(diff, DiffFile{ + Path: p, + Status: "changed-binary", + }) + mu.Unlock() + return + } + + lines := diffTextLines(oldObj.Data, curr.Data) + mu.Lock() + diff = append(diff, DiffFile{ + Path: p, + Status: "changed", + Lines: lines, + }) + mu.Unlock() + }() + } + + for path, entry := range deleted { + p := path + e := entry + wg.Add(1) + go func() { + defer wg.Done() + obj, err := GetObject(url, e.Hash) + if err != nil || obj == nil { + return + } + mu.Lock() + diff = append(diff, DiffFile{ + Path: p, + Status: "deleted", + Content: obj.Data, + }) + mu.Unlock() + }() + } + + for path, entry := range added { + p := path + e := entry + wg.Add(1) + go func() { + defer wg.Done() + obj, err := GetObject(url, e.Hash) + if err != nil || obj == nil { + return + } + mu.Lock() + diff = append(diff, DiffFile{ + Path: p, + Status: "added", + Content: obj.Data, + }) + mu.Unlock() + }() + } + + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + + return diff, nil +} + +func isBinary(data []byte) bool { + for _, b := range data { + if b == 0 { + return true + } + } + return false +} + +func diffTextLines(oldData []byte, newData []byte) []DiffLine { + oldText := string(oldData) + newText := string(newData) + oldLines := splitLines(oldText) + newLines := splitLines(newText) + + ops := lcsOperations(oldLines, newLines) + allLines := make([]DiffLine, 0, len(ops)) + oldIndex := 1 + newIndex := 1 + + for i := 0; i < len(ops); i++ { + op := ops[i] + var next *lcsOp + if i+1 < len(ops) { + next = &ops[i+1] + } + + if op.typ == "del" && next != nil && next.typ == "add" { + allLines = append(allLines, DiffLine{ + Status: "changed", + Index: newIndex, + Text: next.line, + }) + oldIndex++ + newIndex++ + i++ + continue + } + + if op.typ == "add" && next != nil && next.typ == "del" { + allLines = append(allLines, DiffLine{ + Status: "changed", + Index: newIndex, + Text: op.line, + }) + oldIndex++ + newIndex++ + i++ + continue + } + + if op.typ == "add" { + allLines = append(allLines, DiffLine{ + Status: "added", + Index: newIndex, + Text: op.line, + }) + newIndex++ + continue + } + + if op.typ == "del" { + allLines = append(allLines, DiffLine{ + Status: "deleted", + Index: oldIndex, + Text: op.line, + }) + oldIndex++ + continue + } + + oldIndex++ + newIndex++ + } + + if len(allLines) == 0 { + return allLines + } + + keep := make([]bool, len(allLines)) + for i := 0; i < len(allLines); i++ { + start := i - 3 + if start < 0 { + start = 0 + } + end := i + 3 + if end >= len(allLines) { + end = len(allLines) - 1 + } + for j := start; j <= end; j++ { + keep[j] = true + } + } + + result := make([]DiffLine, 0, len(allLines)) + for i := 0; i < len(allLines); i++ { + if keep[i] { + result = append(result, allLines[i]) + } + } + + return result +} + +type lcsOp struct { + typ string + line string +} + +func splitLines(text string) []string { + lines := strings.Split(text, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +func lcsOperations(oldLines []string, newLines []string) []lcsOp { + n := len(oldLines) + m := len(newLines) + + dp := make([][]uint32, n+1) + for i := range dp { + dp[i] = make([]uint32, m+1) + } + + for i := 1; i <= n; i++ { + for j := 1; j <= m; j++ { + if oldLines[i-1] == newLines[j-1] { + dp[i][j] = dp[i-1][j-1] + 1 + } else if dp[i-1][j] >= dp[i][j-1] { + dp[i][j] = dp[i-1][j] + } else { + dp[i][j] = dp[i][j-1] + } + } + } + + ops := make([]lcsOp, 0, n+m) + i := n + j := m + for i > 0 || j > 0 { + if i > 0 && j > 0 && oldLines[i-1] == newLines[j-1] { + ops = append(ops, lcsOp{typ: "equal", line: oldLines[i-1]}) + i-- + j-- + continue + } + + if i > 0 && (j == 0 || dp[i-1][j] >= dp[i][j-1]) { + ops = append(ops, lcsOp{typ: "del", line: oldLines[i-1]}) + i-- + continue + } + + if j > 0 { + ops = append(ops, lcsOp{typ: "add", line: newLines[j-1]}) + j-- + } + } + + for i, j := 0, len(ops)-1; i < j; i, j = i+1, j-1 { + ops[i], ops[j] = ops[j], ops[i] + } + + return ops +} + +func computeTreeDiffs( + url string, + treeHash string, + parentTreeHash string, + basePath string, + added map[string]TreeEntry, + deleted map[string]TreeEntry, + changed map[string]*changedEntry, + unchanged map[string]bool, +) error { + var newTree []TreeEntry + var oldTree []TreeEntry + + if treeHash != "" { + obj, err := GetObject(url, treeHash) + if err != nil { + return err + } + if obj != nil { + newTree = ParseTree(obj.Data) + } + } + + if parentTreeHash != "" { + obj, err := GetObject(url, parentTreeHash) + if err != nil { + return err + } + if obj != nil { + oldTree = ParseTree(obj.Data) + } + } + + for _, entry := range newTree { + var old *TreeEntry + for _, o := range oldTree { + if o.Path == entry.Path { + o := o + old = &o + break + } + } + + if old != nil { + delete(added, basePath+entry.Path) + + if old.Hash == entry.Hash { + unchanged[basePath+entry.Path] = true + } else { + if entry.IsDir { + err := computeTreeDiffs(url, entry.Hash, old.Hash, basePath+entry.Path+"/", added, deleted, changed, unchanged) + if err != nil { + return err + } + } else { + if existing, exists := changed[basePath+entry.Path]; !exists { + changed[basePath+entry.Path] = &changedEntry{ + newVersion: entry, + oldVersions: []TreeEntry{*old}, + } + } else { + existing.oldVersions = append(existing.oldVersions, *old) + } + } + } + } else { + if entry.IsDir { + err := computeTreeDiffs(url, entry.Hash, "", basePath+entry.Path+"/", added, deleted, changed, unchanged) + if err != nil { + return err + } + } else { + added[basePath+entry.Path] = entry + } + } + } + + for _, old := range oldTree { + if unchanged[basePath+old.Path] || changed[basePath+old.Path] != nil { + continue + } + + if old.IsDir { + err := computeTreeDiffs(url, "", old.Hash, basePath+old.Path+"/", added, deleted, changed, unchanged) + if err != nil { + return err + } + } else { + deleted[basePath+old.Path] = old + } + } + + return nil +} diff --git a/nip34/gitnaturalapi/lib.go b/nip34/gitnaturalapi/lib.go new file mode 100644 index 0000000..77407b2 --- /dev/null +++ b/nip34/gitnaturalapi/lib.go @@ -0,0 +1,264 @@ +package gitnaturalapi + +import ( + "fmt" + "slices" + "strings" +) + +type MissingCapability struct { + URL string + Capability string +} + +func (e *MissingCapability) Error() string { + return fmt.Sprintf("server at %s is missing required capability %s", e.URL, e.Capability) +} + +func prepareRequest(url string, commitOrRef string, needFilter bool) (resolvedRef string, capabilities []string, err error) { + var info *InfoRefsUploadPackResponse + if strings.HasPrefix(commitOrRef, "refs/") { + info, err = GetInfoRefs(url) + if err != nil { + return "", nil, err + } + resolved, ok := info.Refs[commitOrRef] + if !ok { + return "", nil, fmt.Errorf("ref %s not found", commitOrRef) + } + commitOrRef = resolved + } + + caps, err := GetCapabilities(url, info) + if err != nil { + return "", nil, err + } + + for _, c := range DefaultCapabilities { + if slices.Contains(caps, c) { + capabilities = append(capabilities, c) + } + } + for _, c := range NecessaryCapabilities { + if slices.Contains(caps, c) { + capabilities = append(capabilities, c) + } else { + return "", nil, &MissingCapability{URL: url, Capability: c} + } + } + for _, c := range RequiredCapabilities { + if !slices.Contains(caps, c) { + return "", nil, &MissingCapability{URL: url, Capability: c} + } + } + if needFilter { + if slices.Contains(caps, "filter") { + capabilities = append(capabilities, "filter") + } else { + return "", nil, &MissingCapability{URL: url, Capability: "filter"} + } + } + + return commitOrRef, capabilities, nil +} + +func GetObject(url string, blobHash string) (*ParsedObject, error) { + ref, caps, err := prepareRequest(url, blobHash, false) + if err != nil { + return nil, err + } + + deepen := 1 + want, err := CreateWantRequest(ref, caps, &deepen, "") + if err != nil { + return nil, err + } + + result, err := FetchPackfile(url, want) + if err != nil { + return nil, err + } + + return result.Objects[blobHash], nil +} + +func GetDirectoryTreeAt(url string, commitOrRef string, nestLimit *int) (*Tree, error) { + ref, caps, err := prepareRequest(url, commitOrRef, true) + if err != nil { + return nil, err + } + + want, err := CreateWantRequest(ref, caps, nestLimit, "blob:none") + if err != nil { + return nil, err + } + + result, err := FetchPackfile(url, want) + if err != nil { + return nil, err + } + + commit := result.Objects[ref] + if commit == nil { + return nil, fmt.Errorf("commit %s not found in packfile", ref) + } + + treeHash := string(commit.Data[5:45]) + rootTree := result.Objects[treeHash] + if rootTree == nil { + return nil, fmt.Errorf("root tree %s not found in packfile", treeHash) + } + + return LoadTree(rootTree, result.Objects, nestLimit), nil +} + +func ShallowCloneRepositoryAt(url string, commitOrRef string) (*Commit, *Tree, error) { + ref, caps, err := prepareRequest(url, commitOrRef, false) + if err != nil { + return nil, nil, err + } + + deepen := 1 + want, err := CreateWantRequest(ref, caps, &deepen, "") + if err != nil { + return nil, nil, err + } + + result, err := FetchPackfile(url, want) + if err != nil { + return nil, nil, err + } + + commitObj := result.Objects[ref] + if commitObj == nil { + return nil, nil, fmt.Errorf("commit %s not found in packfile", ref) + } + + treeHash := string(commitObj.Data[5:45]) + rootTree := result.Objects[treeHash] + if rootTree == nil { + return nil, nil, fmt.Errorf("root tree %s not found in packfile", treeHash) + } + + commit, err := ParseCommit(commitObj.Data, commitObj.Hash) + if err != nil { + return nil, nil, err + } + + tree := LoadTree(rootTree, result.Objects, nil) + return commit, tree, nil +} + +func FetchCommitsOnly(url string, commitOrRef string, maxCommits *int) ([]*Commit, error) { + ref, caps, err := prepareRequest(url, commitOrRef, true) + if err != nil { + return nil, err + } + + want, err := CreateWantRequest(ref, caps, maxCommits, "tree:0") + if err != nil { + return nil, err + } + + result, err := FetchPackfile(url, want) + if err != nil { + return nil, err + } + + commitMap := make(map[string]*Commit, len(result.Objects)) + for hash, obj := range result.Objects { + commit, err := ParseCommit(obj.Data, hash) + if err != nil { + return nil, err + } + commitMap[hash] = commit + } + + // sort topologically starting from the requested ref + sorted := make([]*Commit, 0, len(commitMap)) + visited := make(map[string]bool, len(commitMap)) + var visit func(hash string) + visit = func(hash string) { + if visited[hash] { + return + } + c, ok := commitMap[hash] + if !ok { + return + } + visited[hash] = true + sorted = append(sorted, c) + for _, parent := range c.Parents { + visit(parent) + } + } + visit(ref) + + for _, c := range commitMap { + if !visited[c.Hash] { + sorted = append(sorted, c) + } + } + + return sorted, nil +} + +func GetSingleCommit(url string, commitOrRef string) (*Commit, error) { + maxCommits := 1 + commits, err := FetchCommitsOnly(url, commitOrRef, &maxCommits) + if err != nil { + return nil, err + } + if len(commits) == 0 { + return nil, fmt.Errorf("no commit found for reference: %s", commitOrRef) + } + return commits[0], nil +} + +func GetObjectByPath(url string, commitOrRef string, path string) (*TreeEntry, error) { + normalizedPath := strings.ReplaceAll(path, "\\", "/") + normalizedPath = strings.TrimLeft(normalizedPath, "/") + normalizedPath = strings.TrimRight(normalizedPath, "/") + + var pathSegments []string + if normalizedPath != "" { + pathSegments = strings.Split(normalizedPath, "/") + } + requiredDepth := len(pathSegments) + + tree, err := GetDirectoryTreeAt(url, commitOrRef, &requiredDepth) + if err != nil { + return nil, err + } + + currentLevel := tree +nextSegment: + for i, segment := range pathSegments { + isLastSegment := i == len(pathSegments)-1 + + for _, dir := range currentLevel.Directories { + if dir.Name == segment { + if isLastSegment { + return &TreeEntry{Path: segment, Mode: "40000", IsDir: true, Hash: dir.Hash}, nil + } + if dir.Content != nil { + currentLevel = dir.Content + continue nextSegment + } + return nil, nil + } + } + + if isLastSegment { + for _, file := range currentLevel.Files { + if file.Name == segment { + return &TreeEntry{Path: segment, Mode: "100644", IsDir: false, Hash: file.Hash}, nil + } + } + } + + return nil, nil + } + + return nil, nil +} diff --git a/nip34/gitnaturalapi/lib_test.go b/nip34/gitnaturalapi/lib_test.go new file mode 100644 index 0000000..398c262 --- /dev/null +++ b/nip34/gitnaturalapi/lib_test.go @@ -0,0 +1,289 @@ +package gitnaturalapi + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetRefs(t *testing.T) { + info, err := GetInfoRefs("https://codeberg.org/dluvian/gitplaza.git") + require.NoError(t, err) + + require.Contains(t, info.Capabilities, "shallow") + require.Contains(t, info.Capabilities, "object-format=sha1") + require.Greater(t, len(info.Refs), 5) + require.Contains(t, info.Refs, "refs/heads/master") + require.Equal(t, "a04d0761564b0d23c5edbadf494ab4f1cc4656f4", info.Refs["refs/tags/v0.1.0"]) + require.Equal(t, "refs/heads/master", info.Symrefs["HEAD"]) +} + +func TestGetOnlyTreeAtCurrentCommit(t *testing.T) { + urls := []string{ + "https://codeberg.org/dluvian/gitplaza.git", + "https://github.com/fiatjaf/pyramid.git", + "https://pyramid.fiatjaf.com/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/nostrlib.git", + } + + for _, url := range urls { + t.Run(url, func(t *testing.T) { + tree, err := GetDirectoryTreeAt(url, "refs/heads/master", nil) + require.NoError(t, err) + for _, file := range tree.Files { + require.Nil(t, file.Content, "file %q should have nil content at %s", file.Name, url) + } + require.Greater(t, len(tree.Directories), 2, "at %s", url) + }) + } +} + +func TestCloneRepositoryAtCurrentCommit(t *testing.T) { + urls := []string{ + "https://codeberg.org/dluvian/gitplaza.git", + "https://github.com/fiatjaf/pyramid.git", + "https://pyramid.fiatjaf.com/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/nostrlib.git", + } + + for _, url := range urls { + t.Run(url, func(t *testing.T) { + commit, tree, err := ShallowCloneRepositoryAt(url, "refs/heads/master") + require.NoError(t, err) + require.Greater(t, len(tree.Files), 5, "at %s", url) + require.Greater(t, len(tree.Directories), 2, "at %s", url) + + info, err := GetInfoRefs(url) + require.NoError(t, err) + require.Equal(t, info.Refs["refs/heads/master"], commit.Hash, "at %s", url) + }) + } +} + +func TestGetSpecificObject(t *testing.T) { + url := "https://codeberg.org/dluvian/gitplaza.git" + hash := "0f9438a8fd68594cd663fb8dbd23c5f5139f5263" // shell.nix + + blob, err := GetObject(url, hash) + require.NoError(t, err) + require.NotNil(t, blob) + require.Equal(t, ObjectTypeBlob, blob.Type) + + expected := "(builtins.getFlake\n (\"git+file://\" + toString ./.)).devShells.${builtins.currentSystem}.default\n" + require.Equal(t, expected, string(blob.Data)) +} + +func TestGetNonExistentCommit(t *testing.T) { + urls := []string{ + "https://codeberg.org/dluvian/gitplaza.git", + "https://pyramid.fiatjaf.com/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/nostrlib.git", + "https://github.com/fiatjaf/nak.git", + } + + commit := "1d4438a8fd68594cd663fb8dbd23c5f5139fabcd" // doesn't exist + + for _, url := range urls { + t.Run(url, func(t *testing.T) { + _, err := GetDirectoryTreeAt(url, commit, nil) + require.Error(t, err) + var missingRef *MissingRef + require.ErrorAs(t, err, &missingRef) + }) + } +} + +func TestFetchListOfCommits(t *testing.T) { + commits, err := FetchCommitsOnly( + "https://pyramid.fiatjaf.com/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/nostrlib.git", + "refs/heads/master", + nil, + ) + require.NoError(t, err) + require.Greater(t, len(commits), 10) +} + +func TestFetch10PastCommits(t *testing.T) { + maxCommits := 10 + commits, err := FetchCommitsOnly( + "https://github.com/fiatjaf/pyramid.git", + "57712756e37d7c60d1ac53e0f6b59e9ecad67c9a", + &maxCommits, + ) + require.NoError(t, err) + require.Len(t, commits, 10) + + c := commits[1] + require.Equal(t, "49c1b48f5120bad4089535a190d2233c96188fa2", c.Hash) + require.Equal(t, "286786a6f1072a2ef5ae057fbb611858b8e88bc4", c.Tree) + require.Equal(t, []string{"1599e46c0ee6f460e25048880754868d4f9644fd"}, c.Parents) + require.Equal(t, "fiatjaf", c.Author.Name) + require.Equal(t, "fiatjaf@gmail.com", c.Author.Email) + require.Equal(t, int64(1767157644), c.Author.Timestamp) + require.Equal(t, "-0300", c.Author.Timezone) + require.Equal(t, "fiatjaf", c.Committer.Name) + require.Equal(t, "fiatjaf@gmail.com", c.Committer.Email) + require.Equal(t, int64(1767157724), c.Committer.Timestamp) + require.Equal(t, "-0300", c.Committer.Timezone) + require.Equal(t, "scheduled events.\n", c.Message) + + expectedMsg5 := "turn off groups logic on QueryStore and PreventBroadcast when groups is turned off.\n\nthis was causing crashes that Golang's bizarre iter API showed as happening inside SortedMerge.\n" + require.Equal(t, expectedMsg5, commits[5].Message) +} + +func TestGetSingleCommit(t *testing.T) { + url := "https://github.com/fiatjaf/pyramid.git" + commit, err := GetSingleCommit(url, "5e982dd1122a0bb1b0154c222ec4ba841f3820c6") + require.NoError(t, err) + + require.Equal(t, "5e982dd1122a0bb1b0154c222ec4ba841f3820c6", commit.Hash) + require.Equal(t, "fiatjaf", commit.Author.Name) + require.Equal(t, "validate incoming git-related stuff.\n", commit.Message) +} + +func TestGetDirectoryTreeWithDepthLimit(t *testing.T) { + url := "https://github.com/fiatjaf/pyramid.git" + + fullTree, err := GetDirectoryTreeAt(url, "refs/heads/master", nil) + require.NoError(t, err) + + depth := 1 + shallowTree, err := GetDirectoryTreeAt(url, "refs/heads/master", &depth) + require.NoError(t, err) + + require.Equal(t, len(fullTree.Directories), len(shallowTree.Directories)) + + for _, dir := range shallowTree.Directories { + require.NotNil(t, dir.Content, "directory %q content should not be nil at depth 1", dir.Name) + for _, file := range dir.Content.Files { + require.NotEmpty(t, file.Name) + require.Nil(t, file.Content, "file %q content should be nil", file.Name) + } + for _, subdir := range dir.Content.Directories { + require.NotEmpty(t, subdir.Name) + require.Nil(t, subdir.Content, "subdir %q content should be nil at depth 1", subdir.Name) + } + } + + require.Equal(t, len(fullTree.Files), len(shallowTree.Files)) +} + +func TestGetObjectByPathExistingFile(t *testing.T) { + url := "https://codeberg.org/dluvian/gitplaza.git" + entry, err := GetObjectByPath(url, "refs/heads/master", "README.md") + require.NoError(t, err) + require.NotNil(t, entry) + require.Equal(t, "README.md", entry.Path) + require.False(t, entry.IsDir) + require.Equal(t, "100644", entry.Mode) + require.NotEmpty(t, entry.Hash) +} + +func TestGetObjectByPathExistingDirectory(t *testing.T) { + url := "https://codeberg.org/dluvian/gitplaza.git" + entry, err := GetObjectByPath(url, "refs/heads/master", "src") + require.NoError(t, err) + require.NotNil(t, entry) + require.Equal(t, "src", entry.Path) + require.True(t, entry.IsDir) + require.Equal(t, "40000", entry.Mode) + require.NotEmpty(t, entry.Hash) +} + +func TestGetObjectByPathNestedFile(t *testing.T) { + url := "https://github.com/fiatjaf/pyramid.git" + entry, err := GetObjectByPath(url, "d567c18cd5c144a58b0214216f454b3caf49d4ff", "grasp/grasp.templ") + require.NoError(t, err) + require.NotNil(t, entry) + require.Equal(t, "grasp.templ", entry.Path) + require.False(t, entry.IsDir) + require.Equal(t, "100644", entry.Mode) + require.Equal(t, "05bce14339ece5f48c670d0592faa8dece9e8957", entry.Hash) +} + +func TestGetObjectByPathNonExistent(t *testing.T) { + url := "https://codeberg.org/dluvian/gitplaza.git" + entry, err := GetObjectByPath(url, "refs/heads/master", "whatever/something/x/y/z/non-existent-file.txt") + require.NoError(t, err) + require.Nil(t, entry) +} + +func TestGetCommitDiff(t *testing.T) { + url := "https://github.com/smallhelm/diff-lines.git" + diff, err := GetCommitDiff(url, "a73592653fe9d01f948ca3035e088e45f722eca7") + require.NoError(t, err) + require.NotNil(t, diff) + require.Len(t, diff, 5) + + byPath := make(map[string]DiffFile, len(diff)) + for _, f := range diff { + byPath[f.Path] = f + } + + for _, tc := range []struct { + path string + status string + }{ + {".travis.yml", "added"}, + {".gitignore", "added"}, + {"index.js", "added"}, + {"tests.js", "added"}, + {"package.json", "changed"}, + } { + f, ok := byPath[tc.path] + require.True(t, ok, "missing diff file %q", tc.path) + require.Equal(t, tc.status, f.Status, "%s status", tc.path) + } + + gitignore, ok := byPath[".gitignore"] + require.True(t, ok, "missing .gitignore in diff") + require.Equal(t, "/node_modules\n", string(gitignore.Content)) + + pkg, ok := byPath["package.json"] + require.True(t, ok, "missing package.json in diff") + require.NotEmpty(t, pkg.Lines, "package.json should have diff lines") + + normalizeLineStatus := func(status string) string { + if status == "same" { + return "changed" + } + return status + } + + lineByIndex := make(map[int]DiffLine, len(pkg.Lines)) + for _, line := range pkg.Lines { + lineByIndex[line.Index] = line + } + + expectedLines := []struct { + Index int + Status string + Text string + }{ + {Index: 22, Status: "added", Text: " },"}, + {Index: 23, Status: "added", Text: " \"homepage\": \"https://github.com/smallhelm/diff-lines#readme\","}, + {Index: 24, Status: "added", Text: " \"devDependencies\": {"}, + {Index: 25, Status: "added", Text: " \"tape\": \"^4.6.0\""}, + {Index: 27, Status: "added", Text: " \"dependencies\": {"}, + {Index: 28, Status: "added", Text: " \"diff\": \"^2.2.3\""}, + {Index: 29, Status: "changed", Text: " }"}, + } + + actualLines := make([]struct { + Index int + Status string + Text string + }, 0, len(expectedLines)) + for _, expected := range expectedLines { + line, ok := lineByIndex[expected.Index] + require.True(t, ok, "missing package.json diff line %d", expected.Index) + actualLines = append(actualLines, struct { + Index int + Status string + Text string + }{ + Index: line.Index, + Status: normalizeLineStatus(line.Status), + Text: line.Text, + }) + } + + require.Equal(t, expectedLines, actualLines) +} diff --git a/nip34/gitnaturalapi/packs.go b/nip34/gitnaturalapi/packs.go new file mode 100644 index 0000000..4aafd36 --- /dev/null +++ b/nip34/gitnaturalapi/packs.go @@ -0,0 +1,154 @@ +package gitnaturalapi + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +var NecessaryCapabilities = []string{ + "multi_ack_detailed", + "side-band-64k", +} + +var RequiredCapabilities = []string{ + "shallow", + "object-format=sha1", +} + +var DefaultCapabilities = []string{ + "ofs-delta", + "no-progress", +} + +type MissingRef struct{} + +func (e *MissingRef) Error() string { return "missing ref" } + +type InvalidCommit struct { + Commit string +} + +func (e *InvalidCommit) Error() string { + return fmt.Sprintf("invalid commit '%s', must be 20 byte hex", e.Commit) +} + +func FetchPackfile(url string, want string) (*PackfileResult, error) { + req, err := http.NewRequest("POST", url+"/git-upload-pack", strings.NewReader(want)) + if err != nil { + return nil, fmt.Errorf("failed to create git-upload-pack request: %w", err) + } + req.Header.Set("Content-Type", "application/x-git-upload-pack-request") + req.Header.Set("Accept", "application/x-git-upload-pack-result") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call git-upload-pack: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to call git-upload-pack: %s", string(body)) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read git-upload-pack response: %w", err) + } + + if len(data) == 0 { + return nil, fmt.Errorf("empty response") + } + + offset := 0 + for offset < len(data) { + prev := offset + if prev+1 >= len(data) { + break + } + nlIdx := bytes.IndexByte(data[prev+1:], '\n') + if nlIdx == -1 { + if len(data) >= 32 && string(data[4:32]) == "ERR upload-pack: not our ref" { + return nil, &MissingRef{} + } + end := len(data) + if end > 63 { + end = 63 + } + return nil, fmt.Errorf("unexpected '%s'", string(data[:end])) + } + offset = prev + nlIdx + 1 + if offset >= 3 && string(data[offset-3:offset]) == "NAK" { + break + } + } + offset++ + + var packfileData []byte + for offset < len(data) { + if offset+5 > len(data) { + break + } + pktLen, err := strconv.ParseInt(string(data[offset:offset+4]), 16, 32) + if err != nil { + break + } + length := int(pktLen) + if length == 0 { + break + } + if offset+length > len(data) { + break + } + if data[offset+4] == 2 { + // progress message, ignore + } else if data[offset+4] == 1 { + packfileData = append(packfileData, data[offset+5:offset+length]...) + } + offset += length + } + + if len(packfileData) == 0 { + return nil, &MissingRef{} + } + + return ParsePackfile(packfileData) +} + +func CreateWantRequest(commitSha string, capabilities []string, deepen *int, filter string) (string, error) { + if len(commitSha) != 40 { + return "", &InvalidCommit{Commit: commitSha} + } + + var buf strings.Builder + + wantLine := fmt.Sprintf("want %s %s agent=nsa/1.0.0\n", commitSha, strings.Join(capabilities, " ")) + buf.WriteString(pktEncode(wantLine)) + + if deepen != nil { + deepenLine := fmt.Sprintf("deepen %d\n", *deepen) + buf.WriteString(pktEncode(deepenLine)) + } + + if filter != "" { + filterLine := fmt.Sprintf("filter %s\n", filter) + buf.WriteString(pktEncode(filterLine)) + } + + buf.WriteString("0000") + buf.WriteString(pktEncode("done\n")) + + return buf.String(), nil +} + +func pktEncode(data string) string { + if len(data) == 0 { + return "0000" + } + length := len(data) + 4 + return fmt.Sprintf("%04x%s", length, data) +} diff --git a/nip34/gitnaturalapi/parse_packfile.go b/nip34/gitnaturalapi/parse_packfile.go new file mode 100644 index 0000000..a91d68e --- /dev/null +++ b/nip34/gitnaturalapi/parse_packfile.go @@ -0,0 +1,307 @@ +package gitnaturalapi + +import ( + "bytes" + "compress/zlib" + "crypto/sha1" + "encoding/binary" + "encoding/hex" + "fmt" + "io" +) + +const ( + ObjectTypeCommit = 1 + ObjectTypeTree = 2 + ObjectTypeBlob = 3 + ObjectTypeTag = 4 + ObjectTypeOfsDelta = 6 + ObjectTypeRefDelta = 7 +) + +type ParsedObject struct { + Type int + Size int + Data []byte + Offset int + Hash string +} + +type PackfileResult struct { + Version int + Count int + Objects map[string]*ParsedObject +} + +func ParsePackfile(data []byte) (*PackfileResult, error) { + if len(data) < 12 { + return nil, fmt.Errorf("packfile too short") + } + + header := string(data[0:4]) + if header != "PACK" { + return nil, fmt.Errorf("invalid packfile header: %s", header) + } + + version := int(binary.BigEndian.Uint32(data[4:8])) + if version != 2 { + return nil, fmt.Errorf("unsupported packfile version: %d", version) + } + + count := int(binary.BigEndian.Uint32(data[8:12])) + + objects := make(map[string]*ParsedObject) + pos := 12 + + for i := 0; i < count; i++ { + obj, newPos, err := parsePackObject(data, pos, objects) + if err != nil { + return nil, fmt.Errorf("error parsing object %d/%d: %w", i+1, count, err) + } + objects[obj.Hash] = obj + pos = newPos + } + + return &PackfileResult{ + Version: version, + Count: count, + Objects: objects, + }, nil +} + +func parsePackObject(data []byte, startPos int, objects map[string]*ParsedObject) (*ParsedObject, int, error) { + pos := startPos + offset := startPos + + b := data[pos] + pos++ + objType := int((b >> 4) & 0x07) + size := int(b & 0x0f) + shift := 4 + + for b&0x80 != 0 { + b = data[pos] + pos++ + size |= int(b&0x7f) << shift + shift += 7 + } + + var objData []byte + var err error + + switch objType { + case ObjectTypeOfsDelta: + var actualType int + objData, pos, actualType, err = parseOfsDelta(data, pos, offset, objects) + if err != nil { + return nil, 0, err + } + objType = actualType + case ObjectTypeRefDelta: + var actualType int + objData, pos, actualType, err = parseRefDelta(data, pos, objects) + if err != nil { + return nil, 0, err + } + objType = actualType + case ObjectTypeCommit, ObjectTypeTree, ObjectTypeBlob, ObjectTypeTag: + objData, pos, err = zlibDecompress(data, pos) + if err != nil { + return nil, 0, err + } + default: + return nil, 0, fmt.Errorf("unknown object type: %d", objType) + } + + hash, err := computeObjectHash(objType, objData) + if err != nil { + return nil, 0, err + } + + return &ParsedObject{ + Type: objType, + Size: size, + Data: objData, + Offset: offset, + Hash: hash, + }, pos, nil +} + +func parseOfsDelta(data []byte, pos int, currentOffset int, objects map[string]*ParsedObject) ([]byte, int, int, error) { + b := data[pos] + pos++ + offset := int(b & 0x7f) + + for b&0x80 != 0 { + offset++ + offset <<= 7 + b = data[pos] + pos++ + offset += int(b & 0x7f) + } + + baseOffset := currentOffset - offset + baseObject, _, err := parsePackObject(data, baseOffset, objects) + if err != nil { + return nil, 0, 0, fmt.Errorf("failed to parse base object at offset %d: %w", baseOffset, err) + } + + delta, newPos, err := zlibDecompress(data, pos) + if err != nil { + return nil, 0, 0, err + } + + fullObj, err := applyDelta(delta, baseObject.Data) + if err != nil { + return nil, 0, 0, err + } + + return fullObj, newPos, baseObject.Type, nil +} + +func parseRefDelta(data []byte, pos int, objects map[string]*ParsedObject) ([]byte, int, int, error) { + baseName := hex.EncodeToString(data[pos : pos+20]) + pos += 20 + + delta, newPos, err := zlibDecompress(data, pos) + if err != nil { + return nil, 0, 0, err + } + + baseObject, ok := objects[baseName] + if !ok { + return nil, 0, 0, fmt.Errorf("base object not found with name %s", baseName) + } + + fullObj, err := applyDelta(delta, baseObject.Data) + if err != nil { + return nil, 0, 0, err + } + + return fullObj, newPos, baseObject.Type, nil +} + +func computeObjectHash(objType int, data []byte) (string, error) { + var typeStr string + switch objType { + case ObjectTypeCommit: + typeStr = "commit" + case ObjectTypeTree: + typeStr = "tree" + case ObjectTypeBlob: + typeStr = "blob" + case ObjectTypeTag: + typeStr = "tag" + default: + return "", fmt.Errorf("unknown type when computing object hash: %d", objType) + } + + header := fmt.Sprintf("%s %d\x00", typeStr, len(data)) + h := sha1.New() + h.Write([]byte(header)) + h.Write(data) + return hex.EncodeToString(h.Sum(nil)), nil +} + +func applyDelta(delta []byte, base []byte) ([]byte, error) { + pos := 0 + + _, bytesRead := readVariableInt(delta, pos) + pos += bytesRead + + resultSize, bytesRead := readVariableInt(delta, pos) + pos += bytesRead + + result := make([]byte, resultSize) + resultOffset := 0 + + for pos < len(delta) { + cmd := delta[pos] + pos++ + + if cmd&0x80 != 0 { + var copyOffset, copySize int + + if cmd&0x01 != 0 { + copyOffset = int(delta[pos]) + pos++ + } + if cmd&0x02 != 0 { + copyOffset |= int(delta[pos]) << 8 + pos++ + } + if cmd&0x04 != 0 { + copyOffset |= int(delta[pos]) << 16 + pos++ + } + if cmd&0x08 != 0 { + copyOffset |= int(delta[pos]) << 24 + pos++ + } + + if cmd&0x10 != 0 { + copySize = int(delta[pos]) + pos++ + } + if cmd&0x20 != 0 { + copySize |= int(delta[pos]) << 8 + pos++ + } + if cmd&0x40 != 0 { + copySize |= int(delta[pos]) << 16 + pos++ + } + + if copySize == 0 { + copySize = 0x10000 + } + + copy(result[resultOffset:], base[copyOffset:copyOffset+copySize]) + resultOffset += copySize + } else if cmd > 0 { + copy(result[resultOffset:], delta[pos:pos+int(cmd)]) + pos += int(cmd) + resultOffset += int(cmd) + } else { + return nil, fmt.Errorf("invalid delta command") + } + } + + return result, nil +} + +func zlibDecompress(data []byte, pos int) ([]byte, int, error) { + br := bytes.NewReader(data[pos:]) + r, err := zlib.NewReader(br) + if err != nil { + return nil, 0, fmt.Errorf("zlib init error: %w", err) + } + + decompressed, err := io.ReadAll(r) + r.Close() + if err != nil { + return nil, 0, fmt.Errorf("zlib decompress error: %w", err) + } + + newPos := len(data) - br.Len() + return decompressed, newPos, nil +} + +func readVariableInt(data []byte, pos int) (int, int) { + value := 0 + shift := 0 + bytesRead := 0 + + for { + b := data[pos] + pos++ + bytesRead++ + value |= int(b&0x7f) << shift + shift += 7 + if b&0x80 == 0 { + break + } + } + + return value, bytesRead +} diff --git a/nip34/gitnaturalapi/refs.go b/nip34/gitnaturalapi/refs.go new file mode 100644 index 0000000..d0309ca --- /dev/null +++ b/nip34/gitnaturalapi/refs.go @@ -0,0 +1,120 @@ +package gitnaturalapi + +import ( + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" +) + +type InfoRefsUploadPackResponse struct { + Refs map[string]string + Capabilities []string + Symrefs map[string]string +} + +var capabilitiesCache sync.Map + +func GetCapabilities(url string, existingInfo *InfoRefsUploadPackResponse) ([]string, error) { + if existingInfo != nil { + capabilitiesCache.Store(url, existingInfo.Capabilities) + return existingInfo.Capabilities, nil + } + + if cached, ok := capabilitiesCache.Load(url); ok { + return cached.([]string), nil + } + + info, err := GetInfoRefs(url) + if err != nil { + return nil, err + } + capabilitiesCache.Store(url, info.Capabilities) + return info.Capabilities, nil +} + +func GetInfoRefs(url string) (*InfoRefsUploadPackResponse, error) { + resp, err := http.Get(url + "/info/refs?service=git-upload-pack") + if err != nil { + return nil, fmt.Errorf("failed to fetch info/refs: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read info/refs response: %w", err) + } + response := string(body) + + result := &InfoRefsUploadPackResponse{ + Refs: make(map[string]string), + Symrefs: make(map[string]string), + } + + lines := strings.Split(response, "\n") + firstRef := true + for _, line := range lines { + if len(line) == 0 { + continue + } + + if strings.HasPrefix(line, "0000") { + line = line[4:] + } + if len(line) < 4 { + continue + } + + length, err := strconv.ParseInt(line[:4], 16, 32) + if err != nil { + continue + } + + endIdx := int(length) + if endIdx > len(line) { + endIdx = len(line) + } + if endIdx <= 4 { + continue + } + content := line[4:endIdx] + + if firstRef && strings.HasPrefix(content, "# service=") { + firstRef = false + continue + } + + if !strings.Contains(content, " ") { + continue + } + + parts := strings.SplitN(content, " ", 2) + hash := parts[0] + refAndCaps := parts[1] + + if strings.Contains(refAndCaps, "\x00") { + nulParts := strings.SplitN(refAndCaps, "\x00", 2) + ref := strings.TrimSpace(nulParts[0]) + result.Refs[ref] = hash + + caps := strings.Fields(nulParts[1]) + result.Capabilities = caps + + for _, cap := range caps { + if strings.HasPrefix(cap, "symref=") { + symrefData := cap[7:] + colonIdx := strings.Index(symrefData, ":") + if colonIdx != -1 { + result.Symrefs[symrefData[:colonIdx]] = symrefData[colonIdx+1:] + } + } + } + } else { + result.Refs[strings.TrimSpace(refAndCaps)] = hash + } + } + + return result, nil +} diff --git a/nip34/gitnaturalapi/tree.go b/nip34/gitnaturalapi/tree.go new file mode 100644 index 0000000..29be239 --- /dev/null +++ b/nip34/gitnaturalapi/tree.go @@ -0,0 +1,101 @@ +package gitnaturalapi + +import "encoding/hex" + +type TreeEntry struct { + Path string + Mode string + IsDir bool + Hash string +} + +type TreeFile struct { + Name string + Hash string + Content []byte +} + +type TreeDirectory struct { + Name string + Hash string + Content *Tree +} + +type Tree struct { + Directories []TreeDirectory + Files []TreeFile +} + +func LoadTree(obj *ParsedObject, objects map[string]*ParsedObject, depth *int) *Tree { + directories := make([]TreeDirectory, 0) + files := make([]TreeFile, 0) + entries := ParseTree(obj.Data) + + for _, entry := range entries { + child := objects[entry.Hash] + + if entry.IsDir { + var content *Tree + if child != nil && (depth == nil || *depth > 0) { + var newDepth *int + if depth != nil { + d := *depth - 1 + newDepth = &d + } + content = LoadTree(child, objects, newDepth) + } + directories = append(directories, TreeDirectory{ + Name: entry.Path, + Hash: entry.Hash, + Content: content, + }) + } else { + var content []byte + if child != nil { + content = child.Data + } + files = append(files, TreeFile{ + Name: entry.Path, + Hash: entry.Hash, + Content: content, + }) + } + } + + return &Tree{Directories: directories, Files: files} +} + +func ParseTree(treeData []byte) []TreeEntry { + entries := make([]TreeEntry, 0) + offset := 0 + + for offset < len(treeData) { + modeEnd := offset + for treeData[modeEnd] != 0x20 { + modeEnd++ + } + mode := string(treeData[offset:modeEnd]) + offset = modeEnd + 1 + + filenameEnd := offset + for treeData[filenameEnd] != 0x00 { + filenameEnd++ + } + path := string(treeData[offset:filenameEnd]) + offset = filenameEnd + 1 + + hash := hex.EncodeToString(treeData[offset : offset+20]) + offset += 20 + + isDir := mode == "40000" || mode == "040000" + + entries = append(entries, TreeEntry{ + Mode: mode, + Path: path, + Hash: hash, + IsDir: isDir, + }) + } + + return entries +}