// Package gitutil wraps the few git operations the agent needs. Credentials // are passed in per-call and never written to disk — every command sets them // via -c credential helpers for the lifetime of the subprocess. package gitutil import ( "context" "fmt" "os/exec" "strings" ) // Creds is a username/password. We pass them through GIT_ASKPASS so they // never appear on the command line or in process listings. type Creds struct { Username string Password string } // Fetch runs `git fetch --prune origin`. Uses the per-command credential // helper to inject creds without touching the repo's stored config. func Fetch(ctx context.Context, repoDir string, c Creds) (string, error) { return runGit(ctx, repoDir, c, "fetch", "--prune", "origin") } // Checkout switches to branch and updates the working tree. func Checkout(ctx context.Context, repoDir, branch string, c Creds) (string, error) { return runGit(ctx, repoDir, c, "checkout", "-f", branch) } // Pull fast-forwards the branch to match origin. Safe no-op if up to date. func Pull(ctx context.Context, repoDir string, c Creds) (string, error) { return runGit(ctx, repoDir, c, "pull", "--ff-only") } // Probe validates that the credentials work for a given remote. Used at // login. Tries `git ls-remote HEAD`; succeeds even on an empty repo. func Probe(ctx context.Context, repoDir string, c Creds) error { _, err := runGit(ctx, repoDir, c, "ls-remote", "--heads", "origin", "HEAD") return err } // ListBranches lists local branches. Cheap; no network. func ListBranches(ctx context.Context, repoDir string) ([]string, error) { cmd := exec.CommandContext(ctx, "git", "for-each-ref", "--format=%(refname:short)", "refs/heads/") cmd.Dir = repoDir out, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("git for-each-ref: %w: %s", err, out) } s := strings.TrimSpace(string(out)) if s == "" { return nil, nil } return strings.Split(s, "\n"), nil } func runGit(ctx context.Context, repoDir string, c Creds, args ...string) (string, error) { cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = repoDir // GIT_ASKPASS gives us a per-command credential helper. We just echo the // creds back. The "username" / "password" args are sent to the script's // argv by git. askpass := fmt.Sprintf(`#!/bin/sh case "$1" in username) echo %q ;; password) echo %q ;; esac`, c.Username, c.Password) cmd.Env = append(cmd.Environ(), "GIT_ASKPASS=/dev/stdin", "GIT_TERMINAL_PROMPT=0", ) // ponytail: passing askpass via stdin is portable across Linux/macOS. cmd.Stdin = strings.NewReader(askpass) out, err := cmd.CombinedOutput() if err != nil { return string(out), fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, out) } return string(out), nil }