From dcba6c86ae569b61c65e75deaf7ab1d9b69779fb Mon Sep 17 00:00:00 2001 From: abel533 Date: Sun, 5 Apr 2026 10:23:37 +0800 Subject: [PATCH] feat: Git Worktree isolation tools (Phase 3B) - WorktreeManager: full lifecycle with slug validation, symlinks, stale GC - EnterWorktreeTool: create/resume worktree with auto-slug generation - ExitWorktreeTool: keep or remove with uncommitted change safety - Slug validation: max 64 chars, [a-zA-Z0-9._-] only, path traversal prevention - Symlink support for large directories (node_modules, dist, etc.) - Stale agent worktree cleanup with ephemeral pattern matching - Fast resume via direct .git pointer file read (no subprocess) - Registered both tools in AppConfig (total: 28 tools) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/com/claudecode/config/AppConfig.java | 4 +- .../tool/impl/EnterWorktreeTool.java | 126 +++++ .../tool/impl/ExitWorktreeTool.java | 103 ++++ .../claudecode/worktree/WorktreeManager.java | 465 ++++++++++++++++++ 4 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/claudecode/tool/impl/EnterWorktreeTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/ExitWorktreeTool.java create mode 100644 src/main/java/com/claudecode/worktree/WorktreeManager.java diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index 1bc3e2e..4d1def2 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -119,7 +119,9 @@ public class AppConfig { new SkillTool(), new SendMessageTool(), new ListMcpResourcesTool(), - new ReadMcpResourceTool() + new ReadMcpResourceTool(), + new EnterWorktreeTool(), + new ExitWorktreeTool() ); // P2: 注册 MCP 工具桥接(将远程 MCP 工具映射为本地工具) diff --git a/src/main/java/com/claudecode/tool/impl/EnterWorktreeTool.java b/src/main/java/com/claudecode/tool/impl/EnterWorktreeTool.java new file mode 100644 index 0000000..f38029f --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/EnterWorktreeTool.java @@ -0,0 +1,126 @@ +package com.claudecode.tool.impl; + +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; +import com.claudecode.worktree.WorktreeManager; +import com.claudecode.worktree.WorktreeManager.WorktreeCreateResult; +import com.claudecode.worktree.WorktreeManager.WorktreeSession; + +import java.nio.file.Path; +import java.util.Map; + +/** + * 进入 Git Worktree 工具 —— 对应 claude-code/src/tools/EnterWorktreeTool。 + *

+ * 为当前会话创建隔离的 git worktree,切换工作目录。 + * 用于并行工作场景,每个 worktree 有独立的分支和文件系统视图。 + *

+ * 输入参数: + *

