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>
pull/1/head
abel533 1 month ago
parent b98675f643
commit dcba6c86ae
  1. 4
      src/main/java/com/claudecode/config/AppConfig.java
  2. 126
      src/main/java/com/claudecode/tool/impl/EnterWorktreeTool.java
  3. 103
      src/main/java/com/claudecode/tool/impl/ExitWorktreeTool.java
  4. 465
      src/main/java/com/claudecode/worktree/WorktreeManager.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 工具映射为本地工具)

@ -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
* <p>
* 为当前会话创建隔离的 git worktree切换工作目录
* 用于并行工作场景每个 worktree 有独立的分支和文件系统视图
* <p>
* 输入参数:
* <ul>
* <li>name worktree 名称 (slug)可选默认自动生成</li>
* <li>base_branch 基准分支可选</li>
* </ul>
*/
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<String, Object> 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;
}
}

@ -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
* <p>
* 退出当前 worktree可选择保留或删除
* <ul>
* <li>keep 保留 worktree 在磁盘上可后续恢复</li>
* <li>remove 删除 worktree 和对应分支</li>
* </ul>
*/
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<String, Object> 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();
}
}
}

@ -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
* <p>
* 管理 worktree 的创建恢复清理和验证
* 支持以下工作流:
* <ul>
* <li>EnterWorktreeTool 创建隔离的工作分支</li>
* <li>ExitWorktreeTool 退出并保留/删除 worktree</li>
* <li>Agent 自动创建独立 worktree并行隔离</li>
* <li>会话恢复时重新进入 worktree</li>
* </ul>
*/
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 格式和安全性
* <p>
* 规则
* <ul>
* <li>最大 64 字符</li>
* <li>每段只允许 [a-zA-Z0-9._-]</li>
* <li>不允许 . .. 防路径遍历</li>
* <li>不允许前导/尾随 /</li>
* <li>/ 在分支名和目录名中映射为 +</li>
* </ul>
*
* @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 <branch> <path> <baseBranch>
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 <path>
execGit(gitRoot, "worktree", "remove", "--force", worktreePath.toString());
// 等待 git 锁释放
Thread.sleep(100);
// git branch -D <branch>
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<String> 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 worktreeGC
*
* @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<Path> 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<String> 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;
}
}
Loading…
Cancel
Save