feat(infra/git): clone/fetch/push/commit/status verbs + integration tests

This commit is contained in:
2026-05-01 21:43:56 +08:00
parent f8c7771892
commit faecb05ebd
10 changed files with 280 additions and 0 deletions
+20
View File
@@ -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
}
+32
View File
@@ -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)
}
+28
View File
@@ -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
}
+28
View File
@@ -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)
}
+16
View File
@@ -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
}
+22
View File
@@ -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))
}
+16
View File
@@ -0,0 +1,16 @@
package git
import "context"
// Push 推送 dir 当前 branch 到 origin/<branch>。
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
}
+28
View File
@@ -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))
}
+47
View File
@@ -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 <SP> <path>\0
// renamed/copied 时是 XY <SP> <to>\0<from>\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
}
+43
View File
@@ -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)
}