1
0
Fork 0
mirror of https://github.com/imjasonh/infinite-git synced 2026-07-08 09:05:06 +00:00
infinite-git/internal/protocol/upload_pack.go
Jason Hall 8919cd5826
Implement pure Go Git HTTP server without CLI dependencies
Major rewrite to eliminate git CLI dependency:
- Created object package for Git object format handling (blob, tree, commit)
- Implemented packfile generation from scratch
- Rewrote repo initialization to create Git repos without CLI
- Implemented git-upload-pack protocol in pure Go
- Updated all server handlers to use new implementation
- Added Git packet tracing to test.sh for debugging
- Fixed tests to work with new implementation

The server now works entirely with Go code, no git commands needed.
Clone operations work correctly, but pull operations still have protocol
negotiation issues that need further debugging.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-23 22:28:50 -04:00

315 lines
8 KiB
Go

package protocol
import (
"bytes"
"fmt"
"io"
"strings"
"github.com/imjasonh/infinite-git/internal/object"
"github.com/imjasonh/infinite-git/internal/packfile"
"github.com/imjasonh/infinite-git/internal/pktline"
"github.com/imjasonh/infinite-git/internal/repo"
)
// UploadPack implements the git-upload-pack protocol.
type UploadPack struct {
repo *repo.Repository
}
// NewUploadPack creates a new upload-pack handler.
func NewUploadPack(r *repo.Repository) *UploadPack {
return &UploadPack{repo: r}
}
// HandleRequest processes a git-upload-pack request.
func (u *UploadPack) HandleRequest(r io.Reader, w io.Writer) error {
reader := pktline.NewReader(r)
writer := pktline.NewWriter(w)
// Read want lines first
var wants []string
var capabilities []string
for {
line, err := reader.ReadString()
if err == io.EOF {
break // flush-pkt
}
if err != nil {
return fmt.Errorf("reading wants: %w", err)
}
if strings.HasPrefix(line, "want ") {
wantLine := line[5:]
// First want may have capabilities after space
parts := strings.SplitN(wantLine, " ", 2)
wants = append(wants, parts[0])
// Parse capabilities if present
if len(parts) > 1 && len(capabilities) == 0 {
capabilities = strings.Split(parts[1], " ")
}
}
}
// Now handle negotiation phase
// The client may send:
// 1. "done" immediately (for clone)
// 2. "have" lines followed by flush, then we NAK, then more haves or done
for {
// Read lines until we get a flush or done
var haves []string
gotDone := false
for {
line, err := reader.ReadString()
if err == io.EOF {
// Flush packet - end of this batch
break
}
if err != nil {
return fmt.Errorf("reading negotiation: %w", err)
}
if line == "done" {
gotDone = true
break
} else if strings.HasPrefix(line, "have ") {
haves = append(haves, line[5:])
} else if line != "" {
return fmt.Errorf("unexpected line in negotiation: %q", line)
}
}
// If we got done, we're finished
if gotDone {
break
}
// If we got haves, send NAK and continue
if len(haves) > 0 {
if err := writer.WriteString("NAK\n"); err != nil {
return fmt.Errorf("writing NAK: %w", err)
}
if err := writer.Flush(); err != nil {
return fmt.Errorf("flushing NAK: %w", err)
}
} else if !gotDone {
// Empty flush without haves or done - client expects NAK
if err := writer.WriteString("NAK\n"); err != nil {
return fmt.Errorf("writing NAK for empty flush: %w", err)
}
if err := writer.Flush(); err != nil {
return fmt.Errorf("flushing NAK: %w", err)
}
}
}
// Read the flush after "done"
if _, err := reader.ReadString(); err != io.EOF {
return fmt.Errorf("expected flush after done")
}
// Send final NAK before packfile
if err := writer.WriteString("NAK\n"); err != nil {
return fmt.Errorf("writing final NAK: %w", err)
}
// Check if client supports side-band
sideBand := false
for _, cap := range capabilities {
if cap == "side-band" || cap == "side-band-64k" {
sideBand = true
break
}
}
// Create and send packfile
if sideBand {
// With side-band, we need to prefix data with channel number
return u.sendPackfileWithSideband(writer, wants)
} else {
// Without side-band, write packfile directly to underlying writer
return u.sendPackfile(w, wants)
}
}
// sendPackfile sends a packfile containing the requested objects.
func (u *UploadPack) sendPackfile(w io.Writer, wants []string) error {
pack, err := u.createPackfile(wants)
if err != nil {
return fmt.Errorf("creating packfile: %w", err)
}
// Write packfile data directly (not as pkt-line)
if _, err := w.Write(pack); err != nil {
return fmt.Errorf("writing packfile: %w", err)
}
return nil
}
// sendPackfileWithSideband sends a packfile with sideband encoding.
func (u *UploadPack) sendPackfileWithSideband(w *pktline.Writer, wants []string) error {
pack, err := u.createPackfile(wants)
if err != nil {
return fmt.Errorf("creating packfile: %w", err)
}
// Send packfile data in chunks with sideband 1 prefix
const maxChunkSize = 65515 // Max pkt-line size minus sideband byte
for i := 0; i < len(pack); i += maxChunkSize {
end := i + maxChunkSize
if end > len(pack) {
end = len(pack)
}
chunk := append([]byte{1}, pack[i:end]...) // 1 = pack data channel
if err := w.Write(chunk); err != nil {
return fmt.Errorf("writing sideband chunk: %w", err)
}
}
// Send flush packet to indicate end
return w.Flush()
}
// createPackfile creates a packfile containing the requested objects and their dependencies.
func (u *UploadPack) createPackfile(wants []string) ([]byte, error) {
pw := packfile.NewWriter()
visited := make(map[string]bool)
// Process each wanted object
for _, want := range wants {
if err := u.addObjectToPack(pw, want, visited); err != nil {
return nil, fmt.Errorf("adding object %s: %w", want, err)
}
}
return pw.Finalize(), nil
}
// addObjectToPack recursively adds an object and its dependencies to the packfile.
func (u *UploadPack) addObjectToPack(pw *packfile.Writer, hash string, visited map[string]bool) error {
if visited[hash] {
return nil
}
visited[hash] = true
// Read object with header
data, err := u.repo.ReadObjectFull(hash)
if err != nil {
return fmt.Errorf("reading object: %w", err)
}
// Parse header
nullIndex := bytes.IndexByte(data, 0)
if nullIndex == -1 {
return fmt.Errorf("invalid object format")
}
header := string(data[:nullIndex])
content := data[nullIndex+1:]
var objType int
switch {
case strings.HasPrefix(header, "commit "):
objType = packfile.OBJ_COMMIT
// Parse commit to find tree and parent
if err := u.addCommitDependencies(pw, content, visited); err != nil {
return err
}
case strings.HasPrefix(header, "tree "):
objType = packfile.OBJ_TREE
// Parse tree to find blobs and subtrees
if err := u.addTreeDependencies(pw, content, visited); err != nil {
return err
}
case strings.HasPrefix(header, "blob "):
objType = packfile.OBJ_BLOB
// Blobs have no dependencies
default:
return fmt.Errorf("unknown object type: %s", header)
}
// Add object to packfile
return pw.AddObject(objType, content)
}
// addCommitDependencies adds a commit's tree and parent to the packfile.
func (u *UploadPack) addCommitDependencies(pw *packfile.Writer, commitData []byte, visited map[string]bool) error {
lines := bytes.Split(commitData, []byte("\n"))
for _, line := range lines {
if bytes.HasPrefix(line, []byte("tree ")) {
treeHash := string(line[5:])
if err := u.addObjectToPack(pw, treeHash, visited); err != nil {
return fmt.Errorf("adding tree: %w", err)
}
} else if bytes.HasPrefix(line, []byte("parent ")) {
parentHash := string(line[7:])
if err := u.addObjectToPack(pw, parentHash, visited); err != nil {
return fmt.Errorf("adding parent: %w", err)
}
}
}
return nil
}
// addTreeDependencies adds a tree's entries to the packfile.
func (u *UploadPack) addTreeDependencies(pw *packfile.Writer, treeData []byte, visited map[string]bool) error {
entries := parseTreeData(treeData)
for _, entry := range entries {
if err := u.addObjectToPack(pw, entry.Hash, visited); err != nil {
return fmt.Errorf("adding tree entry %s: %w", entry.Name, err)
}
}
return nil
}
// parseTreeData parses raw tree data into entries.
func parseTreeData(data []byte) []object.TreeEntry {
var entries []object.TreeEntry
i := 0
for i < len(data) {
// Find space (end of mode)
modeEnd := i
for modeEnd < len(data) && data[modeEnd] != ' ' {
modeEnd++
}
if modeEnd >= len(data) {
break
}
mode := string(data[i:modeEnd])
// Find null (end of name)
nameStart := modeEnd + 1
nameEnd := nameStart
for nameEnd < len(data) && data[nameEnd] != 0 {
nameEnd++
}
if nameEnd >= len(data) {
break
}
name := string(data[nameStart:nameEnd])
// Read 20-byte SHA-1
hashStart := nameEnd + 1
if hashStart+20 > len(data) {
break
}
hash := fmt.Sprintf("%x", data[hashStart:hashStart+20])
entries = append(entries, object.TreeEntry{
Mode: mode,
Name: name,
Hash: hash,
})
i = hashStart + 20
}
return entries
}