// 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) Diff(ctx context.Context, dir string, opts DiffOptions) (DiffResult, error) // ListTrackedFiles returns the git-tracked files at HEAD (git ls-files), // worktree-relative, so the indexer never walks ignored/vendored/untracked paths. ListTrackedFiles(ctx context.Context, dir string) ([]string, error) // DiffStat returns the `--stat` summary for a single commit (git show --stat), // used by auto-doc generation to feed the LLM a bounded change overview. DiffStat(ctx context.Context, dir, commitSHA string) (string, 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/ 去前缀 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