fierj
PublicTiny personal git forge
package main
import (
"bytes"
"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
}
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 != "" {
os.WriteFile(filepath.Join(repoPath, "description"), []byte(description+"\n"), 0644)
}
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 != "" {
os.WriteFile(filepath.Join(repoPath, "description"), []byte(description+"\n"), 0644)
}
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 {
data, err := os.ReadFile(path.Join(g.Dir, g.Name+".git", "description"))
if err != nil {
return ""
}
desc := strings.TrimSpace(string(data))
if desc == "" || strings.Contains(desc, "Unnamed repository") {
return ""
}
return desc
}
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 ""
}
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]}
}