- 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
parent
b98675f643
commit
dcba6c86ae
@ -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 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<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…
Reference in new issue