// 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: "\t:\t". '=' 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 "" 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 }