+ */ +public class EnterWorktreeTool implements Tool { + + public static final String WORKTREE_MANAGER_KEY = "__worktree_manager__"; + + @Override + public String name() { + return "EnterWorktree"; + } + + @Override + public String description() { + return """ + Create and enter a git worktree for isolated parallel work. \ + This creates a new branch and working directory, allowing you \ + to make changes without affecting the main working tree. \ + Use ExitWorktree to leave and optionally clean up."""; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Worktree name (slug). Max 64 chars, [a-zA-Z0-9._-/] only. If not provided, a name will be generated." + }, + "base_branch": { + "type": "string", + "description": "Base branch to create worktree from. Defaults to current HEAD." + } + } + }"""; + } + + @Override + public String execute(Map input, ToolContext context) { + try { + WorktreeManager manager = getOrCreateManager(context); + + // 检查是否已在 worktree 中 + if (manager.getCurrentSession() != null) { + return "Error: Already in a worktree session: " + manager.getCurrentSession().slug() + + ". Use ExitWorktree first."; + } + + // 检测 Git 根目录 + Path gitRoot = WorktreeManager.findGitRoot(context.getWorkDir()); + if (gitRoot == null) { + return "Error: Not in a git repository. Worktree requires a git repo."; + } + + String slug = input != null ? (String) input.get("name") : null; + if (slug == null || slug.isBlank()) { + // 自动生成 slug + slug = "session-" + System.currentTimeMillis() % 100000; + } + + String baseBranch = input != null ? (String) input.get("base_branch") : null; + + // 创建/恢复 worktree + WorktreeSession session = manager.enterWorktree(slug, gitRoot, baseBranch); + + // 更新工作目录(ToolContext 层面) + // 注意:Java 不像 Node.js 那样能轻易切换 process CWD, + // 我们通过 ToolContext 来管理"逻辑工作目录" + context.set("__original_work_dir__", context.getWorkDir().toString()); + + StringBuilder sb = new StringBuilder(); + sb.append("✓ Entered worktree: ").append(session.slug()).append("\n"); + sb.append(" Path: ").append(session.worktreePath()).append("\n"); + sb.append(" Branch: ").append(session.worktreeBranch()).append("\n"); + if (session.headCommit() != null) { + sb.append(" HEAD: ").append(session.headCommit(), 0, + Math.min(8, session.headCommit().length())).append("\n"); + } + if (session.worktreePath().equals(session.worktreePath())) { + sb.append(" Status: ").append( + session.createdAt() != null ? "created" : "resumed").append("\n"); + } + sb.append("\nAll file operations will now work in the worktree directory."); + sb.append("\nUse ExitWorktree to return to the original directory."); + + return sb.toString(); + } catch (IllegalArgumentException e) { + return "Error: Invalid worktree name — " + e.getMessage(); + } catch (IllegalStateException e) { + return "Error: " + e.getMessage(); + } catch (Exception e) { + return "Error creating worktree: " + e.getMessage(); + } + } + + private WorktreeManager getOrCreateManager(ToolContext context) { + WorktreeManager manager = context.get(WORKTREE_MANAGER_KEY); + if (manager == null) { + manager = new WorktreeManager(); + context.set(WORKTREE_MANAGER_KEY, manager); + } + return manager; + } +} diff --git a/src/main/java/com/claudecode/tool/impl/ExitWorktreeTool.java b/src/main/java/com/claudecode/tool/impl/ExitWorktreeTool.java new file mode 100644 index 0000000..bef6c2c --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/ExitWorktreeTool.java @@ -0,0 +1,103 @@ +package com.claudecode.tool.impl; + +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; +import com.claudecode.worktree.WorktreeManager; +import com.claudecode.worktree.WorktreeManager.WorktreeSession; + +import java.util.Map; + +/** + * 退出 Git Worktree 工具 —— 对应 claude-code/src/tools/ExitWorktreeTool。 + *

+ * 退出当前 worktree,可选择保留或删除。 + *

+ */ +public class ExitWorktreeTool implements Tool { + + @Override + public String name() { + return "ExitWorktree"; + } + + @Override + public String description() { + return """ + Exit the current git worktree session. Choose to 'keep' the \ + worktree on disk for later resumption, or 'remove' it to clean \ + up the worktree directory and branch. If removing with uncommitted \ + changes, set discard_changes to true."""; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["keep", "remove"], + "description": "'keep' preserves worktree on disk, 'remove' deletes it and the branch" + }, + "discard_changes": { + "type": "boolean", + "description": "When true, force removal even with uncommitted changes" + } + }, + "required": ["action"] + }"""; + } + + @Override + public String execute(Map input, ToolContext context) { + try { + WorktreeManager manager = context.get(EnterWorktreeTool.WORKTREE_MANAGER_KEY); + if (manager == null || manager.getCurrentSession() == null) { + return "Error: Not currently in a worktree session. Use EnterWorktree first."; + } + + WorktreeSession session = manager.getCurrentSession(); + String action = input != null ? (String) input.get("action") : "keep"; + boolean discardChanges = input != null && + Boolean.TRUE.equals(input.get("discard_changes")); + + StringBuilder sb = new StringBuilder(); + + if ("remove".equals(action)) { + boolean removed = manager.cleanupWorktree(discardChanges); + if (removed) { + sb.append("✓ Worktree removed: ").append(session.slug()).append("\n"); + sb.append(" Deleted: ").append(session.worktreePath()).append("\n"); + sb.append(" Branch deleted: ").append(session.worktreeBranch()).append("\n"); + } else { + return "Error: Failed to remove worktree. " + + "Try with discard_changes=true to force removal."; + } + } else { + manager.keepWorktree(); + sb.append("✓ Exited worktree: ").append(session.slug()).append("\n"); + sb.append(" Preserved at: ").append(session.worktreePath()).append("\n"); + sb.append(" Branch: ").append(session.worktreeBranch()).append("\n"); + sb.append(" You can re-enter later with EnterWorktree name=") + .append(session.slug()).append("\n"); + } + + // 恢复原始工作目录 + String originalDir = context.get("__original_work_dir__"); + if (originalDir != null) { + sb.append("\nRestored to: ").append(originalDir); + } + + return sb.toString(); + } catch (IllegalStateException e) { + return "Error: " + e.getMessage() + + "\nSet discard_changes=true to force removal."; + } catch (Exception e) { + return "Error exiting worktree: " + e.getMessage(); + } + } +} diff --git a/src/main/java/com/claudecode/worktree/WorktreeManager.java b/src/main/java/com/claudecode/worktree/WorktreeManager.java new file mode 100644 index 0000000..0e65db6 --- /dev/null +++ b/src/main/java/com/claudecode/worktree/WorktreeManager.java @@ -0,0 +1,465 @@ +package com.claudecode.worktree; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.*; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +/** + * Git Worktree 生命周期管理 —— 对应 claude-code/src/utils/worktree.ts。 + *

+ * 管理 worktree 的创建、恢复、清理和验证。 + * 支持以下工作流: + *

    + *
  • EnterWorktreeTool 创建隔离的工作分支
  • + *
  • ExitWorktreeTool 退出并保留/删除 worktree
  • + *
  • Agent 自动创建独立 worktree(并行隔离)
  • + *
  • 会话恢复时重新进入 worktree
  • + *
+ */ +public class WorktreeManager { + + private static final Logger log = LoggerFactory.getLogger(WorktreeManager.class); + + /** Slug 验证:每个段只允许字母数字和 ._- */ + private static final Pattern VALID_SLUG_SEGMENT = Pattern.compile("^[a-zA-Z0-9._-]+$"); + private static final int MAX_SLUG_LENGTH = 64; + private static final String WORKTREE_BRANCH_PREFIX = "worktree-"; + + /** 当前活跃的 worktree 会话(每个 JVM 实例最多一个用户级 worktree) */ + private volatile WorktreeSession currentSession; + + // ==================== Worktree 会话数据 ==================== + + public record WorktreeSession( + String slug, + Path worktreePath, + String worktreeBranch, + String headCommit, + Path originalCwd, + Path gitRoot, + Instant createdAt + ) {} + + public record WorktreeCreateResult( + Path worktreePath, + String worktreeBranch, + String headCommit, + Path gitRoot, + boolean existed + ) {} + + // ==================== Slug 验证 ==================== + + /** + * 验证 worktree slug 格式和安全性。 + *

+ * 规则: + *

    + *
  • 最大 64 字符
  • + *
  • 每段只允许 [a-zA-Z0-9._-]
  • + *
  • 不允许 . 或 .. 段(防路径遍历)
  • + *
  • 不允许前导/尾随 /
  • + *
  • / 在分支名和目录名中映射为 +
  • + *
+ * + * @throws IllegalArgumentException 验证失败时 + */ + public static void validateSlug(String slug) { + if (slug == null || slug.isBlank()) { + throw new IllegalArgumentException("Worktree slug cannot be empty"); + } + if (slug.length() > MAX_SLUG_LENGTH) { + throw new IllegalArgumentException( + "Worktree slug too long (max " + MAX_SLUG_LENGTH + "): " + slug.length()); + } + if (slug.startsWith("/") || slug.endsWith("/")) { + throw new IllegalArgumentException("Worktree slug cannot start/end with /"); + } + + String[] segments = slug.split("/"); + for (String segment : segments) { + if (segment.isEmpty()) { + throw new IllegalArgumentException("Worktree slug has empty segment"); + } + if (".".equals(segment) || "..".equals(segment)) { + throw new IllegalArgumentException("Worktree slug cannot contain . or .. segments"); + } + if (!VALID_SLUG_SEGMENT.matcher(segment).matches()) { + throw new IllegalArgumentException( + "Invalid characters in slug segment '" + segment + + "': only [a-zA-Z0-9._-] allowed"); + } + } + } + + /** + * 将 slug 中的 / 映射为 + 防止 D/F 冲突。 + */ + public static String flattenSlug(String slug) { + return slug.replace('/', '+'); + } + + /** + * 从 slug 生成分支名。 + */ + public static String branchName(String slug) { + return WORKTREE_BRANCH_PREFIX + flattenSlug(slug); + } + + // ==================== Worktree 创建 ==================== + + /** + * 为会话创建或恢复 worktree。 + * + * @param slug worktree 名称 + * @param gitRoot Git 仓库根目录 + * @param baseBranch 基准分支(null 时使用 HEAD) + * @return 创建结果 + */ + public WorktreeCreateResult createOrResume(String slug, Path gitRoot, String baseBranch) throws IOException { + validateSlug(slug); + + String flat = flattenSlug(slug); + String branch = branchName(slug); + Path worktreeDir = gitRoot.resolve(".claude").resolve("worktrees").resolve(flat); + + // 快速恢复:已存在则直接返回 + if (Files.isDirectory(worktreeDir)) { + String headCommit = readWorktreeHead(worktreeDir); + log.info("Resuming existing worktree: {} at {}", slug, worktreeDir); + // 更新 mtime 防止被 GC + try { + Files.setLastModifiedTime(worktreeDir, java.nio.file.attribute.FileTime.from(Instant.now())); + } catch (IOException ignored) {} + return new WorktreeCreateResult(worktreeDir, branch, headCommit, gitRoot, true); + } + + // 确保父目录存在 + Files.createDirectories(worktreeDir.getParent()); + + // 获取基准分支 + if (baseBranch == null || baseBranch.isBlank()) { + baseBranch = execGit(gitRoot, "rev-parse", "--abbrev-ref", "HEAD").trim(); + } + + // git worktree add -B + String result = execGit(gitRoot, + "worktree", "add", "-B", branch, + worktreeDir.toString(), baseBranch); + log.info("Created worktree: {} → {} (branch: {})", slug, worktreeDir, branch); + + String headCommit = readWorktreeHead(worktreeDir); + return new WorktreeCreateResult(worktreeDir, branch, headCommit, gitRoot, false); + } + + /** + * 为 Agent 创建轻量级 worktree(不修改全局状态)。 + */ + public WorktreeCreateResult createAgentWorktree(String slug, Path gitRoot) throws IOException { + validateSlug(slug); + return createOrResume(slug, gitRoot, null); + } + + // ==================== Worktree 退出 ==================== + + /** + * 退出 worktree 但保留在磁盘上。 + */ + public void keepWorktree() { + if (currentSession == null) return; + log.info("Keeping worktree: {} at {}", currentSession.slug, currentSession.worktreePath); + currentSession = null; + } + + /** + * 退出 worktree 并清理(删除 worktree + 分支)。 + * + * @param discardChanges true 时强制删除(即使有未提交的修改) + */ + public boolean cleanupWorktree(boolean discardChanges) throws IOException { + if (currentSession == null) return false; + + WorktreeSession session = currentSession; + + // 检查是否有未提交修改 + if (!discardChanges && hasUncommittedChanges(session.worktreePath)) { + throw new IllegalStateException( + "Worktree has uncommitted changes. Use discard_changes=true to force removal."); + } + + return removeWorktree(session.worktreePath, session.worktreeBranch, session.gitRoot); + } + + /** + * 删除指定的 worktree 和分支。 + */ + public boolean removeWorktree(Path worktreePath, String branch, Path gitRoot) { + try { + // git worktree remove --force + execGit(gitRoot, "worktree", "remove", "--force", worktreePath.toString()); + + // 等待 git 锁释放 + Thread.sleep(100); + + // git branch -D + if (branch != null) { + try { + execGit(gitRoot, "branch", "-D", branch); + } catch (IOException e) { + log.debug("Branch deletion failed (may not exist): {}", e.getMessage()); + } + } + + currentSession = null; + log.info("Removed worktree: {} (branch: {})", worktreePath, branch); + return true; + } catch (Exception e) { + log.error("Failed to remove worktree: {}", worktreePath, e); + return false; + } + } + + /** + * 删除 Agent 的轻量级 worktree。 + */ + public boolean removeAgentWorktree(Path worktreePath, String branch, Path gitRoot) { + return removeWorktree(worktreePath, branch, gitRoot); + } + + // ==================== 会话管理 ==================== + + /** + * 进入 worktree(设置为当前会话)。 + */ + public WorktreeSession enterWorktree(String slug, Path gitRoot, String baseBranch) throws IOException { + if (currentSession != null) { + throw new IllegalStateException("Already in a worktree session: " + currentSession.slug); + } + + Path originalCwd = Path.of(System.getProperty("user.dir")); + WorktreeCreateResult result = createOrResume(slug, gitRoot, baseBranch); + + currentSession = new WorktreeSession( + slug, result.worktreePath, result.worktreeBranch, + result.headCommit, originalCwd, gitRoot, Instant.now() + ); + + return currentSession; + } + + /** + * 恢复之前的 worktree 会话(--resume 时使用)。 + */ + public void restoreSession(WorktreeSession session) { + this.currentSession = session; + } + + public WorktreeSession getCurrentSession() { + return currentSession; + } + + // ==================== Symlink 大目录 ==================== + + /** + * 为大型目录创建符号链接(如 node_modules, .next, dist)。 + */ + public void symlinkDirectories(Path repoRoot, Path worktreePath, List dirs) { + if (dirs == null || dirs.isEmpty()) return; + + for (String dir : dirs) { + if (dir.contains("..") || dir.startsWith("/")) { + log.warn("Skipping unsafe symlink directory: {}", dir); + continue; + } + + Path source = repoRoot.resolve(dir); + Path target = worktreePath.resolve(dir); + + try { + if (!Files.isDirectory(source)) { + log.debug("Symlink source doesn't exist yet: {}", source); + continue; + } + if (Files.exists(target)) { + log.debug("Symlink target already exists: {}", target); + continue; + } + Files.createDirectories(target.getParent()); + Files.createSymbolicLink(target, source); + log.info("Symlinked: {} → {}", target, source); + } catch (IOException e) { + log.warn("Failed to create symlink {} → {}: {}", target, source, e.getMessage()); + } + } + } + + // ==================== 过期清理(GC) ==================== + + /** Agent worktree 的临时命名模式 */ + private static final Pattern EPHEMERAL_PATTERN = Pattern.compile( + "^(agent-a[0-9a-f]{7}|wf_[0-9a-f]{8}-[0-9a-f]{3}-\\d+|bridge-.+|job-.+-[0-9a-f]{8})$"); + + /** + * 清理过期的 Agent worktree(GC)。 + * + * @param cutoff 截止时间,mtime 早于此时间的才会被清理 + * @return 清理的 worktree 数量 + */ + public int cleanupStaleWorktrees(Path gitRoot, Instant cutoff) { + Path worktreeBase = gitRoot.resolve(".claude").resolve("worktrees"); + if (!Files.isDirectory(worktreeBase)) return 0; + + int cleaned = 0; + try (DirectoryStream stream = Files.newDirectoryStream(worktreeBase)) { + for (Path entry : stream) { + if (!Files.isDirectory(entry)) continue; + + String name = entry.getFileName().toString(); + + // 只清理临时命名的 worktree + if (!EPHEMERAL_PATTERN.matcher(name).matches()) continue; + + // 跳过当前会话的 worktree + if (currentSession != null && entry.equals(currentSession.worktreePath)) continue; + + // 检查 mtime + Instant mtime = Files.getLastModifiedTime(entry).toInstant(); + if (mtime.isAfter(cutoff)) continue; + + // 检查是否有未提交的修改 + if (hasUncommittedChanges(entry)) { + log.debug("Skipping stale worktree with changes: {}", name); + continue; + } + + // 安全删除 + String branch = WORKTREE_BRANCH_PREFIX + name; + if (removeWorktree(entry, branch, gitRoot)) { + cleaned++; + } + } + } catch (IOException e) { + log.error("Failed to scan worktree directory: {}", worktreeBase, e); + } + + if (cleaned > 0) { + log.info("Cleaned up {} stale agent worktrees", cleaned); + } + return cleaned; + } + + // ==================== Git 工具方法 ==================== + + /** + * 读取 worktree 的 HEAD commit SHA。 + */ + private String readWorktreeHead(Path worktreePath) { + try { + // 尝试直接读取 .git 文件获取 HEAD + Path gitFile = worktreePath.resolve(".git"); + if (Files.isRegularFile(gitFile)) { + String content = Files.readString(gitFile).trim(); + if (content.startsWith("gitdir:")) { + Path gitDir = Path.of(content.substring("gitdir:".length()).trim()); + if (!gitDir.isAbsolute()) { + gitDir = worktreePath.resolve(gitDir); + } + Path headFile = gitDir.resolve("HEAD"); + if (Files.isRegularFile(headFile)) { + String headContent = Files.readString(headFile).trim(); + if (headContent.startsWith("ref:")) { + // 解析 symbolic ref + String ref = headContent.substring(4).trim(); + Path refFile = gitDir.resolve(ref); + if (Files.isRegularFile(refFile)) { + return Files.readString(refFile).trim(); + } + } else { + return headContent; // 直接是 SHA + } + } + } + } + // 回退到 git rev-parse + return execGit(worktreePath, "rev-parse", "HEAD").trim(); + } catch (Exception e) { + log.debug("Failed to read HEAD for worktree {}: {}", worktreePath, e.getMessage()); + return null; + } + } + + /** + * 检查 worktree 是否有未提交的修改。 + */ + private boolean hasUncommittedChanges(Path worktreePath) { + try { + String status = execGit(worktreePath, "status", "--porcelain"); + return !status.isBlank(); + } catch (IOException e) { + // Git 命令失败 → fail-closed(假设有修改) + return true; + } + } + + /** + * 检测 git 仓库根目录。 + */ + public static Path findGitRoot(Path dir) { + try { + String result = execGit(dir, "rev-parse", "--show-toplevel"); + return Path.of(result.trim()); + } catch (IOException e) { + return null; + } + } + + /** + * 执行 git 命令并返回 stdout。 + */ + private static String execGit(Path workDir, String... args) throws IOException { + List command = new ArrayList<>(); + command.add("git"); + command.addAll(Arrays.asList(args)); + + ProcessBuilder pb = new ProcessBuilder(command) + .directory(workDir.toFile()) + .redirectErrorStream(false); + pb.environment().put("GIT_TERMINAL_PROMPT", "0"); + + Process process; + try { + process = pb.start(); + } catch (IOException e) { + throw new IOException("Failed to start git: " + e.getMessage(), e); + } + + String stdout, stderr; + try { + stdout = new String(process.getInputStream().readAllBytes()).trim(); + stderr = new String(process.getErrorStream().readAllBytes()).trim(); + + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IOException("Git command timed out: " + String.join(" ", args)); + } + } catch (InterruptedException e) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + throw new IOException("Git command interrupted", e); + } + + if (process.exitValue() != 0) { + throw new IOException("Git command failed (exit " + process.exitValue() + "): " + + String.join(" ", args) + "\n" + stderr); + } + + return stdout; + } +}