Files
agentic-coding-workflow/internal/infra/git/runner.go
T
2026-06-10 15:30:52 +08:00

178 lines
6.0 KiB
Go

// Package git 是 git CLI 子进程封装。所有方法走 exec.CommandContext,stdout/stderr
// 各自捕获,stderr 摘要在失败时通过 GitError 暴露给调用方。
package git
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
// stderrTruncateLimit caps captured stderr to keep audit logs bounded.
const stderrTruncateLimit = 2000
// Runner 是 git 子系统暴露给业务层的全部能力。所有方法接 ctx 与 dir
// (工作目录的绝对路径),不接受用户传入的 path —— 路径在 workspace 包
// 计算后再传入此处,避免命令注入。
type Runner interface {
Clone(ctx context.Context, dst, remoteURL, branch string, cred *Credential) error
Fetch(ctx context.Context, dir string, cred *Credential) error
Push(ctx context.Context, dir, branch string, cred *Credential) error
Commit(ctx context.Context, dir, message string, addAll bool) (sha string, err error)
Status(ctx context.Context, dir string) ([]FileStatus, error)
MergeFFOnly(ctx context.Context, dir string) error
WorktreeAdd(ctx context.Context, dir, branch, path, startPoint string) error
WorktreeRemove(ctx context.Context, dir, path string) error
WorktreeList(ctx context.Context, dir string) ([]Worktree, error)
}
// FileStatus 表示 `git status --porcelain=v1` 的一行。
type FileStatus struct {
Path string // 工作树相对路径
IndexX byte // staged 状态:M/A/D/R/C/U/?/!
WorktreeY byte // worktree 状态:同上
}
// Worktree 表示 `git worktree list --porcelain` 的一项。
type Worktree struct {
Path string
Branch string // refs/heads/<branch> 去前缀
HEAD string // commit sha
Bare bool
}
// Config 控制 Runner 的执行参数;所有 Timeout 都被 ctx 覆盖(取较小值)。
type Config struct {
Binary string // 默认 "git",测试可换 stub
TmpDir string // 凭据临时文件根目录(${DATA_DIR}/tmp)
CloneTimeout time.Duration // 默认 30 min
FetchTimeout time.Duration // 默认 5 min
PushTimeout time.Duration // 默认 5 min
OpsTimeout time.Duration // 默认 60 s(commit/status/worktree)
}
// DefaultConfig 返回带合理默认值的 Config。Binary="git", TmpDir=os.TempDir() 兜底。
func DefaultConfig() Config {
return Config{
Binary: "git",
TmpDir: os.TempDir(),
CloneTimeout: 30 * time.Minute,
FetchTimeout: 5 * time.Minute,
PushTimeout: 5 * time.Minute,
OpsTimeout: 60 * time.Second,
}
}
// DefaultRunner 是 Runner 的生产实现。NewDefaultRunner 是构造器。
type DefaultRunner struct{ cfg Config }
// NewDefaultRunner 用给定配置构造 DefaultRunner。tmpDir 必须存在并可写;
// 调用方负责进程启动期清理 tmpDir 残留文件。
func NewDefaultRunner(cfg Config) *DefaultRunner {
if cfg.Binary == "" {
cfg.Binary = "git"
}
if cfg.TmpDir == "" {
cfg.TmpDir = os.TempDir()
}
if cfg.CloneTimeout == 0 {
cfg.CloneTimeout = 30 * time.Minute
}
if cfg.FetchTimeout == 0 {
cfg.FetchTimeout = 5 * time.Minute
}
if cfg.PushTimeout == 0 {
cfg.PushTimeout = 5 * time.Minute
}
if cfg.OpsTimeout == 0 {
cfg.OpsTimeout = 60 * time.Second
}
return &DefaultRunner{cfg: cfg}
}
// exec 是所有动词的统一执行入口。
//
// cwd: 绝对路径,作为子进程的工作目录;空字符串表示不切换
// env: 追加到 os.Environ() 之后的 KEY=VALUE 切片;nil 表示不追加
// args: git 命令的参数(不含 "git" 本身)
//
// 返回 stdout/stderr 字节切片与错误。错误为 GitError 时携带 stderr 与 exit code。
func (r *DefaultRunner) exec(ctx context.Context, cwd string, env []string, args ...string) (stdout, stderr []byte, err error) {
// 统一前置 `-c credential.helper=` 清空 helper 链,绕过 Git for Windows
// 默认 GCM 抢占 PAT 的问题。
fullArgs := append([]string{"-c", "credential.helper="}, args...)
cmd := exec.CommandContext(ctx, r.cfg.Binary, fullArgs...)
if cwd != "" {
cmd.Dir = cwd
}
// base env 总是禁交互;调用方 env 追加在后可覆盖。
baseEnv := append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GCM_INTERACTIVE=never")
cmd.Env = append(baseEnv, env...)
// WaitDelay:超时 kill git 后,孤儿 ssh/git-remote-https 子进程可能持有
// stderr pipe 写端导致 cmd.Run() 无限阻塞,给一个上限强制收尾。
cmd.WaitDelay = 10 * time.Second
var so, se bytes.Buffer
cmd.Stdout = &so
cmd.Stderr = &se
runErr := cmd.Run()
stdout, stderr = so.Bytes(), se.Bytes()
if runErr == nil {
return stdout, stderr, nil
}
// 区分超时 / 退出非零 / 二进制找不到
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return stdout, stderr, &GitError{
Args: args, ExitCode: -1, Stderr: truncate(stderr, stderrTruncateLimit),
Cause: fmt.Errorf("timeout after %s", deadlineHint(ctx)),
}
}
var ee *exec.ExitError
if errors.As(runErr, &ee) {
return stdout, stderr, &GitError{
Args: args, ExitCode: ee.ExitCode(), Stderr: truncate(stderr, stderrTruncateLimit),
Cause: runErr,
}
}
return stdout, stderr, &GitError{
Args: args, ExitCode: -1, Stderr: truncate(stderr, stderrTruncateLimit), Cause: runErr,
}
}
// withCtx 在 ctx 上挂超时(取 ctx 与 fallback 的较小值)。fallback==0 时不附加超时。
func withCtx(parent context.Context, fallback time.Duration) (context.Context, context.CancelFunc) {
if fallback == 0 {
return parent, func() {}
}
if d, ok := parent.Deadline(); ok && time.Until(d) < fallback {
return parent, func() {}
}
return context.WithTimeout(parent, fallback)
}
// deadlineHint 给超时错误一个可读时间戳。
func deadlineHint(ctx context.Context) string {
d, ok := ctx.Deadline()
if !ok {
return "n/a"
}
return time.Until(d).String()
}
// truncate 裁切 stderr 防爆 audit log。
func truncate(b []byte, limit int) string {
if len(b) <= limit {
return string(b)
}
return string(b[:limit]) + "...[truncated]"
}
// 让代码在 windows 上也能编译时引用 filepath.Separator(避免 unused import)
var _ = filepath.Separator
var _ = runtime.GOOS