package engine // worktree.go 实现 Git Worktree 管理功能. // // 为子 Agent 提供独立的 git worktree 环境,使其可以在隔离的工作目录中 // 执行文件操作而不影响主工作区. // // 使用场景: // - 子 Agent 需要在独立分支上做实验性修改 // - 多个子 Agent 同时工作在不同的分支上 // - 避免子 Agent 的文件操作污染主工作区 // // 实现方式:通过 execenv.Executor 启动 git 子进程, Class=ClassWorkspaceTool, // Env=FullInheritMap (git 需要 PATH/HOME/GIT_* 等). M1 commit 13 前直接调 // os/exec, 方案 β 迁移后所有 git 调用统一走 Executor.Command, 云端 backend // 透明重路由到 microVM. import ( "context" "crypto/rand" "encoding/hex" "fmt" "os" "path/filepath" "strings" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) const ( // worktreeBranchPrefix 是 agent 自动创建的临时分支的统一前缀. // CleanupWorktree 用它判断是否应删除分支(避免误删用户自命名分支). worktreeBranchPrefix = "agent-worktree-" // worktreeTempDir 是临时 worktree 目录的子目录名. worktreeTempDir = "agent-worktrees" ) // WorktreeInfo 包含创建的 worktree 的信息. type WorktreeInfo struct { // Path 是 worktree 的绝对路径 Path string // Branch 是 worktree 所在的分支名 Branch string // RepoRoot 是原始仓库的根目录 RepoRoot string } // WorktreeCleanup 是 worktree 的清理函数类型. // 调用此函数将删除 worktree 目录并从 git 中移除引用. type WorktreeCleanup func() error // CreateWorktree 在指定仓库中创建一个新的 git worktree. // // 参数: // - repoRoot: git 仓库根目录 // - branchName: 新分支名称(如果为空,自动生成) // - executor: 子进程启动器 (方案 β 严格 DI, nil 即 panic) // // 返回 worktree 信息,清理函数和错误. // 调用者必须在使用完毕后调用清理函数释放资源. // // M1: 用 context.Background() 对齐原 exec.Command 无超时语义, 未来加 ctx // 是正交 commit. cleanup closure 捕获 executor 透传给 CleanupWorktree. func CreateWorktree(repoRoot, branchName string, executor execenv.Executor) (*WorktreeInfo, WorktreeCleanup, error) { if executor == nil { panic("engine.CreateWorktree: executor is nil (方案 β 严格 DI)") } // 检查是否在 git 仓库中 if !isGitRepo(repoRoot, executor) { return nil, nil, fmt.Errorf("worktree: %s is not a git repository", repoRoot) } // 如果未指定分支名,自动生成 if branchName == "" { // 升华改进(ELEVATED): 早期方案用 time.Now().UnixNano() 作为后缀-- // 在同一纳秒内(极端情况:虚拟机时间跳变,测试中时钟冻结)两个调用会生成相同分支名, // 导致 git worktree add 因分支已存在而失败,且目录冲突难以诊断. // 修复:改用 crypto/rand 8 字节(64 位)随机后缀,碰撞概率降至 1/2^64(实际为零). // 副作用:移除了唯一的 time.Now() 调用,import "time" 已一并删除. // 替代方案:保留 UnixNano + 4 字节 rand 混合(更长的名称,无额外收益). // 替代方案:UUID v4(需引入外部库或自实现,违反零外部依赖原则). var randBytes [8]byte if _, err := rand.Read(randBytes[:]); err != nil { return nil, nil, fmt.Errorf("worktree: generate branch name: %w", err) } branchName = worktreeBranchPrefix + hex.EncodeToString(randBytes[:]) } // 生成 worktree 目录路径(在系统临时目录中) worktreeDir := filepath.Join(os.TempDir(), worktreeTempDir, branchName) // 确保父目录存在 parentDir := filepath.Dir(worktreeDir) if err := os.MkdirAll(parentDir, 0755); err != nil { return nil, nil, fmt.Errorf("worktree: create parent dir: %w", err) } // 如果目录已存在,先清理 // 升华改进(ELEVATED): 早期方案 os.Stat 会跟随符号链接--攻击向量: // 攻击者在 worktreeDir 路径放一个 symlink 指向 /etc 或用户 home, // os.RemoveAll 会递归删除 symlink 目标目录下的真实文件(而非 link 本身). // 修复:改用 os.Lstat 不跟随 symlink;若路径是 symlink 则只删 link 本身(os.Remove), // 若是真实目录才走 removeWorktree + RemoveAll 原有路径. // 替代方案:保留 os.Stat + 额外 Lstat 二次校验(两次系统调用,TOCTOU 窗口仍存在). if fi, err := os.Lstat(worktreeDir); err == nil { if fi.Mode()&os.ModeSymlink != 0 { _ = os.Remove(worktreeDir) // 只删 symlink 本身,不跟随目标 } else { _ = removeWorktree(repoRoot, worktreeDir, executor) _ = os.RemoveAll(worktreeDir) } } // 获取当前 HEAD 的 commit hash 作为起点 headRef, err := getHeadRef(repoRoot, executor) if err != nil { return nil, nil, fmt.Errorf("worktree: get HEAD ref: %w", err) } // 创建新的 worktree(带新分支) // git worktree add -b proc := executor.Command(context.Background(), execenv.Spec{ Class: execenv.ClassWorkspaceTool, Path: "git", Args: []string{"worktree", "add", "-b", branchName, worktreeDir, headRef}, Env: execenv.FullInheritMap(nil), WorkDir: repoRoot, }) output, err := proc.CombinedOutput() if err != nil { // 升华改进(ELEVATED): 早期方案 git 失败后直接返回错误,不清理可能被 git // 部分创建的目录(git worktree add 在分支冲突等情况下可能创建了空目录). // 未清理的目录会导致下次调用 CreateWorktree 时 os.Stat 判断为"已存在", // 触发 removeWorktree(此时 git 没有 worktree 记录),造成混乱. // 改进:失败时尽力清理,错误忽略(清理本身不影响失败原因的返回). _ = removeWorktree(repoRoot, worktreeDir, executor) _ = os.RemoveAll(worktreeDir) return nil, nil, fmt.Errorf("worktree: git worktree add failed: %s: %w", strings.TrimSpace(string(output)), err) } info := &WorktreeInfo{ Path: worktreeDir, Branch: branchName, RepoRoot: repoRoot, } // 构建清理函数 // 精妙之处(CLEVER): closure 捕获 executor, 调用方只拿到 WorktreeCleanup 零参数 // 回调, 不需要知道执行器的存在. 这让 CleanupWorktree 在引擎已 Close (ctx 失效) // 后仍可调用 -- cleanup 内部用 context.Background() 不受 request ctx 影响. cleanup := func() error { return CleanupWorktree(repoRoot, worktreeDir, branchName, executor) } return info, cleanup, nil } // CleanupWorktree 清理一个 worktree:移除 worktree 引用,删除目录,删除临时分支. // executor 不能为 nil (方案 β 严格 DI). func CleanupWorktree(repoRoot, worktreePath, branchName string, executor execenv.Executor) error { if executor == nil { panic("engine.CleanupWorktree: executor is nil (方案 β 严格 DI)") } var errs []string // 1. 移除 worktree 引用 if err := removeWorktree(repoRoot, worktreePath, executor); err != nil { errs = append(errs, fmt.Sprintf("remove worktree: %v", err)) } // 2. 删除 worktree 目录(如果还存在) if _, err := os.Stat(worktreePath); err == nil { if err := os.RemoveAll(worktreePath); err != nil { errs = append(errs, fmt.Sprintf("remove dir: %v", err)) } } // 3. 删除临时分支(仅删除以 worktreeBranchPrefix 为前缀的分支,避免误删用户分支) if strings.HasPrefix(branchName, worktreeBranchPrefix) { if err := deleteBranch(repoRoot, branchName, executor); err != nil { // 分支删除失败不致命,仅记录 errs = append(errs, fmt.Sprintf("delete branch: %v", err)) } } if len(errs) > 0 { return fmt.Errorf("worktree cleanup: %s", strings.Join(errs, "; ")) } return nil } // DetectGitRepo 检测给定目录是否在 git 仓库中. // 返回仓库根目录和是否检测到仓库. executor 不能为 nil. func DetectGitRepo(cwd string, executor execenv.Executor) (string, bool) { if executor == nil { panic("engine.DetectGitRepo: executor is nil (方案 β 严格 DI)") } proc := executor.Command(context.Background(), execenv.Spec{ Class: execenv.ClassWorkspaceTool, Path: "git", Args: []string{"rev-parse", "--show-toplevel"}, Env: execenv.FullInheritMap(nil), WorkDir: cwd, }) out, err := proc.Output() if err != nil { return "", false } root := strings.TrimSpace(string(out)) if root == "" { return "", false } abs, err := filepath.Abs(root) if err != nil { return root, true } return abs, true } // --- 内部辅助函数 --- // isGitRepo 检查目录是否为 git 仓库. func isGitRepo(dir string, executor execenv.Executor) bool { proc := executor.Command(context.Background(), execenv.Spec{ Class: execenv.ClassWorkspaceTool, Path: "git", Args: []string{"rev-parse", "--is-inside-work-tree"}, Env: execenv.FullInheritMap(nil), WorkDir: dir, }) out, err := proc.Output() if err != nil { return false } return strings.TrimSpace(string(out)) == "true" } // getHeadRef 获取当前 HEAD 的 commit hash. func getHeadRef(repoRoot string, executor execenv.Executor) (string, error) { proc := executor.Command(context.Background(), execenv.Spec{ Class: execenv.ClassWorkspaceTool, Path: "git", Args: []string{"rev-parse", "HEAD"}, Env: execenv.FullInheritMap(nil), WorkDir: repoRoot, }) out, err := proc.Output() if err != nil { return "", fmt.Errorf("git rev-parse HEAD: %w", err) } ref := strings.TrimSpace(string(out)) if ref == "" { return "", fmt.Errorf("empty HEAD ref") } return ref, nil } // removeWorktree 调用 git worktree remove 移除 worktree. func removeWorktree(repoRoot, worktreePath string, executor execenv.Executor) error { proc := executor.Command(context.Background(), execenv.Spec{ Class: execenv.ClassWorkspaceTool, Path: "git", Args: []string{"worktree", "remove", "--force", worktreePath}, Env: execenv.FullInheritMap(nil), WorkDir: repoRoot, }) output, err := proc.CombinedOutput() if err != nil { return fmt.Errorf("git worktree remove: %s: %w", strings.TrimSpace(string(output)), err) } return nil } // deleteBranch 删除本地分支. func deleteBranch(repoRoot, branchName string, executor execenv.Executor) error { proc := executor.Command(context.Background(), execenv.Spec{ Class: execenv.ClassWorkspaceTool, Path: "git", Args: []string{"branch", "-D", branchName}, Env: execenv.FullInheritMap(nil), WorkDir: repoRoot, }) output, err := proc.CombinedOutput() if err != nil { return fmt.Errorf("git branch -D: %s: %w", strings.TrimSpace(string(output)), err) } return nil }