Files
syncbot/internal/gitx/gitx.go
T
iceBear67 a006483bbc
ci / test (push) Canceled after 0s
docker / build (push) Canceled after 0s
init
2026-08-14 07:13:22 +00:00

445 lines
13 KiB
Go

// Package gitx is a thin, hermetic wrapper around the git command line.
//
// Shelling out to git — rather than linking a pure-Go implementation — keeps
// the binary small and the memory profile flat: the heavy lifting happens in a
// short-lived child process that the kernel reclaims when it exits.
//
// Every invocation runs with an isolated HOME and with the system/global git
// config disabled, so syncbot behaves identically no matter whose account or
// container it runs in.
package gitx
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// SSH describes how to authenticate to one endpoint over SSH.
type SSH struct {
KeyPath string // deploy key; empty means "use the agent / default keys"
KnownHosts string // pinned host key file; empty means use Home/known_hosts
StrictHostKey string // yes | no | accept-new
}
// Options carry everything a git invocation needs beyond its arguments.
type Options struct {
Log *slog.Logger
Home string // isolated HOME for git and ssh; must exist
GitConfig []string // extra "key=value" settings passed as -c
SSHCmd string // pre-built GIT_SSH_COMMAND, see PrepareSSH
Secrets []string // substrings scrubbed from logs and error messages
}
// Refs maps a full ref name to the object it points at.
type Refs map[string]string
// Equal reports whether two ref sets are identical.
func (r Refs) Equal(other Refs) bool {
if len(r) != len(other) {
return false
}
for k, v := range r {
if other[k] != v {
return false
}
}
return true
}
// termGrace is how long a git child gets to exit after SIGTERM before we
// escalate to SIGKILL on its whole process group.
const termGrace = 3 * time.Second
// Run executes git with the given arguments and returns its stdout.
//
// The child is put in its own process group so that a timeout or a shutdown
// takes down the helpers git spawns (ssh, git-remote-https) instead of leaking
// them.
func Run(ctx context.Context, o Options, dir string, args ...string) (string, error) {
full := make([]string, 0, len(args)+2*len(o.GitConfig))
for _, kv := range o.GitConfig {
full = append(full, "-c", kv)
}
full = append(full, args...)
cmd := exec.Command("git", full...)
cmd.Dir = dir
cmd.Env = environ(o)
cmd.SysProcAttr = sysProcAttr()
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &limitedWriter{W: &stderr, N: 64 << 10}
started := time.Now()
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("git %s: %w", args[0], err)
}
// Watchdog: translate context cancellation into signals for the group.
watchdogDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
terminate(cmd.Process.Pid)
case <-watchdogDone:
}
}()
err := cmd.Wait()
close(watchdogDone)
if o.Log != nil && o.Log.Enabled(ctx, slog.LevelDebug) {
o.Log.Debug("git", "args", scrub(strings.Join(args, " "), o.Secrets),
"dur", time.Since(started).Round(time.Millisecond), "err", err)
}
if err != nil {
msg := scrub(strings.TrimSpace(stderr.String()), o.Secrets)
if ctx.Err() != nil {
return "", fmt.Errorf("git %s: %w (%s)", args[0], ctx.Err(), firstLines(msg, 3))
}
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("git %s: %s", args[0], firstLines(msg, 8))
}
return stdout.String(), nil
}
// environ builds a deterministic environment: the parent's, minus anything that
// could redirect git's authentication or config, plus our own settings.
func environ(o Options) []string {
drop := map[string]bool{
"HOME": true, "XDG_CONFIG_HOME": true,
"GIT_SSH": true, "GIT_SSH_COMMAND": true, "GIT_ASKPASS": true, "SSH_ASKPASS": true,
"GIT_CONFIG": true, "GIT_CONFIG_GLOBAL": true, "GIT_CONFIG_SYSTEM": true,
"GIT_DIR": true, "GIT_WORK_TREE": true, "GIT_TERMINAL_PROMPT": true,
}
out := make([]string, 0, 16)
for _, kv := range os.Environ() {
if k, _, ok := strings.Cut(kv, "="); ok && !drop[k] {
out = append(out, kv)
}
}
out = append(out,
"HOME="+o.Home,
"GIT_CONFIG_GLOBAL="+os.DevNull,
"GIT_CONFIG_SYSTEM="+os.DevNull,
"GIT_TERMINAL_PROMPT=0", // never block waiting for a password
"SSH_ASKPASS_REQUIRE=never",
"LC_ALL=C",
)
if o.SSHCmd != "" {
out = append(out, "GIT_SSH_COMMAND="+o.SSHCmd)
}
return out
}
// PrepareSSH builds a GIT_SSH_COMMAND for the endpoint.
//
// Deploy keys are usually mounted read-only from a secret store, which often
// means mode 0644 — and ssh flatly refuses group- or world-readable keys. When
// that happens we copy the key into tmpDir at 0600 rather than asking the
// operator to fix permissions they may not control.
func PrepareSSH(s SSH, home, tmpDir string) (string, error) {
knownHosts := s.KnownHosts
if knownHosts == "" {
knownHosts = filepath.Join(home, "known_hosts")
if _, err := os.Stat(knownHosts); os.IsNotExist(err) {
if err := os.WriteFile(knownHosts, nil, 0o600); err != nil {
return "", fmt.Errorf("create known_hosts: %w", err)
}
}
}
strict := s.StrictHostKey
if strict == "" {
strict = "accept-new"
}
args := []string{"ssh",
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=" + strict,
"-o", "UserKnownHostsFile=" + knownHosts,
"-o", "ConnectTimeout=30",
}
if s.KeyPath != "" {
key, err := usableKey(s.KeyPath, tmpDir)
if err != nil {
return "", err
}
// IdentitiesOnly stops ssh from offering an agent's keys first and
// tripping GitHub's "too many authentication failures".
args = append(args, "-i", key, "-o", "IdentitiesOnly=yes")
}
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = shellQuote(a)
}
return strings.Join(quoted, " "), nil
}
func usableKey(path, tmpDir string) (string, error) {
fi, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("ssh_key: %w", err)
}
if fi.IsDir() {
return "", fmt.Errorf("ssh_key: %s is a directory", path)
}
if fi.Mode().Perm()&0o077 == 0 {
return path, nil
}
// Too permissive for ssh: stage a private copy.
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("ssh_key: %w", err)
}
dst := filepath.Join(tmpDir, "id_"+filepath.Base(path))
if err := os.WriteFile(dst, b, 0o600); err != nil {
return "", fmt.Errorf("ssh_key: stage private copy: %w", err)
}
return dst, nil
}
// EnsureMirror makes sure dir holds a usable bare repository, creating it on
// first run. A directory that exists but is not a bare repo is reported rather
// than deleted — that is almost always a misconfigured mount, and silently
// wiping it would be the wrong kind of helpful.
func EnsureMirror(ctx context.Context, o Options, dir string) error {
if _, err := os.Stat(filepath.Join(dir, "HEAD")); err == nil {
out, err := Run(ctx, o, dir, "rev-parse", "--is-bare-repository")
if err != nil {
return fmt.Errorf("%s exists but is not a git repository: %w", dir, err)
}
if strings.TrimSpace(out) != "true" {
return fmt.Errorf("%s is not a bare repository", dir)
}
return nil
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
_, err := Run(ctx, o, o.Home, "init", "--bare", "--quiet", "--initial-branch=main", dir)
return err
}
// LsRemote asks a remote which refs it currently has, without transferring any
// objects. This is the "check for updates" probe: cheap enough to run on a
// short interval even against large repositories.
func LsRemote(ctx context.Context, o Options, dir, repoURL string, patterns []string) (Refs, error) {
args := append([]string{"ls-remote", "--refs", "--", repoURL}, patterns...)
out, err := Run(ctx, o, dir, args...)
if err != nil {
return nil, err
}
return parseRefs(out, "\t", true, patterns), nil
}
// LocalRefs reads the mirror's own refs, filtered to the managed patterns.
func LocalRefs(ctx context.Context, o Options, dir string, patterns []string) (Refs, error) {
out, err := Run(ctx, o, dir, "for-each-ref", "--format=%(objectname)\t%(refname)")
if err != nil {
return nil, err
}
return parseRefs(out, "\t", true, patterns), nil
}
// Fetch updates the mirror from src. Refs that vanished upstream are pruned so
// the mirror is an exact copy of the managed namespace, not an accumulation.
func Fetch(ctx context.Context, o Options, dir, src string, patterns []string) error {
args := []string{"fetch", "--force", "--no-tags", "--no-write-fetch-head", "--prune", "--quiet", "--", src}
for _, p := range patterns {
args = append(args, "+"+p+":"+p)
}
_, err := Run(ctx, o, dir, args...)
return err
}
// PushOptions controls how the mirror is written to the destination.
type PushOptions struct {
Prune bool // delete destination refs that no longer exist upstream
Force bool // allow non-fast-forward updates (a mirror must)
Atomic bool // all refs update, or none do
}
// Push writes the mirror's managed refs to dst and returns the porcelain lines
// describing what actually changed.
func Push(ctx context.Context, o Options, dir, dst string, patterns []string, po PushOptions) ([]string, error) {
args := []string{"push", "--porcelain"}
if po.Prune {
args = append(args, "--prune")
}
if po.Force {
args = append(args, "--force")
}
if po.Atomic {
args = append(args, "--atomic")
}
args = append(args, "--", dst)
for _, p := range patterns {
args = append(args, p+":"+p)
}
out, err := Run(ctx, o, dir, args...)
if err != nil {
return nil, err
}
var changed []string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimRight(line, "\r")
// Porcelain format: "<flag>\t<from>:<to>\t<summary>". '=' means the ref
// was already up to date, which is the boring majority.
if line == "" || strings.HasPrefix(line, "To ") || line == "Done" || strings.HasPrefix(line, "=\t") {
continue
}
changed = append(changed, scrub(line, o.Secrets))
}
return changed, nil
}
// MatchRef implements git's refspec globbing: at most one "*", which matches
// any run of characters including "/".
func MatchRef(pattern, ref string) bool {
i := strings.IndexByte(pattern, '*')
if i < 0 {
return pattern == ref
}
prefix, suffix := pattern[:i], pattern[i+1:]
return len(ref) >= len(prefix)+len(suffix) &&
strings.HasPrefix(ref, prefix) &&
strings.HasSuffix(ref, suffix)
}
// MatchAny reports whether ref matches any of the patterns.
func MatchAny(patterns []string, ref string) bool {
for _, p := range patterns {
if MatchRef(p, ref) {
return true
}
}
return false
}
// parseRefs reads "<object><sep><ref>" lines, optionally filtering to patterns.
func parseRefs(out, sep string, filter bool, patterns []string) Refs {
refs := make(Refs)
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
obj, ref, ok := strings.Cut(line, sep)
if !ok {
continue
}
// Peeled entries ("refs/tags/v1^{}") describe the commit behind an
// annotated tag; the tag object itself is what we mirror.
if strings.HasSuffix(ref, "^{}") {
continue
}
if filter && !MatchAny(patterns, ref) {
continue
}
refs[ref] = obj
}
return refs
}
// RedactURL strips the password from a URL so it can be logged.
func RedactURL(raw string) string {
if !strings.Contains(raw, "://") {
return raw // scp-style (git@host:path) carries no inline secret
}
u, err := url.Parse(raw)
if err != nil || u.User == nil {
return raw
}
if _, hasPassword := u.User.Password(); hasPassword {
// Plain letters: anything punctuation-ish would come back
// percent-encoded from URL.String() and read as noise in a log line.
u.User = url.UserPassword(u.User.Username(), "redacted")
} else {
u.User = url.User(u.User.Username())
}
return u.String()
}
// URLSecret returns the credential embedded in a URL, if any, so callers can
// register it with Options.Secrets and keep it out of logs.
func URLSecret(raw string) string {
if !strings.Contains(raw, "://") {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.User == nil {
return ""
}
if pw, ok := u.User.Password(); ok && pw != "" {
return pw
}
return ""
}
func scrub(s string, secrets []string) string {
for _, sec := range secrets {
if sec != "" {
s = strings.ReplaceAll(s, sec, "***")
}
}
return s
}
func firstLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = append(lines[:n], "...")
}
return strings.Join(lines, "; ")
}
// shellQuote makes a token safe for GIT_SSH_COMMAND, which git hands to a shell.
func shellQuote(s string) string {
if s != "" && !strings.ContainsAny(s, " \t\n\"'\\$`&;|<>()*?[]{}#~!") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// limitedWriter keeps a runaway stderr from growing without bound.
type limitedWriter struct {
W io.Writer
N int
}
// Write always reports the full length: a short write would be treated as an
// error by os/exec and would abort an otherwise healthy git invocation.
func (l *limitedWriter) Write(p []byte) (int, error) {
total := len(p)
if l.N <= 0 {
return total, nil
}
if len(p) > l.N {
p = p[:l.N]
}
n, err := l.W.Write(p)
l.N -= n
return total, err
}