fierj
PublicTiny personal git forge
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
)
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)
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 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 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) {
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) {
return g.cmd("show", ref+":"+path)
}
func (g *Git) Log(ref string, n int) ([]Commit, error) {
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) {
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)
// Verify the default branch actually exists (HEAD might point to
// a not-yet-created branch in an empty/dangling-HEAD repo).
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 {
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 {
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 {
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]}
}