// Package gitx runs git with argument arrays and never a shell, because ref names are untrusted. package gitx import ( "bytes" "context" "encoding/hex" "fmt" "io" "io/fs" "os" "os/exec" "path/filepath" "strconv" "strings" ) // Bin is the git executable. Resolved once at start. var Bin = "git" // Identity signs the commits the server makes itself, and every install wants its own host in it. var Identity = "barerepo@localhost" // Run executes git in dir and returns stdout. Args are passed as an array. func Run(ctx context.Context, dir string, args ...string) (string, error) { var out, errb bytes.Buffer cmd := exec.CommandContext(ctx, Bin, args...) cmd.Dir = dir cmd.Stdout = &out cmd.Stderr = &errb cmd.Env = env() if err := cmd.Run(); err != nil { msg := strings.TrimSpace(errb.String()) if msg == "" { msg = err.Error() } return out.String(), fmt.Errorf("git %s: %s", args[0], msg) } return out.String(), nil } // RunStdin executes git with input on stdin, which is how a blob is written without a file. func RunStdin(ctx context.Context, dir, stdin string, args ...string) (string, error) { var out, errb bytes.Buffer cmd := exec.CommandContext(ctx, Bin, args...) cmd.Dir = dir cmd.Stdin = strings.NewReader(stdin) cmd.Stdout = &out cmd.Stderr = &errb cmd.Env = env() if err := cmd.Run(); err != nil { msg := strings.TrimSpace(errb.String()) if msg == "" { msg = err.Error() } return out.String(), fmt.Errorf("git %s: %s", args[0], msg) } return out.String(), nil } // Pipe wires stdin and stdout to the caller, which is how upload-pack and receive-pack are served. func Pipe(ctx context.Context, dir string, stdin io.Reader, stdout, stderr io.Writer, extraEnv []string, args ...string) error { cmd := exec.CommandContext(ctx, Bin, args...) cmd.Dir = dir cmd.Stdin = stdin cmd.Stdout = stdout cmd.Stderr = stderr cmd.Env = append(env(), extraEnv...) return cmd.Run() } // env is built from nothing, so a user's own git configuration cannot change what the server reads. func env() []string { e := []string{ "GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "GIT_TERMINAL_PROMPT=0", "PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin", "LC_ALL=C", // git spells a path outside ascii as octal escapes unless told not to, and barerepo parses paths. "GIT_CONFIG_COUNT=3", "GIT_CONFIG_KEY_0=core.quotePath", "GIT_CONFIG_VALUE_0=false", "GIT_CONFIG_KEY_1=core.fsync", "GIT_CONFIG_VALUE_1=objects,derived-metadata,reference", "GIT_CONFIG_KEY_2=core.fsyncMethod", "GIT_CONFIG_VALUE_2=batch", // git needs an identity, and chapter 10 gives an account no address to borrow. "GIT_AUTHOR_NAME=barerepo", "GIT_AUTHOR_EMAIL=" + Identity, "GIT_COMMITTER_NAME=barerepo", "GIT_COMMITTER_EMAIL=" + Identity, } for _, k := range passthrough { if v, ok := os.LookupEnv(k); ok { e = append(e, k+"="+v) } } return e } // passthrough is what lets a hook see the quarantined objects the push is still delivering. var passthrough = []string{ "GIT_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_QUARANTINE_PATH", } // Version fails if git is missing, at start, rather than during a user's first clone. func Version(ctx context.Context) (string, error) { path, err := exec.LookPath(Bin) if err != nil { return "", fmt.Errorf("git not found in PATH") } Bin = path out, err := Run(ctx, "", "version") return strings.TrimSpace(out), err } // Object is one object read by CatFile. type Object struct { SHA string Type string Size int64 Body string } // CatFile gets the hash, type, size and content in one process, because chapter 25 counts processes. func CatFile(ctx context.Context, dir, spec string) (*Object, error) { out, err := RunStdin(ctx, dir, spec+"\n", "cat-file", "--batch") if err != nil { return nil, err } header, body, found := strings.Cut(out, "\n") if !found { return nil, fmt.Errorf("cat-file gave no answer for %q", spec) } fields := strings.Fields(header) if len(fields) < 3 { // " missing" is what git says for something that is not there. return nil, fmt.Errorf("no object at %q", spec) } size, err := strconv.ParseInt(fields[2], 10, 64) if err != nil { return nil, fmt.Errorf("cat-file gave a size of %q", fields[2]) } if int64(len(body)) > size { body = body[:size] } return &Object{SHA: fields[0], Type: fields[1], Size: size, Body: body}, nil } // HeadBranch reads the HEAD file, because rule 6 says ask the repository, not start a process. func HeadBranch(dir string) (string, error) { body, err := os.ReadFile(filepath.Join(dir, "HEAD")) if err != nil { return "", err } ref, ok := strings.CutPrefix(strings.TrimSpace(string(body)), "ref: refs/heads/") if !ok { // An object id means a detached HEAD, so this repository was not made by barerepo. return "", fmt.Errorf("HEAD is not on a branch") } return ref, nil } // symbolicDepth bounds the chase, because a ref file pointing at itself is a file somebody wrote. const symbolicDepth = 5 // ResolveRef reads the ref file or packed-refs, saving the process chapter 25 counts. func ResolveRef(dir, ref string) (string, error) { for i := 0; i < symbolicDepth; i++ { sha, err := resolveOnce(dir, ref) if err != nil { return "", err } // HEAD holds "ref: refs/heads/master", so returning the file is returning a name, not a hash. next, symbolic := strings.CutPrefix(sha, "ref: ") if !symbolic { return sha, nil } ref = strings.TrimSpace(next) } return "", fmt.Errorf("%s points through more than %d refs", ref, symbolicDepth) } // resolveOnce reads one ref file, following nothing. func resolveOnce(dir, ref string) (string, error) { if !ValidRef(ref) { return "", fmt.Errorf("%q is not a ref name", ref) } if body, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(ref))); err == nil { return strings.TrimSpace(string(body)), nil } packed, err := os.ReadFile(filepath.Join(dir, "packed-refs")) if err != nil { return "", err } for _, line := range strings.Split(string(packed), "\n") { if line == "" || line[0] == '#' || line[0] == '^' { continue } sha, name, ok := strings.Cut(line, " ") if ok && name == ref { return sha, nil } } return "", fmt.Errorf("no ref named %s", ref) } // ResolveRefOrAsk falls back to git, because reading the file is wrong inside a worktree. func ResolveRefOrAsk(ctx context.Context, dir, ref string) (string, error) { if sha, err := ResolveRef(dir, ref); err == nil { return sha, nil } out, err := Run(ctx, dir, "rev-parse", "--verify", "--quiet", ref) if err != nil { return "", err } sha := strings.TrimSpace(out) if sha == "" { return "", fmt.Errorf("no ref named %s", ref) } return sha, nil } // TreeEntries reads the blob names and hashes out of a raw tree object. func TreeEntries(body string) map[string]string { out := map[string]string{} for i := 0; i < len(body); { sp := strings.IndexByte(body[i:], ' ') nul := strings.IndexByte(body[i:], 0) if sp < 0 || nul < 0 || nul < sp || i+nul+21 > len(body) { break } name := body[i+sp+1 : i+nul] out[name] = hex.EncodeToString([]byte(body[i+nul+1 : i+nul+21])) i += nul + 21 } return out } // AnyRef reports whether one ref exists, from the ref files, because asking git costs a process. func AnyRef(dir string) bool { found := false filepath.WalkDir(filepath.Join(dir, "refs"), func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } body, err := os.ReadFile(p) if err != nil { return nil } sha := strings.TrimSpace(string(body)) if sha == "" || strings.HasPrefix(sha, "ref: ") { return nil } found = true return fs.SkipAll }) if found { return true } body, err := os.ReadFile(filepath.Join(dir, "packed-refs")) if err != nil { return false } for _, line := range strings.Split(string(body), "\n") { line = strings.TrimSpace(line) if line != "" && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "^") { return true } } return false } // TreeRow is one entry of a tree object, with the mode, which is the only thing that says tree or blob. type TreeRow struct { Mode string Name string SHA string } // TreeRows reads a raw tree object in order, which is the order git wrote and ls-tree prints. func TreeRows(body string) []TreeRow { var out []TreeRow for i := 0; i < len(body); { sp := strings.IndexByte(body[i:], ' ') nul := strings.IndexByte(body[i:], 0) if sp < 0 || nul < 0 || nul < sp || i+nul+21 > len(body) { break } out = append(out, TreeRow{ Mode: body[i : i+sp], Name: body[i+sp+1 : i+nul], SHA: hex.EncodeToString([]byte(body[i+nul+1 : i+nul+21])), }) i += nul + 21 } return out } // ListRefs reads the refs under a prefix from the filesystem, because refs are files. Chapter 6. func ListRefs(dir, prefix string) (map[string]string, error) { if !strings.HasSuffix(prefix, "/") { prefix += "/" } if !ValidRef(strings.TrimSuffix(prefix, "/")) { return nil, fmt.Errorf("%q is not a ref prefix", prefix) } out := map[string]string{} root := filepath.Join(dir, filepath.FromSlash(strings.TrimSuffix(prefix, "/"))) err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } body, err := os.ReadFile(p) if err != nil { return nil } sha := strings.TrimSpace(string(body)) // A symbolic ref under a notes prefix is not something barerepo writes, so it is skipped. if strings.HasPrefix(sha, "ref: ") { return nil } name := prefix + filepath.ToSlash(strings.TrimPrefix(p, root+string(filepath.Separator))) out[name] = sha return nil }) if err != nil && !os.IsNotExist(err) { return nil, err } // packed-refs holds what git gc moved out of the tree, and a loose file wins over it. packed, err := os.ReadFile(filepath.Join(dir, "packed-refs")) if err != nil { return out, nil } for _, line := range strings.Split(string(packed), "\n") { if line == "" || line[0] == '#' || line[0] == '^' { continue } sha, name, ok := strings.Cut(line, " ") if !ok || !strings.HasPrefix(name, prefix) { continue } if _, loose := out[name]; !loose { out[name] = sha } } return out, nil }