This commit is contained in:
2026-06-20 19:16:44 +08:00
parent db3d030169
commit dbb87823e8
26 changed files with 676 additions and 115 deletions
+47 -17
View File
@@ -88,31 +88,34 @@ func (s *worktreeService) List(ctx context.Context, c Caller, wsID uuid.UUID) ([
}
func (s *worktreeService) Delete(ctx context.Context, c Caller, wtID uuid.UUID) error {
wt, err := s.repo.GetWorktreeByID(ctx, wtID)
ws, err := s.authorizeWorktreeWrite(ctx, c, wtID)
if err != nil {
return err
}
ws, err := s.repo.GetWorkspaceByID(ctx, wt.WorkspaceID)
if err != nil {
return err
}
if _, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil {
return err
} else if !canWrite {
return errs.New(errs.CodeForbidden, "no write access")
}
if wt.Status == WorktreeStatusActive {
return errs.New(errs.CodeWorktreeAlreadyActive, "release before delete")
}
// 先 set pruning,再调 git rm,然后 DELETE row。中途失败 row 留 pruning。
// check-and-transition 必须原子:在 tx 内对行加 FOR UPDATE,确认 idle 后才转 pruning。
// 否则并发 Acquire(FOR UPDATE)可能刚把它置为 active,而这里用非锁读看到旧的 idle,
// 进而 git rm 掉 agent 正在使用的目录。仅 idle→pruning 在锁下完成。
var wtPath string
err = s.repo.InTx(ctx, func(tx Tx) error {
_, e := tx.SetWorktreePruning(ctx, wtID)
return e
wt, e := tx.GetWorktreeByIDForUpdate(ctx, wtID)
if e != nil {
return e
}
if wt.Status != WorktreeStatusIdle {
return errs.New(errs.CodeWorktreeAlreadyActive, "release before delete")
}
pruned, e := tx.SetWorktreePruning(ctx, wtID)
if e != nil {
return e
}
wtPath = pruned.Path
return nil
})
if err != nil {
return err
}
if rmErr := s.gitr.WorktreeRemove(ctx, ws.MainPath, wt.Path); rmErr != nil {
// 仅在守卫后的转移成功后才动磁盘。中途失败 row 留 pruning。
if rmErr := s.gitr.WorktreeRemove(ctx, ws.MainPath, wtPath); rmErr != nil {
return errs.Wrap(rmErr, errs.CodeGitCmdFailed, "git worktree remove")
}
if err := s.repo.DeleteWorktree(ctx, wtID); err != nil {
@@ -123,6 +126,9 @@ func (s *worktreeService) Delete(ctx context.Context, c Caller, wtID uuid.UUID)
}
func (s *worktreeService) Acquire(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, error) {
if _, err := s.authorizeWorktreeWrite(ctx, c, wtID); err != nil {
return nil, err
}
holder := c.Holder()
var out *Worktree
err := s.repo.InTx(ctx, func(tx Tx) error {
@@ -157,6 +163,9 @@ func (s *worktreeService) Acquire(ctx context.Context, c Caller, wtID uuid.UUID)
}
func (s *worktreeService) Release(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, error) {
if _, err := s.authorizeWorktreeWrite(ctx, c, wtID); err != nil {
return nil, err
}
holder := c.Holder()
var out *Worktree
err := s.repo.InTx(ctx, func(tx Tx) error {
@@ -190,6 +199,27 @@ func (s *worktreeService) ReleaseByHolder(ctx context.Context, holder string) ([
return s.repo.ReleaseWorktreesByHolder(ctx, holder)
}
// authorizeWorktreeWrite 把 worktree → workspace → project 解析出来并要求 write 权限,
// 与 Delete/Create 的鉴权方式一致。返回 worktree 所属的 workspace 供调用方使用
// (如取 MainPath)。Acquire/Release 此前缺失该校验,导致任意已登录用户可凭 UUID
// 跨租户锁定/释放任意 worktree。
func (s *worktreeService) authorizeWorktreeWrite(ctx context.Context, c Caller, wtID uuid.UUID) (*Workspace, error) {
wt, err := s.repo.GetWorktreeByID(ctx, wtID)
if err != nil {
return nil, err
}
ws, err := s.repo.GetWorkspaceByID(ctx, wt.WorkspaceID)
if err != nil {
return nil, err
}
if _, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil {
return nil, err
} else if !canWrite {
return nil, errs.New(errs.CodeForbidden, "no write access")
}
return ws, nil
}
func (s *worktreeService) audit(ctx context.Context, uid *uuid.UUID, action, ttype, tid string, meta map[string]any) {
_ = s.rec.Record(ctx, audit.Entry{
UserID: uid, Action: action, TargetType: ttype, TargetID: tid, Metadata: meta,