From faecb05ebdf297a719b7ef3da27c61b95ddb2954 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 1 May 2026 21:43:56 +0800 Subject: [PATCH] feat(infra/git): clone/fetch/push/commit/status verbs + integration tests --- internal/infra/git/clone.go | 20 +++++++++++++ internal/infra/git/clone_test.go | 32 +++++++++++++++++++++ internal/infra/git/commit.go | 28 ++++++++++++++++++ internal/infra/git/commit_test.go | 28 ++++++++++++++++++ internal/infra/git/fetch.go | 16 +++++++++++ internal/infra/git/fetch_test.go | 22 +++++++++++++++ internal/infra/git/push.go | 16 +++++++++++ internal/infra/git/push_test.go | 28 ++++++++++++++++++ internal/infra/git/status.go | 47 +++++++++++++++++++++++++++++++ internal/infra/git/status_test.go | 43 ++++++++++++++++++++++++++++ 10 files changed, 280 insertions(+) create mode 100644 internal/infra/git/clone.go create mode 100644 internal/infra/git/clone_test.go create mode 100644 internal/infra/git/commit.go create mode 100644 internal/infra/git/commit_test.go create mode 100644 internal/infra/git/fetch.go create mode 100644 internal/infra/git/fetch_test.go create mode 100644 internal/infra/git/push.go create mode 100644 internal/infra/git/push_test.go create mode 100644 internal/infra/git/status.go create mode 100644 internal/infra/git/status_test.go diff --git a/internal/infra/git/clone.go b/internal/infra/git/clone.go new file mode 100644 index 0000000..d07617b --- /dev/null +++ b/internal/infra/git/clone.go @@ -0,0 +1,20 @@ +package git + +import ( + "context" + "fmt" +) + +// Clone 把 remoteURL 上的 branch 浅克隆到 dst。dst 不能预先存在。 +func (r *DefaultRunner) Clone(ctx context.Context, dst, remoteURL, branch string, cred *Credential) error { + ctx, cancel := withCtx(ctx, r.cfg.CloneTimeout) + defer cancel() + env, cleanup, err := credentialEnv(r.cfg.TmpDir, cred) + if err != nil { + return fmt.Errorf("credential env: %w", err) + } + defer cleanup() + args := []string{"clone", "--branch", branch, "--single-branch", remoteURL, dst} + _, _, err = r.exec(ctx, "", env, args...) + return err +} diff --git a/internal/infra/git/clone_test.go b/internal/infra/git/clone_test.go new file mode 100644 index 0000000..b0b249d --- /dev/null +++ b/internal/infra/git/clone_test.go @@ -0,0 +1,32 @@ +package git + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestClone_Success(t *testing.T) { + t.Parallel() + gitAvailable(t) + bare := makeBareRepo(t) + r := newTestRunner(t) + dst := filepath.Join(t.TempDir(), "wc") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + require.NoError(t, r.Clone(ctx, dst, bare, "main", nil)) + require.FileExists(t, filepath.Join(dst, "README.md")) +} + +func TestClone_BadURL(t *testing.T) { + t.Parallel() + gitAvailable(t) + r := newTestRunner(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + err := r.Clone(ctx, filepath.Join(t.TempDir(), "x"), "/no/such/path/repo.git", "main", nil) + require.Error(t, err) +} diff --git a/internal/infra/git/commit.go b/internal/infra/git/commit.go new file mode 100644 index 0000000..d35bd64 --- /dev/null +++ b/internal/infra/git/commit.go @@ -0,0 +1,28 @@ +package git + +import ( + "context" + "strings" +) + +// Commit 在 dir 创建一个提交。addAll=true 时先 git add -A。返回 commit sha。 +// +// 提交者用 git config 在仓库内继承(应用启动时不全局设 user.name;调用方在 +// 创建 main worktree 后会设置 service-level 的 user.name/email)。 +func (r *DefaultRunner) Commit(ctx context.Context, dir, message string, addAll bool) (string, error) { + ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout) + defer cancel() + if addAll { + if _, _, err := r.exec(ctx, dir, nil, "add", "-A"); err != nil { + return "", err + } + } + if _, _, err := r.exec(ctx, dir, nil, "commit", "-m", message); err != nil { + return "", err + } + out, _, err := r.exec(ctx, dir, nil, "rev-parse", "HEAD") + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/infra/git/commit_test.go b/internal/infra/git/commit_test.go new file mode 100644 index 0000000..c68b287 --- /dev/null +++ b/internal/infra/git/commit_test.go @@ -0,0 +1,28 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCommit_Success(t *testing.T) { + t.Parallel() + gitAvailable(t) + bare := makeBareRepo(t) + r := newTestRunner(t) + dst := filepath.Join(t.TempDir(), "wc") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + require.NoError(t, r.Clone(ctx, dst, bare, "main", nil)) + mustGit(t, dst, "config", "user.email", "test@example.com") + mustGit(t, dst, "config", "user.name", "Test") + require.NoError(t, os.WriteFile(filepath.Join(dst, "new.txt"), []byte("body\n"), 0o644)) + sha, err := r.Commit(ctx, dst, "add new.txt", true) + require.NoError(t, err) + require.Len(t, sha, 40) +} diff --git a/internal/infra/git/fetch.go b/internal/infra/git/fetch.go new file mode 100644 index 0000000..aa39916 --- /dev/null +++ b/internal/infra/git/fetch.go @@ -0,0 +1,16 @@ +package git + +import "context" + +// Fetch 执行 git fetch --all --prune。dir 是 main_path 或 worktree path。 +func (r *DefaultRunner) Fetch(ctx context.Context, dir string, cred *Credential) error { + ctx, cancel := withCtx(ctx, r.cfg.FetchTimeout) + defer cancel() + env, cleanup, err := credentialEnv(r.cfg.TmpDir, cred) + if err != nil { + return err + } + defer cleanup() + _, _, err = r.exec(ctx, dir, env, "fetch", "--all", "--prune") + return err +} diff --git a/internal/infra/git/fetch_test.go b/internal/infra/git/fetch_test.go new file mode 100644 index 0000000..08c9863 --- /dev/null +++ b/internal/infra/git/fetch_test.go @@ -0,0 +1,22 @@ +package git + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFetch_Success(t *testing.T) { + t.Parallel() + gitAvailable(t) + bare := makeBareRepo(t) + r := newTestRunner(t) + dst := filepath.Join(t.TempDir(), "wc") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + require.NoError(t, r.Clone(ctx, dst, bare, "main", nil)) + require.NoError(t, r.Fetch(ctx, dst, nil)) +} diff --git a/internal/infra/git/push.go b/internal/infra/git/push.go new file mode 100644 index 0000000..0213911 --- /dev/null +++ b/internal/infra/git/push.go @@ -0,0 +1,16 @@ +package git + +import "context" + +// Push 推送 dir 当前 branch 到 origin/。 +func (r *DefaultRunner) Push(ctx context.Context, dir, branch string, cred *Credential) error { + ctx, cancel := withCtx(ctx, r.cfg.PushTimeout) + defer cancel() + env, cleanup, err := credentialEnv(r.cfg.TmpDir, cred) + if err != nil { + return err + } + defer cleanup() + _, _, err = r.exec(ctx, dir, env, "push", "-u", "origin", branch) + return err +} diff --git a/internal/infra/git/push_test.go b/internal/infra/git/push_test.go new file mode 100644 index 0000000..7901c11 --- /dev/null +++ b/internal/infra/git/push_test.go @@ -0,0 +1,28 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPush_Success(t *testing.T) { + t.Parallel() + gitAvailable(t) + bare := makeBareRepo(t) + r := newTestRunner(t) + dst := filepath.Join(t.TempDir(), "wc") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + require.NoError(t, r.Clone(ctx, dst, bare, "main", nil)) + mustGit(t, dst, "config", "user.email", "test@example.com") + mustGit(t, dst, "config", "user.name", "Test") + require.NoError(t, os.WriteFile(filepath.Join(dst, "p.txt"), []byte("p\n"), 0o644)) + _, err := r.Commit(ctx, dst, "add p", true) + require.NoError(t, err) + require.NoError(t, r.Push(ctx, dst, "main", nil)) +} diff --git a/internal/infra/git/status.go b/internal/infra/git/status.go new file mode 100644 index 0000000..0d5912d --- /dev/null +++ b/internal/infra/git/status.go @@ -0,0 +1,47 @@ +package git + +import ( + "bytes" + "context" +) + +// Status 返回 git status --porcelain=v1 的结构化解析。空 worktree 返回空切片。 +func (r *DefaultRunner) Status(ctx context.Context, dir string) ([]FileStatus, error) { + ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout) + defer cancel() + out, _, err := r.exec(ctx, dir, nil, "status", "--porcelain=v1", "-z") + if err != nil { + return nil, err + } + return parsePorcelainV1(out), nil +} + +// parsePorcelainV1 解析 -z 输出(NUL 分隔,不转义)。 +// 每条记录格式:XY \0 +// renamed/copied 时是 XY \0\0;本实现把 to 当 path,丢弃 from。 +func parsePorcelainV1(buf []byte) []FileStatus { + var out []FileStatus + for len(buf) > 0 { + nul := bytes.IndexByte(buf, 0) + if nul < 0 { + break + } + rec := buf[:nul] + buf = buf[nul+1:] + if len(rec) < 4 { + continue + } + x, y := rec[0], rec[1] + // rec[2] 是空格 + path := string(rec[3:]) + out = append(out, FileStatus{Path: path, IndexX: x, WorktreeY: y}) + // renamed/copied:跳过 from 段 + if x == 'R' || x == 'C' { + nul = bytes.IndexByte(buf, 0) + if nul >= 0 { + buf = buf[nul+1:] + } + } + } + return out +} diff --git a/internal/infra/git/status_test.go b/internal/infra/git/status_test.go new file mode 100644 index 0000000..d744e4a --- /dev/null +++ b/internal/infra/git/status_test.go @@ -0,0 +1,43 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestStatus_CleanAndDirty(t *testing.T) { + t.Parallel() + gitAvailable(t) + bare := makeBareRepo(t) + r := newTestRunner(t) + dst := filepath.Join(t.TempDir(), "wc") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + require.NoError(t, r.Clone(ctx, dst, bare, "main", nil)) + + st, err := r.Status(ctx, dst) + require.NoError(t, err) + require.Empty(t, st, "fresh clone should be clean") + + require.NoError(t, os.WriteFile(filepath.Join(dst, "x.txt"), []byte("x"), 0o644)) + st, err = r.Status(ctx, dst) + require.NoError(t, err) + require.Len(t, st, 1) + require.Equal(t, "x.txt", st[0].Path) + require.Equal(t, byte('?'), st[0].WorktreeY) +} + +func TestParsePorcelainV1_Renamed(t *testing.T) { + t.Parallel() + // "R to_path\0from_path\0" → 1 record,path="to_path" + buf := []byte("R to_path\x00from_path\x00") + got := parsePorcelainV1(buf) + require.Len(t, got, 1) + require.Equal(t, "to_path", got[0].Path) + require.Equal(t, byte('R'), got[0].IndexX) +}