From 023ab2f30e6f8f42b7c8235597b473c70e1b5248 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 10 Jun 2026 15:30:52 +0800 Subject: [PATCH] =?UTF-8?q?git=20=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- internal/app/app.go | 6 +- internal/infra/git/clone.go | 6 +- internal/infra/git/credential.go | 72 ++++++++++++++++---- internal/infra/git/errors.go | 24 ++++++- internal/infra/git/merge.go | 13 ++++ internal/infra/git/runner.go | 15 +++- internal/infra/git/runner_test.go | 28 +++++++- internal/infra/git/worktree.go | 15 ++-- internal/infra/git/worktree_test.go | 2 +- internal/jobs/runners/workspace_fetch.go | 8 ++- internal/workspace/clone_runner.go | 13 ++-- internal/workspace/pathing.go | 20 +++++- internal/workspace/pathing_test.go | 2 + internal/workspace/workspace_service.go | 48 ++++++++----- internal/workspace/workspace_service_test.go | 9 +-- internal/workspace/worktree_service.go | 2 +- 17 files changed, 221 insertions(+), 64 deletions(-) create mode 100644 internal/infra/git/merge.go diff --git a/Dockerfile b/Dockerfile index 3df43ba..ea8682b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/internal/app/app.go b/internal/app/app.go index 840d6a3..b86ca24 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 diff --git a/internal/infra/git/clone.go b/internal/infra/git/clone.go index d07617b..2621cce 100644 --- a/internal/infra/git/clone.go +++ b/internal/infra/git/clone.go @@ -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 } diff --git a/internal/infra/git/credential.go b/internal/infra/git/credential.go index 0f1e223..1df8f45 100644 --- a/internal/infra/git/credential.go +++ b/internal/infra/git/credential.go @@ -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} diff --git a/internal/infra/git/errors.go b/internal/infra/git/errors.go index bc757cb..8511c79 100644 --- a/internal/infra/git/errors.go +++ b/internal/infra/git/errors.go @@ -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 } diff --git a/internal/infra/git/merge.go b/internal/infra/git/merge.go new file mode 100644 index 0000000..a1e2aef --- /dev/null +++ b/internal/infra/git/merge.go @@ -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 +} diff --git a/internal/infra/git/runner.go b/internal/infra/git/runner.go index 305d0f8..4bd6f27 100644 --- a/internal/infra/git/runner.go +++ b/internal/infra/git/runner.go @@ -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 diff --git a/internal/infra/git/runner_test.go b/internal/infra/git/runner_test.go index e5decbd..31b2e49 100644 --- a/internal/infra/git/runner_test.go +++ b/internal/infra/git/runner_test.go @@ -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) { diff --git a/internal/infra/git/worktree.go b/internal/infra/git/worktree.go index 93ce748..4ae19e7 100644 --- a/internal/infra/git/worktree.go +++ b/internal/infra/git/worktree.go @@ -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 } diff --git a/internal/infra/git/worktree_test.go b/internal/infra/git/worktree_test.go index 0027520..a445409 100644 --- a/internal/infra/git/worktree_test.go +++ b/internal/infra/git/worktree_test.go @@ -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) diff --git a/internal/jobs/runners/workspace_fetch.go b/internal/jobs/runners/workspace_fetch.go index f3a9c36..ba222b9 100644 --- a/internal/jobs/runners/workspace_fetch.go +++ b/internal/jobs/runners/workspace_fetch.go @@ -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()) } } diff --git a/internal/workspace/clone_runner.go b/internal/workspace/clone_runner.go index e53a234..75365f8 100644 --- a/internal/workspace/clone_runner.go +++ b/internal/workspace/clone_runner.go @@ -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) { diff --git a/internal/workspace/pathing.go b/internal/workspace/pathing.go index 2b14d5e..ba4349f 100644 --- a/internal/workspace/pathing.go +++ b/internal/workspace/pathing.go @@ -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") diff --git a/internal/workspace/pathing_test.go b/internal/workspace/pathing_test.go index 16eaaf4..1375806 100644 --- a/internal/workspace/pathing_test.go +++ b/internal/workspace/pathing_test.go @@ -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) { diff --git a/internal/workspace/workspace_service.go b/internal/workspace/workspace_service.go index 06311cb..b260132 100644 --- a/internal/workspace/workspace_service.go +++ b/internal/workspace/workspace_service.go @@ -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 } diff --git a/internal/workspace/workspace_service_test.go b/internal/workspace/workspace_service_test.go index fd1fd7a..3e826f6 100644 --- a/internal/workspace/workspace_service_test.go +++ b/internal/workspace/workspace_service_test.go @@ -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 } diff --git a/internal/workspace/worktree_service.go b/internal/workspace/worktree_service.go index 22a607b..97c5372 100644 --- a/internal/workspace/worktree_service.go +++ b/internal/workspace/worktree_service.go @@ -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") }