+ New

fierj

Public

Tiny personal git forge

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