add nip34/git-natural-api, using the same approach as https://jsr.io/@fiatjaf/git-natural-api.

This commit is contained in:
fiatjaf
2026-03-25 15:29:36 -03:00
parent c74ac74a0e
commit b5974cfa45
10 changed files with 1754 additions and 3 deletions
-1
View File
@@ -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
-2
View File
@@ -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=
+106
View File
@@ -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
}
+413
View File
@@ -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
}
+264
View File
@@ -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
}
+289
View File
@@ -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)
}
+154
View File
@@ -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)
}
+307
View File
@@ -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
}
+120
View File
@@ -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
}
+101
View File
@@ -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
}