frieren
a self-hosted git server in one binary — everyone reads, only the owner writes
package main
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// All repository access goes through the real git binary: frieren owns HTTP,
// auth, and rendering, while git owns object storage and the pack protocol.
var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
// 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
}
func validRef(ref string) bool {
if ref == "" || len(ref) > 250 || strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") {
return false
}
for _, r := range ref {
if r < 0x20 || r == 0x7f || r == ' ' || r == '~' || r == '^' || r == ':' || r == '\\' {
return false
}
}
return true
}
func validPath(p string) bool {
if p == "" {
return true
}
if strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") {
return false
}
for _, seg := range strings.Split(p, "/") {
if seg == "" || seg == "." || seg == ".." {
return false
}
}
return true
}
type Store struct {
Root string
}
func (s *Store) repoPath(name string) string {
return filepath.Join(s.Root, name+".git")
}
func (s *Store) exists(name string) bool {
if !validRepoName(name) {
return false
}
fi, err := os.Stat(filepath.Join(s.repoPath(name), "HEAD"))
return err == nil && fi.Mode().IsRegular()
}
func (s *Store) create(name, description string) error {
if !validRepoName(name) {
return fmt.Errorf("invalid repository name %q", name)
}
if s.exists(name) {
return fmt.Errorf("repository %q already exists", name)
}
if _, err := runGit(context.Background(), s.Root, nil, "init", "--bare", "-b", "master", "--quiet", name+".git"); err != nil {
return err
}
if description != "" {
return os.WriteFile(filepath.Join(s.repoPath(name), "description"), []byte(description+"\n"), 0o644)
}
return nil
}
// runGit executes git with -C dir and returns stdout. Stderr is folded into
// the error so callers can surface git's own explanation.
func runGit(ctx context.Context, dir string, stdin []byte, args ...string) ([]byte, error) {
full := append([]string{"-C", dir}, args...)
cmd := exec.CommandContext(ctx, "git", full...)
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(errb.String()))
}
return out.Bytes(), nil
}
type RepoInfo struct {
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) {
if !s.exists(name) {
return nil, fmt.Errorf("no such repository %q", name)
}
info := &RepoInfo{Name: name, Default: "master"}
dir := s.repoPath(name)
if b, err := os.ReadFile(filepath.Join(dir, "description")); err == nil {
d := strings.TrimSpace(string(b))
if !strings.HasPrefix(d, "Unnamed repository") {
info.Description = d
}
}
if b, err := runGit(context.Background(), dir, nil, "symbolic-ref", "--short", "HEAD"); err == nil {
info.Default = strings.TrimSpace(string(b))
}
b, err := runGit(context.Background(), dir, nil,
"for-each-ref", "--sort=-committerdate", "--count=1", "--format=%(committerdate:unix)", "refs/heads")
if err != nil || len(bytes.TrimSpace(b)) == 0 {
info.Empty = true
return info, nil
}
if unix, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64); err == nil {
info.LastCommit = time.Unix(unix, 0)
}
return info, nil
}
func (s *Store) list() []*RepoInfo {
entries, err := os.ReadDir(s.Root)
if err != nil {
return nil
}
var repos []*RepoInfo
for _, e := range entries {
if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") {
continue
}
name := strings.TrimSuffix(e.Name(), ".git")
if info, err := s.open(name); err == nil {
repos = append(repos, info)
}
}
sort.Slice(repos, func(i, j int) bool { return repos[i].LastCommit.After(repos[j].LastCommit) })
return repos
}
type TreeEntry struct {
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) {
spec := ref
if path != "" {
spec += ":" + path
}
out, err := runGit(ctx, s.repoPath(repo), nil, "ls-tree", "-z", "-l", spec)
if err != nil {
return nil, err
}
var entries []TreeEntry
for _, rec := range bytes.Split(out, []byte{0}) {
if len(rec) == 0 {
continue
}
meta, name, ok := bytes.Cut(rec, []byte{'\t'})
if !ok {
continue
}
f := strings.Fields(string(meta))
if len(f) != 4 {
continue
}
size := int64(-1)
if f[3] != "-" {
size, _ = strconv.ParseInt(f[3], 10, 64)
}
entries = append(entries, TreeEntry{Mode: f[0], Type: f[1], Hash: f[2], Size: size, Name: string(name)})
}
sort.Slice(entries, func(i, j int) bool {
if (entries[i].Type == "tree") != (entries[j].Type == "tree") {
return entries[i].Type == "tree"
}
return entries[i].Name < entries[j].Name
})
return entries, nil
}
const maxBlobBytes = 2 << 20 // 2 MiB shown in the web UI; raw endpoint streams everything
func (s *Store) catBlob(ctx context.Context, repo, ref, path string) ([]byte, error) {
return runGit(ctx, s.repoPath(repo), nil, "cat-file", "blob", ref+":"+path)
}
type Commit struct {
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"
func parseCommits(out []byte) []Commit {
var commits []Commit
for _, rec := range bytes.Split(out, []byte{0x1e}) {
rec = bytes.TrimSpace(rec)
if len(rec) == 0 {
continue
}
f := strings.Split(string(rec), "\x1f")
if len(f) != 5 {
continue
}
unix, _ := strconv.ParseInt(f[3], 10, 64)
commits = append(commits, Commit{Hash: f[0], Short: f[1], Author: f[2], When: time.Unix(unix, 0), Subject: f[4]})
}
return commits
}
func (s *Store) log(ctx context.Context, repo, ref string, limit int) ([]Commit, error) {
out, err := runGit(ctx, s.repoPath(repo), nil,
"log", "--format="+logFormat, "-n", strconv.Itoa(limit), ref, "--")
if err != nil {
return nil, err
}
return parseCommits(out), nil
}
func (s *Store) commit(ctx context.Context, repo, hash string) (*Commit, error) {
out, err := runGit(ctx, s.repoPath(repo), nil, "show", "-s", "--format="+logFormat, hash, "--")
if err != nil {
return nil, err
}
commits := parseCommits(out)
if len(commits) == 0 {
return nil, fmt.Errorf("no such commit %q", hash)
}
return &commits[0], nil
}
const maxDiffBytes = 1 << 20 // 1 MiB of rendered patch per commit page
func (s *Store) patch(ctx context.Context, repo, hash string) (string, bool, error) {
out, err := runGit(ctx, s.repoPath(repo), nil,
"show", "--format=", "--stat", "--patch", "--no-color", hash, "--")
if err != nil {
return "", false, err
}
if len(out) > maxDiffBytes {
return string(out[:maxDiffBytes]), true, nil
}
return string(out), false, nil
}
type Ref struct {
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) {
out, err := runGit(ctx, s.repoPath(repo), nil,
"for-each-ref", "--sort=-creatordate",
"--format=%(refname:short)%1f%(objectname:short)%1f%(creatordate:unix)%1f%(subject)%1e", "refs/"+kind)
if err != nil {
return nil, err
}
var refs []Ref
for _, rec := range bytes.Split(out, []byte{0x1e}) {
rec = bytes.TrimSpace(rec)
if len(rec) == 0 {
continue
}
f := strings.Split(string(rec), "\x1f")
if len(f) != 4 {
continue
}
unix, _ := strconv.ParseInt(f[2], 10, 64)
refs = append(refs, Ref{Name: f[0], Short: f[1], When: time.Unix(unix, 0), Subject: f[3]})
}
return refs, nil
}
// readme returns the first README-ish blob at the root of ref, if any.
func (s *Store) readme(ctx context.Context, repo, ref string) (name string, body []byte) {
entries, err := s.lsTree(ctx, repo, ref, "")
if err != nil {
return "", nil
}
for _, e := range entries {
if e.Type != "blob" {
continue
}
switch strings.ToLower(e.Name) {
case "readme.md", "readme", "readme.txt":
if b, err := s.catBlob(ctx, repo, ref, e.Name); err == nil && len(b) <= maxBlobBytes {
return e.Name, b
}
}
}
return "", nil
}