Commit a108f3a

HPCesia <me@hpcesia.com>
2026-07-01 17:26:13
Add dumb HTTP protocol support for hosting git repos on static servers
1 parent 5ff9c09
pkg/git/git.go
@@ -74,14 +74,16 @@ func Tags(repoDir string) ([]Tag, error) {
 		if err != nil {
 			return nil, fmt.Errorf("failed to parse tag creation date: %w", err)
 		}
-		if commitHash == "" {
-			commitHash = objectName // tag is lightweight
-		}
-		tags = append(tags, Tag{
+		t := Tag{
 			Name:       name,
 			Date:       time.Unix(int64(timestampInt), 0),
+			ObjectHash: objectName,
 			CommitHash: commitHash,
-		})
+		}
+		if t.CommitHash == "" {
+			t.CommitHash = objectName // tag is lightweight
+		}
+		tags = append(tags, t)
 	}
 
 	return tags, nil
pkg/git/types.go
@@ -69,5 +69,6 @@ type RefName struct {
 type Tag struct {
 	Name       string
 	Date       time.Time
-	CommitHash string
+	ObjectHash string // The hash the tag ref directly points to (tag object for annotated, commit for lightweight)
+	CommitHash string // The peeled commit hash
 }
dumb.go
@@ -0,0 +1,173 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"github.com/antonmedv/gitmal/pkg/git"
+)
+
+func generateDumbProtocol(params Params, branches []git.Ref, defaultBranch string, tags []git.Tag, commitsFor map[git.Ref][]git.Commit, allCommits map[string]git.Commit) error {
+	out := params.OutputDir
+
+	dirs := []string{
+		filepath.Join(out, "info"),
+		filepath.Join(out, "objects", "info"),
+		filepath.Join(out, "objects", "pack"),
+		filepath.Join(out, "refs", "heads"),
+		filepath.Join(out, "refs", "tags"),
+	}
+	for _, d := range dirs {
+		if err := os.MkdirAll(d, 0o755); err != nil {
+			return fmt.Errorf("mkdir %s: %w", d, err)
+		}
+	}
+
+	// HEAD
+	head := fmt.Sprintf("ref: refs/heads/%s\n", defaultBranch)
+	if err := os.WriteFile(filepath.Join(out, "HEAD"), []byte(head), 0o644); err != nil {
+		return fmt.Errorf("write HEAD: %w", err)
+	}
+
+	// info/refs
+	var refsBuf strings.Builder
+	for _, branch := range branches {
+		commits, ok := commitsFor[branch]
+		if !ok {
+			continue
+		}
+		if len(commits) > 0 {
+			h := commits[0].Hash
+			refsBuf.WriteString(fmt.Sprintf("%s\trefs/heads/%s\n", h, branch.String()))
+			if branch.String() == defaultBranch {
+				refsBuf.WriteString(fmt.Sprintf("%s\tHEAD\n", h))
+			}
+		}
+	}
+	for _, t := range tags {
+		refsBuf.WriteString(fmt.Sprintf("%s\trefs/tags/%s\n", t.ObjectHash, t.Name))
+		if t.ObjectHash != t.CommitHash {
+			refsBuf.WriteString(fmt.Sprintf("%s\trefs/tags/%s^{}\n", t.CommitHash, t.Name))
+		}
+	}
+	if err := os.WriteFile(filepath.Join(out, "info", "refs"), []byte(refsBuf.String()), 0o644); err != nil {
+		return fmt.Errorf("write info/refs: %w", err)
+	}
+
+	// refs/heads/<branch>
+	for _, branch := range branches {
+		commits, ok := commitsFor[branch]
+		if !ok || len(commits) == 0 {
+			continue
+		}
+		p := filepath.Join(out, "refs", "heads", branch.String())
+		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+			return fmt.Errorf("mkdir %s: %w", filepath.Dir(p), err)
+		}
+		if err := os.WriteFile(p, []byte(commits[0].Hash+"\n"), 0o644); err != nil {
+			return fmt.Errorf("write %s: %w", p, err)
+		}
+	}
+
+	// refs/tags/<tag>
+	for _, t := range tags {
+		p := filepath.Join(out, "refs", "tags", t.Name)
+		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+			return fmt.Errorf("mkdir %s: %w", filepath.Dir(p), err)
+		}
+		if err := os.WriteFile(p, []byte(t.ObjectHash+"\n"), 0o644); err != nil {
+			return fmt.Errorf("write %s: %w", p, err)
+		}
+	}
+
+	// Copy objects
+	srcObjects, err := findObjectsDir(params.RepoDir)
+	if err != nil {
+		return fmt.Errorf("find objects dir: %w", err)
+	}
+	dstObjects := filepath.Join(out, "objects")
+	if err := hardlinkDir(srcObjects, dstObjects); err != nil {
+		return fmt.Errorf("copy objects: %w", err)
+	}
+
+	// objects/info/packs
+	packsDir := filepath.Join(out, "objects", "pack")
+	entries, err := os.ReadDir(packsDir)
+	if os.IsNotExist(err) {
+		// no pack files — nothing to advertise
+	} else if err != nil {
+		return fmt.Errorf("read packs dir: %w", err)
+	} else {
+		var packsBuf strings.Builder
+		for _, e := range entries {
+			if !e.IsDir() && strings.HasSuffix(e.Name(), ".pack") {
+				packsBuf.WriteString("P " + e.Name() + "\n")
+			}
+		}
+		if packsBuf.Len() > 0 {
+			p := filepath.Join(out, "objects", "info", "packs")
+			if err := os.WriteFile(p, []byte(packsBuf.String()), 0o644); err != nil {
+				return fmt.Errorf("write objects/info/packs: %w", err)
+			}
+		}
+	}
+
+	return nil
+}
+
+func findObjectsDir(repoDir string) (string, error) {
+	cmd := exec.Command("git", "rev-parse", "--git-dir")
+	cmd.Dir = repoDir
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("rev-parse --git-dir: %w", err)
+	}
+	gitDir := strings.TrimSpace(string(out))
+	if !filepath.IsAbs(gitDir) {
+		gitDir = filepath.Join(repoDir, gitDir)
+	}
+	objectsDir := filepath.Join(gitDir, "objects")
+	if fi, err := os.Stat(objectsDir); err != nil || !fi.IsDir() {
+		return "", fmt.Errorf("objects dir not found at %s", objectsDir)
+	}
+	return objectsDir, nil
+}
+
+func hardlinkDir(src, dst string) error {
+	return filepath.Walk(src, func(path string, fi os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		rel, err := filepath.Rel(src, path)
+		if err != nil {
+			return err
+		}
+		target := filepath.Join(dst, rel)
+		if fi.IsDir() {
+			return os.MkdirAll(target, fi.Mode())
+		}
+		if err := os.Link(path, target); err != nil {
+			return copyFile(path, target, fi.Mode())
+		}
+		return nil
+	})
+}
+
+func copyFile(src, dst string, mode os.FileMode) error {
+	in, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+	out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+	_, err = io.Copy(out, in)
+	return err
+}
main.go
@@ -23,6 +23,7 @@ var (
 	flagPreviewThemes bool
 	flagMinify        bool
 	flagGzip          bool
+	flagGit           bool
 )
 
 type Params struct {
@@ -68,6 +69,7 @@ func main() {
 	flag.BoolVar(&flagPreviewThemes, "preview-themes", false, "Preview available themes")
 	flag.BoolVar(&flagMinify, "minify", false, "Minify all generated HTML files")
 	flag.BoolVar(&flagGzip, "gzip", false, "Compress all generated HTML files")
+	flag.BoolVar(&flagGit, "git", false, "Generate static files for Git dumb HTTP protocol")
 	flag.Usage = usage
 	flag.Parse()
 
@@ -244,6 +246,14 @@ func main() {
 		panic(err)
 	}
 
+	// Git dumb protocol files generation
+	if flagGit {
+		echo("> generating dumb protocol files...")
+		if err := generateDumbProtocol(params, branches, flagDefaultBranch, tags, commitsFor, commits); err != nil {
+			panic(err)
+		}
+	}
+
 	// Index page generation
 	if !noFiles {
 		if len(defaultBranchFiles) == 0 {