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
+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
}
+5 -4
View File
@@ -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
}
+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")
}