You've already forked agentic-coding-workflow
182 lines
6.3 KiB
Go
182 lines
6.3 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
|
|
FetchRemoteBranch(ctx context.Context, dir, branch 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)
|
|
ListRemoteBranches(ctx context.Context, dir string, cred *Credential) ([]string, error)
|
|
Checkout(ctx context.Context, dir, branch string) error
|
|
Log(ctx context.Context, dir string, n int) ([]Commit, 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
|