// Package gitcmd runs the `git` binary for all repository access. // It never links a git library. Every call goes through os/exec with a // sanitized environment and an explicit context. package gitcmd import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "hearthforge/internal/config" "hearthforge/internal/util" ) // Caps and cache settings for git command results. const ( MaxRefList = 1000 refCacheTTL = 30 * time.Second maxBranchCache = 200 maxTagCache = 200 staleLockAge = 60 * time.Second maxPatchCache = 100 patchCacheTTL = time.Hour ) // Sentinel errors. Handlers map these to 404 / 400 / 409. var ( ErrInvalidName = errors.New("invalid repository name") ErrInvalidRef = errors.New("invalid ref") ErrNotFound = errors.New("not found") ErrExists = errors.New("already exists") ErrBadRef = errors.New("ref does not resolve") ErrConflict = errors.New("patch does not apply") ErrRefChanged = errors.New("ref changed concurrently") ) var validRepoName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) // ValidRepoName mirrors VALID_REPO_NAME_RE plus the traversal guard. func ValidRepoName(name string) bool { return name != "" && !strings.Contains(name, "..") && validRepoName.MatchString(name) } // ValidRef rejects names git would read as options or path traversal. // `--end-of-options` covers the option case too. This is a second guard. func ValidRef(ref string) bool { if ref == "" || strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") { return false } // A colon would let a ref smuggle a path into `ref:path` forms. return !strings.ContainsAny(ref, " \t\n\r\x00:\\") } // ValidPath rejects paths that escape the tree or look like an option. func ValidPath(p string) bool { if p == "" || strings.HasPrefix(p, "-") || strings.HasPrefix(p, "/") { return false } if strings.ContainsAny(p, "\x00\n") { return false } for _, seg := range strings.Split(p, "/") { if seg == ".." { return false } } return true } type Git struct { cfg *config.Config env []string mu sync.Mutex locks map[string]*sync.Mutex branches *util.Cache[string, []string] tags *util.Cache[string, []string] archiveSem chan struct{} } func New(cfg *config.Config) *Git { return &Git{ cfg: cfg, env: Env(), locks: map[string]*sync.Mutex{}, branches: util.NewCache[string, []string](maxBranchCache, refCacheTTL), tags: util.NewCache[string, []string](maxTagCache, refCacheTTL), archiveSem: make(chan struct{}, cfg.MaxConcurrentArchives), } } // Env is the sanitized environment every git subprocess runs with. A fixed // environment keeps git output parseable and stops git from reading user or // system config, or prompting for credentials. Callers that spawn git // themselves (the transports, the CI runner) use it too. func Env() []string { return append(os.Environ(), "LC_ALL=C", "LANG=C", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_CONFIG_COUNT=0", "GIT_ASKPASS=echo", "GIT_TERMINAL_PROMPT=0", ) } // RepoPath is the bare repo directory for a validated name. func (g *Git) RepoPath(name string) string { return filepath.Join(g.cfg.ReposDir(), name+".git") } func (g *Git) repoDir(name string) (string, error) { if !ValidRepoName(name) { return "", fmt.Errorf("%q: %w", name, ErrInvalidName) } return g.RepoPath(name), nil } // lock serializes writes per repository. Two concurrent index writes in the // same bare repo corrupt each other. func (g *Git) lock(name string) *sync.Mutex { g.mu.Lock() defer g.mu.Unlock() m, ok := g.locks[name] if !ok { m = &sync.Mutex{} g.locks[name] = m } return m } type runOpts struct { extraEnv []string // appended to the sanitized env stdin []byte } // run executes git and returns stdout. Stderr goes into the error. func (g *Git) run(ctx context.Context, opt runOpts, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, "git", args...) cmd.Env = g.env if len(opt.extraEnv) > 0 { cmd.Env = append(append([]string(nil), g.env...), opt.extraEnv...) } if opt.stdin != nil { cmd.Stdin = bytes.NewReader(opt.stdin) } var out, errBuf bytes.Buffer cmd.Stdout = &out cmd.Stderr = &errBuf if err := cmd.Run(); err != nil { return out.Bytes(), fmt.Errorf("git %s: %w: %s", args[0], err, strings.TrimSpace(errBuf.String())) } return out.Bytes(), nil } func (g *Git) text(ctx context.Context, args ...string) (string, error) { out, err := g.run(ctx, runOpts{}, args...) return string(out), err } func (g *Git) line(ctx context.Context, args ...string) (string, error) { s, err := g.text(ctx, args...) return strings.TrimSpace(s), err } // signArgs configure ssh commit signing with the server host key. func (g *Git) signArgs() []string { return []string{"-c", "gpg.format=ssh", "-c", "user.signingKey=" + g.cfg.SSHHostKeyPath} } // verifyArgs configure signature verification against the allowed_signers file. func (g *Git) verifyArgs() []string { return []string{"-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile=" + g.cfg.AllowedSignersPath()} } // SigStatus is the badge shown next to a commit. type SigStatus string const ( SigGood SigStatus = "good" SigBad SigStatus = "bad" SigNone SigStatus = "none" ) // parseSigStatus maps git's %G? codes onto the three badges. func parseSigStatus(code string) SigStatus { switch code { case "G", "X", "Y", "R": return SigGood case "B", "U", "E": return SigBad } return SigNone } type Commit struct { Hash string Subject string Author string Date string SigStatus SigStatus } type CommitMeta struct { Hash string Subject string Body string Author string Email string Date string Committer string CommitterEmail string CommitterDate string Parents []string SigStatus SigStatus } type TreeEntry struct { Mode string Type string // blob or tree Hash string Size string Name string } type BranchInfo struct { Name string ShortHash string Subject string AuthorName string Date string } type TagInfo struct { Name string ShortHash string Subject string TaggerName string Date string IsAnnotated bool } // Ident is a git author or committer identity. type Ident struct { Name string Email string } func identEnv(author, committer Ident) []string { return []string{ "GIT_AUTHOR_NAME=" + author.Name, "GIT_AUTHOR_EMAIL=" + author.Email, "GIT_COMMITTER_NAME=" + committer.Name, "GIT_COMMITTER_EMAIL=" + committer.Email, } } func splitLines(s string) []string { var out []string for _, l := range strings.Split(s, "\n") { if l != "" { out = append(out, l) } } return out } func field(parts []string, i int) string { if i < len(parts) { return parts[i] } return "" } // --- read operations --- func (g *Git) Init(ctx context.Context, name, branch string) error { p, err := g.repoDir(name) if err != nil { return err } if branch == "" { branch = "main" } if !ValidRef(branch) { return fmt.Errorf("%q: %w", branch, ErrInvalidRef) } m := g.lock(name) m.Lock() defer m.Unlock() _, err = g.run(ctx, runOpts{}, "init", "--bare", "--initial-branch="+branch, p) return err } // EnsureBare sets core.bare on a repo discovered on disk. func (g *Git) EnsureBare(ctx context.Context, name string) error { p, err := g.repoDir(name) if err != nil { return err } cfgFile := filepath.Join(p, "config") m := g.lock(name) m.Lock() defer m.Unlock() if cur, err := g.line(ctx, "config", "--file", cfgFile, "--get", "core.bare"); err == nil && cur == "true" { return nil } _, err = g.run(ctx, runOpts{}, "config", "--file", cfgFile, "core.bare", "true") return err } // asBadRef maps git's "this ref does not resolve" stderr onto ErrBadRef. // It covers an unknown revision, a bad default HEAD and a repo with no // commits. Any other failure is returned unchanged. func asBadRef(ref string, err error) error { msg := err.Error() switch { case strings.Contains(msg, "unknown revision"), strings.Contains(msg, "not a valid object name"), strings.Contains(msg, "bad revision"), strings.Contains(msg, "bad object"), strings.Contains(msg, "bad default revision"), strings.Contains(msg, "does not have any commits yet"), strings.Contains(msg, "ambiguous argument"): return fmt.Errorf("%q: %w", ref, ErrBadRef) } return err } // Log returns up to limit commits starting at ref, skipping skip. func (g *Git) Log(ctx context.Context, name, ref string, limit, skip int) ([]Commit, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if ref == "" { ref = "HEAD" } if !ValidRef(ref) { return nil, fmt.Errorf("%q: %w", ref, ErrInvalidRef) } args := append(g.verifyArgs(), "-C", p, "log", "--format=%H%x1f%s%x1f%an%x1f%ai%x1f%G?", "--max-count="+strconv.Itoa(limit), "--skip="+strconv.Itoa(skip), // Everything after --end-of-options is data, never an option. "--end-of-options", ref, "--") out, err := g.text(ctx, args...) if err != nil { return nil, fmt.Errorf("log %s: %w", ref, asBadRef(ref, err)) } var commits []Commit for _, line := range splitLines(out) { parts := strings.Split(line, "\x1f") commits = append(commits, Commit{ Hash: field(parts, 0), Subject: field(parts, 1), Author: field(parts, 2), Date: field(parts, 3), SigStatus: parseSigStatus(field(parts, 4)), }) } return commits, nil } // LsTree lists one directory level. subpath "" means the repo root. func (g *Git) LsTree(ctx context.Context, name, ref, subpath string) ([]TreeEntry, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if !ValidRef(ref) { return nil, fmt.Errorf("%q: %w", ref, ErrInvalidRef) } // A trailing `--` with no pathspec means "match nothing" to ls-tree, // so only add the separator when there is a path. args := []string{"-C", p, "ls-tree", "--long", "--end-of-options", ref} if subpath != "" { if !ValidPath(subpath) { return nil, fmt.Errorf("%q: %w", subpath, ErrInvalidRef) } args = append(args, "--", subpath+"/") } out, err := g.text(ctx, args...) if err != nil { return nil, fmt.Errorf("ls-tree %s: %w", ref, asBadRef(ref, err)) } prefix := subpath + "/" var entries []TreeEntry for _, line := range splitLines(out) { // format: SP SP SP TAB tab := strings.IndexByte(line, '\t') if tab < 0 { continue } meta := strings.Fields(line[:tab]) e := TreeEntry{ Mode: field(meta, 0), Type: field(meta, 1), Hash: field(meta, 2), Size: field(meta, 3), Name: line[tab+1:], } if subpath != "" { e.Name = strings.TrimPrefix(e.Name, prefix) } entries = append(entries, e) } return entries, nil } // Show returns the blob contents at ref:filePath. func (g *Git) Show(ctx context.Context, name, ref, filePath string) ([]byte, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if !ValidRef(ref) { return nil, fmt.Errorf("%q: %w", ref, ErrInvalidRef) } if !ValidPath(filePath) { return nil, fmt.Errorf("%q: %w", filePath, ErrNotFound) } out, err := g.run(ctx, runOpts{}, "-C", p, "show", "--end-of-options", ref+":"+filePath) if err != nil { // A missing path is a normal answer, for example probing for a CI config. return nil, fmt.Errorf("show %s:%s: %w", ref, filePath, ErrNotFound) } return out, nil } // Diff returns the patch text for one commit. func (g *Git) Diff(ctx context.Context, name, sha string) (string, error) { p, err := g.repoDir(name) if err != nil { return "", err } if !ValidRef(sha) { return "", fmt.Errorf("%q: %w", sha, ErrInvalidRef) } return g.text(ctx, "-C", p, "diff-tree", "--no-commit-id", "-r", "-p", "-M", "--root", "--end-of-options", sha, "--") } // BlobSize returns the size of a blob object, 0 for the all-zero hash. func (g *Git) BlobSize(ctx context.Context, name, hash string) (int64, error) { p, err := g.repoDir(name) if err != nil { return 0, err } if strings.Trim(hash, "0") == "" { return 0, nil } if !ValidRef(hash) { return 0, fmt.Errorf("%q: %w", hash, ErrInvalidRef) } out, err := g.line(ctx, "-C", p, "cat-file", "-s", "--end-of-options", hash) if err != nil { return 0, fmt.Errorf("cat-file: %w", err) } return strconv.ParseInt(out, 10, 64) } // FileSize returns the size of the blob at ref:filePath. A path that is not a // blob, a directory for example, is reported as not found. func (g *Git) FileSize(ctx context.Context, name, ref, filePath string) (int64, error) { p, err := g.repoDir(name) if err != nil { return 0, err } if !ValidRef(ref) || !ValidPath(filePath) { return 0, ErrInvalidRef } // --batch-check reports the type as well as the size in one process. // Without the type a directory would answer with the tree's size, and // the streaming readers would then send an empty body. out, err := g.run(ctx, runOpts{stdin: []byte(ref + ":" + filePath + "\n")}, "-C", p, "cat-file", "--batch-check") if err != nil { return 0, fmt.Errorf("%s:%s: %w", ref, filePath, ErrNotFound) } fields := strings.Fields(string(out)) if len(fields) != 3 || fields[1] != "blob" { return 0, fmt.Errorf("%s:%s: %w", ref, filePath, ErrNotFound) } return strconv.ParseInt(fields[2], 10, 64) } // cachedRefs serves a ref list from cache, or fills it via load. func (g *Git) cachedRefs(cache *util.Cache[string, []string], name string, load func() ([]string, error)) ([]string, error) { if v, ok := cache.Get(name); ok { return v, nil } value, err := load() if err != nil { return nil, err } cache.Set(name, value) return value, nil } // InvalidateRefCache drops the cached branch and tag lists for a repo. func (g *Git) InvalidateRefCache(name string) { g.branches.Delete(name) g.tags.Delete(name) } func (g *Git) Branches(ctx context.Context, name string) ([]string, error) { p, err := g.repoDir(name) if err != nil { return nil, err } return g.cachedRefs(g.branches, name, func() ([]string, error) { out, err := g.text(ctx, "-C", p, "for-each-ref", "--count="+strconv.Itoa(MaxRefList), "--format=%(refname:short)", "refs/heads/") if err != nil { return nil, fmt.Errorf("branches: %w", err) } return splitLines(out), nil }) } func (g *Git) Tags(ctx context.Context, name string) ([]string, error) { p, err := g.repoDir(name) if err != nil { return nil, err } return g.cachedRefs(g.tags, name, func() ([]string, error) { out, err := g.text(ctx, "-C", p, "for-each-ref", "--count="+strconv.Itoa(MaxRefList), "--format=%(refname:short)", "refs/tags/") if err != nil { return nil, fmt.Errorf("tags: %w", err) } return splitLines(out), nil }) } func (g *Git) BranchesWithInfo(ctx context.Context, name string, maxCount int) ([]BranchInfo, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if maxCount <= 0 { maxCount = MaxRefList } // for-each-ref has no %x1f escape, so embed the separator byte directly. const f = "%(refname:short)\x1f%(objectname:short)\x1f%(contents:subject)\x1f%(authorname)\x1f%(authordate:iso8601)" out, err := g.text(ctx, "-C", p, "for-each-ref", "--sort=-creatordate", "--count="+strconv.Itoa(maxCount), "--format="+f, "refs/heads/") if err != nil { return nil, fmt.Errorf("branchesWithInfo: %w", err) } var list []BranchInfo for _, line := range splitLines(out) { parts := strings.Split(line, "\x1f") list = append(list, BranchInfo{ Name: field(parts, 0), ShortHash: field(parts, 1), Subject: field(parts, 2), AuthorName: field(parts, 3), Date: field(parts, 4), }) } return list, nil } func (g *Git) TagsWithInfo(ctx context.Context, name string, maxCount int) ([]TagInfo, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if maxCount <= 0 { maxCount = MaxRefList } // %(*objectname:short) resolves annotated tags to their commit. It is // empty for lightweight tags, which is how we tell the two apart. const f = "%(refname:short)\x1f%(*objectname:short)\x1f%(objectname:short)\x1f%(contents:subject)\x1f%(taggername)\x1f%(creatordate:iso8601)" out, err := g.text(ctx, "-C", p, "for-each-ref", "--sort=-creatordate", "--count="+strconv.Itoa(maxCount), "--format="+f, "refs/tags/") if err != nil { return nil, fmt.Errorf("tagsWithInfo: %w", err) } var list []TagInfo for _, line := range splitLines(out) { parts := strings.Split(line, "\x1f") deref := strings.TrimSpace(field(parts, 1)) own := strings.TrimSpace(field(parts, 2)) hash := own if deref != "" { hash = deref } list = append(list, TagInfo{ Name: field(parts, 0), ShortHash: hash, Subject: field(parts, 3), TaggerName: field(parts, 4), Date: field(parts, 5), IsAnnotated: deref != "", }) } return list, nil } // DefaultBranch trusts HEAD only when it names a branch that exists. func (g *Git) DefaultBranch(ctx context.Context, name string) string { p, err := g.repoDir(name) if err != nil { return "main" } branches, err := g.Branches(ctx, name) if err != nil { return "main" } head, _ := g.line(ctx, "-C", p, "symbolic-ref", "--short", "HEAD") for _, b := range branches { if b == head { return head } } for _, want := range []string{"main", "master"} { for _, b := range branches { if b == want { return want } } } if len(branches) > 0 { return branches[0] } return "main" } // ResolveRef returns the object id a ref points at. func (g *Git) ResolveRef(ctx context.Context, name, ref string) (string, error) { p, err := g.repoDir(name) if err != nil { return "", err } if !ValidRef(ref) { return "", fmt.Errorf("%q: %w", ref, ErrInvalidRef) } out, err := g.line(ctx, "-C", p, "rev-parse", "--verify", "--end-of-options", ref) if err != nil || out == "" { return "", fmt.Errorf("%q: %w", ref, ErrBadRef) } return out, nil } // HasCommits reports whether the repo has at least one commit. func (g *Git) HasCommits(ctx context.Context, name string) bool { p, err := g.repoDir(name) if err != nil { return false } out, err := g.line(ctx, "-C", p, "log", "--oneline", "-1", "--") return err == nil && out != "" } // CommitMeta returns the full detail for one commit, including its // signature badge. func (g *Git) CommitMeta(ctx context.Context, name, sha string) (*CommitMeta, error) { p, err := g.repoDir(name) if err != nil { return nil, err } if !ValidRef(sha) { return nil, fmt.Errorf("%q: %w", sha, ErrInvalidRef) } args := append(g.verifyArgs(), "-C", p, "show", "--no-patch", "--format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P%x1f%G?", "--end-of-options", sha, "--") metaOut, err := g.line(ctx, args...) if err != nil { return nil, fmt.Errorf("commitMeta %s: %w", sha, ErrNotFound) } msgOut, err := g.text(ctx, "-C", p, "log", "--format=%B", "-1", "--end-of-options", sha, "--") if err != nil { return nil, fmt.Errorf("commitMeta message %s: %w", sha, err) } parts := strings.Split(metaOut, "\x1f") full := strings.TrimRight(msgOut, "\n") subject, body, _ := strings.Cut(full, "\n") hash := field(parts, 0) if hash == "" { hash = sha } return &CommitMeta{ Hash: hash, Subject: subject, Body: strings.TrimSpace(body), Author: field(parts, 1), Email: field(parts, 2), Date: field(parts, 3), Committer: field(parts, 4), CommitterEmail: field(parts, 5), CommitterDate: field(parts, 6), Parents: strings.Fields(field(parts, 7)), SigStatus: parseSigStatus(field(parts, 8)), }, nil } // SetHead points HEAD at a branch. func (g *Git) SetHead(ctx context.Context, name, branch string) error { p, err := g.repoDir(name) if err != nil { return err } if !ValidRef(branch) { return fmt.Errorf("%q: %w", branch, ErrInvalidRef) } _, err = g.run(ctx, runOpts{}, "-C", p, "symbolic-ref", "HEAD", "refs/heads/"+branch) return err }