clawt 3.9.10 → 3.9.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/dist/index.js +116 -80
- package/dist/postinstall.js +2 -0
- package/docs/post-create-hook.md +1 -5
- package/docs/run.md +1 -2
- package/docs/validate.md +15 -0
- package/package.json +1 -1
- package/src/commands/validate.ts +7 -0
- package/src/constants/config.ts +0 -4
- package/src/constants/index.ts +1 -1
- package/src/constants/messages/validate.ts +3 -0
- package/src/utils/claude.ts +3 -7
- package/src/utils/index.ts +1 -0
- package/src/utils/symlink-guard.ts +61 -0
- package/src/utils/task-executor.ts +1 -5
- package/tests/unit/commands/validate.test.ts +2 -0
- package/tests/unit/utils/claude.test.ts +34 -26
- package/tests/unit/utils/conflict-resolver.test.ts +4 -0
- package/tests/unit/utils/git-lock.test.ts +3 -3
- package/tests/unit/utils/shell.test.ts +2 -0
- package/tests/unit/utils/symlink-guard.test.ts +70 -0
package/README.md
CHANGED
|
@@ -159,6 +159,8 @@ clawt validate -b <branch> -r "pnpm test & pnpm build" # Run multiple commands
|
|
|
159
159
|
|
|
160
160
|
Supports incremental mode: when re-validating the same branch, you can view incremental diffs between validations via `git diff`.
|
|
161
161
|
|
|
162
|
+
Automatically removes external symlinks (e.g., `node_modules` links to the main worktree) before detecting changes — these symlinks, typically created by AI Agents, can cause patch apply failures.
|
|
163
|
+
|
|
162
164
|
When patch apply fails (target branch diverges too much from main), it automatically prompts whether to run `sync` to synchronize the main branch to the target worktree — no manual action needed.
|
|
163
165
|
|
|
164
166
|
The `-r, --run` option auto-executes a specified command in the main worktree after successful validation (e.g., tests, builds). Command failure does not affect the validation result. Without `-r`, it automatically reads from the project's `validateRunCommand` config (configurable via `clawt init show`). Use `&` to separate multiple commands for parallel execution:
|
package/README.zh-CN.md
CHANGED
|
@@ -159,6 +159,8 @@ clawt validate -b <branch> -r "pnpm test & pnpm build" # 并行执行多个命
|
|
|
159
159
|
|
|
160
160
|
支持增量模式:再次 validate 同一分支时,可通过 `git diff` 查看两次之间的增量差异。
|
|
161
161
|
|
|
162
|
+
执行前会自动清理目标 worktree 中指向外部的软链接(如 AI Agent 创建的 `node_modules` 链接),这些软链接会导致 patch apply 失败。
|
|
163
|
+
|
|
162
164
|
当 patch apply 失败(目标分支与主分支差异过大)时,会自动询问是否执行 `sync` 同步主分支到目标 worktree,无需手动操作。
|
|
163
165
|
|
|
164
166
|
`-r, --run` 选项可在 validate 成功后自动在主 worktree 中执行指定命令(如测试、构建等),命令执行失败不影响 validate 结果。不传 `-r` 时会自动从项目配置的 `validateRunCommand` 读取(可通过 `clawt init show` 设置)。支持用 `&` 分隔多个命令并行执行:
|
package/dist/index.js
CHANGED
|
@@ -269,6 +269,8 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
|
|
|
269
269
|
VALIDATE_RUN_ERROR_COPIED: "\u2702 \u9519\u8BEF\u4FE1\u606F\u5DF2\u590D\u5236\u5230\u526A\u8D34\u677F",
|
|
270
270
|
/** 剪贴板复制失败提示 */
|
|
271
271
|
VALIDATE_RUN_ERROR_COPY_FAILED: "\u26A0 \u9519\u8BEF\u4FE1\u606F\u590D\u5236\u5230\u526A\u8D34\u677F\u5931\u8D25",
|
|
272
|
+
/** 检测到外部软链接警告 */
|
|
273
|
+
VALIDATE_EXTERNAL_SYMLINKS_FOUND: (count) => `\u26A0 \u68C0\u6D4B\u5230 ${count} \u4E2A\u6307\u5411 worktree \u5916\u90E8\u7684\u8F6F\u94FE\u63A5\uFF08\u53EF\u80FD\u7531 AI Agent \u521B\u5EFA\uFF09\uFF0C\u5DF2\u81EA\u52A8\u79FB\u9664`,
|
|
272
274
|
/** 单命令(含 && 链)剪贴板错误格式 */
|
|
273
275
|
VALIDATE_CLIPBOARD_SINGLE_ERROR: (command, stderr) => `${command} \u6307\u4EE4\u6267\u884C\u51FA\u9519\uFF0C\u9519\u8BEF\u4FE1\u606F\uFF1A
|
|
274
276
|
${stderr}`,
|
|
@@ -704,7 +706,6 @@ var VALID_TERMINAL_APPS = ["auto", "iterm2", "terminal", "cmux"];
|
|
|
704
706
|
var ITERM2_APP_PATH = "/Applications/iTerm.app";
|
|
705
707
|
|
|
706
708
|
// src/constants/config.ts
|
|
707
|
-
var APPEND_SYSTEM_PROMPT = "Currently, you are in the git worktree directory.";
|
|
708
709
|
var CLAUDE_CODE_ENTRYPOINT_VALUE = "cli";
|
|
709
710
|
var CONFIG_DEFINITIONS = {
|
|
710
711
|
autoDeleteBranch: {
|
|
@@ -1167,7 +1168,7 @@ function parseParallelCommands(commandString) {
|
|
|
1167
1168
|
return parts.map((part) => part.replace(new RegExp(placeholder, "g"), "&&").trim()).filter((part) => part.length > 0);
|
|
1168
1169
|
}
|
|
1169
1170
|
function spawnWithStderrCapture(command, options) {
|
|
1170
|
-
return new Promise((
|
|
1171
|
+
return new Promise((resolve5) => {
|
|
1171
1172
|
const child = spawn(command, {
|
|
1172
1173
|
cwd: options?.cwd,
|
|
1173
1174
|
stdio: ["inherit", "inherit", "pipe"],
|
|
@@ -1179,14 +1180,14 @@ function spawnWithStderrCapture(command, options) {
|
|
|
1179
1180
|
stderrChunks.push(chunk);
|
|
1180
1181
|
});
|
|
1181
1182
|
child.on("error", (err) => {
|
|
1182
|
-
|
|
1183
|
+
resolve5({
|
|
1183
1184
|
exitCode: 1,
|
|
1184
1185
|
error: err.message,
|
|
1185
1186
|
stderr: Buffer.concat(stderrChunks).toString("utf-8")
|
|
1186
1187
|
});
|
|
1187
1188
|
});
|
|
1188
1189
|
child.on("close", (code) => {
|
|
1189
|
-
|
|
1190
|
+
resolve5({
|
|
1190
1191
|
exitCode: code ?? 1,
|
|
1191
1192
|
stderr: Buffer.concat(stderrChunks).toString("utf-8")
|
|
1192
1193
|
});
|
|
@@ -1527,14 +1528,14 @@ function confirmAction(question, nonInteractiveDefault = true) {
|
|
|
1527
1528
|
if (isNonInteractive()) {
|
|
1528
1529
|
return Promise.resolve(nonInteractiveDefault);
|
|
1529
1530
|
}
|
|
1530
|
-
return new Promise((
|
|
1531
|
+
return new Promise((resolve5) => {
|
|
1531
1532
|
const rl = createInterface({
|
|
1532
1533
|
input: process.stdin,
|
|
1533
1534
|
output: process.stdout
|
|
1534
1535
|
});
|
|
1535
1536
|
rl.question(`${question} (y/N) `, (answer) => {
|
|
1536
1537
|
rl.close();
|
|
1537
|
-
|
|
1538
|
+
resolve5(answer.toLowerCase() === "y");
|
|
1538
1539
|
});
|
|
1539
1540
|
});
|
|
1540
1541
|
}
|
|
@@ -2125,6 +2126,42 @@ function getWorktreeStatus(worktree) {
|
|
|
2125
2126
|
}
|
|
2126
2127
|
}
|
|
2127
2128
|
|
|
2129
|
+
// src/utils/symlink-guard.ts
|
|
2130
|
+
import { readdirSync as readdirSync3, lstatSync, unlinkSync, readlinkSync } from "fs";
|
|
2131
|
+
import { join as join6, relative, isAbsolute as isAbsolute2, resolve } from "path";
|
|
2132
|
+
function isExternalSymlink(linkPath, worktreeRoot) {
|
|
2133
|
+
try {
|
|
2134
|
+
const target = readlinkSync(linkPath);
|
|
2135
|
+
const resolvedTarget = isAbsolute2(target) ? target : resolve(worktreeRoot, target);
|
|
2136
|
+
const relativePath = relative(worktreeRoot, resolvedTarget);
|
|
2137
|
+
return relativePath.startsWith("..") || isAbsolute2(relativePath);
|
|
2138
|
+
} catch {
|
|
2139
|
+
return false;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
function removeExternalSymlinks(dir) {
|
|
2143
|
+
const removed = [];
|
|
2144
|
+
try {
|
|
2145
|
+
const entries = readdirSync3(dir, { withFileTypes: true });
|
|
2146
|
+
for (const entry of entries) {
|
|
2147
|
+
if (!entry.isSymbolicLink()) continue;
|
|
2148
|
+
const fullPath = join6(dir, entry.name);
|
|
2149
|
+
if (!isExternalSymlink(fullPath, dir)) continue;
|
|
2150
|
+
try {
|
|
2151
|
+
const stat = lstatSync(fullPath);
|
|
2152
|
+
if (!stat.isSymbolicLink()) continue;
|
|
2153
|
+
unlinkSync(fullPath);
|
|
2154
|
+
removed.push(fullPath);
|
|
2155
|
+
logger.info(`\u5DF2\u79FB\u9664\u5916\u90E8\u8F6F\u94FE\u63A5: ${fullPath}`);
|
|
2156
|
+
} catch (error) {
|
|
2157
|
+
logger.warn(`\u79FB\u9664\u5916\u90E8\u8F6F\u94FE\u63A5\u5931\u8D25: ${fullPath} - ${error}`);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
} catch {
|
|
2161
|
+
}
|
|
2162
|
+
return removed;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2128
2165
|
// src/utils/prompt.ts
|
|
2129
2166
|
import Enquirer2 from "enquirer";
|
|
2130
2167
|
async function promptCommitMessage(promptMessage, nonInteractiveErrorMessage) {
|
|
@@ -2143,8 +2180,8 @@ async function promptCommitMessage(promptMessage, nonInteractiveErrorMessage) {
|
|
|
2143
2180
|
|
|
2144
2181
|
// src/utils/claude.ts
|
|
2145
2182
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
2146
|
-
import { existsSync as existsSync7, readdirSync as
|
|
2147
|
-
import { join as
|
|
2183
|
+
import { existsSync as existsSync7, readdirSync as readdirSync4 } from "fs";
|
|
2184
|
+
import { join as join7 } from "path";
|
|
2148
2185
|
|
|
2149
2186
|
// src/utils/terminal.ts
|
|
2150
2187
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
@@ -2288,11 +2325,11 @@ function encodeClaudeProjectPath(absolutePath) {
|
|
|
2288
2325
|
}
|
|
2289
2326
|
function hasClaudeSessionHistory(worktreePath) {
|
|
2290
2327
|
const encodedName = encodeClaudeProjectPath(worktreePath);
|
|
2291
|
-
const projectDir =
|
|
2328
|
+
const projectDir = join7(CLAUDE_PROJECTS_DIR, encodedName);
|
|
2292
2329
|
if (!existsSync7(projectDir)) {
|
|
2293
2330
|
return false;
|
|
2294
2331
|
}
|
|
2295
|
-
const entries =
|
|
2332
|
+
const entries = readdirSync4(projectDir);
|
|
2296
2333
|
return entries.some((entry) => entry.endsWith(".jsonl"));
|
|
2297
2334
|
}
|
|
2298
2335
|
function launchInteractiveClaude(worktree, options = {}) {
|
|
@@ -2300,9 +2337,7 @@ function launchInteractiveClaude(worktree, options = {}) {
|
|
|
2300
2337
|
const parts = commandStr.split(/\s+/).filter(Boolean);
|
|
2301
2338
|
const cmd = parts[0];
|
|
2302
2339
|
const args = [
|
|
2303
|
-
...parts.slice(1)
|
|
2304
|
-
"--append-system-prompt",
|
|
2305
|
-
APPEND_SYSTEM_PROMPT
|
|
2340
|
+
...parts.slice(1)
|
|
2306
2341
|
];
|
|
2307
2342
|
const hasPreviousSession = options.autoContinue === true && hasClaudeSessionHistory(worktree.path);
|
|
2308
2343
|
if (hasPreviousSession) {
|
|
@@ -2332,11 +2367,9 @@ function escapeShellSingleQuote(str) {
|
|
|
2332
2367
|
}
|
|
2333
2368
|
function buildClaudeCommand(worktree, hasPreviousSession) {
|
|
2334
2369
|
const commandStr = resolveClaudeCodeCommand();
|
|
2335
|
-
const systemPrompt = APPEND_SYSTEM_PROMPT;
|
|
2336
2370
|
const escapedPath = escapeShellSingleQuote(worktree.path);
|
|
2337
|
-
const escapedPrompt = escapeShellSingleQuote(systemPrompt);
|
|
2338
2371
|
const continueFlag = hasPreviousSession ? " --continue" : "";
|
|
2339
|
-
return `cd '${escapedPath}' && ${commandStr}
|
|
2372
|
+
return `cd '${escapedPath}' && ${commandStr}${continueFlag}`;
|
|
2340
2373
|
}
|
|
2341
2374
|
function launchInteractiveClaudeInNewTerminal(worktree, hasPreviousSession) {
|
|
2342
2375
|
const command = buildClaudeCommand(worktree, hasPreviousSession);
|
|
@@ -2346,16 +2379,16 @@ function launchInteractiveClaudeInNewTerminal(worktree, hasPreviousSession) {
|
|
|
2346
2379
|
}
|
|
2347
2380
|
|
|
2348
2381
|
// src/utils/validate-snapshot.ts
|
|
2349
|
-
import { join as
|
|
2350
|
-
import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync, readdirSync as
|
|
2382
|
+
import { join as join8 } from "path";
|
|
2383
|
+
import { existsSync as existsSync8, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2, readdirSync as readdirSync5, rmdirSync as rmdirSync2, statSync as statSync2 } from "fs";
|
|
2351
2384
|
function getSnapshotPath(projectName, branchName) {
|
|
2352
|
-
return
|
|
2385
|
+
return join8(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
|
|
2353
2386
|
}
|
|
2354
2387
|
function getSnapshotHeadPath(projectName, branchName) {
|
|
2355
|
-
return
|
|
2388
|
+
return join8(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
|
|
2356
2389
|
}
|
|
2357
2390
|
function getSnapshotStagedPath(projectName, branchName) {
|
|
2358
|
-
return
|
|
2391
|
+
return join8(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.staged`);
|
|
2359
2392
|
}
|
|
2360
2393
|
function hasSnapshot(projectName, branchName) {
|
|
2361
2394
|
return existsSync8(getSnapshotPath(projectName, branchName));
|
|
@@ -2377,7 +2410,7 @@ function readSnapshot(projectName, branchName) {
|
|
|
2377
2410
|
return { treeHash, headCommitHash, stagedTreeHash };
|
|
2378
2411
|
}
|
|
2379
2412
|
function writeSnapshot(projectName, branchName, treeHash, headCommitHash, stagedTreeHash) {
|
|
2380
|
-
const snapshotDir =
|
|
2413
|
+
const snapshotDir = join8(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
2381
2414
|
ensureDir(snapshotDir);
|
|
2382
2415
|
if (treeHash !== void 0) {
|
|
2383
2416
|
writeFileSync3(getSnapshotPath(projectName, branchName), treeHash, "utf-8");
|
|
@@ -2395,34 +2428,34 @@ function removeSnapshot(projectName, branchName) {
|
|
|
2395
2428
|
const headPath = getSnapshotHeadPath(projectName, branchName);
|
|
2396
2429
|
const stagedPath = getSnapshotStagedPath(projectName, branchName);
|
|
2397
2430
|
if (existsSync8(snapshotPath)) {
|
|
2398
|
-
|
|
2431
|
+
unlinkSync2(snapshotPath);
|
|
2399
2432
|
logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${snapshotPath}`);
|
|
2400
2433
|
}
|
|
2401
2434
|
if (existsSync8(headPath)) {
|
|
2402
|
-
|
|
2435
|
+
unlinkSync2(headPath);
|
|
2403
2436
|
logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${headPath}`);
|
|
2404
2437
|
}
|
|
2405
2438
|
if (existsSync8(stagedPath)) {
|
|
2406
|
-
|
|
2439
|
+
unlinkSync2(stagedPath);
|
|
2407
2440
|
logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${stagedPath}`);
|
|
2408
2441
|
}
|
|
2409
2442
|
}
|
|
2410
2443
|
function getProjectSnapshotBranches(projectName) {
|
|
2411
|
-
const projectDir =
|
|
2444
|
+
const projectDir = join8(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
2412
2445
|
if (!existsSync8(projectDir)) {
|
|
2413
2446
|
return [];
|
|
2414
2447
|
}
|
|
2415
|
-
const files =
|
|
2448
|
+
const files = readdirSync5(projectDir);
|
|
2416
2449
|
return files.filter((f) => f.endsWith(".tree")).map((f) => f.replace(/\.tree$/, ""));
|
|
2417
2450
|
}
|
|
2418
2451
|
function removeProjectSnapshots(projectName) {
|
|
2419
|
-
const projectDir =
|
|
2452
|
+
const projectDir = join8(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
2420
2453
|
if (!existsSync8(projectDir)) {
|
|
2421
2454
|
return;
|
|
2422
2455
|
}
|
|
2423
|
-
const files =
|
|
2456
|
+
const files = readdirSync5(projectDir);
|
|
2424
2457
|
for (const file of files) {
|
|
2425
|
-
|
|
2458
|
+
unlinkSync2(join8(projectDir, file));
|
|
2426
2459
|
}
|
|
2427
2460
|
try {
|
|
2428
2461
|
rmdirSync2(projectDir);
|
|
@@ -2949,7 +2982,7 @@ var ProgressRenderer = class {
|
|
|
2949
2982
|
};
|
|
2950
2983
|
|
|
2951
2984
|
// src/utils/task-file.ts
|
|
2952
|
-
import { resolve } from "path";
|
|
2985
|
+
import { resolve as resolve2 } from "path";
|
|
2953
2986
|
import { existsSync as existsSync9, readFileSync as readFileSync4 } from "fs";
|
|
2954
2987
|
var TASK_BLOCK_REGEX = /<!-- CLAWT-TASKS:START -->([\s\S]*?)<!-- CLAWT-TASKS:END -->/g;
|
|
2955
2988
|
var BRANCH_LINE_REGEX = /^#\s*branch:\s*(.+)$/;
|
|
@@ -2995,7 +3028,7 @@ function parseTaskFile(content, options) {
|
|
|
2995
3028
|
return entries;
|
|
2996
3029
|
}
|
|
2997
3030
|
function loadTaskFile(filePath, options) {
|
|
2998
|
-
const absolutePath =
|
|
3031
|
+
const absolutePath = resolve2(filePath);
|
|
2999
3032
|
if (!existsSync9(absolutePath)) {
|
|
3000
3033
|
throw new ClawtError(MESSAGES.TASK_FILE_NOT_FOUND(absolutePath));
|
|
3001
3034
|
}
|
|
@@ -3112,8 +3145,7 @@ function parseStreamEvent(event) {
|
|
|
3112
3145
|
|
|
3113
3146
|
// src/utils/task-executor.ts
|
|
3114
3147
|
function executeClaudeTask(worktree, task, onActivity, continueSession) {
|
|
3115
|
-
const
|
|
3116
|
-
const args = ["-p", task, "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions", "--append-system-prompt", systemPrompt];
|
|
3148
|
+
const args = ["-p", task, "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions"];
|
|
3117
3149
|
if (continueSession) {
|
|
3118
3150
|
args.push("--continue");
|
|
3119
3151
|
}
|
|
@@ -3129,7 +3161,7 @@ function executeClaudeTask(worktree, task, onActivity, continueSession) {
|
|
|
3129
3161
|
stdio: ["ignore", "pipe", "pipe"]
|
|
3130
3162
|
}
|
|
3131
3163
|
);
|
|
3132
|
-
const promise = new Promise((
|
|
3164
|
+
const promise = new Promise((resolve5) => {
|
|
3133
3165
|
let stderr = "";
|
|
3134
3166
|
let finalResult = null;
|
|
3135
3167
|
const lineBuffer = createLineBuffer();
|
|
@@ -3165,7 +3197,7 @@ function executeClaudeTask(worktree, task, onActivity, continueSession) {
|
|
|
3165
3197
|
if (finalResult) {
|
|
3166
3198
|
success = !finalResult.is_error;
|
|
3167
3199
|
}
|
|
3168
|
-
|
|
3200
|
+
resolve5({
|
|
3169
3201
|
task,
|
|
3170
3202
|
branch: worktree.branch,
|
|
3171
3203
|
worktreePath: worktree.path,
|
|
@@ -3175,7 +3207,7 @@ function executeClaudeTask(worktree, task, onActivity, continueSession) {
|
|
|
3175
3207
|
});
|
|
3176
3208
|
});
|
|
3177
3209
|
child.on("error", (err) => {
|
|
3178
|
-
|
|
3210
|
+
resolve5({
|
|
3179
3211
|
task,
|
|
3180
3212
|
branch: worktree.branch,
|
|
3181
3213
|
worktreePath: worktree.path,
|
|
@@ -3234,7 +3266,7 @@ async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, s
|
|
|
3234
3266
|
const results = new Array(total);
|
|
3235
3267
|
let nextIndex = 0;
|
|
3236
3268
|
let completedCount = 0;
|
|
3237
|
-
return new Promise((
|
|
3269
|
+
return new Promise((resolve5) => {
|
|
3238
3270
|
function launchNext() {
|
|
3239
3271
|
if (nextIndex >= total || isInterrupted()) return;
|
|
3240
3272
|
const index = nextIndex;
|
|
@@ -3255,7 +3287,7 @@ async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, s
|
|
|
3255
3287
|
}
|
|
3256
3288
|
launchNext();
|
|
3257
3289
|
if (completedCount === total) {
|
|
3258
|
-
|
|
3290
|
+
resolve5(results);
|
|
3259
3291
|
}
|
|
3260
3292
|
});
|
|
3261
3293
|
}
|
|
@@ -3315,11 +3347,11 @@ async function executeBatchTasks(worktrees, tasks, concurrency, continueFlags) {
|
|
|
3315
3347
|
printWarning(MESSAGES.INTERRUPTED);
|
|
3316
3348
|
killAllChildProcesses(childProcesses);
|
|
3317
3349
|
await Promise.allSettled(childProcesses.map(
|
|
3318
|
-
(cp) => new Promise((
|
|
3350
|
+
(cp) => new Promise((resolve5) => {
|
|
3319
3351
|
if (cp.exitCode !== null) {
|
|
3320
|
-
|
|
3352
|
+
resolve5();
|
|
3321
3353
|
} else {
|
|
3322
|
-
cp.on("close", () =>
|
|
3354
|
+
cp.on("close", () => resolve5());
|
|
3323
3355
|
}
|
|
3324
3356
|
})
|
|
3325
3357
|
));
|
|
@@ -3345,7 +3377,7 @@ async function executeBatchTasks(worktrees, tasks, concurrency, continueFlags) {
|
|
|
3345
3377
|
|
|
3346
3378
|
// src/utils/dry-run.ts
|
|
3347
3379
|
import chalk6 from "chalk";
|
|
3348
|
-
import { join as
|
|
3380
|
+
import { join as join9 } from "path";
|
|
3349
3381
|
var DRY_RUN_TASK_DESC_MAX_LENGTH = 80;
|
|
3350
3382
|
function truncateTaskDesc(task) {
|
|
3351
3383
|
const oneLine = task.replace(/\n/g, " ").trim();
|
|
@@ -3373,7 +3405,7 @@ function printDryRunPreview(branchNames, tasks, concurrency) {
|
|
|
3373
3405
|
let hasConflict = false;
|
|
3374
3406
|
for (let i = 0; i < branchNames.length; i++) {
|
|
3375
3407
|
const branch = branchNames[i];
|
|
3376
|
-
const worktreePath =
|
|
3408
|
+
const worktreePath = join9(projectDir, branch);
|
|
3377
3409
|
const exists = checkBranchExists(branch);
|
|
3378
3410
|
if (exists) hasConflict = true;
|
|
3379
3411
|
const indexLabel = `[${i + 1}/${branchNames.length}]`;
|
|
@@ -3565,7 +3597,7 @@ function isNewerVersion(latest, current) {
|
|
|
3565
3597
|
return false;
|
|
3566
3598
|
}
|
|
3567
3599
|
function fetchLatestVersion() {
|
|
3568
|
-
return new Promise((
|
|
3600
|
+
return new Promise((resolve5) => {
|
|
3569
3601
|
const req = request(NPM_REGISTRY_URL, { timeout: NPM_REGISTRY_TIMEOUT_MS }, (res) => {
|
|
3570
3602
|
let data = "";
|
|
3571
3603
|
res.on("data", (chunk) => {
|
|
@@ -3574,16 +3606,16 @@ function fetchLatestVersion() {
|
|
|
3574
3606
|
res.on("end", () => {
|
|
3575
3607
|
try {
|
|
3576
3608
|
const parsed = JSON.parse(data);
|
|
3577
|
-
|
|
3609
|
+
resolve5(parsed.version ?? null);
|
|
3578
3610
|
} catch {
|
|
3579
|
-
|
|
3611
|
+
resolve5(null);
|
|
3580
3612
|
}
|
|
3581
3613
|
});
|
|
3582
3614
|
});
|
|
3583
|
-
req.on("error", () =>
|
|
3615
|
+
req.on("error", () => resolve5(null));
|
|
3584
3616
|
req.on("timeout", () => {
|
|
3585
3617
|
req.destroy();
|
|
3586
|
-
|
|
3618
|
+
resolve5(null);
|
|
3587
3619
|
});
|
|
3588
3620
|
req.end();
|
|
3589
3621
|
});
|
|
@@ -4236,8 +4268,8 @@ var InteractivePanel = class {
|
|
|
4236
4268
|
return;
|
|
4237
4269
|
}
|
|
4238
4270
|
this.stateManager.updateData(await this.collectStatusFn());
|
|
4239
|
-
return new Promise((
|
|
4240
|
-
this.resolveStart =
|
|
4271
|
+
return new Promise((resolve5) => {
|
|
4272
|
+
this.resolveStart = resolve5;
|
|
4241
4273
|
this.initTerminal();
|
|
4242
4274
|
this.keyboardController.start();
|
|
4243
4275
|
this.startAutoRefresh();
|
|
@@ -4514,14 +4546,14 @@ var InteractivePanel = class {
|
|
|
4514
4546
|
* @returns {Promise<void>} 用户按回车时 resolve
|
|
4515
4547
|
*/
|
|
4516
4548
|
waitForEnter() {
|
|
4517
|
-
return new Promise((
|
|
4549
|
+
return new Promise((resolve5) => {
|
|
4518
4550
|
const rl = createInterface2({
|
|
4519
4551
|
input: process.stdin,
|
|
4520
4552
|
output: process.stdout
|
|
4521
4553
|
});
|
|
4522
4554
|
rl.once("line", () => {
|
|
4523
4555
|
rl.close();
|
|
4524
|
-
|
|
4556
|
+
resolve5();
|
|
4525
4557
|
});
|
|
4526
4558
|
});
|
|
4527
4559
|
}
|
|
@@ -4614,7 +4646,7 @@ async function handleMergeConflict(currentBranch, incomingBranch, cwd, autoFlag)
|
|
|
4614
4646
|
// src/hooks/post-create.ts
|
|
4615
4647
|
import { existsSync as existsSync10, accessSync, chmodSync, constants as fsConstants } from "fs";
|
|
4616
4648
|
import { spawn as spawn2 } from "child_process";
|
|
4617
|
-
import { join as
|
|
4649
|
+
import { join as join10 } from "path";
|
|
4618
4650
|
var POST_CREATE_SCRIPT_RELATIVE_PATH = ".clawt/postCreate.sh";
|
|
4619
4651
|
function isExecutable(filePath) {
|
|
4620
4652
|
try {
|
|
@@ -4646,7 +4678,7 @@ function resolvePostCreateHook() {
|
|
|
4646
4678
|
}
|
|
4647
4679
|
}
|
|
4648
4680
|
const mainWorktreePath = getMainWorktreePath();
|
|
4649
|
-
const scriptPath =
|
|
4681
|
+
const scriptPath = join10(mainWorktreePath, POST_CREATE_SCRIPT_RELATIVE_PATH);
|
|
4650
4682
|
if (existsSync10(scriptPath)) {
|
|
4651
4683
|
if (!isExecutable(scriptPath)) {
|
|
4652
4684
|
autoFixExecutablePermission(scriptPath);
|
|
@@ -4659,7 +4691,7 @@ function getSourceLabel(hook) {
|
|
|
4659
4691
|
return hook.source === "projectConfig" ? "\u9879\u76EE\u914D\u7F6E (postCreate)" : ".clawt/postCreate.sh";
|
|
4660
4692
|
}
|
|
4661
4693
|
function executeOneHook(worktree, hook) {
|
|
4662
|
-
return new Promise((
|
|
4694
|
+
return new Promise((resolve5) => {
|
|
4663
4695
|
const result = {
|
|
4664
4696
|
worktreePath: worktree.path,
|
|
4665
4697
|
branch: worktree.branch,
|
|
@@ -4676,7 +4708,7 @@ function executeOneHook(worktree, hook) {
|
|
|
4676
4708
|
result.success = false;
|
|
4677
4709
|
result.error = err.message;
|
|
4678
4710
|
logger.error(`postCreate hook \u5F02\u5E38: ${hook.command} @ ${worktree.path}: ${result.error}`);
|
|
4679
|
-
|
|
4711
|
+
resolve5(result);
|
|
4680
4712
|
});
|
|
4681
4713
|
child.on("close", (code) => {
|
|
4682
4714
|
if (code !== null && code !== 0) {
|
|
@@ -4686,13 +4718,13 @@ function executeOneHook(worktree, hook) {
|
|
|
4686
4718
|
} else {
|
|
4687
4719
|
logger.info(`postCreate hook \u6210\u529F: ${hook.command} @ ${worktree.path}`);
|
|
4688
4720
|
}
|
|
4689
|
-
|
|
4721
|
+
resolve5(result);
|
|
4690
4722
|
});
|
|
4691
4723
|
} catch (err) {
|
|
4692
4724
|
result.success = false;
|
|
4693
4725
|
result.error = err instanceof Error ? err.message : String(err);
|
|
4694
4726
|
logger.error(`postCreate hook \u542F\u52A8\u5931\u8D25: ${hook.command} @ ${worktree.path}: ${result.error}`);
|
|
4695
|
-
|
|
4727
|
+
resolve5(result);
|
|
4696
4728
|
}
|
|
4697
4729
|
});
|
|
4698
4730
|
}
|
|
@@ -5255,6 +5287,10 @@ async function handleValidate(options) {
|
|
|
5255
5287
|
const branchName = worktree.branch;
|
|
5256
5288
|
const targetWorktreePath = worktree.path;
|
|
5257
5289
|
logger.info(`validate \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${branchName}`);
|
|
5290
|
+
const removedSymlinks = removeExternalSymlinks(targetWorktreePath);
|
|
5291
|
+
if (removedSymlinks.length > 0) {
|
|
5292
|
+
printWarning(MESSAGES.VALIDATE_EXTERNAL_SYMLINKS_FOUND(removedSymlinks.length));
|
|
5293
|
+
}
|
|
5258
5294
|
const hasUncommitted = !isWorkingDirClean(targetWorktreePath);
|
|
5259
5295
|
const hasCommitted = hasLocalCommits(branchName, mainWorktreePath);
|
|
5260
5296
|
if (!hasUncommitted && !hasCommitted) {
|
|
@@ -5888,8 +5924,8 @@ function registerAliasCommand(program2) {
|
|
|
5888
5924
|
}
|
|
5889
5925
|
|
|
5890
5926
|
// src/commands/projects.ts
|
|
5891
|
-
import { existsSync as existsSync11, readdirSync as
|
|
5892
|
-
import { join as
|
|
5927
|
+
import { existsSync as existsSync11, readdirSync as readdirSync6, statSync as statSync4 } from "fs";
|
|
5928
|
+
import { join as join11 } from "path";
|
|
5893
5929
|
import chalk13 from "chalk";
|
|
5894
5930
|
function registerProjectsCommand(program2) {
|
|
5895
5931
|
program2.command("projects [name]").description("\u5C55\u793A\u6240\u6709\u9879\u76EE\u7684 worktree \u6982\u89C8\uFF0C\u6216\u67E5\u770B\u6307\u5B9A\u9879\u76EE\u7684 worktree \u8BE6\u60C5").option("--json", "\u4EE5 JSON \u683C\u5F0F\u8F93\u51FA").action((name, options) => {
|
|
@@ -5913,7 +5949,7 @@ function handleProjectsOverview(json) {
|
|
|
5913
5949
|
printProjectsOverviewAsText(result);
|
|
5914
5950
|
}
|
|
5915
5951
|
function handleProjectDetail(name, json) {
|
|
5916
|
-
const projectDir =
|
|
5952
|
+
const projectDir = join11(WORKTREES_DIR, name);
|
|
5917
5953
|
if (!existsSync11(projectDir)) {
|
|
5918
5954
|
printError(MESSAGES.PROJECTS_NOT_FOUND(name));
|
|
5919
5955
|
process.exit(1);
|
|
@@ -5930,13 +5966,13 @@ function collectProjectsOverview() {
|
|
|
5930
5966
|
if (!existsSync11(WORKTREES_DIR)) {
|
|
5931
5967
|
return { projects: [], totalProjects: 0, totalDiskUsage: 0 };
|
|
5932
5968
|
}
|
|
5933
|
-
const entries =
|
|
5969
|
+
const entries = readdirSync6(WORKTREES_DIR, { withFileTypes: true });
|
|
5934
5970
|
const projects = [];
|
|
5935
5971
|
for (const entry of entries) {
|
|
5936
5972
|
if (!entry.isDirectory()) {
|
|
5937
5973
|
continue;
|
|
5938
5974
|
}
|
|
5939
|
-
const projectDir =
|
|
5975
|
+
const projectDir = join11(WORKTREES_DIR, entry.name);
|
|
5940
5976
|
const overview = collectSingleProjectOverview(entry.name, projectDir);
|
|
5941
5977
|
projects.push(overview);
|
|
5942
5978
|
}
|
|
@@ -5949,11 +5985,11 @@ function collectProjectsOverview() {
|
|
|
5949
5985
|
};
|
|
5950
5986
|
}
|
|
5951
5987
|
function collectSingleProjectOverview(name, projectDir) {
|
|
5952
|
-
const subEntries =
|
|
5988
|
+
const subEntries = readdirSync6(projectDir, { withFileTypes: true });
|
|
5953
5989
|
const worktreeDirs = subEntries.filter((e) => e.isDirectory());
|
|
5954
5990
|
const worktreeCount = worktreeDirs.length;
|
|
5955
5991
|
const diskUsage = calculateDirSize(projectDir);
|
|
5956
|
-
const lastActiveTime = resolveProjectLastActiveTime(projectDir, worktreeDirs.map((e) =>
|
|
5992
|
+
const lastActiveTime = resolveProjectLastActiveTime(projectDir, worktreeDirs.map((e) => join11(projectDir, e.name)));
|
|
5957
5993
|
return {
|
|
5958
5994
|
name,
|
|
5959
5995
|
worktreeCount,
|
|
@@ -5962,13 +5998,13 @@ function collectSingleProjectOverview(name, projectDir) {
|
|
|
5962
5998
|
};
|
|
5963
5999
|
}
|
|
5964
6000
|
function collectProjectDetail(name, projectDir) {
|
|
5965
|
-
const subEntries =
|
|
6001
|
+
const subEntries = readdirSync6(projectDir, { withFileTypes: true });
|
|
5966
6002
|
const worktrees = [];
|
|
5967
6003
|
for (const entry of subEntries) {
|
|
5968
6004
|
if (!entry.isDirectory()) {
|
|
5969
6005
|
continue;
|
|
5970
6006
|
}
|
|
5971
|
-
const wtPath =
|
|
6007
|
+
const wtPath = join11(projectDir, entry.name);
|
|
5972
6008
|
const detail = collectSingleWorktreeDetail(entry.name, wtPath);
|
|
5973
6009
|
worktrees.push(detail);
|
|
5974
6010
|
}
|
|
@@ -6068,7 +6104,7 @@ function printWorktreeDetailItem(wt) {
|
|
|
6068
6104
|
|
|
6069
6105
|
// src/commands/completion.ts
|
|
6070
6106
|
import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync13 } from "fs";
|
|
6071
|
-
import { resolve as
|
|
6107
|
+
import { resolve as resolve3 } from "path";
|
|
6072
6108
|
import { homedir as homedir2 } from "os";
|
|
6073
6109
|
|
|
6074
6110
|
// src/utils/completion-scripts.ts
|
|
@@ -6118,23 +6154,23 @@ compdef _clawt_completion clawt
|
|
|
6118
6154
|
}
|
|
6119
6155
|
|
|
6120
6156
|
// src/utils/completion-engine.ts
|
|
6121
|
-
import { existsSync as existsSync12, readdirSync as
|
|
6122
|
-
import { join as
|
|
6157
|
+
import { existsSync as existsSync12, readdirSync as readdirSync7, statSync as statSync5 } from "fs";
|
|
6158
|
+
import { join as join12, dirname, basename as basename2 } from "path";
|
|
6123
6159
|
function completeFilePath(partial) {
|
|
6124
6160
|
const cwd = process.cwd();
|
|
6125
6161
|
const hasDir = partial.includes("/");
|
|
6126
|
-
const searchDir = hasDir ?
|
|
6162
|
+
const searchDir = hasDir ? join12(cwd, dirname(partial)) : cwd;
|
|
6127
6163
|
const prefix = hasDir ? basename2(partial) : partial;
|
|
6128
6164
|
if (!existsSync12(searchDir)) {
|
|
6129
6165
|
return [];
|
|
6130
6166
|
}
|
|
6131
|
-
const entries =
|
|
6167
|
+
const entries = readdirSync7(searchDir);
|
|
6132
6168
|
const results = [];
|
|
6133
6169
|
const dirPrefix = hasDir ? dirname(partial) + "/" : "";
|
|
6134
6170
|
for (const entry of entries) {
|
|
6135
6171
|
if (!entry.startsWith(prefix)) continue;
|
|
6136
6172
|
if (entry.startsWith(".")) continue;
|
|
6137
|
-
const fullPath =
|
|
6173
|
+
const fullPath = join12(searchDir, entry);
|
|
6138
6174
|
try {
|
|
6139
6175
|
const stat = statSync5(fullPath);
|
|
6140
6176
|
if (stat.isDirectory()) {
|
|
@@ -6240,14 +6276,14 @@ function installCompletions() {
|
|
|
6240
6276
|
const home = homedir2();
|
|
6241
6277
|
try {
|
|
6242
6278
|
if (shell.includes("zsh")) {
|
|
6243
|
-
const rcPath =
|
|
6279
|
+
const rcPath = resolve3(home, ".zshrc");
|
|
6244
6280
|
const script = `
|
|
6245
6281
|
# clawt completion
|
|
6246
6282
|
source <(clawt completion zsh)
|
|
6247
6283
|
`;
|
|
6248
6284
|
appendToFile(rcPath, script);
|
|
6249
6285
|
} else if (shell.includes("bash")) {
|
|
6250
|
-
const rcPath =
|
|
6286
|
+
const rcPath = resolve3(home, ".bashrc");
|
|
6251
6287
|
const script = `
|
|
6252
6288
|
# clawt completion
|
|
6253
6289
|
eval "$(clawt completion bash)"
|
|
@@ -6257,7 +6293,7 @@ eval "$(clawt completion bash)"
|
|
|
6257
6293
|
printWarning(MESSAGES.COMPLETION_INSTALL_UNKNOWN_SHELL);
|
|
6258
6294
|
}
|
|
6259
6295
|
} catch (error) {
|
|
6260
|
-
const filePath = shell.includes("zsh") ?
|
|
6296
|
+
const filePath = shell.includes("zsh") ? resolve3(home, ".zshrc") : resolve3(home, ".bashrc");
|
|
6261
6297
|
printError(MESSAGES.COMPLETION_INSTALL_WRITE_ERROR(filePath));
|
|
6262
6298
|
}
|
|
6263
6299
|
}
|
|
@@ -6359,17 +6395,17 @@ async function handleHome() {
|
|
|
6359
6395
|
}
|
|
6360
6396
|
|
|
6361
6397
|
// src/commands/tasks.ts
|
|
6362
|
-
import { resolve as
|
|
6398
|
+
import { resolve as resolve4, dirname as dirname2, join as join13 } from "path";
|
|
6363
6399
|
import { existsSync as existsSync14, writeFileSync as writeFileSync6 } from "fs";
|
|
6364
6400
|
function registerTasksCommand(program2) {
|
|
6365
6401
|
const taskCmd = program2.command("tasks").description("\u4EFB\u52A1\u6587\u4EF6\u7BA1\u7406");
|
|
6366
6402
|
taskCmd.command("init").description("\u751F\u6210\u4EFB\u52A1\u6A21\u677F\u6587\u4EF6").argument("[path]", "\u8F93\u51FA\u6587\u4EF6\u8DEF\u5F84").action(async (path2) => {
|
|
6367
|
-
const filePath = path2 ??
|
|
6403
|
+
const filePath = path2 ?? join13(TASK_TEMPLATE_OUTPUT_DIR, generateTaskFilename(TASK_TEMPLATE_FILENAME_PREFIX));
|
|
6368
6404
|
await handleTasksInit(filePath);
|
|
6369
6405
|
});
|
|
6370
6406
|
}
|
|
6371
6407
|
async function handleTasksInit(filePath) {
|
|
6372
|
-
const absolutePath =
|
|
6408
|
+
const absolutePath = resolve4(filePath);
|
|
6373
6409
|
logger.info(`tasks init \u547D\u4EE4\u6267\u884C\uFF0C\u76EE\u6807\u6587\u4EF6: ${absolutePath}`);
|
|
6374
6410
|
if (existsSync14(absolutePath)) {
|
|
6375
6411
|
throw new ClawtError(MESSAGES.TASK_INIT_FILE_EXISTS(filePath));
|
package/dist/postinstall.js
CHANGED
|
@@ -260,6 +260,8 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
|
|
|
260
260
|
VALIDATE_RUN_ERROR_COPIED: "\u2702 \u9519\u8BEF\u4FE1\u606F\u5DF2\u590D\u5236\u5230\u526A\u8D34\u677F",
|
|
261
261
|
/** 剪贴板复制失败提示 */
|
|
262
262
|
VALIDATE_RUN_ERROR_COPY_FAILED: "\u26A0 \u9519\u8BEF\u4FE1\u606F\u590D\u5236\u5230\u526A\u8D34\u677F\u5931\u8D25",
|
|
263
|
+
/** 检测到外部软链接警告 */
|
|
264
|
+
VALIDATE_EXTERNAL_SYMLINKS_FOUND: (count) => `\u26A0 \u68C0\u6D4B\u5230 ${count} \u4E2A\u6307\u5411 worktree \u5916\u90E8\u7684\u8F6F\u94FE\u63A5\uFF08\u53EF\u80FD\u7531 AI Agent \u521B\u5EFA\uFF09\uFF0C\u5DF2\u81EA\u52A8\u79FB\u9664`,
|
|
263
265
|
/** 单命令(含 && 链)剪贴板错误格式 */
|
|
264
266
|
VALIDATE_CLIPBOARD_SINGLE_ERROR: (command, stderr) => `${command} \u6307\u4EE4\u6267\u884C\u51FA\u9519\uFF0C\u9519\u8BEF\u4FE1\u606F\uFF1A
|
|
265
267
|
${stderr}`,
|
package/docs/post-create-hook.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
postCreate hook 是在 worktree 创建完成后自动执行的钩子命令,可用于执行任意初始化操作(如安装依赖、生成配置文件、编译资源等)。`create` 和 `run` 命令在创建 worktree 之后,会尝试解析并执行 postCreate hook。
|
|
6
6
|
|
|
7
|
-
hook 以 **fire-and-forget** 模式后台异步并行执行,不阻塞主流程(不 await)。执行结果仅写入日志,不影响后续 Claude Code
|
|
7
|
+
hook 以 **fire-and-forget** 模式后台异步并行执行,不阻塞主流程(不 await)。执行结果仅写入日志,不影响后续 Claude Code 的启动。
|
|
8
8
|
|
|
9
9
|
#### 配置方式
|
|
10
10
|
|
|
@@ -100,10 +100,6 @@ clawt run -b feat --no-post-create
|
|
|
100
100
|
- **结果汇总**:后台执行完毕后通过 `.then()` 回调写入日志汇总(成功数 + 失败数)
|
|
101
101
|
- **返回值**:`runPostCreateHooks()` 返回 `void`——以 fire-and-forget 模式后台执行,不等待结果
|
|
102
102
|
|
|
103
|
-
#### 系统提示
|
|
104
|
-
|
|
105
|
-
Claude Code 启动时统一使用 `APPEND_SYSTEM_PROMPT` 常量(定义在 `src/constants/config.ts`)作为 `--append-system-prompt` 参数值,内容为通用的 worktree 目录提示,不因 hook 执行结果而变化。
|
|
106
|
-
|
|
107
103
|
#### 相关类型定义
|
|
108
104
|
|
|
109
105
|
类型定义位于 `src/types/postCreateHook.ts`:
|
package/docs/run.md
CHANGED
|
@@ -86,9 +86,8 @@ clawt run -b <branchName>
|
|
|
86
86
|
5. 通过公共函数 `executeBatchTasks`(`src/utils/task-executor.ts`)启动批量任务执行,该函数负责进度面板渲染、SIGINT 中断处理、并发控制和汇总输出。对每个 worktree 并行启动 Claude Code CLI:
|
|
87
87
|
```bash
|
|
88
88
|
cd ~/.clawt/worktrees/<project>/<branchName>-<i>
|
|
89
|
-
claude -p "<tasks[i]>" --output-format stream-json --verbose --permission-mode bypassPermissions
|
|
89
|
+
claude -p "<tasks[i]>" --output-format stream-json --verbose --permission-mode bypassPermissions
|
|
90
90
|
```
|
|
91
|
-
其中 `--append-system-prompt` 使用统一的 `APPEND_SYSTEM_PROMPT` 常量(定义在 `src/constants/config.ts`)。
|
|
92
91
|
子进程通过 `spawnProcess()`(`src/utils/shell.ts`)启动,会自动注入环境变量 `CLAUDE_CODE_ENTRYPOINT="cli"`(通过 `getEnvWithoutNestedSessionFlag()` 函数),使会话支持通过 `--continue` 恢复。
|
|
93
92
|
使用 `stream-json` 格式可实时获取 Claude Code 的流式事件(工具调用、文本输出、最终结果),用于在进度面板中显示每个任务的实时活动描述和结果预览。流式事件解析由 `src/utils/stream-parser.ts` 负责。
|
|
94
93
|
6. 进入**事件监听通知**阶段(见 [5.3](#53-任务完成通知机制))
|
package/docs/validate.md
CHANGED
|
@@ -66,6 +66,21 @@ validate 命令引入了**快照(snapshot)机制**来支持增量对比。
|
|
|
66
66
|
- 多个匹配 → 通过交互式列表让用户从匹配结果中选择
|
|
67
67
|
3. **无匹配** → 报错退出,并列出所有可用分支名
|
|
68
68
|
|
|
69
|
+
##### 步骤 0.5:清理外部软链接
|
|
70
|
+
|
|
71
|
+
在变更检测之前,自动扫描目标 worktree 根目录,移除指向 worktree 外部路径的软链接。
|
|
72
|
+
|
|
73
|
+
**背景:** AI Agent(如 Claude Code)在 worktree 中执行任务时,可能会通过软链接(如 `node_modules → /path/to/main-worktree/node_modules`)引用主 worktree 或其他外部路径的依赖目录。这些指向外部的软链接会导致 `git diff` 和 `git apply` 的行为异常,进而使 patch apply 失败。validate 命令在变更检测前自动清理这些软链接,确保后续 patch 流程正常执行。
|
|
74
|
+
|
|
75
|
+
**实现要点:**
|
|
76
|
+
|
|
77
|
+
- `removeExternalSymlinks(dir)`(`src/utils/symlink-guard.ts`):扫描目录中所有软链接,判断其目标路径是否在 worktree 根目录之外(通过 `path.relative` 判断是否以 `..` 开头),移除外部软链接并返回被移除的路径列表
|
|
78
|
+
- 内部辅助函数 `isExternalSymlink(linkPath, worktreeRoot)` 判断软链接目标是否指向外部:先解析软链接目标(绝对路径直接使用,相对路径基于 worktreeRoot 解析),再通过 `relative` 判断目标是否在 worktree 之外
|
|
79
|
+
- 安全措施:删除前通过 `lstatSync` 再次确认目标仍是软链接(而非已被替换的普通文件),缩小 TOCTOU 口
|
|
80
|
+
- 不可读目录或删除失败时静默处理(仅输出 warn 日志),不中断 validate 流程
|
|
81
|
+
- 如果移除了外部软链接,输出警告提示:`⚠ 检测到 N 个指向 worktree 外部的软链接(可能由 AI Agent 创建),已自动移除`
|
|
82
|
+
- 消息常量:`MESSAGES.VALIDATE_EXTERNAL_SYMLINKS_FOUND`(`src/constants/messages/validate.ts`)
|
|
83
|
+
|
|
69
84
|
##### 步骤 1:检测目标分支变更
|
|
70
85
|
|
|
71
86
|
统一检测目标 worktree 的未提交修改和已提交 commit:
|
package/package.json
CHANGED
package/src/commands/validate.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
saveCurrentSnapshotTree,
|
|
35
35
|
loadOldSnapshotToStage,
|
|
36
36
|
switchToValidateBranch,
|
|
37
|
+
removeExternalSymlinks,
|
|
37
38
|
} from '../utils/index.js';
|
|
38
39
|
import type { WorktreeResolveMessages } from '../utils/index.js';
|
|
39
40
|
|
|
@@ -274,6 +275,12 @@ async function handleValidate(options: ValidateOptions): Promise<void> {
|
|
|
274
275
|
|
|
275
276
|
logger.info(`validate 命令执行,分支: ${branchName}`);
|
|
276
277
|
|
|
278
|
+
// 通常由 AI Agent 创建的指向外部的软链接会导致 patch apply 失败
|
|
279
|
+
const removedSymlinks = removeExternalSymlinks(targetWorktreePath);
|
|
280
|
+
if (removedSymlinks.length > 0) {
|
|
281
|
+
printWarning(MESSAGES.VALIDATE_EXTERNAL_SYMLINKS_FOUND(removedSymlinks.length));
|
|
282
|
+
}
|
|
283
|
+
|
|
277
284
|
// 统一检测未提交修改 + 已提交 commit
|
|
278
285
|
const hasUncommitted = !isWorkingDirClean(targetWorktreePath);
|
|
279
286
|
const hasCommitted = hasLocalCommits(branchName, mainWorktreePath);
|
package/src/constants/config.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import type { ClawtConfig, ConfigDefinitions } from '../types/index.js';
|
|
2
2
|
import { VALID_TERMINAL_APPS } from './terminal.js';
|
|
3
3
|
|
|
4
|
-
/** Claude Code 系统约束提示 */
|
|
5
|
-
export const APPEND_SYSTEM_PROMPT =
|
|
6
|
-
'Currently, you are in the git worktree directory.';
|
|
7
|
-
|
|
8
4
|
/**
|
|
9
5
|
* 通过 clawt 启动的 Claude Code 非交互式会话(claude -p)的 entrypoint 标识
|
|
10
6
|
* 设置为 'cli' 使 claude -p 启动的会话可以通过 --continue 恢复
|
package/src/constants/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { CONFIG_ALIAS_DISABLED_HINT } from './messages/index.js';
|
|
|
5
5
|
export { UPDATE_MESSAGES, UPDATE_COMMANDS } from './messages/update.js';
|
|
6
6
|
export { EXIT_CODES } from './exitCodes.js';
|
|
7
7
|
export { ENABLE_BRACKETED_PASTE, DISABLE_BRACKETED_PASTE, PASTE_THRESHOLD_MS, VALID_TERMINAL_APPS, ITERM2_APP_PATH } from './terminal.js';
|
|
8
|
-
export { DEFAULT_CONFIG, CONFIG_DESCRIPTIONS, CONFIG_DEFINITIONS,
|
|
8
|
+
export { DEFAULT_CONFIG, CONFIG_DESCRIPTIONS, CONFIG_DEFINITIONS, CLAUDE_CODE_ENTRYPOINT_VALUE } from './config.js';
|
|
9
9
|
export { PROJECT_CONFIG_DEFINITIONS, PROJECT_DEFAULT_CONFIG, PROJECT_CONFIG_DESCRIPTIONS } from './project-config.js';
|
|
10
10
|
export { AUTO_SAVE_COMMIT_MESSAGE_PREFIX } from './git.js';
|
|
11
11
|
export { DEBUG_LOG_PREFIX, DEBUG_TIMESTAMP_FORMAT } from './logger.js';
|
|
@@ -75,6 +75,9 @@ export const VALIDATE_MESSAGES = {
|
|
|
75
75
|
VALIDATE_RUN_ERROR_COPIED: '✂ 错误信息已复制到剪贴板',
|
|
76
76
|
/** 剪贴板复制失败提示 */
|
|
77
77
|
VALIDATE_RUN_ERROR_COPY_FAILED: '⚠ 错误信息复制到剪贴板失败',
|
|
78
|
+
/** 检测到外部软链接警告 */
|
|
79
|
+
VALIDATE_EXTERNAL_SYMLINKS_FOUND: (count: number) =>
|
|
80
|
+
`⚠ 检测到 ${count} 个指向 worktree 外部的软链接(可能由 AI Agent 创建),已自动移除`,
|
|
78
81
|
/** 单命令(含 && 链)剪贴板错误格式 */
|
|
79
82
|
VALIDATE_CLIPBOARD_SINGLE_ERROR: (command: string, stderr: string) =>
|
|
80
83
|
`${command} 指令执行出错,错误信息:\n${stderr}`,
|
package/src/utils/claude.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { ClawtError } from '../errors/index.js';
|
|
5
|
-
import {
|
|
5
|
+
import { CLAUDE_PROJECTS_DIR } from '../constants/index.js';
|
|
6
6
|
import { resolveClaudeCodeCommand } from './project-config.js';
|
|
7
7
|
import { printInfo, printWarning } from './formatter.js';
|
|
8
8
|
import { openCommandInNewTerminalTab } from './terminal.js';
|
|
@@ -56,8 +56,6 @@ export function launchInteractiveClaude(worktree: WorktreeInfo, options: LaunchC
|
|
|
56
56
|
const cmd = parts[0];
|
|
57
57
|
const args = [
|
|
58
58
|
...parts.slice(1),
|
|
59
|
-
'--append-system-prompt',
|
|
60
|
-
APPEND_SYSTEM_PROMPT,
|
|
61
59
|
];
|
|
62
60
|
|
|
63
61
|
// 仅在启用 autoContinue 时检测历史会话并追加 --continue
|
|
@@ -101,20 +99,18 @@ function escapeShellSingleQuote(str: string): string {
|
|
|
101
99
|
|
|
102
100
|
/**
|
|
103
101
|
* 构建在指定 worktree 中启动 Claude Code 的完整 shell 命令
|
|
104
|
-
* 生成格式:cd <path> && <claudeCommand>
|
|
102
|
+
* 生成格式:cd <path> && <claudeCommand> [--continue]
|
|
105
103
|
* @param {WorktreeInfo} worktree - worktree 信息
|
|
106
104
|
* @param {boolean} hasPreviousSession - 是否存在历史会话(由调用方预计算,避免重复 I/O)
|
|
107
105
|
* @returns {string} 完整的 shell 命令字符串
|
|
108
106
|
*/
|
|
109
107
|
export function buildClaudeCommand(worktree: WorktreeInfo, hasPreviousSession: boolean): string {
|
|
110
108
|
const commandStr = resolveClaudeCodeCommand();
|
|
111
|
-
const systemPrompt = APPEND_SYSTEM_PROMPT;
|
|
112
109
|
|
|
113
110
|
const escapedPath = escapeShellSingleQuote(worktree.path);
|
|
114
|
-
const escapedPrompt = escapeShellSingleQuote(systemPrompt);
|
|
115
111
|
const continueFlag = hasPreviousSession ? ' --continue' : '';
|
|
116
112
|
|
|
117
|
-
return `cd '${escapedPath}' && ${commandStr}
|
|
113
|
+
return `cd '${escapedPath}' && ${commandStr}${continueFlag}`;
|
|
118
114
|
}
|
|
119
115
|
|
|
120
116
|
/**
|
package/src/utils/index.ts
CHANGED
|
@@ -71,6 +71,7 @@ export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWor
|
|
|
71
71
|
export { loadConfig, writeDefaultConfig, writeConfig, saveConfig, getConfigValue, ensureClawtDirs, parseConcurrency } from './config.js';
|
|
72
72
|
export { printSuccess, printError, printWarning, printInfo, printHint, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration, formatRelativeTime, formatDiskSize, formatLocalISOString, generateTaskFilename } from './formatter.js';
|
|
73
73
|
export { ensureDir, removeEmptyDir, calculateDirSize } from './fs.js';
|
|
74
|
+
export { removeExternalSymlinks } from './symlink-guard.js';
|
|
74
75
|
export { multilineInput, promptCommitMessage } from './prompt.js';
|
|
75
76
|
export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
|
|
76
77
|
export { getSnapshotPath, hasSnapshot, getSnapshotModifiedTime, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readdirSync, lstatSync, unlinkSync, readlinkSync } from 'node:fs';
|
|
2
|
+
import { join, relative, isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { logger } from '../logger/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 检测路径是否为指向 worktree 外部的外部软链接
|
|
7
|
+
* @param {string} linkPath - 软链接的绝对路径
|
|
8
|
+
* @param {string} worktreeRoot - worktree 根目录的绝对路径
|
|
9
|
+
* @returns {boolean} 是否为外部软链接
|
|
10
|
+
*/
|
|
11
|
+
function isExternalSymlink(linkPath: string, worktreeRoot: string): boolean {
|
|
12
|
+
try {
|
|
13
|
+
const target = readlinkSync(linkPath);
|
|
14
|
+
// 顶层软链接的相对路径基于 worktreeRoot 解析
|
|
15
|
+
const resolvedTarget = isAbsolute(target)
|
|
16
|
+
? target
|
|
17
|
+
: resolve(worktreeRoot, target);
|
|
18
|
+
|
|
19
|
+
const relativePath = relative(worktreeRoot, resolvedTarget);
|
|
20
|
+
return relativePath.startsWith('..') || isAbsolute(relativePath);
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 移除目录中指向外部路径的软链接
|
|
28
|
+
* 同一循环内边扫描边删除,缩小 TOCTOU 窗口;
|
|
29
|
+
* 删除前用 lstatSync 确认目标仍是软链接,避免误删已被替换的普通文件
|
|
30
|
+
* @param {string} dir - 要清理的目录绝对路径
|
|
31
|
+
* @returns {string[]} 被移除的软链接路径列表
|
|
32
|
+
*/
|
|
33
|
+
export function removeExternalSymlinks(dir: string): string[] {
|
|
34
|
+
const removed: string[] = [];
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (!entry.isSymbolicLink()) continue;
|
|
40
|
+
|
|
41
|
+
const fullPath = join(dir, entry.name);
|
|
42
|
+
if (!isExternalSymlink(fullPath, dir)) continue;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// lstatSync 不跟随软链接,确认删除前仍是软链接而非被替换的普通文件
|
|
46
|
+
const stat = lstatSync(fullPath);
|
|
47
|
+
if (!stat.isSymbolicLink()) continue;
|
|
48
|
+
|
|
49
|
+
unlinkSync(fullPath);
|
|
50
|
+
removed.push(fullPath);
|
|
51
|
+
logger.info(`已移除外部软链接: ${fullPath}`);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
logger.warn(`移除外部软链接失败: ${fullPath} - ${error}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// 目录不可读时静默返回
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return removed;
|
|
61
|
+
}
|
|
@@ -5,7 +5,6 @@ import type { ClaudeCodeResult, TaskResult, TaskSummary, WorktreeInfo } from '..
|
|
|
5
5
|
import { spawnProcess, killAllChildProcesses } from './shell.js';
|
|
6
6
|
import { cleanupWorktrees } from './worktree.js';
|
|
7
7
|
import { getConfigValue } from './config.js';
|
|
8
|
-
import { APPEND_SYSTEM_PROMPT } from '../constants/index.js';
|
|
9
8
|
import { printSuccess, printWarning, printInfo, printDoubleSeparator, confirmAction } from './formatter.js';
|
|
10
9
|
import { ProgressRenderer } from './progress.js';
|
|
11
10
|
import { createLineBuffer, parseStreamLine, parseStreamEvent, truncateText } from './stream-parser.js';
|
|
@@ -35,11 +34,8 @@ type ActivityCallback = (activityText: string) => void;
|
|
|
35
34
|
* @returns {ClaudeTaskHandle} 包含子进程引用和结果 Promise
|
|
36
35
|
*/
|
|
37
36
|
function executeClaudeTask(worktree: WorktreeInfo, task: string, onActivity?: ActivityCallback, continueSession?: boolean): ClaudeTaskHandle {
|
|
38
|
-
// 使用统一的系统提示常量
|
|
39
|
-
const systemPrompt = APPEND_SYSTEM_PROMPT;
|
|
40
|
-
|
|
41
37
|
// 旧版使用 --output-format json,现改为 stream-json --verbose 以支持实时活动信息
|
|
42
|
-
const args = ['-p', task, '--output-format', 'stream-json', '--verbose', '--permission-mode', 'bypassPermissions'
|
|
38
|
+
const args = ['-p', task, '--output-format', 'stream-json', '--verbose', '--permission-mode', 'bypassPermissions'];
|
|
43
39
|
|
|
44
40
|
// 追问模式:追加 --continue 继续该目录下最新会话
|
|
45
41
|
if (continueSession) {
|
|
@@ -46,6 +46,7 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
46
46
|
VALIDATE_CONFIRM_AUTO_SYNC: (branch: string) => `是否自动 sync ${branch}`,
|
|
47
47
|
VALIDATE_AUTO_SYNC_DECLINED: (branch: string) => `已跳过 ${branch} 的自动 sync`,
|
|
48
48
|
VALIDATE_AUTO_SYNC_START: (branch: string) => `正在自动 sync ${branch}`,
|
|
49
|
+
VALIDATE_EXTERNAL_SYMLINKS_FOUND: (count: number) => `检测到 ${count} 个外部软链接`,
|
|
49
50
|
},
|
|
50
51
|
}));
|
|
51
52
|
|
|
@@ -94,6 +95,7 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
94
95
|
executeRunCommand: vi.fn(),
|
|
95
96
|
guardMainWorkBranch: vi.fn().mockResolvedValue(undefined),
|
|
96
97
|
guardMainWorkBranchExists: vi.fn(),
|
|
98
|
+
removeExternalSymlinks: vi.fn().mockReturnValue([]),
|
|
97
99
|
}));
|
|
98
100
|
|
|
99
101
|
import { registerValidateCommand } from '../../../src/commands/validate.js';
|
|
@@ -7,6 +7,10 @@ vi.mock('../../../src/logger/index.js', () => ({
|
|
|
7
7
|
|
|
8
8
|
// mock node:child_process
|
|
9
9
|
vi.mock('node:child_process', () => ({
|
|
10
|
+
exec: vi.fn(),
|
|
11
|
+
execSync: vi.fn(),
|
|
12
|
+
execFileSync: vi.fn(),
|
|
13
|
+
spawn: vi.fn(),
|
|
10
14
|
spawnSync: vi.fn(),
|
|
11
15
|
}));
|
|
12
16
|
|
|
@@ -21,6 +25,11 @@ vi.mock('../../../src/utils/config.js', () => ({
|
|
|
21
25
|
getConfigValue: vi.fn(),
|
|
22
26
|
}));
|
|
23
27
|
|
|
28
|
+
// mock project-config(避免 resolveClaudeCodeCommand 调用 getGitTopLevel/execSync)
|
|
29
|
+
vi.mock('../../../src/utils/project-config.js', () => ({
|
|
30
|
+
resolveClaudeCodeCommand: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
24
33
|
// mock formatter
|
|
25
34
|
vi.mock('../../../src/utils/formatter.js', () => ({
|
|
26
35
|
printInfo: vi.fn(),
|
|
@@ -31,13 +40,14 @@ import { spawnSync } from 'node:child_process';
|
|
|
31
40
|
import { existsSync, readdirSync } from 'node:fs';
|
|
32
41
|
import { launchInteractiveClaude, hasClaudeSessionHistory, buildClaudeCommand } from '../../../src/utils/claude.js';
|
|
33
42
|
import { getConfigValue } from '../../../src/utils/config.js';
|
|
43
|
+
import { resolveClaudeCodeCommand } from '../../../src/utils/project-config.js';
|
|
34
44
|
import { printInfo, printWarning } from '../../../src/utils/formatter.js';
|
|
35
45
|
import { ClawtError } from '../../../src/errors/index.js';
|
|
36
|
-
import { APPEND_SYSTEM_PROMPT } from '../../../src/constants/config.js';
|
|
37
46
|
import { createWorktreeInfo } from '../../helpers/fixtures.js';
|
|
38
47
|
|
|
39
48
|
const mockedSpawnSync = vi.mocked(spawnSync);
|
|
40
49
|
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
50
|
+
const mockedResolveClaudeCodeCommand = vi.mocked(resolveClaudeCodeCommand);
|
|
41
51
|
const mockedPrintInfo = vi.mocked(printInfo);
|
|
42
52
|
const mockedPrintWarning = vi.mocked(printWarning);
|
|
43
53
|
const mockedExistsSync = vi.mocked(existsSync);
|
|
@@ -87,7 +97,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
87
97
|
});
|
|
88
98
|
|
|
89
99
|
it('正常启动 Claude Code(退出码为 0)', () => {
|
|
90
|
-
|
|
100
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
91
101
|
mockedExistsSync.mockReturnValue(false);
|
|
92
102
|
mockedSpawnSync.mockReturnValue({
|
|
93
103
|
status: 0,
|
|
@@ -101,10 +111,10 @@ describe('launchInteractiveClaude', () => {
|
|
|
101
111
|
|
|
102
112
|
launchInteractiveClaude(worktree);
|
|
103
113
|
|
|
104
|
-
expect(
|
|
114
|
+
expect(mockedResolveClaudeCodeCommand).toHaveBeenCalled();
|
|
105
115
|
expect(mockedSpawnSync).toHaveBeenCalledWith(
|
|
106
116
|
'claude',
|
|
107
|
-
expect.
|
|
117
|
+
expect.any(Array),
|
|
108
118
|
expect.objectContaining({
|
|
109
119
|
cwd: '/tmp/test-worktree',
|
|
110
120
|
stdio: 'inherit',
|
|
@@ -113,7 +123,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
113
123
|
});
|
|
114
124
|
|
|
115
125
|
it('输出分支和路径信息', () => {
|
|
116
|
-
|
|
126
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
117
127
|
mockedExistsSync.mockReturnValue(false);
|
|
118
128
|
mockedSpawnSync.mockReturnValue({
|
|
119
129
|
status: 0,
|
|
@@ -132,7 +142,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
132
142
|
});
|
|
133
143
|
|
|
134
144
|
it('支持带参数的命令(如 npx claude)', () => {
|
|
135
|
-
|
|
145
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('npx claude');
|
|
136
146
|
mockedExistsSync.mockReturnValue(false);
|
|
137
147
|
mockedSpawnSync.mockReturnValue({
|
|
138
148
|
status: 0,
|
|
@@ -148,13 +158,13 @@ describe('launchInteractiveClaude', () => {
|
|
|
148
158
|
|
|
149
159
|
expect(mockedSpawnSync).toHaveBeenCalledWith(
|
|
150
160
|
'npx',
|
|
151
|
-
expect.arrayContaining(['claude'
|
|
161
|
+
expect.arrayContaining(['claude']),
|
|
152
162
|
expect.any(Object),
|
|
153
163
|
);
|
|
154
164
|
});
|
|
155
165
|
|
|
156
166
|
it('spawnSync 返回 error 时抛出 ClawtError', () => {
|
|
157
|
-
|
|
167
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
158
168
|
mockedExistsSync.mockReturnValue(false);
|
|
159
169
|
mockedSpawnSync.mockReturnValue({
|
|
160
170
|
status: null,
|
|
@@ -171,7 +181,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
171
181
|
});
|
|
172
182
|
|
|
173
183
|
it('非零退出码时调用 printWarning', () => {
|
|
174
|
-
|
|
184
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
175
185
|
mockedExistsSync.mockReturnValue(false);
|
|
176
186
|
mockedSpawnSync.mockReturnValue({
|
|
177
187
|
status: 1,
|
|
@@ -189,7 +199,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
189
199
|
});
|
|
190
200
|
|
|
191
201
|
it('退出码为 null 时不调用 printWarning', () => {
|
|
192
|
-
|
|
202
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
193
203
|
mockedExistsSync.mockReturnValue(false);
|
|
194
204
|
mockedSpawnSync.mockReturnValue({
|
|
195
205
|
status: null,
|
|
@@ -207,7 +217,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
207
217
|
});
|
|
208
218
|
|
|
209
219
|
it('退出码为 0 时不调用 printWarning', () => {
|
|
210
|
-
|
|
220
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
211
221
|
mockedExistsSync.mockReturnValue(false);
|
|
212
222
|
mockedSpawnSync.mockReturnValue({
|
|
213
223
|
status: 0,
|
|
@@ -225,7 +235,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
225
235
|
});
|
|
226
236
|
|
|
227
237
|
it('autoContinue 启用且有会话历史时追加 --continue 参数', () => {
|
|
228
|
-
|
|
238
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
229
239
|
mockedExistsSync.mockReturnValue(true);
|
|
230
240
|
mockedReaddirSync.mockReturnValue(['session-abc.jsonl'] as unknown as ReturnType<typeof readdirSync>);
|
|
231
241
|
mockedSpawnSync.mockReturnValue({
|
|
@@ -246,7 +256,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
246
256
|
});
|
|
247
257
|
|
|
248
258
|
it('autoContinue 启用但无会话历史时不追加 --continue 参数', () => {
|
|
249
|
-
|
|
259
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
250
260
|
mockedExistsSync.mockReturnValue(false);
|
|
251
261
|
mockedSpawnSync.mockReturnValue({
|
|
252
262
|
status: 0,
|
|
@@ -266,7 +276,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
266
276
|
});
|
|
267
277
|
|
|
268
278
|
it('不传 autoContinue 时即使有会话历史也不追加 --continue', () => {
|
|
269
|
-
|
|
279
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
270
280
|
mockedExistsSync.mockReturnValue(true);
|
|
271
281
|
mockedReaddirSync.mockReturnValue(['session-abc.jsonl'] as unknown as ReturnType<typeof readdirSync>);
|
|
272
282
|
mockedSpawnSync.mockReturnValue({
|
|
@@ -285,8 +295,8 @@ describe('launchInteractiveClaude', () => {
|
|
|
285
295
|
expect(callArgs).not.toContain('--continue');
|
|
286
296
|
});
|
|
287
297
|
|
|
288
|
-
it('
|
|
289
|
-
|
|
298
|
+
it('不包含 --append-system-prompt 参数', () => {
|
|
299
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
290
300
|
mockedExistsSync.mockReturnValue(false);
|
|
291
301
|
mockedSpawnSync.mockReturnValue({
|
|
292
302
|
status: 0,
|
|
@@ -301,8 +311,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
301
311
|
launchInteractiveClaude(worktree);
|
|
302
312
|
|
|
303
313
|
const callArgs = mockedSpawnSync.mock.calls[0][1] as string[];
|
|
304
|
-
|
|
305
|
-
expect(callArgs[promptIndex + 1]).toBe(APPEND_SYSTEM_PROMPT);
|
|
314
|
+
expect(callArgs).not.toContain('--append-system-prompt');
|
|
306
315
|
});
|
|
307
316
|
});
|
|
308
317
|
|
|
@@ -313,17 +322,16 @@ describe('buildClaudeCommand', () => {
|
|
|
313
322
|
});
|
|
314
323
|
|
|
315
324
|
it('生成包含 cd 和 claude 命令的字符串', () => {
|
|
316
|
-
|
|
325
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
317
326
|
|
|
318
327
|
const cmd = buildClaudeCommand(worktree, false);
|
|
319
328
|
|
|
320
329
|
expect(cmd).toContain("cd '/tmp/test-worktree'");
|
|
321
330
|
expect(cmd).toContain('claude');
|
|
322
|
-
expect(cmd).toContain('--append-system-prompt');
|
|
323
331
|
});
|
|
324
332
|
|
|
325
333
|
it('hasPreviousSession 为 true 时包含 --continue', () => {
|
|
326
|
-
|
|
334
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
327
335
|
|
|
328
336
|
const cmd = buildClaudeCommand(worktree, true);
|
|
329
337
|
|
|
@@ -331,23 +339,23 @@ describe('buildClaudeCommand', () => {
|
|
|
331
339
|
});
|
|
332
340
|
|
|
333
341
|
it('hasPreviousSession 为 false 时不包含 --continue', () => {
|
|
334
|
-
|
|
342
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
335
343
|
|
|
336
344
|
const cmd = buildClaudeCommand(worktree, false);
|
|
337
345
|
|
|
338
346
|
expect(cmd).not.toContain('--continue');
|
|
339
347
|
});
|
|
340
348
|
|
|
341
|
-
it('
|
|
342
|
-
|
|
349
|
+
it('不包含 --append-system-prompt 参数', () => {
|
|
350
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
343
351
|
|
|
344
352
|
const cmd = buildClaudeCommand(worktree, false);
|
|
345
353
|
|
|
346
|
-
expect(cmd).toContain(
|
|
354
|
+
expect(cmd).not.toContain('--append-system-prompt');
|
|
347
355
|
});
|
|
348
356
|
|
|
349
357
|
it('路径中的单引号被正确转义', () => {
|
|
350
|
-
|
|
358
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
351
359
|
const wtWithQuote = createWorktreeInfo({
|
|
352
360
|
path: "/tmp/test's-worktree",
|
|
353
361
|
branch: 'feat',
|
|
@@ -18,7 +18,11 @@ vi.mock('../../../src/errors/index.js', () => ({
|
|
|
18
18
|
|
|
19
19
|
// mock node:child_process
|
|
20
20
|
vi.mock('node:child_process', () => ({
|
|
21
|
+
exec: vi.fn(),
|
|
22
|
+
execSync: vi.fn(),
|
|
21
23
|
execFileSync: vi.fn(),
|
|
24
|
+
spawn: vi.fn(),
|
|
25
|
+
spawnSync: vi.fn(),
|
|
22
26
|
}));
|
|
23
27
|
|
|
24
28
|
// mock constants(使用与 src/constants/messages/merge.ts 一致的消息文本)
|
|
@@ -236,9 +236,9 @@ describe('waitForGitIndexLockRetrySync', () => {
|
|
|
236
236
|
waitForGitIndexLockRetrySync();
|
|
237
237
|
const elapsed = Date.now() - startTime;
|
|
238
238
|
|
|
239
|
-
// 延迟时间应该接近
|
|
240
|
-
expect(elapsed).toBeGreaterThanOrEqual(
|
|
241
|
-
expect(elapsed).toBeLessThanOrEqual(
|
|
239
|
+
// 延迟时间应该接近 1000ms(允许 500ms 误差,兼容 CI 环境的调度延迟)
|
|
240
|
+
expect(elapsed).toBeGreaterThanOrEqual(500);
|
|
241
|
+
expect(elapsed).toBeLessThanOrEqual(2000);
|
|
242
242
|
|
|
243
243
|
stderrWriteSpy.mockRestore();
|
|
244
244
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdirSync, rmSync, symlinkSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { removeExternalSymlinks } from '../../../src/utils/symlink-guard.js';
|
|
6
|
+
|
|
7
|
+
/** 创建临时测试目录的唯一路径 */
|
|
8
|
+
function createTestDir(prefix: string): string {
|
|
9
|
+
return join(tmpdir(), `clawt-test-symlink-${prefix}-${Date.now()}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('symlink-guard', () => {
|
|
13
|
+
let worktreeDir: string;
|
|
14
|
+
let externalDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
worktreeDir = createTestDir('worktree');
|
|
18
|
+
externalDir = createTestDir('external');
|
|
19
|
+
mkdirSync(worktreeDir, { recursive: true });
|
|
20
|
+
mkdirSync(externalDir, { recursive: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
rmSync(worktreeDir, { recursive: true, force: true });
|
|
25
|
+
rmSync(externalDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('removeExternalSymlinks', () => {
|
|
29
|
+
it('应移除外部软链接并返回被移除的路径列表', () => {
|
|
30
|
+
mkdirSync(join(externalDir, 'node_modules_real'), { recursive: true });
|
|
31
|
+
symlinkSync(join(externalDir, 'node_modules_real'), join(worktreeDir, 'node_modules'));
|
|
32
|
+
|
|
33
|
+
const removed = removeExternalSymlinks(worktreeDir);
|
|
34
|
+
expect(removed).toHaveLength(1);
|
|
35
|
+
expect(existsSync(join(worktreeDir, 'node_modules'))).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('不应移除内部软链接', () => {
|
|
39
|
+
mkdirSync(join(worktreeDir, 'target'), { recursive: true });
|
|
40
|
+
symlinkSync(join(worktreeDir, 'target'), join(worktreeDir, 'link'));
|
|
41
|
+
|
|
42
|
+
const removed = removeExternalSymlinks(worktreeDir);
|
|
43
|
+
expect(removed).toHaveLength(0);
|
|
44
|
+
expect(existsSync(join(worktreeDir, 'link'))).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('应处理无软链接的目录', () => {
|
|
48
|
+
const removed = removeExternalSymlinks(worktreeDir);
|
|
49
|
+
expect(removed).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('应处理不存在的目录', () => {
|
|
53
|
+
const removed = removeExternalSymlinks('/nonexistent/path/12345');
|
|
54
|
+
expect(removed).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('应移除多个外部软链接', () => {
|
|
58
|
+
mkdirSync(join(externalDir, 'target1'), { recursive: true });
|
|
59
|
+
mkdirSync(join(externalDir, 'target2'), { recursive: true });
|
|
60
|
+
|
|
61
|
+
symlinkSync(join(externalDir, 'target1'), join(worktreeDir, 'node_modules'));
|
|
62
|
+
symlinkSync(join(externalDir, 'target2'), join(worktreeDir, '.venv'));
|
|
63
|
+
|
|
64
|
+
const removed = removeExternalSymlinks(worktreeDir);
|
|
65
|
+
expect(removed).toHaveLength(2);
|
|
66
|
+
expect(existsSync(join(worktreeDir, 'node_modules'))).toBe(false);
|
|
67
|
+
expect(existsSync(join(worktreeDir, '.venv'))).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|