1
0
Fork 0
mirror of https://github.com/imjasonh/infinite-git synced 2026-07-08 09:05:06 +00:00

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 <noreply@anthropic.com>
This commit is contained in:
Jason Hall 2025-07-23 21:19:02 -04:00
parent fefad053a8
commit 30ca57dae3
Failed to extract signature
19 changed files with 2172 additions and 0 deletions

78
README.md Normal file
View file

@ -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 <cloned-repo>
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

26
go.mod Normal file
View file

@ -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
)

68
go.sum Normal file
View file

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

View file

@ -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@example.com>",
"Infinite Git <infinite@example.com>",
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
}

21
internal/object/blob.go Normal file
View file

@ -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
}

75
internal/object/commit.go Normal file
View file

@ -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()
}

136
internal/object/object.go Normal file
View file

@ -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
}

66
internal/object/tree.go Normal file
View file

@ -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: <mode> <name>\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()
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

265
internal/repo/repo.go Normal file
View file

@ -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@example.com>",
"Infinite Git <infinite@example.com>",
"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
}

108
internal/server/handlers.go Normal file
View file

@ -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")
}

67
internal/server/server.go Normal file
View file

@ -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)
}

86
main.go Normal file
View file

@ -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")
}

284
main_test.go Normal file
View file

@ -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
}

80
plan.md Normal file
View file

@ -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"
```

52
test.sh Executable file
View file

@ -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!"