You've already forked agentic-coding-workflow
git 逻辑
This commit is contained in:
@@ -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