mirror of
https://github.com/imjasonh/infinite-git
synced 2026-07-13 11:19:14 +00:00
Add cmd/infinite-go: serve Go package with PullTime via git
Refactor infinite-git to support pluggable content generation: - Extract ContentProvider interface (InitialFiles, GenerateFiles, CommitMessage) - Make repo.New accept initial files map instead of hardcoding content - Move infinite-git to cmd/infinite-git with gitContent provider - Add cmd/infinite-go that serves a Go module where each pull generates a new version with PullTime set to the pull timestamp - Include ?go-get=1 discovery middleware for Go module resolution https://claude.ai/code/session_01HpvELk7HnbJoXEUjqHP7SU
This commit is contained in:
parent
16db43a3af
commit
96dd2d9604
8 changed files with 325 additions and 106 deletions
69
cmd/infinite-git/main.go
Normal file
69
cmd/infinite-git/main.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/chainguard-dev/clog/gcp/init"
|
||||
"github.com/imjasonh/infinite-git/internal/generator"
|
||||
"github.com/imjasonh/infinite-git/internal/repo"
|
||||
"github.com/imjasonh/infinite-git/internal/server"
|
||||
"github.com/sethvargo/go-envconfig"
|
||||
)
|
||||
|
||||
var env = envconfig.MustProcess(context.Background(), &struct {
|
||||
Port string `env:"PORT,default=8080"`
|
||||
RepoPath string `env:"REPO_PATH,default=./infinite-repo"`
|
||||
}{})
|
||||
|
||||
// gitContent provides the default infinite-git file content.
|
||||
type gitContent struct{}
|
||||
|
||||
func (g *gitContent) InitialFiles() map[string][]byte {
|
||||
return map[string][]byte{
|
||||
"README.md": []byte("# Infinite Git Repository\n\nThis repository generates a new commit every time you pull.\n"),
|
||||
"hello.txt": []byte("Pull #0\nTimestamp: Initial commit\n"),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gitContent) GenerateFiles(count int64, now time.Time) map[string][]byte {
|
||||
return map[string][]byte{
|
||||
"hello.txt": []byte(fmt.Sprintf("Pull #%d\nTimestamp: %s\n", count, now.Format("2006-01-02 15:04:05.999999999"))),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gitContent) CommitMessage(count int64, now time.Time) string {
|
||||
return fmt.Sprintf("Pull #%d at %s", count, now.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
var _ generator.ContentProvider = (*gitContent)(nil)
|
||||
|
||||
func main() {
|
||||
slog.Info("initializing repository", "env", env)
|
||||
content := &gitContent{}
|
||||
gitRepo, err := repo.New(env.RepoPath, content.InitialFiles())
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize repository", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
srv := server.New(gitRepo, content)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + env.Port,
|
||||
Handler: srv.Handler(),
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
slog.Info("starting HTTP server", "port", env.Port)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("HTTP server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
258
cmd/infinite-git/main_test.go
Normal file
258
cmd/infinite-git/main_test.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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 newTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
content := &gitContent{}
|
||||
serverRepo, err := repo.New(t.TempDir(), content.InitialFiles())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server repo: %v", err)
|
||||
}
|
||||
srv := server.New(serverRepo, content)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func TestCloneAndPull(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
clientRepoDir := t.TempDir()
|
||||
|
||||
// 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 hello.txt exists and has been updated
|
||||
helloFile := filepath.Join(clientRepoDir, "hello.txt")
|
||||
content, err := os.ReadFile(helloFile)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read hello.txt: %v", err)
|
||||
} else {
|
||||
// Clone increments counter to 1, so first pull is #2, second is #3, etc.
|
||||
expectedContent := fmt.Sprintf("Pull #%d\n", i+2)
|
||||
if !strings.Contains(string(content), expectedContent) {
|
||||
t.Errorf("hello.txt does not contain expected content %q, got: %s", expectedContent, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ts := newTestServer(t)
|
||||
|
||||
// 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) {
|
||||
ts := newTestServer(t)
|
||||
clientRepoDir := t.TempDir()
|
||||
|
||||
// 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
|
||||
}
|
||||
122
cmd/infinite-go/main.go
Normal file
122
cmd/infinite-go/main.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/chainguard-dev/clog/gcp/init"
|
||||
"github.com/imjasonh/infinite-git/internal/generator"
|
||||
"github.com/imjasonh/infinite-git/internal/repo"
|
||||
"github.com/imjasonh/infinite-git/internal/server"
|
||||
"github.com/sethvargo/go-envconfig"
|
||||
)
|
||||
|
||||
var env = envconfig.MustProcess(context.Background(), &struct {
|
||||
Port string `env:"PORT,default=8080"`
|
||||
RepoPath string `env:"REPO_PATH,default=./infinite-go-repo"`
|
||||
ModulePath string `env:"MODULE_PATH,default=example.com/infinite-go"`
|
||||
}{})
|
||||
|
||||
// goContent generates a Go package with PullTime set to the pull timestamp.
|
||||
type goContent struct {
|
||||
modulePath string
|
||||
pkgName string
|
||||
}
|
||||
|
||||
func newGoContent(modulePath string) *goContent {
|
||||
// Derive package name from last path element, removing hyphens.
|
||||
pkg := path.Base(modulePath)
|
||||
pkg = strings.ReplaceAll(pkg, "-", "")
|
||||
pkg = strings.ReplaceAll(pkg, ".", "")
|
||||
return &goContent{
|
||||
modulePath: modulePath,
|
||||
pkgName: pkg,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *goContent) InitialFiles() map[string][]byte {
|
||||
return g.GenerateFiles(0, time.Now())
|
||||
}
|
||||
|
||||
func (g *goContent) GenerateFiles(count int64, now time.Time) map[string][]byte {
|
||||
now = now.UTC()
|
||||
|
||||
goMod := fmt.Sprintf("module %s\n\ngo 1.24\n", g.modulePath)
|
||||
|
||||
goFile := fmt.Sprintf(`package %s
|
||||
|
||||
import "time"
|
||||
|
||||
// PullTime is the time this module version was generated.
|
||||
var PullTime = time.Date(%d, time.%s, %d, %d, %d, %d, %d, time.UTC)
|
||||
`,
|
||||
g.pkgName,
|
||||
now.Year(), now.Month(), now.Day(),
|
||||
now.Hour(), now.Minute(), now.Second(), now.Nanosecond(),
|
||||
)
|
||||
|
||||
return map[string][]byte{
|
||||
"go.mod": []byte(goMod),
|
||||
"pulltime.go": []byte(goFile),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *goContent) CommitMessage(count int64, now time.Time) string {
|
||||
return fmt.Sprintf("Pull #%d at %s", count, now.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
var _ generator.ContentProvider = (*goContent)(nil)
|
||||
|
||||
// goGetMiddleware intercepts ?go-get=1 requests for Go module discovery.
|
||||
func goGetMiddleware(modulePath string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("go-get") == "1" {
|
||||
scheme := "https"
|
||||
if r.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
repoURL := fmt.Sprintf("%s://%s", scheme, r.Host)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta name="go-import" content="%s git %s">
|
||||
</head></html>
|
||||
`, modulePath, repoURL)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
slog.Info("initializing repository", "env", env)
|
||||
content := newGoContent(env.ModulePath)
|
||||
gitRepo, err := repo.New(env.RepoPath, content.InitialFiles())
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize repository", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
srv := server.New(gitRepo, content)
|
||||
handler := goGetMiddleware(env.ModulePath, srv.Handler())
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + env.Port,
|
||||
Handler: handler,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
slog.Info("starting HTTP server", "port", env.Port, "module", env.ModulePath)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("HTTP server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
104
cmd/infinite-go/main_test.go
Normal file
104
cmd/infinite-go/main_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/imjasonh/infinite-git/internal/repo"
|
||||
"github.com/imjasonh/infinite-git/internal/server"
|
||||
)
|
||||
|
||||
func TestGoCloneAndPull(t *testing.T) {
|
||||
modulePath := "example.com/infinite-go"
|
||||
content := newGoContent(modulePath)
|
||||
serverRepo, err := repo.New(t.TempDir(), content.InitialFiles())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server repo: %v", err)
|
||||
}
|
||||
srv := server.New(serverRepo, content)
|
||||
ts := httptest.NewServer(goGetMiddleware(modulePath, srv.Handler()))
|
||||
defer ts.Close()
|
||||
|
||||
// Clone the repository
|
||||
clientDir := t.TempDir()
|
||||
gitRepo, err := git.PlainClone(clientDir, false, &git.CloneOptions{
|
||||
URL: ts.URL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to clone: %v", err)
|
||||
}
|
||||
|
||||
// Verify go.mod exists with correct module path
|
||||
goMod, err := os.ReadFile(filepath.Join(clientDir, "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read go.mod: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(goMod), fmt.Sprintf("module %s", modulePath)) {
|
||||
t.Errorf("go.mod does not contain expected module path, got: %s", goMod)
|
||||
}
|
||||
|
||||
// Verify pulltime.go exists with PullTime var
|
||||
goFile, err := os.ReadFile(filepath.Join(clientDir, "pulltime.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read pulltime.go: %v", err)
|
||||
}
|
||||
goFileStr := string(goFile)
|
||||
if !strings.Contains(goFileStr, "package infinitego") {
|
||||
t.Errorf("pulltime.go does not contain expected package name, got: %s", goFile)
|
||||
}
|
||||
if !strings.Contains(goFileStr, "var PullTime = time.Date(") {
|
||||
t.Errorf("pulltime.go does not contain PullTime var, got: %s", goFile)
|
||||
}
|
||||
|
||||
// Pull again and verify PullTime changed
|
||||
firstGoFile := goFileStr
|
||||
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 failed: %v", err)
|
||||
}
|
||||
goFile, err = os.ReadFile(filepath.Join(clientDir, "pulltime.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read pulltime.go after pull: %v", err)
|
||||
}
|
||||
if string(goFile) == firstGoFile {
|
||||
t.Error("pulltime.go did not change after pull")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoGetDiscovery(t *testing.T) {
|
||||
modulePath := "example.com/infinite-go"
|
||||
content := newGoContent(modulePath)
|
||||
serverRepo, err := repo.New(t.TempDir(), content.InitialFiles())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server repo: %v", err)
|
||||
}
|
||||
srv := server.New(serverRepo, content)
|
||||
ts := httptest.NewServer(goGetMiddleware(modulePath, srv.Handler()))
|
||||
defer ts.Close()
|
||||
|
||||
// Test ?go-get=1 returns go-import meta tag
|
||||
resp, err := http.Get(ts.URL + "/?go-get=1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch go-get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := resp.Body.Read(buf)
|
||||
body := string(buf[:n])
|
||||
|
||||
expected := fmt.Sprintf(`<meta name="go-import" content="%s git`, modulePath)
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("go-get response does not contain expected meta tag.\nexpected to contain: %s\ngot: %s", expected, body)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue