aboutsummaryrefslogtreecommitdiff
path: root/git.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed /git.go
downloadgit-forge-master.tar.gz
Enter git-forgeHEADmaster
A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree.
Diffstat (limited to 'git.go')
-rw-r--r--git.go296
1 files changed, 296 insertions, 0 deletions
diff --git a/git.go b/git.go
new file mode 100644
index 0000000..17eaa81
--- /dev/null
+++ b/git.go
@@ -0,0 +1,296 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Git is driven by exec'ing it: cgit and git-http-backend already require the
+// binary, and a Go implementation could only disagree with it.
+
+// gitEnv neutralises the system and global configuration.
+func gitEnv(extra []string) []string {
+ env := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
+ return append(env, extra...)
+}
+
+// git returns stdout alone; stderr goes into the error, since stdout is
+// usually an object id.
+func git(dir string, env []string, args ...string) ([]byte, error) {
+ return gitIn(dir, nil, env, args...)
+}
+
+func gitIn(dir string, stdin []byte, env []string, args ...string) ([]byte, error) {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = gitEnv(env)
+ if stdin != nil {
+ cmd.Stdin = bytes.NewReader(stdin)
+ }
+ var out, fail bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &out, &fail
+ if err := cmd.Run(); err != nil {
+ // %w, so a caller can ask git's exit status.
+ if msg := bytes.TrimSpace(fail.Bytes()); len(msg) > 0 {
+ return out.Bytes(), fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, msg)
+ }
+ return out.Bytes(), fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
+ }
+ return out.Bytes(), nil
+}
+
+// gitInit uses an empty template, so no sample hooks or placeholder
+// description, and an explicit branch, so nothing is inherited from
+// init.defaultBranch.
+func gitInit(dir string, bare bool) error {
+ tmpl, err := os.MkdirTemp("", "git-forge-template")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tmpl)
+
+ args := []string{"init", "--quiet", "--template=" + tmpl, "--initial-branch=master"}
+ if bare {
+ args = append(args, "--bare")
+ }
+ _, err = git("", nil, append(args, dir)...)
+ return err
+}
+
+// change is one path in a tree. Nil data removes it.
+type change struct {
+ path string
+ data []byte
+}
+
+// branch is the only branch git-forge ever writes.
+const branch = "refs/heads/master"
+
+// commitWith moves the branch with a compare and swap, retrying when it moved.
+// build is handed the commit the branch points at, "" when empty, on every
+// attempt: a sequential id must be read again after a lost race. It returns
+// the subject too.
+func commitWith(dir string, author, committer Author, build func(base string) ([]change, string, error)) error {
+ defer lockRepo(dir)()
+
+ var err error
+ for attempt := 0; attempt < 8; attempt++ {
+ if attempt > 0 {
+ // Git holds its ref lock for a moment.
+ time.Sleep(time.Duration(attempt) * 10 * time.Millisecond)
+ }
+ var base string
+ if base, err = resolveRef(dir, branch); err != nil {
+ return err
+ }
+ var changes []change
+ var subject string
+ if changes, subject, err = build(base); err != nil {
+ return err
+ }
+ var tree string
+ if tree, err = writeTree(dir, base, changes); err != nil {
+ return err
+ }
+ var commit string
+ if commit, err = commitTree(dir, tree, base, subject, author, committer); err != nil {
+ return err
+ }
+ // An empty old value means the branch must not exist yet.
+ if _, err = git(dir, nil, "update-ref", branch, commit, base); err == nil {
+ // Plumbing runs no maintenance, and nothing else collects these
+ // repositories.
+ gcAuto(dir)
+ return nil
+ }
+ }
+ return fmt.Errorf("%s: gave up after eight attempts: %v", dir, err)
+}
+
+// Git's own thresholds decide whether anything happens; gc.autoDetach keeps
+// the work off the request that triggered it.
+func gcAuto(dir string) {
+ env := []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=gc.autoDetach", "GIT_CONFIG_VALUE_0=true"}
+ if _, err := git(dir, env, "gc", "--auto", "--quiet"); err != nil {
+ log.Printf("git: gc --auto in %s: %v", dir, err)
+ }
+}
+
+// lockRepo serialises this process's writers to one repository, so they do not
+// burn their retries on each other. Git's compare and swap is what makes them
+// correct.
+var writeLocks sync.Map // repository directory to *sync.Mutex
+
+func lockRepo(dir string) func() {
+ value, _ := writeLocks.LoadOrStore(dir, &sync.Mutex{})
+ mu := value.(*sync.Mutex)
+ mu.Lock()
+ return mu.Unlock
+}
+
+// resolveRef returns "" for a ref that is not there, and an error for a
+// repository that is not: rev-parse exits 1 for the first and 128 for the
+// second, and a missing directory must not read as an empty page.
+func resolveRef(dir, ref string) (string, error) {
+ out, err := git(dir, nil, "rev-parse", "--verify", "--quiet", ref)
+ if err == nil {
+ return strings.TrimSpace(string(out)), nil
+ }
+ var exit *exec.ExitError
+ if errors.As(err, &exit) && exit.ExitCode() == 1 {
+ return "", nil
+ }
+ return "", err
+}
+
+// writeTree uses a temporary index rather than mktree, because these paths
+// nest and update-index rewrites the intermediate trees. The index sits
+// outside the repository: nothing the forge writes goes inside one.
+func writeTree(dir, base string, changes []change) (string, error) {
+ // A directory, not a file: git refuses an index file that exists and is
+ // empty.
+ tmp, err := os.MkdirTemp("", "git-forge-index")
+ if err != nil {
+ return "", err
+ }
+ defer os.RemoveAll(tmp)
+ env := []string{"GIT_INDEX_FILE=" + filepath.Join(tmp, "index")}
+
+ if base != "" {
+ if _, err := git(dir, env, "read-tree", base); err != nil {
+ return "", err
+ }
+ }
+ for _, c := range changes {
+ if c.data == nil {
+ if _, err := git(dir, env, "update-index", "--force-remove", c.path); err != nil {
+ return "", err
+ }
+ continue
+ }
+ blob, err := gitIn(dir, c.data, nil, "hash-object", "-w", "--stdin")
+ if err != nil {
+ return "", err
+ }
+ id := strings.TrimSpace(string(blob))
+ if _, err := git(dir, env, "update-index", "--add", "--cacheinfo", "100644,"+id+","+c.path); err != nil {
+ return "", err
+ }
+ }
+ tree, err := git(dir, env, "write-tree")
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(tree)), nil
+}
+
+func commitTree(dir, tree, parent, subject string, author, committer Author) (string, error) {
+ args := []string{"commit-tree", tree, "-m", subject}
+ if parent != "" {
+ args = append(args, "-p", parent)
+ }
+ env := []string{
+ "GIT_AUTHOR_NAME=" + author.Name,
+ "GIT_AUTHOR_EMAIL=" + author.Email,
+ "GIT_COMMITTER_NAME=" + committer.Name,
+ "GIT_COMMITTER_EMAIL=" + committer.Email,
+ }
+ out, err := git(dir, env, args...)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// catFile reads one blob, named by its id or as commit:path.
+func catFile(dir, rev string) ([]byte, error) {
+ return git(dir, nil, "cat-file", "blob", rev)
+}
+
+func listTreeRecursive(dir, commit string) ([]string, error) {
+ return treePaths(git(dir, nil, "ls-tree", "-r", "--name-only", commit))
+}
+
+func listTree(dir, commit, prefix string) ([]string, error) {
+ return treePaths(git(dir, nil, "ls-tree", "--name-only", commit, prefix))
+}
+
+func treePaths(out []byte, err error) ([]string, error) {
+ if err != nil {
+ return nil, err
+ }
+ var paths []string
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ if line != "" {
+ paths = append(paths, line)
+ }
+ }
+ return paths, nil
+}
+
+// catFiles reads many blobs in one exec, in the order asked; a missing path
+// yields nil.
+func catFiles(dir string, specs []string) ([][]byte, error) {
+ if len(specs) == 0 {
+ return nil, nil
+ }
+ out, err := gitIn(dir, []byte(strings.Join(specs, "\n")+"\n"), nil, "cat-file", "--batch")
+ if err != nil {
+ return nil, err
+ }
+
+ blobs := make([][]byte, 0, len(specs))
+ for rest := out; len(blobs) < len(specs); {
+ line, tail, ok := bytes.Cut(rest, []byte("\n"))
+ if !ok {
+ break
+ }
+ fields := bytes.Fields(line)
+ // "<oid> <type> <size>" for a hit, "<spec> missing" for a miss.
+ if len(fields) != 3 {
+ blobs = append(blobs, nil)
+ rest = tail
+ continue
+ }
+ size, err := strconv.Atoi(string(fields[2]))
+ if err != nil || size > len(tail) {
+ return nil, fmt.Errorf("cat-file --batch: unreadable record %q", line)
+ }
+ blobs = append(blobs, tail[:size])
+ rest = tail[size:]
+ rest = bytes.TrimPrefix(rest, []byte("\n"))
+ }
+ return blobs, nil
+}
+
+// grepTree is the whole search. A fixed string, not a regular expression: the
+// input is hostile.
+func grepTree(dir, commit, query, pathspec string) ([]string, error) {
+ out, err := git(dir, nil, "grep", "-i", "-l", "-F", "-e", query, commit, "--", pathspec)
+ if err != nil {
+ // Exit 1 is nothing matched; anything else must not read as an empty
+ // result.
+ var exit *exec.ExitError
+ if errors.As(err, &exit) && exit.ExitCode() == 1 {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var paths []string
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ // Lines are "<commit>:<path>".
+ if _, path, ok := strings.Cut(line, ":"); ok {
+ paths = append(paths, path)
+ }
+ }
+ return paths, nil
+}