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) // " " for a hit, " 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 ":". if _, path, ok := strings.Cut(line, ":"); ok { paths = append(paths, path) } } return paths, nil }