diff options
Diffstat (limited to 'git.go')
| -rw-r--r-- | git.go | 296 |
1 files changed, 296 insertions, 0 deletions
@@ -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 +} |