You've already forked agentic-coding-workflow
git 逻辑
This commit is contained in:
+1
-1
@@ -24,7 +24,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/server
|
||||
# ─── Stage 3: runtime ───
|
||||
FROM registry.jerryyan.top/library/alpine:3.20
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache ca-certificates tzdata && \
|
||||
&& apk add --no-cache ca-certificates tzdata git openssh-client && \
|
||||
addgroup -S app && adduser -S app -G app
|
||||
WORKDIR /app
|
||||
COPY --from=go /out/server /app/server
|
||||
|
||||
+5
-1
@@ -803,7 +803,11 @@ func (a workspaceFetcherAdapter) Fetch(ctx context.Context, mainPath string, wsI
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.gitr.Fetch(ctx, mainPath, cred)
|
||||
// 后台 fetch 成功后与手动 Sync 一致地把 main 工作区快进到远端
|
||||
if err := a.gitr.Fetch(ctx, mainPath, cred); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.gitr.MergeFFOnly(ctx, mainPath)
|
||||
}
|
||||
|
||||
// projectLookup is the minimal interface needed by worktreePruneAdapter to
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Clone 把 remoteURL 上的 branch 浅克隆到 dst。dst 不能预先存在。
|
||||
// Clone 把 remoteURL 克隆到 dst(初始 checkout 指定 branch)。dst 不能预先存在。
|
||||
// 不加 --single-branch:保留全部远端分支,使后续 fetch --all 与基于其他分支建
|
||||
// worktree 可行。
|
||||
func (r *DefaultRunner) Clone(ctx context.Context, dst, remoteURL, branch string, cred *Credential) error {
|
||||
ctx, cancel := withCtx(ctx, r.cfg.CloneTimeout)
|
||||
defer cancel()
|
||||
@@ -14,7 +16,7 @@ func (r *DefaultRunner) Clone(ctx context.Context, dst, remoteURL, branch string
|
||||
return fmt.Errorf("credential env: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
args := []string{"clone", "--branch", branch, "--single-branch", remoteURL, dst}
|
||||
args := []string{"clone", "--branch", branch, remoteURL, dst}
|
||||
_, _, err = r.exec(ctx, "", env, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -44,11 +45,13 @@ func credentialEnv(tmpDir string, cred *Credential) (env []string, cleanup func(
|
||||
}
|
||||
}
|
||||
|
||||
// patEnv 创建 askpass 脚本(Linux .sh / Windows .bat),脚本读 GIT_PAT_TOKEN 输出。
|
||||
// git 在询问 username/password 时会调 GIT_ASKPASS 两次,第一次问 username,第二次问 password;
|
||||
// 我们的脚本忽略提示参数 $1,无论谁问都先返回 username 再返回 token。
|
||||
// patEnv 创建 askpass 脚本(Linux .sh / Windows .bat),并把 username 与 token
|
||||
// 各写入一个 0600 临时文件,脚本按提示前缀(git 固定以 "Username for"/"Password for"
|
||||
// 开头)路由:命中 Username 输出用户名文件,否则输出 token 文件。
|
||||
//
|
||||
// Windows note: cmd.exe 的 .bat 没有 stderr 隔离风险(脚本只 echo 一行)。
|
||||
// 用文件 + type/cat 输出内容而非 echo env,规避 token 含 & | < > " 等特殊字符时的
|
||||
// 命令注入/认证失败,也避免 token 经 env 泄漏给所有子进程。git 会自行裁掉输出末尾
|
||||
// 的 CR/LF。
|
||||
func patEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
|
||||
username := cred.Username
|
||||
if username == "" {
|
||||
@@ -77,30 +80,66 @@ func patEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, func() {}, fmt.Errorf("chmod askpass: %w", err)
|
||||
}
|
||||
|
||||
userFile, err := writeSecretFile(tmpDir, "askpass-user-*", []byte(username))
|
||||
if err != nil {
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, func() {}, fmt.Errorf("write username file: %w", err)
|
||||
}
|
||||
tokenFile, err := writeSecretFile(tmpDir, "askpass-token-*", cred.Secret)
|
||||
if err != nil {
|
||||
_ = os.Remove(f.Name())
|
||||
_ = os.Remove(userFile)
|
||||
return nil, func() {}, fmt.Errorf("write token file: %w", err)
|
||||
}
|
||||
|
||||
env := []string{
|
||||
"GIT_ASKPASS=" + f.Name(),
|
||||
"GIT_PAT_USERNAME=" + username,
|
||||
"GIT_PAT_TOKEN=" + string(cred.Secret),
|
||||
"GIT_PAT_USERNAME_FILE=" + userFile,
|
||||
"GIT_PAT_TOKEN_FILE=" + tokenFile,
|
||||
// GIT_TERMINAL_PROMPT=0 防止 askpass 失效时 git 退回交互
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
}
|
||||
cleanup := func() { _ = os.Remove(f.Name()) }
|
||||
cleanup := func() {
|
||||
_ = os.Remove(f.Name())
|
||||
_ = os.Remove(userFile)
|
||||
_ = os.Remove(tokenFile)
|
||||
}
|
||||
return env, cleanup, nil
|
||||
}
|
||||
|
||||
// writeSecretFile 把 data 写入 tmpDir 下的 0600 临时文件,返回路径。
|
||||
func writeSecretFile(tmpDir, pattern string, data []byte) (string, error) {
|
||||
f, err := os.CreateTemp(tmpDir, pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
_ = f.Close()
|
||||
if err := os.Chmod(f.Name(), 0o600); err != nil {
|
||||
_ = os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
func patScriptShBody() string {
|
||||
return `#!/bin/sh
|
||||
case "$1" in
|
||||
*Username*) printf '%s' "$GIT_PAT_USERNAME" ;;
|
||||
*) printf '%s' "$GIT_PAT_TOKEN" ;;
|
||||
Username*) cat "$GIT_PAT_USERNAME_FILE" ;;
|
||||
*) cat "$GIT_PAT_TOKEN_FILE" ;;
|
||||
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"
|
||||
"echo %1 | findstr /i /c:\"Username for\" >NUL\r\n" +
|
||||
"if %ERRORLEVEL%==0 (type \"%GIT_PAT_USERNAME_FILE%\") else (type \"%GIT_PAT_TOKEN_FILE%\")\r\n"
|
||||
}
|
||||
|
||||
// sshKeyEnv 写临时密钥 + 空 known_hosts,构造 GIT_SSH_COMMAND。
|
||||
@@ -112,7 +151,12 @@ func sshKeyEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
|
||||
if err != nil {
|
||||
return nil, func() {}, fmt.Errorf("create ssh key tmp: %w", err)
|
||||
}
|
||||
if _, err := keyFile.Write(cred.Secret); err != nil {
|
||||
// 规整换行:\r\n 与单独 \r 统一为 \n,并确保末尾有且仅有一个 \n。
|
||||
// OpenSSH 新格式 / ED25519 密钥缺尾换行会报 invalid format。不改 cred.Secret 本身。
|
||||
key := bytes.ReplaceAll(cred.Secret, []byte("\r\n"), []byte("\n"))
|
||||
key = bytes.ReplaceAll(key, []byte("\r"), []byte("\n"))
|
||||
key = append(bytes.TrimRight(key, "\n"), '\n')
|
||||
if _, err := keyFile.Write(key); err != nil {
|
||||
_ = keyFile.Close()
|
||||
_ = os.Remove(keyFile.Name())
|
||||
return nil, func() {}, fmt.Errorf("write ssh key: %w", err)
|
||||
@@ -136,8 +180,10 @@ func sshKeyEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
|
||||
}
|
||||
_ = khFile.Close()
|
||||
|
||||
// BatchMode=yes:带 passphrase 私钥或任何交互需求立即失败并给出明确 stderr,
|
||||
// 而非挂起。
|
||||
cmd := fmt.Sprintf(
|
||||
"ssh -i %q -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%q",
|
||||
"ssh -i %q -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%q",
|
||||
keyFile.Name(), khFile.Name(),
|
||||
)
|
||||
env := []string{"GIT_SSH_COMMAND=" + cmd}
|
||||
|
||||
@@ -3,6 +3,8 @@ package git
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GitError 是 git 子进程返回非零或超时时的统一错误形式。Stderr 已截断 ≤2000 字符;
|
||||
@@ -15,7 +17,27 @@ type GitError struct {
|
||||
}
|
||||
|
||||
func (e *GitError) Error() string {
|
||||
return fmt.Sprintf("git %v: exit %d: %s", e.Args, e.ExitCode, e.Stderr)
|
||||
// 对 Args 中形如 scheme://user:pass@host 的 URL 脱敏,避免 PAT 明文
|
||||
// 写进 last_sync_error / audit。
|
||||
redacted := make([]string, len(e.Args))
|
||||
for i, a := range e.Args {
|
||||
redacted[i] = redactURLUserinfo(a)
|
||||
}
|
||||
return fmt.Sprintf("git %v: exit %d: %s", redacted, e.ExitCode, e.Stderr)
|
||||
}
|
||||
|
||||
// redactURLUserinfo 剥离形如 scheme://user[:pass]@host 中的 userinfo,
|
||||
// 输出 scheme://host...。非 URL 字符串或解析失败时原样返回。
|
||||
func redactURLUserinfo(s string) string {
|
||||
if !strings.Contains(s, "://") {
|
||||
return s
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil || u.User == nil {
|
||||
return s
|
||||
}
|
||||
u.User = nil
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (e *GitError) Unwrap() error { return e.Cause }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package git
|
||||
|
||||
import "context"
|
||||
|
||||
// MergeFFOnly 在 dir 执行 git merge --ff-only(无参数,默认合并当前分支的
|
||||
// @{upstream})。fetch 后用它把当前分支快进到 @{upstream};当分支分叉或工作区
|
||||
// 有冲突而无法 fast-forward 时返回错误,由上层处理。
|
||||
func (r *DefaultRunner) MergeFFOnly(ctx context.Context, dir string) error {
|
||||
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
|
||||
defer cancel()
|
||||
_, _, err := r.exec(ctx, dir, nil, "merge", "--ff-only")
|
||||
return err
|
||||
}
|
||||
@@ -26,7 +26,8 @@ type Runner interface {
|
||||
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
|
||||
MergeFFOnly(ctx context.Context, dir string) error
|
||||
WorktreeAdd(ctx context.Context, dir, branch, path, startPoint string) error
|
||||
WorktreeRemove(ctx context.Context, dir, path string) error
|
||||
WorktreeList(ctx context.Context, dir string) ([]Worktree, error)
|
||||
}
|
||||
@@ -103,11 +104,19 @@ func NewDefaultRunner(cfg Config) *DefaultRunner {
|
||||
//
|
||||
// 返回 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...)
|
||||
// 统一前置 `-c credential.helper=` 清空 helper 链,绕过 Git for Windows
|
||||
// 默认 GCM 抢占 PAT 的问题。
|
||||
fullArgs := append([]string{"-c", "credential.helper="}, args...)
|
||||
cmd := exec.CommandContext(ctx, r.cfg.Binary, fullArgs...)
|
||||
if cwd != "" {
|
||||
cmd.Dir = cwd
|
||||
}
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
// base env 总是禁交互;调用方 env 追加在后可覆盖。
|
||||
baseEnv := append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GCM_INTERACTIVE=never")
|
||||
cmd.Env = append(baseEnv, env...)
|
||||
// WaitDelay:超时 kill git 后,孤儿 ssh/git-remote-https 子进程可能持有
|
||||
// stderr pipe 写端导致 cmd.Run() 无限阻塞,给一个上限强制收尾。
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
var so, se bytes.Buffer
|
||||
cmd.Stdout = &so
|
||||
cmd.Stderr = &se
|
||||
|
||||
@@ -3,6 +3,7 @@ package git
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -11,6 +12,17 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// envValue 从 "K=V" 形式的 env 列表中取出 key 对应的 value。
|
||||
func envValue(env []string, key string) string {
|
||||
prefix := key + "="
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, prefix) {
|
||||
return strings.TrimPrefix(e, prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestDefaultRunner_exec_BinaryNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewDefaultRunner(Config{Binary: "this-is-not-a-real-binary-xyz"})
|
||||
@@ -75,9 +87,21 @@ func TestCredentialEnv_PAT(t *testing.T) {
|
||||
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")
|
||||
// 凭据通过 0600 临时文件下发(避免出现在进程 env 中),env 仅暴露文件路径,
|
||||
// 文件内容须等于用户名/令牌原文。
|
||||
require.Contains(t, joined, "GIT_PAT_USERNAME_FILE=")
|
||||
require.Contains(t, joined, "GIT_PAT_TOKEN_FILE=")
|
||||
userFile := envValue(env, "GIT_PAT_USERNAME_FILE")
|
||||
tokenFile := envValue(env, "GIT_PAT_TOKEN_FILE")
|
||||
require.NotEmpty(t, userFile)
|
||||
require.NotEmpty(t, tokenFile)
|
||||
userData, err := os.ReadFile(userFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "alice", string(userData))
|
||||
tokenData, err := os.ReadFile(tokenFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "tok", string(tokenData))
|
||||
}
|
||||
|
||||
func TestCredentialEnv_SSHKey(t *testing.T) {
|
||||
|
||||
@@ -9,18 +9,21 @@ import (
|
||||
|
||||
// WorktreeAdd 在 dir(main worktree)创建一个支线 worktree。
|
||||
//
|
||||
// path: 绝对路径,由 workspace 包计算
|
||||
// branch: 用 -B 强制创建/重置 — 已存在的分支会被 reset 到 origin/HEAD
|
||||
// (本地未推送 commit 会丢失,可从 reflog 找回);--track 让分支
|
||||
// 默认 track origin/HEAD(通常是 main),首次 push 时由 service
|
||||
// 层 set-upstream 到对应远端分支。
|
||||
// path: 绝对路径,由 workspace 包计算
|
||||
// branch: 用 -B 强制创建/重置 —— 已存在的分支会被重置(本地未推送
|
||||
// commit 会丢失,可从 reflog 找回)。
|
||||
// startPoint: 为空时分支基于当前 HEAD;非空时分支基于该 start-point,
|
||||
// 且 --track 会把 upstream 指向它。
|
||||
//
|
||||
// 失败时如果 stderr 含 "already exists" / "is already used by worktree",
|
||||
// errors.IsConflict 返回 true,调用方应映射为 WORKTREE_BRANCH_CONFLICT。
|
||||
func (r *DefaultRunner) WorktreeAdd(ctx context.Context, dir, branch, path string) error {
|
||||
func (r *DefaultRunner) WorktreeAdd(ctx context.Context, dir, branch, path, startPoint string) error {
|
||||
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
|
||||
defer cancel()
|
||||
args := []string{"worktree", "add", "--track", "-B", branch, path}
|
||||
if startPoint != "" {
|
||||
args = append(args, startPoint)
|
||||
}
|
||||
_, _, err := r.exec(ctx, dir, nil, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestWorktree_AddListRemove(t *testing.T) {
|
||||
require.NoError(t, r.Clone(ctx, main, bare, "main", nil))
|
||||
|
||||
feat := filepath.Join(filepath.Dir(main), "feat-x")
|
||||
require.NoError(t, r.WorktreeAdd(ctx, main, "feat-x", feat))
|
||||
require.NoError(t, r.WorktreeAdd(ctx, main, "feat-x", feat, ""))
|
||||
|
||||
list, err := r.WorktreeList(ctx, main)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -82,13 +82,15 @@ func (r *WorkspaceFetch) fetchOne(ctx context.Context, t WorkspaceFetchTarget) {
|
||||
// 别的 runner / 手工同步抢先了
|
||||
return
|
||||
}
|
||||
// 即便 runner ctx 因关停被取消,fetch 结果状态仍需可靠落库,避免行永久残留 syncing。
|
||||
writeCtx := context.WithoutCancel(ctx)
|
||||
if fetchErr := r.gitr.Fetch(ctx, t.MainPath, t.ID); fetchErr != nil {
|
||||
msg := truncate(fetchErr.Error(), 2000)
|
||||
if err := r.repo.MarkFetchResult(ctx, t.ID, false, msg); err != nil {
|
||||
if err := r.repo.MarkFetchResult(writeCtx, t.ID, false, msg); err != nil {
|
||||
r.log.Error("workspace_fetch.mark_fail", "workspace_id", t.ID, "err", err.Error())
|
||||
return
|
||||
}
|
||||
if derr := r.disp.Dispatch(ctx, notify.Message{
|
||||
if derr := r.disp.Dispatch(writeCtx, notify.Message{
|
||||
UserID: t.OwnerID,
|
||||
Topic: "workspace.sync_failed",
|
||||
Severity: notify.SeverityError,
|
||||
@@ -106,7 +108,7 @@ func (r *WorkspaceFetch) fetchOne(ctx context.Context, t WorkspaceFetchTarget) {
|
||||
r.log.Warn("workspace_fetch.failed", "workspace_id", t.ID, "err", msg)
|
||||
return
|
||||
}
|
||||
if err := r.repo.MarkFetchResult(ctx, t.ID, true, ""); err != nil {
|
||||
if err := r.repo.MarkFetchResult(writeCtx, t.ID, true, ""); err != nil {
|
||||
r.log.Error("workspace_fetch.mark_ok", "workspace_id", t.ID, "err", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,22 +63,25 @@ func (r *CloneRunner) run(ctx context.Context, wsID uuid.UUID) {
|
||||
r.log.Error("clone runner: get workspace", "ws_id", wsID, "err", err.Error())
|
||||
return
|
||||
}
|
||||
// 状态/审计写操作与 clone 的超时 ctx 解耦:clone 超时后 ctx 已 DeadlineExceeded,
|
||||
// 仍需把状态写成 error,否则永久卡在 cloning。
|
||||
writeCtx := context.WithoutCancel(ctx)
|
||||
cred, err := r.resolveCred(ctx, wsID)
|
||||
if err != nil {
|
||||
r.markError(ctx, wsID, "resolve credential: "+err.Error())
|
||||
r.markError(writeCtx, wsID, "resolve credential: "+err.Error())
|
||||
return
|
||||
}
|
||||
if cloneErr := r.gitr.Clone(ctx, ws.MainPath, ws.GitRemoteURL, ws.DefaultBranch, cred); cloneErr != nil {
|
||||
r.markError(ctx, wsID, summarizeErr(cloneErr))
|
||||
_ = r.rec.Record(ctx, audit.Entry{Action: "workspace.clone_failed", TargetType: "workspace", TargetID: wsID.String(), Metadata: map[string]any{"err": summarizeErr(cloneErr)}})
|
||||
r.markError(writeCtx, wsID, summarizeErr(cloneErr))
|
||||
_ = r.rec.Record(writeCtx, audit.Entry{Action: "workspace.clone_failed", TargetType: "workspace", TargetID: wsID.String(), Metadata: map[string]any{"err": summarizeErr(cloneErr)}})
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if _, err := r.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusIdle, &now, ""); err != nil {
|
||||
if _, err := r.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusIdle, &now, ""); err != nil {
|
||||
r.log.Error("clone runner: update sync status idle", "ws_id", wsID, "err", err.Error())
|
||||
return
|
||||
}
|
||||
_ = r.rec.Record(ctx, audit.Entry{Action: "workspace.clone_ok", TargetType: "workspace", TargetID: wsID.String()})
|
||||
_ = r.rec.Record(writeCtx, audit.Entry{Action: "workspace.clone_ok", TargetType: "workspace", TargetID: wsID.String()})
|
||||
}
|
||||
|
||||
func (r *CloneRunner) markError(ctx context.Context, wsID uuid.UUID, msg string) {
|
||||
|
||||
@@ -37,13 +37,27 @@ func ValidateBranch(b string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateRemoteURL 检查 scheme 白名单。
|
||||
// ValidateRemoteURL 检查 scheme 白名单,并拒绝 scheme:// 形式中内嵌的 userinfo(凭据)。
|
||||
func ValidateRemoteURL(u string) error {
|
||||
switch {
|
||||
case strings.HasPrefix(u, "https://"),
|
||||
strings.HasPrefix(u, "http://"),
|
||||
strings.HasPrefix(u, "ssh://"),
|
||||
strings.HasPrefix(u, "git@"):
|
||||
strings.HasPrefix(u, "ssh://"):
|
||||
// 拒绝 scheme://user:pass@host 形式的内嵌凭据:内嵌凭据会绕过本系统凭据
|
||||
// 存储并短路数据库 PAT。注意 ssh://git@host 这类裸用户名(无 ":" 密码)是
|
||||
// 合法 SSH 形式,仅当 userinfo 中带 ":" 密码时才判定为内嵌凭据。
|
||||
rest := u[strings.Index(u, "://")+len("://"):]
|
||||
authority := rest
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
authority = rest[:i]
|
||||
}
|
||||
if at := strings.IndexByte(authority, '@'); at >= 0 {
|
||||
if strings.ContainsRune(authority[:at], ':') {
|
||||
return errors.New("git_remote_url must not contain embedded credentials")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case strings.HasPrefix(u, "git@"):
|
||||
return nil
|
||||
}
|
||||
return errors.New("git_remote_url must be https/http/ssh/git@ scheme")
|
||||
|
||||
@@ -44,6 +44,8 @@ func TestValidateRemoteURL(t *testing.T) {
|
||||
require.NoError(t, ValidateRemoteURL("ssh://git@host/x.git"))
|
||||
require.Error(t, ValidateRemoteURL("file:///etc/passwd"))
|
||||
require.Error(t, ValidateRemoteURL("ftp://x"))
|
||||
// 内嵌凭据(user:pass@)须被拒绝,避免绕过本系统凭据存储
|
||||
require.Error(t, ValidateRemoteURL("https://user:pass@github.com/x/y.git"))
|
||||
}
|
||||
|
||||
func TestPaths(t *testing.T) {
|
||||
|
||||
@@ -212,31 +212,43 @@ func (s *workspaceService) Sync(ctx context.Context, c Caller, wsID uuid.UUID) (
|
||||
} else if !canWrite {
|
||||
return nil, errs.New(errs.CodeForbidden, "no write access")
|
||||
}
|
||||
if ws.SyncStatus == SyncStatusCloning || ws.SyncStatus == SyncStatusSyncing {
|
||||
return nil, errs.New(errs.CodeWorkspaceSyncInProgress, "sync already in progress")
|
||||
}
|
||||
if _, err := s.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusSyncing, ws.LastSyncedAt, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 先解密凭据,再抢锁置 syncing:避免置 syncing 后 load 失败留下残留。
|
||||
cred, err := s.loadDecryptedCredential(ctx, wsID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, s.fetchTimout)
|
||||
defer cancel()
|
||||
fetchErr := s.gitr.Fetch(fetchCtx, ws.MainPath, cred)
|
||||
if fetchErr != nil {
|
||||
errMsg := summarizeErr(fetchErr)
|
||||
_, _ = s.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusError, ws.LastSyncedAt, errMsg)
|
||||
s.audit(ctx, &c.UserID, "workspace.sync_failed", "workspace", wsID.String(), map[string]any{"err": errMsg})
|
||||
return nil, errs.Wrap(fetchErr, errs.CodeGitCmdFailed, "git fetch failed")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updated, err := s.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusIdle, &now, "")
|
||||
// 原子 CAS 抢锁(WHERE sync_status IN ('idle','error')),消除"读后写"TOCTOU。
|
||||
// 抢到才继续,否则说明已有 clone/sync 在进行中。
|
||||
acquired, err := s.repo.MarkSyncing(ctx, wsID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(ctx, &c.UserID, "workspace.sync", "workspace", wsID.String(), nil)
|
||||
if !acquired {
|
||||
return nil, errs.New(errs.CodeWorkspaceSyncInProgress, "sync already in progress")
|
||||
}
|
||||
// 状态回写与请求生命周期解耦:客户端断开/请求超时也要能写回状态,避免永久卡 syncing。
|
||||
writeCtx := context.WithoutCancel(ctx)
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, s.fetchTimout)
|
||||
defer cancel()
|
||||
if fetchErr := s.gitr.Fetch(fetchCtx, ws.MainPath, cred); fetchErr != nil {
|
||||
errMsg := summarizeErr(fetchErr)
|
||||
_, _ = s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusError, ws.LastSyncedAt, errMsg)
|
||||
s.audit(writeCtx, &c.UserID, "workspace.sync_failed", "workspace", wsID.String(), map[string]any{"err": errMsg})
|
||||
return nil, errs.Wrap(fetchErr, errs.CodeGitCmdFailed, "git fetch failed")
|
||||
}
|
||||
// fetch 只更新远端引用,需 ff-only merge 把 main 工作区当前分支快进到远端。
|
||||
if mergeErr := s.gitr.MergeFFOnly(fetchCtx, ws.MainPath); mergeErr != nil {
|
||||
errMsg := summarizeErr(mergeErr)
|
||||
_, _ = s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusError, ws.LastSyncedAt, errMsg)
|
||||
s.audit(writeCtx, &c.UserID, "workspace.sync_failed", "workspace", wsID.String(), map[string]any{"err": errMsg})
|
||||
return nil, errs.Wrap(mergeErr, errs.CodeGitCmdFailed, "git merge --ff-only failed")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updated, err := s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusIdle, &now, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(writeCtx, &c.UserID, "workspace.sync", "workspace", wsID.String(), nil)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ func (f *fakeRepo) ListWorktreesNeedingPruneWarning(_ context.Context, _ time.Ti
|
||||
func (f *fakeRepo) ListWorktreesNeedingPrune(_ context.Context, _ time.Time) ([]*Worktree, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeRepo) MarkPruneWarning(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
func (f *fakeRepo) MarkPruneWarning(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
func (f *fakeRepo) TryStartPrune(_ context.Context, _ uuid.UUID) (*Worktree, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -204,9 +204,10 @@ func (f *fakeGit) Push(_ context.Context, _, _ string, _ *git.Credential) error
|
||||
func (f *fakeGit) Commit(_ context.Context, _, _ string, _ bool) (string, error) {
|
||||
return "deadbeef", nil
|
||||
}
|
||||
func (f *fakeGit) Status(_ context.Context, _ string) ([]git.FileStatus, error) { return nil, nil }
|
||||
func (f *fakeGit) WorktreeAdd(_ context.Context, _, _, _ string) error { return nil }
|
||||
func (f *fakeGit) WorktreeRemove(_ context.Context, _, _ string) error { return nil }
|
||||
func (f *fakeGit) Status(_ context.Context, _ string) ([]git.FileStatus, error) { return nil, nil }
|
||||
func (f *fakeGit) MergeFFOnly(_ context.Context, _ string) error { return nil }
|
||||
func (f *fakeGit) WorktreeAdd(_ context.Context, _, _, _, _ string) error { return nil }
|
||||
func (f *fakeGit) WorktreeRemove(_ context.Context, _, _ string) error { return nil }
|
||||
func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func (s *worktreeService) Create(ctx context.Context, c Caller, wsID uuid.UUID,
|
||||
return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid base branch")
|
||||
}
|
||||
wtPath := WorktreePath(s.dataDir, wsID, in.Branch)
|
||||
if err := s.gitr.WorktreeAdd(ctx, ws.MainPath, in.Branch, wtPath); err != nil {
|
||||
if err := s.gitr.WorktreeAdd(ctx, ws.MainPath, in.Branch, wtPath, "origin/"+base); err != nil {
|
||||
if git.IsConflict(err) {
|
||||
return nil, errs.Wrap(err, errs.CodeWorktreeBranchConflict, "branch already used")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user