add nip34/git-natural-api, using the same approach as https://jsr.io/@fiatjaf/git-natural-api.
This commit is contained in:
@@ -40,7 +40,6 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fiatjaf.com/lib v0.3.6
|
|
||||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||||
github.com/go-git/go-git/v5 v5.16.3
|
github.com/go-git/go-git/v5 v5.16.3
|
||||||
github.com/sivukhin/godjot v1.0.6
|
github.com/sivukhin/godjot v1.0.6
|
||||||
|
|||||||
@@ -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 h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc=
|
||||||
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw=
|
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=
|
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user