fierj
PublicTiny personal git forge
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
)
const zeroSHA = "0000000000000000000000000000000000000000"
// runPreReceive is invoked as a git pre-receive hook. It reads protected
// branches from .fierj.json in the current directory (the repo root) and
// rejects direct pushes to them.
func runPreReceive() int {
data, err := os.ReadFile(".fierj.json")
if err != nil {
return 0
}
var meta RepoMeta
if err := json.Unmarshal(data, &meta); err != nil || len(meta.ProtectedBranches) == 0 {
return 0
}
protected := make(map[string]bool, len(meta.ProtectedBranches))
for _, b := range meta.ProtectedBranches {
protected[b] = true
}
sc := bufio.NewScanner(os.Stdin)
for sc.Scan() {
parts := strings.Fields(sc.Text())
if len(parts) != 3 {
continue
}
old, new_, ref := parts[0], parts[1], parts[2]
branch := strings.TrimPrefix(ref, "refs/heads/")
if !protected[branch] {
continue
}
if new_ == zeroSHA {
fmt.Fprintf(os.Stderr, "ERROR: Cannot delete protected branch %q.\n", branch)
return 1
}
if old == zeroSHA {
continue // new branch, allow
}
fmt.Fprintf(os.Stderr, "ERROR: Direct push to protected branch %q rejected.\n", branch)
fmt.Fprintf(os.Stderr, " Use a feature branch and submit a patch instead.\n")
return 1
}
return 0
}
// writePreReceiveHook installs the pre-receive hook into a bare repository.
func writePreReceiveHook(repoPath string) error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("find executable: %w", err)
}
script := fmt.Sprintf("#!/bin/sh\nexec %s hook pre-receive\n", exe)
hookPath := filepath.Join(repoPath, "hooks", "pre-receive")
if err := os.WriteFile(hookPath, []byte(script), 0755); err != nil {
return fmt.Errorf("write pre-receive hook: %w", err)
}
return nil
}
type TreeEntry struct {
Mode string
Type string // "blob" or "tree"
Hash string
Size string
Name string
}
type Commit struct {
Hash string
Author string
Date string
Message string
}
// RepoMeta is per-repository configuration stored in .fierj.json inside the bare repo.
type RepoMeta struct {
Description string `json:"description,omitempty"`
IsPrivate bool `json:"is_private"` // requires login to view
AuthorizedKeys []string `json:"authorized_keys,omitempty"` // SSH public keys allowed to push
ProtectedBranches []string `json:"protected_branches,omitempty"` // branches that reject force-push
}
func (g *Git) metaPath() string {
return path.Join(g.Dir, g.Name+".git", ".fierj.json")
}
// LoadMeta reads the .fierj.json file, falling back to the git description file for Description.
func (g *Git) LoadMeta() RepoMeta {
var m RepoMeta
if data, err := os.ReadFile(g.metaPath()); err == nil {
json.Unmarshal(data, &m)
}
if m.Description == "" {
if data, err := os.ReadFile(path.Join(g.Dir, g.Name+".git", "description")); err == nil {
d := strings.TrimSpace(string(data))
if d != "" && !strings.Contains(d, "Unnamed repository") {
m.Description = d
}
}
}
return m
}
// SaveMeta writes the .fierj.json file.
func (g *Git) SaveMeta(m RepoMeta) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(g.metaPath(), data, 0644)
}
type VCS interface {
List(ref, path string) ([]TreeEntry, error)
Blob(ref, path string) (string, error)
Log(ref string, n int) ([]Commit, error)
Diff(hash string) (string, error)
DefaultBranch() string
IsEmpty() bool
Branches() []string
Tags() []string
CommitCount(ref string) int
Readme(ref, dir string) string
LastCommit(ref, path string) *Commit
}
type Git struct {
Dir string
Name string
}
var _ VCS = (*Git)(nil)
// isValidRef checks that a branch, tag, or ref name contains only safe characters
// and won't be interpreted as a flag or cause path traversal.
func isValidRef(ref string) bool {
if ref == "" || strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") {
return false
}
for _, r := range ref {
if r <= ' ' || r == 0x7f || r == '~' || r == '^' || r == ':' || r == '\\' {
return false
}
}
for _, part := range strings.Split(ref, "/") {
if part == "" || part == "." || part == ".." || strings.HasPrefix(part, ".") {
return false
}
}
return true
}
// isValidPath checks that a path within a repository is safe.
func isValidPath(p string) bool {
if strings.HasPrefix(p, "/") || strings.Contains(p, "..") {
return false
}
for _, r := range p {
if r <= ' ' || r == 0x7f || r == ':' || r == '\\' {
return false
}
}
return true
}
// isValidHash checks that a string looks like a git commit hash.
func isValidHash(h string) bool {
if len(h) < 7 || len(h) > 40 {
return false
}
for _, r := range h {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
return true
}
func (g *Git) cmd(args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = path.Join(g.Dir, g.Name+".git")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("git %v failed: %v, stderr: %s", args, err, stderr.String())
}
return stdout.String(), nil
}
func validateRepoName(name string) error {
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "..") {
return fmt.Errorf("invalid repo name")
}
return nil
}
func repoNameFromURL(rawURL string) string {
s := rawURL
s = strings.TrimSuffix(s, ".git")
s = strings.TrimSuffix(s, "/")
if idx := strings.LastIndex(s, "/"); idx >= 0 {
s = s[idx+1:]
}
if idx := strings.LastIndex(s, ":"); idx >= 0 {
s = s[idx+1:]
}
return s
}
func InitRepo(cfg Config, name, description string) error {
if err := validateRepoName(name); err != nil {
return err
}
absRoot, err := filepath.Abs(cfg.Dir)
if err != nil {
return err
}
repoPath := filepath.Join(absRoot, name+".git")
cmd := exec.Command("git", "init", "--bare", repoPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git init failed: %v, stderr: %s", err, stderr.String())
}
if err := writePreReceiveHook(repoPath); err != nil {
return err
}
if description != "" {
g := &Git{Dir: absRoot, Name: name}
g.SaveMeta(RepoMeta{Description: description})
}
return nil
}
func ImportRepo(cfg Config, cloneURL, description string) (string, error) {
name := repoNameFromURL(cloneURL)
if err := validateRepoName(name); err != nil {
return "", err
}
absRoot, err := filepath.Abs(cfg.Dir)
if err != nil {
return "", err
}
repoPath := filepath.Join(absRoot, name+".git")
if _, err := os.Stat(repoPath); err == nil {
return "", fmt.Errorf("repository %s already exists", name)
}
cmd := exec.Command("git", "clone", "--bare", cloneURL, repoPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("clone failed: %v, stderr: %s", err, stderr.String())
}
if err := writePreReceiveHook(repoPath); err != nil {
return "", err
}
if description != "" {
g := &Git{Dir: absRoot, Name: name}
g.SaveMeta(RepoMeta{Description: description})
}
return name, nil
}
func ListRepos(cfg Config) ([]*Git, error) {
entries, err := os.ReadDir(cfg.Dir)
if err != nil {
return nil, err
}
repos := []*Git{}
for _, e := range entries {
if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") {
continue
}
name := strings.TrimSuffix(e.Name(), ".git")
git := &Git{Dir: cfg.Dir, Name: name}
repos = append(repos, git)
}
return repos, nil
}
func (g *Git) Repo() string { return g.Name }
func (g *Git) Description() string {
return g.LoadMeta().Description
}
func (g *Git) IsPrivate() bool {
return g.LoadMeta().IsPrivate
}
func (g *Git) List(ref, path string) ([]TreeEntry, error) {
if !isValidRef(ref) || (path != "" && !isValidPath(path)) {
return nil, fmt.Errorf("invalid ref or path")
}
args := []string{"ls-tree", "-l", ref}
if path != "" {
args = append(args, "--", path+"/")
}
out, err := g.cmd(args...)
if err != nil {
return nil, err
}
var entries []TreeEntry
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if line == "" {
continue
}
// format: <mode> <type> <hash> <size>\t<name>
parts := strings.SplitN(line, "\t", 2)
if len(parts) != 2 {
continue
}
meta := strings.Fields(parts[0])
if len(meta) < 4 {
continue
}
name := parts[1]
if path != "" {
name = strings.TrimPrefix(name, path+"/")
}
entries = append(entries, TreeEntry{
Mode: meta[0],
Type: meta[1],
Hash: meta[2],
Size: meta[3],
Name: name,
})
}
return entries, nil
}
func (g *Git) Blob(ref, path string) (string, error) {
if !isValidRef(ref) || !isValidPath(path) {
return "", fmt.Errorf("invalid ref or path")
}
return g.cmd("show", ref+":"+path)
}
func (g *Git) Log(ref string, n int) ([]Commit, error) {
if !isValidRef(ref) {
return nil, fmt.Errorf("invalid ref")
}
format := "%H%n%an%n%aI%n%s%n---"
out, err := g.cmd("log", fmt.Sprintf("-%d", n), "--format="+format, "--", ref)
if err != nil {
return nil, err
}
var commits []Commit
blocks := strings.Split(strings.TrimSpace(out), "---")
for _, block := range blocks {
lines := strings.Split(strings.TrimSpace(block), "\n")
if len(lines) < 4 {
continue
}
commits = append(commits, Commit{
Hash: lines[0],
Author: lines[1],
Date: lines[2],
Message: lines[3],
})
}
return commits, nil
}
func (g *Git) Diff(hash string) (string, error) {
if !isValidHash(hash) {
return "", fmt.Errorf("invalid hash")
}
return g.cmd("diff-tree", "-p", "--", hash)
}
func (g *Git) DefaultBranch() string {
out, err := g.cmd("symbolic-ref", "--short", "HEAD")
if err == nil {
ref := strings.TrimSpace(out)
if isValidRef(ref) {
if _, err2 := g.cmd("rev-parse", "--verify", "--", ref); err2 == nil {
return ref
}
}
}
// Fall back to the first real branch, or "main" as last resort.
branches := g.Branches()
if len(branches) > 0 {
return branches[0]
}
return "main"
}
func (g *Git) IsEmpty() bool {
// Check if any refs exist, not just HEAD.
// HEAD can be a dangling symbolic ref after git init --bare.
branches := g.Branches()
tags := g.Tags()
return len(branches) == 0 && len(tags) == 0
}
func (g *Git) Branches() []string {
out, err := g.cmd("for-each-ref", "--format=%(refname:short)", "refs/heads/")
if err != nil {
return nil
}
var branches []string
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if line != "" {
branches = append(branches, line)
}
}
return branches
}
func (g *Git) Tags() []string {
out, err := g.cmd("for-each-ref", "--sort=-creatordate", "--format=%(refname:short)", "refs/tags/")
if err != nil {
return nil
}
var tags []string
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if line != "" {
tags = append(tags, line)
}
}
return tags
}
func (g *Git) CommitCount(ref string) int {
if !isValidRef(ref) {
return 0
}
out, err := g.cmd("rev-list", "--count", "--", ref)
if err != nil {
return 0
}
n := 0
fmt.Sscanf(strings.TrimSpace(out), "%d", &n)
return n
}
func (g *Git) Readme(ref, dir string) string {
if !isValidRef(ref) || (dir != "" && !isValidPath(dir)) {
return ""
}
names := []string{"README.md", "readme.md", "README", "README.txt"}
for _, name := range names {
p := name
if dir != "" {
p = path.Join(dir, name)
}
content, err := g.Blob(ref, p)
if err == nil {
return content
}
}
return ""
}
// defaultBranch returns the default branch for a bare repo at repoPath.
func defaultBranch(repoPath string) string {
name := strings.TrimSuffix(filepath.Base(repoPath), ".git")
g := &Git{Dir: filepath.Dir(repoPath), Name: name}
return g.DefaultBranch()
}
// listBranches returns all branches for a bare repo at repoPath.
func listBranches(repoPath string) []string {
name := strings.TrimSuffix(filepath.Base(repoPath), ".git")
g := &Git{Dir: filepath.Dir(repoPath), Name: name}
return g.Branches()
}
func (g *Git) LastCommit(ref, path string) *Commit {
if !isValidRef(ref) || (path != "" && !isValidPath(path)) {
return nil
}
out, err := g.cmd("log", "-1", "--format=%H%n%an%n%ar%n%s", "--", ref, "--", path)
if err != nil {
return nil
}
lines := strings.SplitN(strings.TrimSpace(out), "\n", 4)
if len(lines) < 4 {
return nil
}
return &Commit{Hash: lines[0], Author: lines[1], Date: lines[2], Message: lines[3]}
}