From f8c77718923b5d2c688295f4dc23b61f42b1437a Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 1 May 2026 19:39:26 +0800 Subject: [PATCH] feat(infra/git): Runner interface + Credential injection + GitError --- internal/infra/git/credential.go | 186 ++++++++++++++++++++++++++++ internal/infra/git/errors.go | 91 ++++++++++++++ internal/infra/git/runner.go | 168 +++++++++++++++++++++++++ internal/infra/git/runner_test.go | 99 +++++++++++++++ internal/infra/git/testutil_test.go | 55 ++++++++ 5 files changed, 599 insertions(+) create mode 100644 internal/infra/git/credential.go create mode 100644 internal/infra/git/errors.go create mode 100644 internal/infra/git/runner.go create mode 100644 internal/infra/git/runner_test.go create mode 100644 internal/infra/git/testutil_test.go diff --git a/internal/infra/git/credential.go b/internal/infra/git/credential.go new file mode 100644 index 0000000..0f1e223 --- /dev/null +++ b/internal/infra/git/credential.go @@ -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() +} diff --git a/internal/infra/git/errors.go b/internal/infra/git/errors.go new file mode 100644 index 0000000..bc757cb --- /dev/null +++ b/internal/infra/git/errors.go @@ -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 +} diff --git a/internal/infra/git/runner.go b/internal/infra/git/runner.go new file mode 100644 index 0000000..305d0f8 --- /dev/null +++ b/internal/infra/git/runner.go @@ -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/ 去前缀 + 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 diff --git a/internal/infra/git/runner_test.go b/internal/infra/git/runner_test.go new file mode 100644 index 0000000..e5decbd --- /dev/null +++ b/internal/infra/git/runner_test.go @@ -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") +} diff --git a/internal/infra/git/testutil_test.go b/internal/infra/git/testutil_test.go new file mode 100644 index 0000000..e42658c --- /dev/null +++ b/internal/infra/git/testutil_test.go @@ -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()}) +}