git 逻辑

This commit is contained in:
2026-06-10 15:30:52 +08:00
parent 41f2a84979
commit 023ab2f30e
17 changed files with 221 additions and 64 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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
}
+59 -13
View File
@@ -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}
+23 -1
View File
@@ -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 }
+13
View File
@@ -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
}
+12 -3
View File
@@ -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
+26 -2
View File
@@ -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) {
+8 -5
View File
@@ -10,17 +10,20 @@ import (
// WorktreeAdd 在 dir(main worktree)创建一个支线 worktree。
//
// path: 绝对路径,由 workspace 包计算
// branch: 用 -B 强制创建/重置 — 已存在的分支会被 reset 到 origin/HEAD
// (本地未推送 commit 会丢失,可从 reflog 找回);--track 让分支
// 默认 track origin/HEAD(通常是 main),首次 push 时由 service
// 层 set-upstream 到对应远端分支
// 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
}
+1 -1
View File
@@ -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)
+5 -3
View File
@@ -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())
}
}
+8 -5
View File
@@ -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) {
+17 -3
View File
@@ -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")
+2
View File
@@ -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) {
+30 -18
View File
@@ -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
}
+2 -1
View File
@@ -205,7 +205,8 @@ 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) 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
+1 -1
View File
@@ -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")
}