You've already forked agentic-coding-workflow
feat(infra/git): Runner interface + Credential injection + GitError
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CredentialKind 与 DB 列 git_credentials.kind 完全一致。
|
||||||
|
type CredentialKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CredentialPAT CredentialKind = "pat"
|
||||||
|
CredentialSSHKey CredentialKind = "ssh_key"
|
||||||
|
CredentialNone CredentialKind = "none"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credential 携带解密后的明文 secret。Service 层从 git_credentials 行 + crypto.Encryptor.Decrypt
|
||||||
|
// 解出后构造 Credential,git 调用结束立即丢弃。
|
||||||
|
type Credential struct {
|
||||||
|
Kind CredentialKind
|
||||||
|
Username string // PAT 时填,否则空
|
||||||
|
Secret []byte // PAT: token 字节;SSH: PEM-encoded private key;None: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialEnv 把 cred 物化为临时文件 + env KEY=VALUE 切片,并返回 cleanup 闭包。
|
||||||
|
// tmpDir 必须存在且可写;调用方在 git 命令完成后必须 defer cleanup()。
|
||||||
|
//
|
||||||
|
// nil cred 等价于 Kind==CredentialNone:返回 nil env、nil cleanup。
|
||||||
|
func credentialEnv(tmpDir string, cred *Credential) (env []string, cleanup func(), err error) {
|
||||||
|
if cred == nil || cred.Kind == CredentialNone {
|
||||||
|
return nil, func() {}, nil
|
||||||
|
}
|
||||||
|
switch cred.Kind {
|
||||||
|
case CredentialPAT:
|
||||||
|
return patEnv(tmpDir, cred)
|
||||||
|
case CredentialSSHKey:
|
||||||
|
return sshKeyEnv(tmpDir, cred)
|
||||||
|
default:
|
||||||
|
return nil, func() {}, fmt.Errorf("unknown credential kind %q", cred.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// patEnv 创建 askpass 脚本(Linux .sh / Windows .bat),脚本读 GIT_PAT_TOKEN 输出。
|
||||||
|
// git 在询问 username/password 时会调 GIT_ASKPASS 两次,第一次问 username,第二次问 password;
|
||||||
|
// 我们的脚本忽略提示参数 $1,无论谁问都先返回 username 再返回 token。
|
||||||
|
//
|
||||||
|
// Windows note: cmd.exe 的 .bat 没有 stderr 隔离风险(脚本只 echo 一行)。
|
||||||
|
func patEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
|
||||||
|
username := cred.Username
|
||||||
|
if username == "" {
|
||||||
|
// GitHub PAT 习惯 username='git';GitLab/Gitea 类似
|
||||||
|
username = "git"
|
||||||
|
}
|
||||||
|
suffix := ".sh"
|
||||||
|
body := patScriptShBody()
|
||||||
|
mode := os.FileMode(0o700)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
suffix = ".bat"
|
||||||
|
body = patScriptBatBody()
|
||||||
|
mode = 0o755
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp(tmpDir, "askpass-*"+suffix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, func() {}, fmt.Errorf("create askpass: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := f.WriteString(body); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
_ = os.Remove(f.Name())
|
||||||
|
return nil, func() {}, fmt.Errorf("write askpass: %w", err)
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
if err := os.Chmod(f.Name(), mode); err != nil {
|
||||||
|
_ = os.Remove(f.Name())
|
||||||
|
return nil, func() {}, fmt.Errorf("chmod askpass: %w", err)
|
||||||
|
}
|
||||||
|
env := []string{
|
||||||
|
"GIT_ASKPASS=" + f.Name(),
|
||||||
|
"GIT_PAT_USERNAME=" + username,
|
||||||
|
"GIT_PAT_TOKEN=" + string(cred.Secret),
|
||||||
|
// GIT_TERMINAL_PROMPT=0 防止 askpass 失效时 git 退回交互
|
||||||
|
"GIT_TERMINAL_PROMPT=0",
|
||||||
|
}
|
||||||
|
cleanup := func() { _ = os.Remove(f.Name()) }
|
||||||
|
return env, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func patScriptShBody() string {
|
||||||
|
return `#!/bin/sh
|
||||||
|
case "$1" in
|
||||||
|
*Username*) printf '%s' "$GIT_PAT_USERNAME" ;;
|
||||||
|
*) printf '%s' "$GIT_PAT_TOKEN" ;;
|
||||||
|
esac
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
func patScriptBatBody() string {
|
||||||
|
return "@echo off\r\n" +
|
||||||
|
"echo %1 | findstr /i Username >NUL\r\n" +
|
||||||
|
"if %ERRORLEVEL%==0 (echo %GIT_PAT_USERNAME%) else (echo %GIT_PAT_TOKEN%)\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshKeyEnv 写临时密钥 + 空 known_hosts,构造 GIT_SSH_COMMAND。
|
||||||
|
//
|
||||||
|
// Linux: chmod 0600 已通过 os.WriteFile mode 0600 实现
|
||||||
|
// Windows: 通过 icacls 限权(OpenSSH for Windows 检查文件 ACL)
|
||||||
|
func sshKeyEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
|
||||||
|
keyFile, err := os.CreateTemp(tmpDir, "ssh-key-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, func() {}, fmt.Errorf("create ssh key tmp: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := keyFile.Write(cred.Secret); err != nil {
|
||||||
|
_ = keyFile.Close()
|
||||||
|
_ = os.Remove(keyFile.Name())
|
||||||
|
return nil, func() {}, fmt.Errorf("write ssh key: %w", err)
|
||||||
|
}
|
||||||
|
_ = keyFile.Close()
|
||||||
|
if err := os.Chmod(keyFile.Name(), 0o600); err != nil {
|
||||||
|
_ = os.Remove(keyFile.Name())
|
||||||
|
return nil, func() {}, fmt.Errorf("chmod ssh key: %w", err)
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if err := windowsRestrictACL(keyFile.Name()); err != nil {
|
||||||
|
_ = os.Remove(keyFile.Name())
|
||||||
|
return nil, func() {}, fmt.Errorf("icacls ssh key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
khFile, err := os.CreateTemp(tmpDir, "known-hosts-*")
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(keyFile.Name())
|
||||||
|
return nil, func() {}, fmt.Errorf("create known_hosts tmp: %w", err)
|
||||||
|
}
|
||||||
|
_ = khFile.Close()
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf(
|
||||||
|
"ssh -i %q -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%q",
|
||||||
|
keyFile.Name(), khFile.Name(),
|
||||||
|
)
|
||||||
|
env := []string{"GIT_SSH_COMMAND=" + cmd}
|
||||||
|
cleanup := func() {
|
||||||
|
_ = os.Remove(keyFile.Name())
|
||||||
|
_ = os.Remove(khFile.Name())
|
||||||
|
}
|
||||||
|
return env, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// windowsRestrictACL 通过 icacls 把文件 ACL 限制为当前用户独占读取。
|
||||||
|
// OpenSSH 否则会拒绝使用密钥("Permissions for 'xxx' are too open")。
|
||||||
|
func windowsRestrictACL(path string) error {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
user := os.Getenv("USERNAME")
|
||||||
|
if user == "" {
|
||||||
|
return errors.New("USERNAME env empty on windows")
|
||||||
|
}
|
||||||
|
out, err := runWindowsTool("icacls", path, "/inheritance:r", "/grant:r", user+":(R)")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("icacls failed: %w (%s)", err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWindowsTool 把 icacls 调用单独抽出便于测试 stub。
|
||||||
|
var runWindowsTool = func(name string, args ...string) (string, error) {
|
||||||
|
return runExec(name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 filepath.Join 引用避免 import 被 lint 抹掉
|
||||||
|
var _ = filepath.Join
|
||||||
|
|
||||||
|
// runExec 执行外部小工具(icacls 等),返回 stdout+stderr 与错误。
|
||||||
|
// 不复用 DefaultRunner.exec 以避免对 Config 的循环依赖。
|
||||||
|
func runExec(name string, args ...string) (string, error) {
|
||||||
|
out, err := osExec(name, args...)
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// osExec 是 os/exec.Command 的薄包装,暴露成变量便于测试 stub。
|
||||||
|
var osExec = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return exec.Command(name, args...).CombinedOutput()
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitError 是 git 子进程返回非零或超时时的统一错误形式。Stderr 已截断 ≤2000 字符;
|
||||||
|
// 上层(workspace.Service)会根据 stderr 内容映射到具体业务错误码。
|
||||||
|
type GitError struct {
|
||||||
|
Args []string
|
||||||
|
ExitCode int
|
||||||
|
Stderr string
|
||||||
|
Cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GitError) Error() string {
|
||||||
|
return fmt.Sprintf("git %v: exit %d: %s", e.Args, e.ExitCode, e.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GitError) Unwrap() error { return e.Cause }
|
||||||
|
|
||||||
|
// IsAuthError 通过 stderr 关键字识别 401/403 类型的远端拒绝(PAT 失效、SSH key
|
||||||
|
// 不被信任等)。grep 的关键字源自常见 git 远端的英文响应;模糊但可用。
|
||||||
|
func IsAuthError(err error) bool {
|
||||||
|
var ge *GitError
|
||||||
|
if !errors.As(err, &ge) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, k := range []string{
|
||||||
|
"Authentication failed",
|
||||||
|
"could not read Username",
|
||||||
|
"Permission denied (publickey)",
|
||||||
|
"403 Forbidden",
|
||||||
|
"401 Unauthorized",
|
||||||
|
"fatal: Authentication",
|
||||||
|
} {
|
||||||
|
if containsCase(ge.Stderr, k) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConflict 用于识别 "branch already exists" 等 worktree add 冲突场景。
|
||||||
|
func IsConflict(err error) bool {
|
||||||
|
var ge *GitError
|
||||||
|
if !errors.As(err, &ge) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, k := range []string{
|
||||||
|
"already exists",
|
||||||
|
"is already checked out",
|
||||||
|
"already used by worktree",
|
||||||
|
} {
|
||||||
|
if containsCase(ge.Stderr, k) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsCase 大小写不敏感子串匹配;避免引入 strings.EqualFold 之类逐字符开销。
|
||||||
|
func containsCase(s, sub string) bool {
|
||||||
|
if len(sub) == 0 || len(s) < len(sub) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
S, Sub := lower(s), lower(sub)
|
||||||
|
return indexOf(S, Sub) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func lower(s string) string {
|
||||||
|
b := make([]byte, len(s))
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if c >= 'A' && c <= 'Z' {
|
||||||
|
c += 'a' - 'A'
|
||||||
|
}
|
||||||
|
b[i] = c
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultRunner_exec_BinaryNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
r := NewDefaultRunner(Config{Binary: "this-is-not-a-real-binary-xyz"})
|
||||||
|
_, _, err := r.exec(context.Background(), "", nil, "--version")
|
||||||
|
require.Error(t, err)
|
||||||
|
var ge *GitError
|
||||||
|
require.True(t, errors.As(err, &ge))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRunner_exec_NonZeroExit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
gitAvailable(t)
|
||||||
|
r := newTestRunner(t)
|
||||||
|
_, _, err := r.exec(context.Background(), t.TempDir(), nil, "rev-parse", "HEAD")
|
||||||
|
require.Error(t, err)
|
||||||
|
var ge *GitError
|
||||||
|
require.True(t, errors.As(err, &ge))
|
||||||
|
require.NotZero(t, ge.ExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRunner_exec_Timeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
gitAvailable(t)
|
||||||
|
r := newTestRunner(t)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||||
|
defer cancel()
|
||||||
|
// 任意命令都会因 ctx 已过期而立刻被 kill
|
||||||
|
_, _, err := r.exec(ctx, "", nil, "version")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAuthError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
stderr string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"Authentication failed for https://...", true},
|
||||||
|
{"fatal: could not read Username for 'https://...'", true},
|
||||||
|
{"Permission denied (publickey).", true},
|
||||||
|
{"random other failure", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := IsAuthError(&GitError{Stderr: c.stderr})
|
||||||
|
require.Equal(t, c.want, got, "stderr=%q", c.stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialEnv_None(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
env, cleanup, err := credentialEnv(t.TempDir(), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, env)
|
||||||
|
cleanup() // 不应 panic
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialEnv_PAT(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
env, cleanup, err := credentialEnv(tmp, &Credential{Kind: CredentialPAT, Username: "alice", Secret: []byte("tok")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer cleanup()
|
||||||
|
joined := strings.Join(env, "\n")
|
||||||
|
require.Contains(t, joined, "GIT_ASKPASS=")
|
||||||
|
require.Contains(t, joined, "GIT_PAT_USERNAME=alice")
|
||||||
|
require.Contains(t, joined, "GIT_PAT_TOKEN=tok")
|
||||||
|
require.Contains(t, joined, "GIT_TERMINAL_PROMPT=0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialEnv_SSHKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if _, err := exec.LookPath("ssh"); err != nil {
|
||||||
|
t.Skip("ssh not available")
|
||||||
|
}
|
||||||
|
tmp := t.TempDir()
|
||||||
|
env, cleanup, err := credentialEnv(tmp, &Credential{
|
||||||
|
Kind: CredentialSSHKey,
|
||||||
|
Secret: []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nstub\n-----END OPENSSH PRIVATE KEY-----\n"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer cleanup()
|
||||||
|
require.Len(t, env, 1)
|
||||||
|
require.True(t, strings.HasPrefix(env[0], "GIT_SSH_COMMAND="))
|
||||||
|
require.Contains(t, env[0], "IdentitiesOnly=yes")
|
||||||
|
require.Contains(t, env[0], "StrictHostKeyChecking=accept-new")
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gitAvailable 跳过测试如果 git 不在 PATH。
|
||||||
|
func gitAvailable(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
|
t.Skip("git not found in PATH")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeBareRepo 在 tmpdir 下 init --bare 一个仓库并塞一个初始 commit。
|
||||||
|
// 返回 bare repo 绝对路径(可作为 remote URL 的 file:// 路径)。
|
||||||
|
func makeBareRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
gitAvailable(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
bare := filepath.Join(dir, "remote.git")
|
||||||
|
require.NoError(t, exec.Command("git", "init", "--bare", "-b", "main", bare).Run())
|
||||||
|
|
||||||
|
// 用一个 working repo 推一次 commit 进 bare,避免空 remote 导致 clone 报错
|
||||||
|
work := filepath.Join(dir, "seed")
|
||||||
|
require.NoError(t, os.MkdirAll(work, 0o755))
|
||||||
|
mustGit(t, work, "init", "-b", "main")
|
||||||
|
mustGit(t, work, "config", "user.email", "test@example.com")
|
||||||
|
mustGit(t, work, "config", "user.name", "Test")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\n"), 0o644))
|
||||||
|
mustGit(t, work, "add", ".")
|
||||||
|
mustGit(t, work, "commit", "-m", "initial")
|
||||||
|
mustGit(t, work, "remote", "add", "origin", bare)
|
||||||
|
mustGit(t, work, "push", "origin", "main")
|
||||||
|
return bare
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustGit(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
c := exec.Command("git", args...)
|
||||||
|
c.Dir = dir
|
||||||
|
out, err := c.CombinedOutput()
|
||||||
|
require.NoError(t, err, "git %v: %s", args, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestRunner 返回挂在 t.TempDir() 的 Runner。
|
||||||
|
func newTestRunner(t *testing.T) *DefaultRunner {
|
||||||
|
t.Helper()
|
||||||
|
return NewDefaultRunner(Config{Binary: "git", TmpDir: t.TempDir()})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user