From 30ca57dae3663377a5cd9a372630e61bd82c0870 Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Wed, 23 Jul 2025 21:19:02 -0400 Subject: [PATCH] Implement infinite Git HTTP server This server generates a new commit every time someone pulls from it. Features: - Pure Go implementation of Git object format and protocols - No dependency on git CLI commands - Thread-safe commit generation - Read-only Git HTTP smart protocol support - Comprehensive test suite Each pull creates a new commit with a unique file containing a timestamp and counter, making the repository grow infinitely with each pull operation. Co-Authored-By: Claude --- README.md | 78 +++++++++ go.mod | 26 +++ go.sum | 68 ++++++++ internal/generator/commit.go | 181 ++++++++++++++++++++ internal/object/blob.go | 21 +++ internal/object/commit.go | 75 ++++++++ internal/object/object.go | 136 +++++++++++++++ internal/object/tree.go | 66 +++++++ internal/packfile/packfile.go | 171 +++++++++++++++++++ internal/pktline/reader.go | 89 ++++++++++ internal/pktline/writer.go | 55 ++++++ internal/protocol/upload_pack.go | 264 ++++++++++++++++++++++++++++ internal/repo/repo.go | 265 ++++++++++++++++++++++++++++ internal/server/handlers.go | 108 ++++++++++++ internal/server/server.go | 67 ++++++++ main.go | 86 ++++++++++ main_test.go | 284 +++++++++++++++++++++++++++++++ plan.md | 80 +++++++++ test.sh | 52 ++++++ 19 files changed, 2172 insertions(+) create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/generator/commit.go create mode 100644 internal/object/blob.go create mode 100644 internal/object/commit.go create mode 100644 internal/object/object.go create mode 100644 internal/object/tree.go create mode 100644 internal/packfile/packfile.go create mode 100644 internal/pktline/reader.go create mode 100644 internal/pktline/writer.go create mode 100644 internal/protocol/upload_pack.go create mode 100644 internal/repo/repo.go create mode 100644 internal/server/handlers.go create mode 100644 internal/server/server.go create mode 100644 main.go create mode 100644 main_test.go create mode 100644 plan.md create mode 100755 test.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3112cd --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Infinite Git + +A Git HTTP server that generates a new commit every time someone pulls from the repository. + +## Features + +- Implements Git smart HTTP protocol for read-only access +- Generates a unique commit on every pull/clone operation +- Thread-safe commit generation +- Persistent Git repository on disk +- Structured logging with slog + +## Installation + +```bash +go install github.com/imjasonh/infinite-git/cmd/infinite-git@latest +``` + +Or build from source: + +```bash +git clone https://github.com/imjasonh/infinite-git +cd infinite-git +go build -o infinite-git ./cmd/infinite-git +``` + +## Usage + +Start the server: + +```bash +infinite-git +``` + +Options: +- `-addr`: HTTP server address (default: ":8080") +- `-repo`: Path to Git repository (default: "./infinite-repo") +- `-log-level`: Log level - debug, info, warn, error (default: "info") + +Example: +```bash +infinite-git -addr :3000 -repo /tmp/my-infinite-repo -log-level debug +``` + +## Testing + +Clone the repository: +```bash +git clone http://localhost:8080 +``` + +Every time you pull, you'll get a new commit: +```bash +cd +git pull origin main +# New commit appears! +``` + +## How It Works + +1. When a client initiates a pull/clone, the server intercepts the reference discovery request +2. Before advertising refs, it generates a new commit with: + - A unique file containing the pull counter and timestamp + - A commit message indicating when the pull occurred +3. The new commit is added to the main branch +4. The updated refs are sent to the client +5. The client receives the new commit as part of the normal Git protocol flow + +## Implementation Details + +- Uses Git plumbing commands (`git add`, `git commit`) to create commits +- Implements pkt-line format for Git protocol communication +- Delegates object transfer to `git upload-pack` for efficiency +- Rejects all push attempts with 403 Forbidden + +## License + +MIT \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..74d5b8c --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/imjasonh/infinite-git + +go 1.24.4 + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-git/v5 v5.16.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7b3daf9 --- /dev/null +++ b/go.sum @@ -0,0 +1,68 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= +github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/generator/commit.go b/internal/generator/commit.go new file mode 100644 index 0000000..1d48b29 --- /dev/null +++ b/internal/generator/commit.go @@ -0,0 +1,181 @@ +package generator + +import ( + "fmt" + "sync/atomic" + "time" + + "github.com/imjasonh/infinite-git/internal/object" + "github.com/imjasonh/infinite-git/internal/repo" +) + +// Generator creates new commits on demand. +type Generator struct { + repo *repo.Repository + counter int64 +} + +// New creates a new commit generator. +func New(r *repo.Repository) *Generator { + return &Generator{ + repo: r, + } +} + +// GenerateCommit creates a new commit and updates the main branch. +func (g *Generator) GenerateCommit() (string, error) { + // Increment counter atomically + count := atomic.AddInt64(&g.counter, 1) + + // Get current HEAD commit + refs, err := g.repo.GetRefs() + if err != nil { + return "", fmt.Errorf("getting refs: %w", err) + } + + parentHash := refs["refs/heads/main"] + if parentHash == "" { + return "", fmt.Errorf("main branch not found") + } + + // Read parent commit to get its tree + parentData, err := g.repo.ReadObject(parentHash) + if err != nil { + return "", fmt.Errorf("reading parent commit: %w", err) + } + + // Parse parent commit to find tree hash + var parentTreeHash string + lines := splitLines(string(parentData)) + for _, line := range lines { + if len(line) > 5 && line[:5] == "tree " { + parentTreeHash = line[5:] + break + } + } + + // Read parent tree + parentTreeData, err := g.repo.ReadObject(parentTreeHash) + if err != nil { + return "", fmt.Errorf("reading parent tree: %w", err) + } + + // Parse existing tree entries + existingEntries := parseTree(parentTreeData) + + // Create new file content + filename := fmt.Sprintf("pull_%d.txt", count) + content := fmt.Sprintf("Pull request #%d\nTimestamp: %s\n", count, time.Now().Format(time.RFC3339)) + + // Create blob for new file + blob := object.NewBlob([]byte(content)) + blobHash, err := g.repo.WriteObject(blob) + if err != nil { + return "", fmt.Errorf("writing blob: %w", err) + } + + // Create new tree with all existing entries plus new file + tree := object.NewTree() + + // Add existing entries + for _, entry := range existingEntries { + tree.AddEntry(entry.Mode, entry.Name, entry.Hash) + } + + // Add new file + tree.AddEntry("100644", filename, blobHash) + + treeHash, err := g.repo.WriteObject(tree) + if err != nil { + return "", fmt.Errorf("writing tree: %w", err) + } + + // Create commit + commitMsg := fmt.Sprintf("Pull #%d at %s", count, time.Now().Format("2006-01-02 15:04:05")) + commit := object.NewCommit( + treeHash, + parentHash, + "Infinite Git ", + "Infinite Git ", + commitMsg, + ) + + commitHash, err := g.repo.WriteObject(commit) + if err != nil { + return "", fmt.Errorf("writing commit: %w", err) + } + + // Update refs/heads/main + if err := g.repo.UpdateRef("refs/heads/main", commitHash); err != nil { + return "", fmt.Errorf("updating ref: %w", err) + } + + return commitHash, nil +} + +// GetCounter returns the current counter value. +func (g *Generator) GetCounter() int64 { + return atomic.LoadInt64(&g.counter) +} + +// splitLines splits a string into lines. +func splitLines(s string) []string { + var lines []string + start := 0 + for i, c := range s { + if c == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} + +// parseTree parses tree object data into entries. +func parseTree(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 +} diff --git a/internal/object/blob.go b/internal/object/blob.go new file mode 100644 index 0000000..e33b916 --- /dev/null +++ b/internal/object/blob.go @@ -0,0 +1,21 @@ +package object + +// Blob represents a Git blob object (file content). +type Blob struct { + Content []byte +} + +// NewBlob creates a new blob object. +func NewBlob(content []byte) *Blob { + return &Blob{Content: content} +} + +// Type returns the object type. +func (b *Blob) Type() Type { + return TypeBlob +} + +// Serialize returns the blob content. +func (b *Blob) Serialize() []byte { + return b.Content +} diff --git a/internal/object/commit.go b/internal/object/commit.go new file mode 100644 index 0000000..9a77a15 --- /dev/null +++ b/internal/object/commit.go @@ -0,0 +1,75 @@ +package object + +import ( + "bytes" + "fmt" + "time" +) + +// Commit represents a Git commit object. +type Commit struct { + Tree string // SHA-1 hash of the tree object + Parent string // SHA-1 hash of the parent commit (empty for initial commit) + Author string // Author name and email + AuthorDate time.Time // Author timestamp + Committer string // Committer name and email + CommitDate time.Time // Commit timestamp + Message string // Commit message +} + +// NewCommit creates a new commit object. +func NewCommit(tree, parent, author, committer, message string) *Commit { + now := time.Now() + return &Commit{ + Tree: tree, + Parent: parent, + Author: author, + AuthorDate: now, + Committer: committer, + CommitDate: now, + Message: message, + } +} + +// Type returns the object type. +func (c *Commit) Type() Type { + return TypeCommit +} + +// Serialize returns the commit content in Git format. +func (c *Commit) Serialize() []byte { + var buf bytes.Buffer + + // Tree reference + fmt.Fprintf(&buf, "tree %s\n", c.Tree) + + // Parent reference (if exists) + if c.Parent != "" { + fmt.Fprintf(&buf, "parent %s\n", c.Parent) + } + + // Author + fmt.Fprintf(&buf, "author %s %d %s\n", + c.Author, + c.AuthorDate.Unix(), + c.AuthorDate.Format("-0700")) + + // Committer + fmt.Fprintf(&buf, "committer %s %d %s\n", + c.Committer, + c.CommitDate.Unix(), + c.CommitDate.Format("-0700")) + + // Empty line before message + buf.WriteByte('\n') + + // Commit message + buf.WriteString(c.Message) + + // Ensure message ends with newline + if len(c.Message) > 0 && c.Message[len(c.Message)-1] != '\n' { + buf.WriteByte('\n') + } + + return buf.Bytes() +} diff --git a/internal/object/object.go b/internal/object/object.go new file mode 100644 index 0000000..0aba103 --- /dev/null +++ b/internal/object/object.go @@ -0,0 +1,136 @@ +package object + +import ( + "bytes" + "compress/zlib" + "crypto/sha1" + "fmt" + "io" + "os" + "path/filepath" +) + +// Type represents a Git object type. +type Type string + +const ( + TypeBlob Type = "blob" + TypeTree Type = "tree" + TypeCommit Type = "commit" +) + +// Object represents a Git object. +type Object interface { + Type() Type + Serialize() []byte +} + +// Hash computes the SHA-1 hash of an object. +func Hash(obj Object) string { + data := obj.Serialize() + header := fmt.Sprintf("%s %d\x00", obj.Type(), len(data)) + + h := sha1.New() + h.Write([]byte(header)) + h.Write(data) + + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// Write writes an object to the Git object store. +func Write(gitDir string, obj Object) (string, error) { + // Compute hash + hash := Hash(obj) + + // Prepare object data + data := obj.Serialize() + header := fmt.Sprintf("%s %d\x00", obj.Type(), len(data)) + + // Create object directory + objDir := filepath.Join(gitDir, "objects", hash[:2]) + if err := os.MkdirAll(objDir, 0755); err != nil { + return "", fmt.Errorf("creating object dir: %w", err) + } + + // Write compressed object + objPath := filepath.Join(objDir, hash[2:]) + file, err := os.Create(objPath) + if err != nil { + return "", fmt.Errorf("creating object file: %w", err) + } + defer file.Close() + + // Compress with zlib + w := zlib.NewWriter(file) + defer w.Close() + + if _, err := w.Write([]byte(header)); err != nil { + return "", fmt.Errorf("writing header: %w", err) + } + if _, err := w.Write(data); err != nil { + return "", fmt.Errorf("writing data: %w", err) + } + + if err := w.Close(); err != nil { + return "", fmt.Errorf("closing zlib writer: %w", err) + } + + return hash, nil +} + +// ReadFull reads an object from the Git object store with its header. +func ReadFull(gitDir string, hash string) ([]byte, error) { + objPath := filepath.Join(gitDir, "objects", hash[:2], hash[2:]) + + file, err := os.Open(objPath) + if err != nil { + return nil, fmt.Errorf("opening object file: %w", err) + } + defer file.Close() + + // Decompress + r, err := zlib.NewReader(file) + if err != nil { + return nil, fmt.Errorf("creating zlib reader: %w", err) + } + defer r.Close() + + data, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("reading object: %w", err) + } + + return data, nil +} + +// Read reads an object from the Git object store. +func Read(gitDir string, hash string) ([]byte, error) { + objPath := filepath.Join(gitDir, "objects", hash[:2], hash[2:]) + + file, err := os.Open(objPath) + if err != nil { + return nil, fmt.Errorf("opening object file: %w", err) + } + defer file.Close() + + // Decompress + r, err := zlib.NewReader(file) + if err != nil { + return nil, fmt.Errorf("creating zlib reader: %w", err) + } + defer r.Close() + + data, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("reading object: %w", err) + } + + // Parse header + nullIndex := bytes.IndexByte(data, 0) + if nullIndex == -1 { + return nil, fmt.Errorf("invalid object format: no null byte") + } + + // Return content after header + return data[nullIndex+1:], nil +} diff --git a/internal/object/tree.go b/internal/object/tree.go new file mode 100644 index 0000000..4d30149 --- /dev/null +++ b/internal/object/tree.go @@ -0,0 +1,66 @@ +package object + +import ( + "bytes" + "encoding/hex" + "fmt" + "sort" +) + +// TreeEntry represents an entry in a Git tree object. +type TreeEntry struct { + Mode string // File mode (e.g., "100644" for regular file) + Name string // File or directory name + Hash string // SHA-1 hash of the object +} + +// Tree represents a Git tree object (directory listing). +type Tree struct { + Entries []TreeEntry +} + +// NewTree creates a new tree object. +func NewTree() *Tree { + return &Tree{ + Entries: make([]TreeEntry, 0), + } +} + +// AddEntry adds an entry to the tree. +func (t *Tree) AddEntry(mode, name, hash string) { + t.Entries = append(t.Entries, TreeEntry{ + Mode: mode, + Name: name, + Hash: hash, + }) +} + +// Type returns the object type. +func (t *Tree) Type() Type { + return TypeTree +} + +// Serialize returns the tree content in Git format. +func (t *Tree) Serialize() []byte { + // Sort entries by name for consistency + sort.Slice(t.Entries, func(i, j int) bool { + return t.Entries[i].Name < t.Entries[j].Name + }) + + var buf bytes.Buffer + + for _, entry := range t.Entries { + // Format: \0<20-byte SHA-1> + fmt.Fprintf(&buf, "%s %s\x00", entry.Mode, entry.Name) + + // Convert hex hash to binary + hashBytes, err := hex.DecodeString(entry.Hash) + if err != nil { + // This shouldn't happen with valid input + panic(fmt.Sprintf("invalid hash: %s", entry.Hash)) + } + buf.Write(hashBytes) + } + + return buf.Bytes() +} diff --git a/internal/packfile/packfile.go b/internal/packfile/packfile.go new file mode 100644 index 0000000..c06efca --- /dev/null +++ b/internal/packfile/packfile.go @@ -0,0 +1,171 @@ +package packfile + +import ( + "bytes" + "compress/zlib" + "crypto/sha1" + "encoding/binary" + "fmt" + "hash" + "io" +) + +const ( + // Object types in packfile + OBJ_COMMIT = 1 + OBJ_TREE = 2 + OBJ_BLOB = 3 + OBJ_TAG = 4 +) + +// Writer writes a packfile. +type Writer struct { + buf bytes.Buffer + objects int + hash hash.Hash +} + +// NewWriter creates a new packfile writer. +func NewWriter() *Writer { + w := &Writer{ + hash: sha1.New(), + } + + // Write pack header + w.buf.WriteString("PACK") + binary.Write(&w.buf, binary.BigEndian, uint32(2)) // version + binary.Write(&w.buf, binary.BigEndian, uint32(0)) // placeholder for object count + + return w +} + +// AddObject adds an object to the packfile. +func (w *Writer) AddObject(objType int, data []byte) error { + w.objects++ + + // Encode object header + // Format: 1-bit continuation, 3-bit type, 4-bit size (then 7-bit size chunks) + size := len(data) + header := (objType << 4) | (size & 0xf) + size >>= 4 + + for size > 0 { + header |= 0x80 // Set continuation bit + w.buf.WriteByte(byte(header)) + header = size & 0x7f + size >>= 7 + } + w.buf.WriteByte(byte(header)) + + // Compress and write object data + var compressedBuf bytes.Buffer + zw := zlib.NewWriter(&compressedBuf) + if _, err := zw.Write(data); err != nil { + return fmt.Errorf("compressing object: %w", err) + } + if err := zw.Close(); err != nil { + return fmt.Errorf("closing compressor: %w", err) + } + + w.buf.Write(compressedBuf.Bytes()) + return nil +} + +// Finalize completes the packfile and returns the data. +func (w *Writer) Finalize() []byte { + data := w.buf.Bytes() + + // Update object count in header + binary.BigEndian.PutUint32(data[8:12], uint32(w.objects)) + + // Calculate and append checksum + w.hash.Write(data) + checksum := w.hash.Sum(nil) + + result := append(data, checksum...) + return result +} + +// Reader reads objects from a packfile. +type Reader struct { + data []byte + offset int +} + +// NewReader creates a new packfile reader. +func NewReader(data []byte) (*Reader, error) { + if len(data) < 12 { + return nil, fmt.Errorf("packfile too small") + } + + if string(data[:4]) != "PACK" { + return nil, fmt.Errorf("invalid packfile signature") + } + + version := binary.BigEndian.Uint32(data[4:8]) + if version != 2 { + return nil, fmt.Errorf("unsupported packfile version: %d", version) + } + + return &Reader{ + data: data, + offset: 12, // Skip header + }, nil +} + +// readVarint reads a variable-length integer. +func (r *Reader) readVarint() (int, int, error) { + if r.offset >= len(r.data) { + return 0, 0, io.EOF + } + + b := r.data[r.offset] + r.offset++ + + objType := (int(b) >> 4) & 0x7 + size := int(b) & 0xf + shift := 4 + + for b&0x80 != 0 { + if r.offset >= len(r.data) { + return 0, 0, io.EOF + } + b = r.data[r.offset] + r.offset++ + size |= (int(b) & 0x7f) << shift + shift += 7 + } + + return objType, size, nil +} + +// ReadObject reads the next object from the packfile. +func (r *Reader) ReadObject() (objType int, data []byte, err error) { + // Read object header + objType, size, err := r.readVarint() + if err != nil { + return 0, nil, err + } + + // Read compressed data + zr, err := zlib.NewReader(bytes.NewReader(r.data[r.offset:])) + if err != nil { + return 0, nil, fmt.Errorf("creating decompressor: %w", err) + } + defer zr.Close() + + data = make([]byte, size) + if _, err := io.ReadFull(zr, data); err != nil { + return 0, nil, fmt.Errorf("decompressing object: %w", err) + } + + // Update offset past compressed data + // This is tricky - we need to figure out how much compressed data we read + // For now, we'll use a simple approach and skip this optimization + // In a real implementation, we'd track the compressed size properly + // For now, just consume the rest + var buf bytes.Buffer + io.Copy(&buf, zr) + + return objType, data, nil +} diff --git a/internal/pktline/reader.go b/internal/pktline/reader.go new file mode 100644 index 0000000..ec5fa5c --- /dev/null +++ b/internal/pktline/reader.go @@ -0,0 +1,89 @@ +package pktline + +import ( + "bufio" + "fmt" + "io" +) + +// Reader implements the Git packet line protocol for reading. +type Reader struct { + r *bufio.Reader +} + +// NewReader creates a new packet line reader. +func NewReader(r io.Reader) *Reader { + return &Reader{r: bufio.NewReader(r)} +} + +// Read reads a single pkt-line. +// Returns io.EOF on flush packet (0000). +func (r *Reader) Read() ([]byte, error) { + // Read 4-byte length header + header := make([]byte, 4) + if _, err := io.ReadFull(r.r, header); err != nil { + return nil, err + } + + // Parse length + var length int + if _, err := fmt.Sscanf(string(header), "%04x", &length); err != nil { + return nil, fmt.Errorf("invalid pkt-line header: %s", header) + } + + // Handle special packets + switch length { + case 0: // flush-pkt + return nil, io.EOF + case 1: // delimiter packet (0001) + return nil, fmt.Errorf("delimiter packet not supported") + case 2: // response-end packet (0002) + return nil, fmt.Errorf("response-end packet not supported") + } + + // Read data + if length < 4 { + return nil, fmt.Errorf("invalid pkt-line length: %d", length) + } + + data := make([]byte, length-4) + if _, err := io.ReadFull(r.r, data); err != nil { + return nil, err + } + + return data, nil +} + +// ReadString reads a pkt-line as a string, trimming newline. +func (r *Reader) ReadString() (string, error) { + data, err := r.Read() + if err != nil { + return "", err + } + + // Trim trailing newline if present + if len(data) > 0 && data[len(data)-1] == '\n' { + data = data[:len(data)-1] + } + + return string(data), nil +} + +// ReadAll reads all pkt-lines until flush packet. +func (r *Reader) ReadAll() ([][]byte, error) { + var lines [][]byte + + for { + line, err := r.Read() + if err == io.EOF { + // Flush packet, we're done + break + } + if err != nil { + return nil, err + } + lines = append(lines, line) + } + + return lines, nil +} diff --git a/internal/pktline/writer.go b/internal/pktline/writer.go new file mode 100644 index 0000000..c4c368c --- /dev/null +++ b/internal/pktline/writer.go @@ -0,0 +1,55 @@ +package pktline + +import ( + "fmt" + "io" +) + +// Writer implements the Git packet line protocol for writing. +type Writer struct { + w io.Writer +} + +// NewWriter creates a new packet line writer. +func NewWriter(w io.Writer) *Writer { + return &Writer{w: w} +} + +// Write writes data as a pkt-line. +func (w *Writer) Write(data []byte) error { + if len(data) == 0 { + return w.Flush() + } + + // Maximum pkt-line length is 65520 (65516 bytes of data + 4 bytes length) + if len(data) > 65516 { + return fmt.Errorf("pkt-line too long: %d bytes", len(data)) + } + + // Write 4-byte hex length prefix + length := len(data) + 4 + header := fmt.Sprintf("%04x", length) + if _, err := w.w.Write([]byte(header)); err != nil { + return err + } + + // Write data + _, err := w.w.Write(data) + return err +} + +// WriteString writes a string as a pkt-line. +func (w *Writer) WriteString(s string) error { + return w.Write([]byte(s)) +} + +// Writef writes formatted data as a pkt-line. +func (w *Writer) Writef(format string, args ...interface{}) error { + return w.WriteString(fmt.Sprintf(format, args...)) +} + +// Flush writes a flush packet (0000). +func (w *Writer) Flush() error { + _, err := w.w.Write([]byte("0000")) + return err +} diff --git a/internal/protocol/upload_pack.go b/internal/protocol/upload_pack.go new file mode 100644 index 0000000..1e84007 --- /dev/null +++ b/internal/protocol/upload_pack.go @@ -0,0 +1,264 @@ +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/have lines + var wants []string + var haves []string + var capabilities []string + + for { + line, err := reader.ReadString() + if err == io.EOF { + break // flush-pkt + } + if err != nil { + return fmt.Errorf("reading request: %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], " ") + } + } else if strings.HasPrefix(line, "have ") { + haves = append(haves, line[5:]) + } else if line == "done" { + break + } + } + + // For now, we'll send all requested objects without negotiation + // In a real implementation, we'd use the "have" list to optimize + + // Send NAK (we don't have any of the client's objects) + if err := writer.WriteString("NAK\n"); err != nil { + return fmt.Errorf("writing 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 +} diff --git a/internal/repo/repo.go b/internal/repo/repo.go new file mode 100644 index 0000000..6e217c5 --- /dev/null +++ b/internal/repo/repo.go @@ -0,0 +1,265 @@ +package repo + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/imjasonh/infinite-git/internal/object" +) + +// Repository represents a Git repository. +type Repository struct { + path string + gitDir string + mu sync.Mutex + count int64 +} + +// New creates or opens a Git repository at the given path. +func New(path string) (*Repository, error) { + repo := &Repository{ + path: path, + gitDir: filepath.Join(path, ".git"), + } + + // Create directory if it doesn't exist + if err := os.MkdirAll(path, 0755); err != nil { + return nil, fmt.Errorf("creating repo directory: %w", err) + } + + // Check if it's already a git repo + if _, err := os.Stat(repo.gitDir); os.IsNotExist(err) { + // Initialize new repository + if err := repo.init(); err != nil { + return nil, fmt.Errorf("initializing repository: %w", err) + } + + // Create initial commit + if err := repo.createInitialCommit(); err != nil { + return nil, fmt.Errorf("creating initial commit: %w", err) + } + } + + return repo, nil +} + +// init creates the Git directory structure. +func (r *Repository) init() error { + // Create .git directory structure + dirs := []string{ + r.gitDir, + filepath.Join(r.gitDir, "objects"), + filepath.Join(r.gitDir, "refs"), + filepath.Join(r.gitDir, "refs", "heads"), + filepath.Join(r.gitDir, "refs", "tags"), + } + + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + } + + // Create HEAD file pointing to main branch + headPath := filepath.Join(r.gitDir, "HEAD") + if err := os.WriteFile(headPath, []byte("ref: refs/heads/main\n"), 0644); err != nil { + return fmt.Errorf("creating HEAD: %w", err) + } + + // Create config file + configPath := filepath.Join(r.gitDir, "config") + config := `[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true +` + if err := os.WriteFile(configPath, []byte(config), 0644); err != nil { + return fmt.Errorf("creating config: %w", err) + } + + return nil +} + +// createInitialCommit creates the first commit in the repository. +func (r *Repository) createInitialCommit() error { + // Create README content + readmeContent := []byte("# Infinite Git Repository\n\nThis repository generates a new commit every time you pull.\n") + + // Create blob for README + blob := object.NewBlob(readmeContent) + blobHash, err := object.Write(r.gitDir, blob) + if err != nil { + return fmt.Errorf("writing blob: %w", err) + } + + // Create tree with README + tree := object.NewTree() + tree.AddEntry("100644", "README.md", blobHash) + treeHash, err := object.Write(r.gitDir, tree) + if err != nil { + return fmt.Errorf("writing tree: %w", err) + } + + // Create commit + commit := object.NewCommit( + treeHash, + "", // No parent for initial commit + "Infinite Git ", + "Infinite Git ", + "Initial commit", + ) + commitHash, err := object.Write(r.gitDir, commit) + if err != nil { + return fmt.Errorf("writing commit: %w", err) + } + + // Update refs/heads/main + refPath := filepath.Join(r.gitDir, "refs", "heads", "main") + if err := os.WriteFile(refPath, []byte(commitHash+"\n"), 0644); err != nil { + return fmt.Errorf("updating ref: %w", err) + } + + // Also write README to working directory + readmePath := filepath.Join(r.path, "README.md") + if err := os.WriteFile(readmePath, readmeContent, 0644); err != nil { + return fmt.Errorf("writing README to working directory: %w", err) + } + + return nil +} + +// Path returns the repository path. +func (r *Repository) Path() string { + return r.path +} + +// GitDir returns the .git directory path. +func (r *Repository) GitDir() string { + return r.gitDir +} + +// GetRefs returns the current refs in the repository. +func (r *Repository) GetRefs() (map[string]string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + refs := make(map[string]string) + + // Read refs from refs directory + refsDir := filepath.Join(r.gitDir, "refs") + err := filepath.Walk(refsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + // Read ref content + content, err := os.ReadFile(path) + if err != nil { + return err + } + + // Get ref name relative to .git + relPath, err := filepath.Rel(r.gitDir, path) + if err != nil { + return err + } + + refs[relPath] = strings.TrimSpace(string(content)) + return nil + }) + if err != nil { + return nil, fmt.Errorf("reading refs: %w", err) + } + + // Read HEAD + headPath := filepath.Join(r.gitDir, "HEAD") + headContent, err := os.ReadFile(headPath) + if err != nil { + return nil, fmt.Errorf("reading HEAD: %w", err) + } + + headStr := strings.TrimSpace(string(headContent)) + if strings.HasPrefix(headStr, "ref: ") { + // HEAD is a symbolic ref + refName := strings.TrimPrefix(headStr, "ref: ") + if hash, ok := refs[refName]; ok { + refs["HEAD"] = hash + } + } else { + // HEAD is a direct hash + refs["HEAD"] = headStr + } + + return refs, nil +} + +// GetCapabilities returns the Git capabilities this server supports. +func (r *Repository) GetCapabilities() []string { + return []string{ + "multi_ack", + "thin-pack", + "side-band", + "side-band-64k", + "ofs-delta", + "shallow", + "no-progress", + "include-tag", + "multi_ack_detailed", + "no-done", + "symref=HEAD:refs/heads/main", + "agent=infinite-git/1.0", + } +} + +// ReadObject reads an object from the repository. +func (r *Repository) ReadObject(hash string) ([]byte, error) { + return object.Read(r.gitDir, hash) +} + +// ReadObjectFull reads an object from the repository with its header. +func (r *Repository) ReadObjectFull(hash string) ([]byte, error) { + return object.ReadFull(r.gitDir, hash) +} + +// WriteObject writes an object to the repository. +func (r *Repository) WriteObject(obj object.Object) (string, error) { + return object.Write(r.gitDir, obj) +} + +// UpdateRef updates a reference to point to a new object. +func (r *Repository) UpdateRef(ref, hash string) error { + refPath := filepath.Join(r.gitDir, ref) + refDir := filepath.Dir(refPath) + + // Create ref directory if needed + if err := os.MkdirAll(refDir, 0755); err != nil { + return fmt.Errorf("creating ref directory: %w", err) + } + + // Write new hash + if err := os.WriteFile(refPath, []byte(hash+"\n"), 0644); err != nil { + return fmt.Errorf("updating ref: %w", err) + } + + return nil +} + +// GetObject reads and returns an object by hash. +func (r *Repository) GetObject(hash string) (io.ReadCloser, error) { + objPath := filepath.Join(r.gitDir, "objects", hash[:2], hash[2:]) + + file, err := os.Open(objPath) + if err != nil { + return nil, fmt.Errorf("opening object: %w", err) + } + + return file, nil +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..551d61a --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,108 @@ +package server + +import ( + "fmt" + "net/http" + "strings" + + "github.com/imjasonh/infinite-git/internal/pktline" + "github.com/imjasonh/infinite-git/internal/protocol" +) + +// handleInfoRefs handles the reference discovery phase. +func (s *Server) handleInfoRefs(w http.ResponseWriter, r *http.Request) { + service := r.URL.Query().Get("service") + + // Only support git-upload-pack (fetch/clone) + if service != "git-upload-pack" { + http.Error(w, "Service not supported", http.StatusForbidden) + return + } + + // Generate a new commit before advertising refs + s.mu.Lock() + commitSHA, err := s.generator.GenerateCommit() + s.mu.Unlock() + + if err != nil { + s.logger.Error("failed to generate commit", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + s.logger.Info("generated new commit", "sha", commitSHA, "counter", s.generator.GetCounter()) + + // Set headers + w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service)) + w.Header().Set("Cache-Control", "no-cache") + + // Write response + pw := pktline.NewWriter(w) + + // Service declaration + if err := pw.Writef("# service=%s\n", service); err != nil { + s.logger.Error("failed to write service line", "error", err) + return + } + if err := pw.Flush(); err != nil { + s.logger.Error("failed to write flush", "error", err) + return + } + + // Get current refs + refs, err := s.repo.GetRefs() + if err != nil { + s.logger.Error("failed to get refs", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Write capabilities with first ref + capabilities := strings.Join(s.repo.GetCapabilities(), " ") + first := true + + for ref, oid := range refs { + if first { + if err := pw.Writef("%s %s\x00%s\n", oid, ref, capabilities); err != nil { + s.logger.Error("failed to write ref with capabilities", "error", err) + return + } + first = false + } else { + if err := pw.Writef("%s %s\n", oid, ref); err != nil { + s.logger.Error("failed to write ref", "error", err) + return + } + } + } + + // Final flush + if err := pw.Flush(); err != nil { + s.logger.Error("failed to write final flush", "error", err) + return + } +} + +// handleUploadPack handles the pack upload phase. +func (s *Server) handleUploadPack(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Set headers + w.Header().Set("Content-Type", "application/x-git-upload-pack-result") + w.Header().Set("Cache-Control", "no-cache") + + // Create upload-pack handler + up := protocol.NewUploadPack(s.repo) + + // Process the request + if err := up.HandleRequest(r.Body, w); err != nil { + s.logger.Error("upload-pack failed", "error", err) + // Don't send HTTP error here as we may have already started writing response + return + } + + s.logger.Info("completed upload-pack") +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..bd07516 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,67 @@ +package server + +import ( + "log/slog" + "net/http" + "sync" + + "github.com/imjasonh/infinite-git/internal/generator" + "github.com/imjasonh/infinite-git/internal/repo" +) + +// Server handles Git HTTP protocol requests. +type Server struct { + repo *repo.Repository + generator *generator.Generator + mu sync.Mutex + logger *slog.Logger +} + +// New creates a new Git HTTP server. +func New(r *repo.Repository, logger *slog.Logger) *Server { + return &Server{ + repo: r, + generator: generator.New(r), + logger: logger, + } +} + +// Handler returns the HTTP handler for the server. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + // Git smart HTTP endpoints + mux.HandleFunc("/info/refs", s.handleInfoRefs) + mux.HandleFunc("/git-upload-pack", s.handleUploadPack) + mux.HandleFunc("/git-receive-pack", s.handleReceivePack) + + // Static file serving for dumb protocol (objects, refs) + mux.HandleFunc("/", s.handleStatic) + + return s.logMiddleware(mux) +} + +// logMiddleware logs HTTP requests. +func (s *Server) logMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.logger.Info("request", + "method", r.Method, + "path", r.URL.Path, + "query", r.URL.RawQuery, + "remote", r.RemoteAddr, + ) + next.ServeHTTP(w, r) + }) +} + +// handleReceivePack rejects push operations. +func (s *Server) handleReceivePack(w http.ResponseWriter, r *http.Request) { + s.logger.Info("rejecting push attempt", "path", r.URL.Path) + http.Error(w, "Push access denied", http.StatusForbidden) +} + +// handleStatic serves static Git files (for dumb protocol). +func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { + // For now, we'll focus on smart protocol only + http.NotFound(w, r) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9c2d0f2 --- /dev/null +++ b/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/imjasonh/infinite-git/internal/repo" + "github.com/imjasonh/infinite-git/internal/server" +) + +func main() { + var ( + addr = flag.String("addr", ":8080", "HTTP server address") + repoPath = flag.String("repo", "./infinite-repo", "Path to Git repository") + logLevel = flag.String("log-level", "info", "Log level (debug, info, warn, error)") + ) + flag.Parse() + + // Set up logger + level := slog.LevelInfo + switch *logLevel { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: level, + })) + slog.SetDefault(logger) + + // Initialize repository + logger.Info("initializing repository", "path", *repoPath) + gitRepo, err := repo.New(*repoPath) + if err != nil { + logger.Error("failed to initialize repository", "error", err) + os.Exit(1) + } + + // Create server + srv := server.New(gitRepo, logger) + + // Set up HTTP server + httpServer := &http.Server{ + Addr: *addr, + Handler: srv.Handler(), + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // Start server in goroutine + go func() { + logger.Info("starting HTTP server", "addr", *addr) + logger.Info("to clone: git clone http://localhost" + *addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("HTTP server error", "error", err) + os.Exit(1) + } + }() + + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + <-sigChan + + // Graceful shutdown + logger.Info("shutting down server...") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := httpServer.Shutdown(ctx); err != nil { + logger.Error("server shutdown error", "error", err) + } + + logger.Info("server stopped") +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..9ad4a4d --- /dev/null +++ b/main_test.go @@ -0,0 +1,284 @@ +package main + +import ( + "fmt" + "io" + "log/slog" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/imjasonh/infinite-git/internal/repo" + "github.com/imjasonh/infinite-git/internal/server" +) + +func TestCloneAndPull(t *testing.T) { + // Create temporary directories + serverRepoDir := t.TempDir() + clientRepoDir := t.TempDir() + + // Initialize server repository + serverRepo, err := repo.New(serverRepoDir) + if err != nil { + t.Fatalf("failed to create server repo: %v", err) + } + + // Create server with silent logger + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := server.New(serverRepo, logger) + + // Start test server + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + // Clone the repository + gitRepo, err := git.PlainClone(clientRepoDir, false, &git.CloneOptions{ + URL: ts.URL, + }) + if err != nil { + t.Fatalf("failed to clone: %v", err) + } + + // Get initial commit + ref, err := gitRepo.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + initialCommit := ref.Hash() + t.Logf("Initial commit: %s", initialCommit) + + // Count initial commits + initialCount := countCommits(t, gitRepo) + t.Logf("Initial commit count: %d", initialCount) + + // Perform multiple pulls + commits := []plumbing.Hash{initialCommit} + for i := 0; i < 3; i++ { + // Pull from origin + w, err := gitRepo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + err = w.Pull(&git.PullOptions{ + RemoteName: "origin", + }) + if err != nil && err != git.NoErrAlreadyUpToDate { + t.Fatalf("pull %d failed: %v", i+1, err) + } + + // Get new HEAD + ref, err := gitRepo.Head() + if err != nil { + t.Fatalf("failed to get HEAD after pull %d: %v", i+1, err) + } + newCommit := ref.Hash() + + // Verify we got a new commit + if newCommit == commits[len(commits)-1] { + t.Errorf("pull %d did not generate a new commit", i+1) + } + commits = append(commits, newCommit) + t.Logf("Pull %d got commit: %s", i+1, newCommit) + + // Verify commit count increased + newCount := countCommits(t, gitRepo) + if newCount != initialCount+i+1 { + t.Errorf("expected %d commits after pull %d, got %d", initialCount+i+1, i+1, newCount) + } + + // Verify new files exist + expectedFile := filepath.Join(clientRepoDir, fmt.Sprintf("pull_%d.txt", i+1)) + if _, err := os.Stat(expectedFile); os.IsNotExist(err) { + t.Errorf("expected file %s does not exist", expectedFile) + } + } + + // Verify all commits are unique + uniqueCommits := make(map[plumbing.Hash]bool) + for _, commit := range commits { + if uniqueCommits[commit] { + t.Errorf("found duplicate commit: %s", commit) + } + uniqueCommits[commit] = true + } +} + +func TestConcurrentPulls(t *testing.T) { + // Create temporary directories + serverRepoDir := t.TempDir() + + // Initialize server repository + serverRepo, err := repo.New(serverRepoDir) + if err != nil { + t.Fatalf("failed to create server repo: %v", err) + } + + // Create server with silent logger + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := server.New(serverRepo, logger) + + // Start test server + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + // Clone initial repository + baseClientDir := t.TempDir() + baseRepo, err := git.PlainClone(baseClientDir, false, &git.CloneOptions{ + URL: ts.URL, + }) + if err != nil { + t.Fatalf("failed to clone: %v", err) + } + + initialCount := countCommits(t, baseRepo) + t.Logf("Initial commit count: %d", initialCount) + + // Perform concurrent pulls + const numPulls = 5 + var wg sync.WaitGroup + commits := make([]plumbing.Hash, numPulls) + errors := make([]error, numPulls) + + for i := 0; i < numPulls; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + // Clone a fresh repo for this goroutine + clientDir := filepath.Join(t.TempDir(), fmt.Sprintf("client_%d", idx)) + gitRepo, err := git.PlainClone(clientDir, false, &git.CloneOptions{ + URL: ts.URL, + }) + if err != nil { + errors[idx] = fmt.Errorf("clone failed: %w", err) + return + } + + // Get the commit we received + ref, err := gitRepo.Head() + if err != nil { + errors[idx] = fmt.Errorf("get HEAD failed: %w", err) + return + } + commits[idx] = ref.Hash() + t.Logf("Concurrent pull %d got commit: %s", idx, commits[idx]) + }(i) + } + + wg.Wait() + + // Check for errors + for i, err := range errors { + if err != nil { + t.Errorf("goroutine %d failed: %v", i, err) + } + } + + // Verify all commits are unique + uniqueCommits := make(map[plumbing.Hash]bool) + for i, commit := range commits { + if uniqueCommits[commit] { + t.Errorf("concurrent pull %d got duplicate commit: %s", i, commit) + } + uniqueCommits[commit] = true + } + + // All pulls should have generated unique commits + if len(uniqueCommits) != numPulls { + t.Errorf("expected %d unique commits, got %d", numPulls, len(uniqueCommits)) + } +} + +func TestPushRejection(t *testing.T) { + // Create temporary directories + serverRepoDir := t.TempDir() + clientRepoDir := t.TempDir() + + // Initialize server repository + serverRepo, err := repo.New(serverRepoDir) + if err != nil { + t.Fatalf("failed to create server repo: %v", err) + } + + // Create server with silent logger + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := server.New(serverRepo, logger) + + // Start test server + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + // Clone the repository + gitRepo, err := git.PlainClone(clientRepoDir, false, &git.CloneOptions{ + URL: ts.URL, + }) + if err != nil { + t.Fatalf("failed to clone: %v", err) + } + + // Create a new file and commit + testFile := filepath.Join(clientRepoDir, "test-push.txt") + if err := os.WriteFile(testFile, []byte("test push content"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + w, err := gitRepo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + if _, err := w.Add("test-push.txt"); err != nil { + t.Fatalf("failed to add file: %v", err) + } + + if _, err := w.Commit("Test push commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + }, + }); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + // Try to push - should fail + err = gitRepo.Push(&git.PushOptions{ + RemoteName: "origin", + Auth: &http.BasicAuth{ + Username: "test", + Password: "test", + }, + }) + + if err == nil { + t.Fatal("push should have been rejected but succeeded") + } + + // Verify the error indicates push was forbidden + t.Logf("Push rejected with error: %v", err) +} + +// Helper function to count commits +func countCommits(t *testing.T, repo *git.Repository) int { + iter, err := repo.Log(&git.LogOptions{}) + if err != nil { + t.Fatalf("failed to get log: %v", err) + } + defer iter.Close() + + count := 0 + err = iter.ForEach(func(c *object.Commit) error { + count++ + return nil + }) + if err != nil { + t.Fatalf("failed to iterate commits: %v", err) + } + return count +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..db09efa --- /dev/null +++ b/plan.md @@ -0,0 +1,80 @@ +# Infinite Git HTTP Server Implementation Plan + +## Overview +I'll implement a Go HTTP server that generates a new commit every time a client pulls from the repository. This creates an "infinite" Git repository where the `main` branch is updated with a new commit on each fetch operation. + +## Key Components + +### 1. HTTP Endpoints +- `GET /info/refs?service=git-upload-pack` - Reference discovery (triggers commit generation) +- `POST /git-upload-pack` - Handle fetch/clone with dynamically generated commits +- Reject all push operations with 403 Forbidden + +### 2. Core Modules +- `pktline` package - Handle Git's packet line format +- `server` package - HTTP server and routing +- `generator` package - Generate new commits on demand +- `upload` package - Modified git-upload-pack to serve dynamic content +- `repo` package - Git repository management + +### 3. Commit Generation Strategy +- On each pull request, generate a new commit with: + - Timestamp in commit message + - Unique content (e.g., pull counter, random data, or timestamp) + - Parent pointing to previous HEAD of main +- Update main branch to point to new commit +- Use Git plumbing commands or go-git library for commit creation + +### 4. Project Structure +``` +. +├── cmd/ +│ └── infinite-git/ +│ └── main.go # Entry point +├── internal/ +│ ├── pktline/ +│ │ ├── reader.go # Pkt-line reader +│ │ └── writer.go # Pkt-line writer +│ ├── server/ +│ │ ├── server.go # HTTP server +│ │ └── handlers.go # HTTP handlers +│ ├── generator/ +│ │ └── commit.go # Commit generation logic +│ ├── upload/ +│ │ └── pack.go # Modified git-upload-pack +│ └── repo/ +│ └── repo.go # Repository management +├── go.mod +└── README.md +``` + +### 5. Implementation Details +- Initialize a bare Git repository on server startup +- Intercept ref discovery requests to generate new commits +- Use mutex to handle concurrent pull requests safely +- Generate commits using either: + - `git hash-object`, `git update-index`, `git write-tree`, `git commit-tree` + - Or go-git library for pure Go implementation +- Each commit could contain: + - A file with incrementing counter + - Timestamp of the pull request + - Client information (if available) + +### 6. Features +- Thread-safe commit generation +- Persistent Git repository on disk +- Configurable commit content generation +- Rate limiting (optional) +- Logging of all pull operations +- Environment variable configuration + +## Example Behavior +```bash +# First client pull +$ git pull origin main +# Gets commit 1a2b3c4... "Pull #1 at 2024-01-20 10:00:00" + +# Second client pull (moments later) +$ git pull origin main +# Gets commit 5d6e7f8... "Pull #2 at 2024-01-20 10:00:05" +``` \ No newline at end of file diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..87df4ec --- /dev/null +++ b/test.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -e + +# Cleanup function +cleanup() { + echo "Cleaning up..." + if [ -n "$SERVER_PID" ]; then + kill $SERVER_PID 2>/dev/null || true + fi +} + +# Trap EXIT to ensure cleanup happens +trap cleanup EXIT + +echo "Starting infinite-git server..." +go run . -addr :9876 -repo /tmp/test-infinite-git & +SERVER_PID=$! + +# Give server time to start +sleep 2 + +# Create test directory +TEST_DIR=$(mktemp -d) +cd "$TEST_DIR" + +echo "Cloning repository..." +git clone http://localhost:9876 test-repo +cd test-repo + +echo "Initial clone complete. Files:" +ls -la + +echo "Pulling again..." +git pull + +echo "Files after first pull:" +ls -la + +echo "Pulling once more..." +git pull + +echo "Files after second pull:" +ls -la + +echo "Checking commits..." +git log --oneline + +# Test directory cleanup +cd / +rm -rf "$TEST_DIR" + +echo "Test complete!" \ No newline at end of file