a self-hosted git server in one binary — everyone reads, only the owner writes
README.md | 17 +++++
api.go | 219 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
api_test.go | 87 ++++++++++++++++++++++++
gitcmd.go | 42 ++++++------
server.go | 10 +++
5 files changed, 354 insertions(+), 21 deletions(-)
diff --git a/README.md b/README.md
index 85e03aa..67e9be0 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,23 @@ Since this machine becomes the source of truth, back the repo root up somewhere
rsync -a /srv/frieren/repos/ backup-host:frieren-repos/
```
+## JSON API
+
+Everything the web UI shows is also served as JSON under `/api`, so external frontends can build their own experience on top. Same access model: world-readable, nothing writes. Refs and paths travel as query parameters, so branch names with slashes just work.
+
+```
+GET /api/repos all repositories
+GET /api/repos/{name} one repository's info
+GET /api/repos/{name}/tree?ref=&path= directory listing
+GET /api/repos/{name}/blob?ref=&path= file content (text inline, binary flagged)
+GET /api/repos/{name}/readme?ref= root README, if any
+GET /api/repos/{name}/commits?ref=&n= commit log
+GET /api/repos/{name}/commit/{hash} one commit with its patch
+GET /api/repos/{name}/refs branches and tags
+```
+
+Omitting `ref` uses the repository's default branch.
+
## What it deliberately isn't
No issues, no pull requests, no user accounts, no markdown rendering yet — it hosts and shows git repositories, and stops there. The single-writer model is the point: if you need collaborators with write access, you want a full forge like Forgejo.
diff --git a/api.go b/api.go
new file mode 100644
index 0000000..7a3c261
--- /dev/null
+++ b/api.go
@@ -0,0 +1,219 @@
+package main
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "strconv"
+)
+
+// Read-only JSON API for external frontends. Same access model as the rest
+// of the server: everything here is world-readable, nothing writes.
+
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ if err := json.NewEncoder(w).Encode(v); err != nil {
+ log.Printf("encode json: %v", err)
+ }
+}
+
+func apiError(w http.ResponseWriter, code int, msg string) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(code)
+ json.NewEncoder(w).Encode(map[string]string{"error": msg})
+}
+
+func (srv *Server) apiRepo(w http.ResponseWriter, r *http.Request) *RepoInfo {
+ info, err := srv.Store.open(repoParam(r))
+ if err != nil {
+ apiError(w, http.StatusNotFound, "no such repository")
+ return nil
+ }
+ return info
+}
+
+// refParam returns the requested ref, falling back to the repo default,
+// or writes a 400 and returns "" when the ref is malformed.
+func refParam(w http.ResponseWriter, r *http.Request, repo *RepoInfo) string {
+ ref := r.URL.Query().Get("ref")
+ if ref == "" {
+ ref = repo.Default
+ }
+ if !validRef(ref) {
+ apiError(w, http.StatusBadRequest, "invalid ref")
+ return ""
+ }
+ return ref
+}
+
+func (srv *Server) apiRepos(w http.ResponseWriter, r *http.Request) {
+ repos := srv.Store.list()
+ if repos == nil {
+ repos = []*RepoInfo{}
+ }
+ writeJSON(w, repos)
+}
+
+func (srv *Server) apiRepoInfo(w http.ResponseWriter, r *http.Request) {
+ repo := srv.apiRepo(w, r)
+ if repo == nil {
+ return
+ }
+ writeJSON(w, repo)
+}
+
+func (srv *Server) apiTree(w http.ResponseWriter, r *http.Request) {
+ repo := srv.apiRepo(w, r)
+ if repo == nil {
+ return
+ }
+ ref := refParam(w, r, repo)
+ if ref == "" {
+ return
+ }
+ path := r.URL.Query().Get("path")
+ if !validPath(path) {
+ apiError(w, http.StatusBadRequest, "invalid path")
+ return
+ }
+ entries, err := srv.Store.lsTree(r.Context(), repo.Name, ref, path)
+ if err != nil {
+ apiError(w, http.StatusNotFound, "no such tree")
+ return
+ }
+ writeJSON(w, entries)
+}
+
+type blobResponse struct {
+ Path string `json:"path"`
+ Size int64 `json:"size"`
+ Binary bool `json:"binary"`
+ Truncated bool `json:"truncated"`
+ Content string `json:"content"`
+}
+
+func (srv *Server) apiBlob(w http.ResponseWriter, r *http.Request) {
+ repo := srv.apiRepo(w, r)
+ if repo == nil {
+ return
+ }
+ ref := refParam(w, r, repo)
+ if ref == "" {
+ return
+ }
+ path := r.URL.Query().Get("path")
+ if !validPath(path) || path == "" {
+ apiError(w, http.StatusBadRequest, "invalid path")
+ return
+ }
+ blob, err := srv.Store.catBlob(r.Context(), repo.Name, ref, path)
+ if err != nil {
+ apiError(w, http.StatusNotFound, "no such blob")
+ return
+ }
+ resp := blobResponse{Path: path, Size: int64(len(blob))}
+ switch {
+ case isBinary(blob):
+ resp.Binary = true
+ case len(blob) > maxBlobBytes:
+ resp.Truncated = true
+ default:
+ resp.Content = string(blob)
+ }
+ writeJSON(w, resp)
+}
+
+type readmeResponse struct {
+ Name string `json:"name"`
+ Content string `json:"content"`
+}
+
+func (srv *Server) apiReadme(w http.ResponseWriter, r *http.Request) {
+ repo := srv.apiRepo(w, r)
+ if repo == nil {
+ return
+ }
+ ref := refParam(w, r, repo)
+ if ref == "" {
+ return
+ }
+ name, body := srv.Store.readme(r.Context(), repo.Name, ref)
+ if name == "" || isBinary(body) {
+ apiError(w, http.StatusNotFound, "no readme")
+ return
+ }
+ writeJSON(w, readmeResponse{Name: name, Content: string(body)})
+}
+
+func (srv *Server) apiCommits(w http.ResponseWriter, r *http.Request) {
+ repo := srv.apiRepo(w, r)
+ if repo == nil {
+ return
+ }
+ if repo.Empty {
+ writeJSON(w, []Commit{})
+ return
+ }
+ ref := refParam(w, r, repo)
+ if ref == "" {
+ return
+ }
+ limit := 100
+ if n, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && n > 0 && n <= 500 {
+ limit = n
+ }
+ commits, err := srv.Store.log(r.Context(), repo.Name, ref, limit)
+ if err != nil {
+ apiError(w, http.StatusNotFound, "no such ref")
+ return
+ }
+ if commits == nil {
+ commits = []Commit{}
+ }
+ writeJSON(w, commits)
+}
+
+type commitResponse struct {
+ Commit
+ Patch string `json:"patch"`
+ Truncated bool `json:"truncated"`
+}
+
+func (srv *Server) apiCommit(w http.ResponseWriter, r *http.Request) {
+ repo := srv.apiRepo(w, r)
+ if repo == nil {
+ return
+ }
+ hash := r.PathValue("hash")
+ if !validRef(hash) {
+ apiError(w, http.StatusBadRequest, "invalid hash")
+ return
+ }
+ commit, err := srv.Store.commit(r.Context(), repo.Name, hash)
+ if err != nil {
+ apiError(w, http.StatusNotFound, "no such commit")
+ return
+ }
+ patch, truncated, err := srv.Store.patch(r.Context(), repo.Name, commit.Hash)
+ if err != nil {
+ apiError(w, http.StatusInternalServerError, "patch failed")
+ return
+ }
+ writeJSON(w, commitResponse{Commit: *commit, Patch: patch, Truncated: truncated})
+}
+
+func (srv *Server) apiRefs(w http.ResponseWriter, r *http.Request) {
+ repo := srv.apiRepo(w, r)
+ if repo == nil {
+ return
+ }
+ branches, _ := srv.Store.refs(r.Context(), repo.Name, "heads")
+ tags, _ := srv.Store.refs(r.Context(), repo.Name, "tags")
+ if branches == nil {
+ branches = []Ref{}
+ }
+ if tags == nil {
+ tags = []Ref{}
+ }
+ writeJSON(w, map[string][]Ref{"branches": branches, "tags": tags})
+}
diff --git a/api_test.go b/api_test.go
new file mode 100644
index 0000000..33a8a22
--- /dev/null
+++ b/api_test.go
@@ -0,0 +1,87 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func getJSON(t *testing.T, url string, v any) int {
+ t.Helper()
+ resp, err := http.Get(url)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
+ t.Fatalf("GET %s: bad json: %v", url, err)
+ }
+ return resp.StatusCode
+}
+
+func TestJSONAPI(t *testing.T) {
+ ts, store := newTestServer(t)
+ if err := store.create("apidemo", "api smoke test"); err != nil {
+ t.Fatal(err)
+ }
+
+ work := t.TempDir()
+ mustGit(t, work, "clone", ts.URL+"/apidemo.git", "w")
+ dir := filepath.Join(work, "w")
+ os.WriteFile(filepath.Join(dir, "README.md"), []byte("# apidemo\nhello api\n"), 0o644)
+ os.MkdirAll(filepath.Join(dir, "src"), 0o755)
+ os.WriteFile(filepath.Join(dir, "src", "main.go"), []byte("package main\n"), 0o644)
+ mustGit(t, dir, "add", ".")
+ mustGit(t, dir, "commit", "-m", "seed api test")
+ mustGit(t, dir, "push", withToken(t, ts.URL+"/apidemo.git"), "master")
+
+ var repos []RepoInfo
+ if code := getJSON(t, ts.URL+"/api/repos", &repos); code != 200 || len(repos) != 1 || repos[0].Name != "apidemo" {
+ t.Fatalf("repos: code %d, %+v", code, repos)
+ }
+ if repos[0].Empty || repos[0].Description != "api smoke test" {
+ t.Errorf("repo info wrong: %+v", repos[0])
+ }
+
+ var entries []TreeEntry
+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/tree?path=src", &entries); code != 200 || len(entries) != 1 || entries[0].Name != "main.go" {
+ t.Fatalf("tree: code %d, %+v", code, entries)
+ }
+
+ var blob blobResponse
+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/blob?path=src/main.go", &blob); code != 200 || blob.Content != "package main\n" {
+ t.Fatalf("blob: code %d, %+v", code, blob)
+ }
+
+ var readme readmeResponse
+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/readme", &readme); code != 200 || !strings.Contains(readme.Content, "hello api") {
+ t.Fatalf("readme: code %d, %+v", code, readme)
+ }
+
+ var commits []Commit
+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/commits", &commits); code != 200 || len(commits) != 1 || commits[0].Subject != "seed api test" {
+ t.Fatalf("commits: code %d, %+v", code, commits)
+ }
+
+ var full commitResponse
+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/commit/"+commits[0].Hash, &full); code != 200 || !strings.Contains(full.Patch, "package main") {
+ t.Fatalf("commit: code %d", code)
+ }
+
+ var refs map[string][]Ref
+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/refs", &refs); code != 200 || len(refs["branches"]) != 1 {
+ t.Fatalf("refs: code %d, %+v", code, refs)
+ }
+
+ // Errors are JSON with proper codes.
+ var e map[string]string
+ if code := getJSON(t, ts.URL+"/api/repos/ghost", &e); code != 404 || e["error"] == "" {
+ t.Errorf("missing repo: code %d, %+v", code, e)
+ }
+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/blob?path=../secret", &e); code != 400 {
+ t.Errorf("traversal blob: code %d", code)
+ }
+}
diff --git a/gitcmd.go b/gitcmd.go
index faaab78..3bcaa35 100644
--- a/gitcmd.go
+++ b/gitcmd.go
@@ -19,8 +19,8 @@ import (
var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
-// reservedNames are path roots the web UI claims for itself.
-var reservedNames = map[string]bool{"static": true}
+// reservedNames are path roots the web UI and JSON API claim for themselves.
+var reservedNames = map[string]bool{"static": true, "api": true}
func validRepoName(name string) bool {
return repoNameRe.MatchString(name) && !reservedNames[name] && len(name) <= 100
@@ -103,11 +103,11 @@ func runGit(ctx context.Context, dir string, stdin []byte, args ...string) ([]by
}
type RepoInfo struct {
- Name string
- Description string
- Default string
- LastCommit time.Time
- Empty bool
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Default string `json:"default"`
+ LastCommit time.Time `json:"lastCommit"`
+ Empty bool `json:"empty"`
}
func (s *Store) open(name string) (*RepoInfo, error) {
@@ -157,11 +157,11 @@ func (s *Store) list() []*RepoInfo {
}
type TreeEntry struct {
- Mode string
- Type string // blob, tree, commit (submodule)
- Hash string
- Size int64 // -1 for trees
- Name string
+ Mode string `json:"mode"`
+ Type string `json:"type"` // blob, tree, commit (submodule)
+ Hash string `json:"hash"`
+ Size int64 `json:"size"` // -1 for trees
+ Name string `json:"name"`
}
func (s *Store) lsTree(ctx context.Context, repo, ref, path string) ([]TreeEntry, error) {
@@ -208,11 +208,11 @@ func (s *Store) catBlob(ctx context.Context, repo, ref, path string) ([]byte, er
}
type Commit struct {
- Hash string
- Short string
- Author string
- When time.Time
- Subject string
+ Hash string `json:"hash"`
+ Short string `json:"short"`
+ Author string `json:"author"`
+ When time.Time `json:"when"`
+ Subject string `json:"subject"`
}
const logFormat = "%H%x1f%h%x1f%an%x1f%at%x1f%s%x1e"
@@ -270,10 +270,10 @@ func (s *Store) patch(ctx context.Context, repo, hash string) (string, bool, err
}
type Ref struct {
- Name string
- Short string
- When time.Time
- Subject string
+ Name string `json:"name"`
+ Short string `json:"short"`
+ When time.Time `json:"when"`
+ Subject string `json:"subject"`
}
func (s *Store) refs(ctx context.Context, repo, kind string) ([]Ref, error) {
diff --git a/server.go b/server.go
index fc2f5bf..7eb2152 100644
--- a/server.go
+++ b/server.go
@@ -24,6 +24,16 @@ func (srv *Server) handler() http.Handler {
mux.HandleFunc("POST /{repo}/git-upload-pack", srv.uploadPack)
mux.HandleFunc("POST /{repo}/git-receive-pack", srv.receivePack)
+ // JSON API (external frontends, read-only)
+ mux.HandleFunc("GET /api/repos", srv.apiRepos)
+ mux.HandleFunc("GET /api/repos/{repo}", srv.apiRepoInfo)
+ mux.HandleFunc("GET /api/repos/{repo}/tree", srv.apiTree)
+ mux.HandleFunc("GET /api/repos/{repo}/blob", srv.apiBlob)
+ mux.HandleFunc("GET /api/repos/{repo}/readme", srv.apiReadme)
+ mux.HandleFunc("GET /api/repos/{repo}/commits", srv.apiCommits)
+ mux.HandleFunc("GET /api/repos/{repo}/commit/{hash}", srv.apiCommit)
+ mux.HandleFunc("GET /api/repos/{repo}/refs", srv.apiRefs)
+
// Web UI (browsers, read-only)
mux.HandleFunc("GET /{$}", srv.indexPage)
mux.HandleFunc("GET /static/style.css", srv.styleCSS)