You've already forked agentic-coding-workflow
feat(infra/git): Runner interface + Credential injection + GitError
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// 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)
|
||||
WorktreeAdd(ctx context.Context, dir, branch, path 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) {
|
||||
cmd := exec.CommandContext(ctx, r.cfg.Binary, args...)
|
||||
if cwd != "" {
|
||||
cmd.Dir = cwd
|
||||
}
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
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
|
||||
Reference in New Issue
Block a user