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:
parent
fefad053a8
commit
30ca57dae3
19 changed files with 2172 additions and 0 deletions
78
README.md
Normal file
78
README.md
Normal 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
26
go.mod
Normal 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
68
go.sum
Normal 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=
|
||||
181
internal/generator/commit.go
Normal file
181
internal/generator/commit.go
Normal 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
21
internal/object/blob.go
Normal 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
75
internal/object/commit.go
Normal 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
136
internal/object/object.go
Normal 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
66
internal/object/tree.go
Normal 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()
|
||||
}
|
||||
171
internal/packfile/packfile.go
Normal file
171
internal/packfile/packfile.go
Normal 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
|
||||
}
|
||||
89
internal/pktline/reader.go
Normal file
89
internal/pktline/reader.go
Normal 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
|
||||
}
|
||||
55
internal/pktline/writer.go
Normal file
55
internal/pktline/writer.go
Normal 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
|
||||
}
|
||||
264
internal/protocol/upload_pack.go
Normal file
264
internal/protocol/upload_pack.go
Normal 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
265
internal/repo/repo.go
Normal 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
108
internal/server/handlers.go
Normal 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
67
internal/server/server.go
Normal 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
86
main.go
Normal 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
284
main_test.go
Normal 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
80
plan.md
Normal 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
52
test.sh
Executable 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!"
|
||||
Loading…
Add table
Add a link
Reference in a new issue