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 (
|
||||
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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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