New Sign in

fierj

Public

Tiny personal git forge

← fierj / git.go
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]}
}