diff --git a/main.go b/cmd/infinite-git/main.go similarity index 50% rename from main.go rename to cmd/infinite-git/main.go index 1f5de6b..0a62cda 100644 --- a/main.go +++ b/cmd/infinite-git/main.go @@ -2,12 +2,14 @@ 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" @@ -18,21 +20,39 @@ var env = envconfig.MustProcess(context.Background(), &struct { RepoPath string `env:"REPO_PATH,default=./infinite-repo"` }{}) -func main() { - // clog/gcp/init automatically sets up the logger +// gitContent provides the default infinite-git file content. +type gitContent struct{} - // Initialize repository +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) - gitRepo, err := repo.New(env.RepoPath) + content := &gitContent{} + gitRepo, err := repo.New(env.RepoPath, content.InitialFiles()) if err != nil { slog.Error("failed to initialize repository", "error", err) os.Exit(1) } - // Create server - srv := server.New(gitRepo) + srv := server.New(gitRepo, content) - // Set up HTTP server httpServer := &http.Server{ Addr: ":" + env.Port, Handler: srv.Handler(), diff --git a/main_test.go b/cmd/infinite-git/main_test.go similarity index 87% rename from main_test.go rename to cmd/infinite-git/main_test.go index 008bef4..555fe1e 100644 --- a/main_test.go +++ b/cmd/infinite-git/main_test.go @@ -17,22 +17,22 @@ import ( "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) +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) - - // Start test server + srv := server.New(serverRepo, content) ts := httptest.NewServer(srv.Handler()) - defer ts.Close() + 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{ @@ -115,20 +115,7 @@ func TestCloneAndPull(t *testing.T) { } 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) - } - - srv := server.New(serverRepo) - - // Start test server - ts := httptest.NewServer(srv.Handler()) - defer ts.Close() + ts := newTestServer(t) // Clone initial repository baseClientDir := t.TempDir() @@ -199,22 +186,9 @@ func TestConcurrentPulls(t *testing.T) { } func TestPushRejection(t *testing.T) { - // Create temporary directories - serverRepoDir := t.TempDir() + ts := newTestServer(t) clientRepoDir := t.TempDir() - // Initialize server repository - serverRepo, err := repo.New(serverRepoDir) - if err != nil { - t.Fatalf("failed to create server repo: %v", err) - } - - srv := server.New(serverRepo) - - // 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, diff --git a/cmd/infinite-go/main.go b/cmd/infinite-go/main.go new file mode 100644 index 0000000..67ebfab --- /dev/null +++ b/cmd/infinite-go/main.go @@ -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, ` +
+ + +`, 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) + } +} diff --git a/cmd/infinite-go/main_test.go b/cmd/infinite-go/main_test.go new file mode 100644 index 0000000..e14382d --- /dev/null +++ b/cmd/infinite-go/main_test.go @@ -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(`