You've already forked agentic-coding-workflow
feat(infra/git): clone/fetch/push/commit/status verbs + integration tests
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user