clawt 3.9.11 → 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 +113 -71
- package/dist/postinstall.js +2 -0
- package/docs/validate.md +15 -0
- package/package.json +1 -1
- package/src/commands/validate.ts +7 -0
- package/src/constants/messages/validate.ts +3 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/symlink-guard.ts +61 -0
- package/tests/unit/commands/validate.test.ts +2 -0
- package/tests/unit/utils/claude.test.ts +28 -17
- 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}`,
|
|
@@ -1166,7 +1168,7 @@ function parseParallelCommands(commandString) {
|
|
|
1166
1168
|
return parts.map((part) => part.replace(new RegExp(placeholder, "g"), "&&").trim()).filter((part) => part.length > 0);
|
|
1167
1169
|
}
|
|
1168
1170
|
function spawnWithStderrCapture(command, options) {
|
|
1169
|
-
return new Promise((
|
|
1171
|
+
return new Promise((resolve5) => {
|
|
1170
1172
|
const child = spawn(command, {
|
|
1171
1173
|
cwd: options?.cwd,
|
|
1172
1174
|
stdio: ["inherit", "inherit", "pipe"],
|
|
@@ -1178,14 +1180,14 @@ function spawnWithStderrCapture(command, options) {
|
|
|
1178
1180
|
stderrChunks.push(chunk);
|
|
1179
1181
|
});
|
|
1180
1182
|
child.on("error", (err) => {
|
|
1181
|
-
|
|
1183
|
+
resolve5({
|
|
1182
1184
|
exitCode: 1,
|
|
1183
1185
|
error: err.message,
|
|
1184
1186
|
stderr: Buffer.concat(stderrChunks).toString("utf-8")
|
|
1185
1187
|
});
|
|
1186
1188
|
});
|
|
1187
1189
|
child.on("close", (code) => {
|
|
1188
|
-
|
|
1190
|
+
resolve5({
|
|
1189
1191
|
exitCode: code ?? 1,
|
|
1190
1192
|
stderr: Buffer.concat(stderrChunks).toString("utf-8")
|
|
1191
1193
|
});
|
|
@@ -1526,14 +1528,14 @@ function confirmAction(question, nonInteractiveDefault = true) {
|
|
|
1526
1528
|
if (isNonInteractive()) {
|
|
1527
1529
|
return Promise.resolve(nonInteractiveDefault);
|
|
1528
1530
|
}
|
|
1529
|
-
return new Promise((
|
|
1531
|
+
return new Promise((resolve5) => {
|
|
1530
1532
|
const rl = createInterface({
|
|
1531
1533
|
input: process.stdin,
|
|
1532
1534
|
output: process.stdout
|
|
1533
1535
|
});
|
|
1534
1536
|
rl.question(`${question} (y/N) `, (answer) => {
|
|
1535
1537
|
rl.close();
|
|
1536
|
-
|
|
1538
|
+
resolve5(answer.toLowerCase() === "y");
|
|
1537
1539
|
});
|
|
1538
1540
|
});
|
|
1539
1541
|
}
|
|
@@ -2124,6 +2126,42 @@ function getWorktreeStatus(worktree) {
|
|
|
2124
2126
|
}
|
|
2125
2127
|
}
|
|
2126
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
|
+
|
|
2127
2165
|
// src/utils/prompt.ts
|
|
2128
2166
|
import Enquirer2 from "enquirer";
|
|
2129
2167
|
async function promptCommitMessage(promptMessage, nonInteractiveErrorMessage) {
|
|
@@ -2142,8 +2180,8 @@ async function promptCommitMessage(promptMessage, nonInteractiveErrorMessage) {
|
|
|
2142
2180
|
|
|
2143
2181
|
// src/utils/claude.ts
|
|
2144
2182
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
2145
|
-
import { existsSync as existsSync7, readdirSync as
|
|
2146
|
-
import { join as
|
|
2183
|
+
import { existsSync as existsSync7, readdirSync as readdirSync4 } from "fs";
|
|
2184
|
+
import { join as join7 } from "path";
|
|
2147
2185
|
|
|
2148
2186
|
// src/utils/terminal.ts
|
|
2149
2187
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
@@ -2287,11 +2325,11 @@ function encodeClaudeProjectPath(absolutePath) {
|
|
|
2287
2325
|
}
|
|
2288
2326
|
function hasClaudeSessionHistory(worktreePath) {
|
|
2289
2327
|
const encodedName = encodeClaudeProjectPath(worktreePath);
|
|
2290
|
-
const projectDir =
|
|
2328
|
+
const projectDir = join7(CLAUDE_PROJECTS_DIR, encodedName);
|
|
2291
2329
|
if (!existsSync7(projectDir)) {
|
|
2292
2330
|
return false;
|
|
2293
2331
|
}
|
|
2294
|
-
const entries =
|
|
2332
|
+
const entries = readdirSync4(projectDir);
|
|
2295
2333
|
return entries.some((entry) => entry.endsWith(".jsonl"));
|
|
2296
2334
|
}
|
|
2297
2335
|
function launchInteractiveClaude(worktree, options = {}) {
|
|
@@ -2341,16 +2379,16 @@ function launchInteractiveClaudeInNewTerminal(worktree, hasPreviousSession) {
|
|
|
2341
2379
|
}
|
|
2342
2380
|
|
|
2343
2381
|
// src/utils/validate-snapshot.ts
|
|
2344
|
-
import { join as
|
|
2345
|
-
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";
|
|
2346
2384
|
function getSnapshotPath(projectName, branchName) {
|
|
2347
|
-
return
|
|
2385
|
+
return join8(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
|
|
2348
2386
|
}
|
|
2349
2387
|
function getSnapshotHeadPath(projectName, branchName) {
|
|
2350
|
-
return
|
|
2388
|
+
return join8(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
|
|
2351
2389
|
}
|
|
2352
2390
|
function getSnapshotStagedPath(projectName, branchName) {
|
|
2353
|
-
return
|
|
2391
|
+
return join8(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.staged`);
|
|
2354
2392
|
}
|
|
2355
2393
|
function hasSnapshot(projectName, branchName) {
|
|
2356
2394
|
return existsSync8(getSnapshotPath(projectName, branchName));
|
|
@@ -2372,7 +2410,7 @@ function readSnapshot(projectName, branchName) {
|
|
|
2372
2410
|
return { treeHash, headCommitHash, stagedTreeHash };
|
|
2373
2411
|
}
|
|
2374
2412
|
function writeSnapshot(projectName, branchName, treeHash, headCommitHash, stagedTreeHash) {
|
|
2375
|
-
const snapshotDir =
|
|
2413
|
+
const snapshotDir = join8(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
2376
2414
|
ensureDir(snapshotDir);
|
|
2377
2415
|
if (treeHash !== void 0) {
|
|
2378
2416
|
writeFileSync3(getSnapshotPath(projectName, branchName), treeHash, "utf-8");
|
|
@@ -2390,34 +2428,34 @@ function removeSnapshot(projectName, branchName) {
|
|
|
2390
2428
|
const headPath = getSnapshotHeadPath(projectName, branchName);
|
|
2391
2429
|
const stagedPath = getSnapshotStagedPath(projectName, branchName);
|
|
2392
2430
|
if (existsSync8(snapshotPath)) {
|
|
2393
|
-
|
|
2431
|
+
unlinkSync2(snapshotPath);
|
|
2394
2432
|
logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${snapshotPath}`);
|
|
2395
2433
|
}
|
|
2396
2434
|
if (existsSync8(headPath)) {
|
|
2397
|
-
|
|
2435
|
+
unlinkSync2(headPath);
|
|
2398
2436
|
logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${headPath}`);
|
|
2399
2437
|
}
|
|
2400
2438
|
if (existsSync8(stagedPath)) {
|
|
2401
|
-
|
|
2439
|
+
unlinkSync2(stagedPath);
|
|
2402
2440
|
logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${stagedPath}`);
|
|
2403
2441
|
}
|
|
2404
2442
|
}
|
|
2405
2443
|
function getProjectSnapshotBranches(projectName) {
|
|
2406
|
-
const projectDir =
|
|
2444
|
+
const projectDir = join8(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
2407
2445
|
if (!existsSync8(projectDir)) {
|
|
2408
2446
|
return [];
|
|
2409
2447
|
}
|
|
2410
|
-
const files =
|
|
2448
|
+
const files = readdirSync5(projectDir);
|
|
2411
2449
|
return files.filter((f) => f.endsWith(".tree")).map((f) => f.replace(/\.tree$/, ""));
|
|
2412
2450
|
}
|
|
2413
2451
|
function removeProjectSnapshots(projectName) {
|
|
2414
|
-
const projectDir =
|
|
2452
|
+
const projectDir = join8(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
2415
2453
|
if (!existsSync8(projectDir)) {
|
|
2416
2454
|
return;
|
|
2417
2455
|
}
|
|
2418
|
-
const files =
|
|
2456
|
+
const files = readdirSync5(projectDir);
|
|
2419
2457
|
for (const file of files) {
|
|
2420
|
-
|
|
2458
|
+
unlinkSync2(join8(projectDir, file));
|
|
2421
2459
|
}
|
|
2422
2460
|
try {
|
|
2423
2461
|
rmdirSync2(projectDir);
|
|
@@ -2944,7 +2982,7 @@ var ProgressRenderer = class {
|
|
|
2944
2982
|
};
|
|
2945
2983
|
|
|
2946
2984
|
// src/utils/task-file.ts
|
|
2947
|
-
import { resolve } from "path";
|
|
2985
|
+
import { resolve as resolve2 } from "path";
|
|
2948
2986
|
import { existsSync as existsSync9, readFileSync as readFileSync4 } from "fs";
|
|
2949
2987
|
var TASK_BLOCK_REGEX = /<!-- CLAWT-TASKS:START -->([\s\S]*?)<!-- CLAWT-TASKS:END -->/g;
|
|
2950
2988
|
var BRANCH_LINE_REGEX = /^#\s*branch:\s*(.+)$/;
|
|
@@ -2990,7 +3028,7 @@ function parseTaskFile(content, options) {
|
|
|
2990
3028
|
return entries;
|
|
2991
3029
|
}
|
|
2992
3030
|
function loadTaskFile(filePath, options) {
|
|
2993
|
-
const absolutePath =
|
|
3031
|
+
const absolutePath = resolve2(filePath);
|
|
2994
3032
|
if (!existsSync9(absolutePath)) {
|
|
2995
3033
|
throw new ClawtError(MESSAGES.TASK_FILE_NOT_FOUND(absolutePath));
|
|
2996
3034
|
}
|
|
@@ -3123,7 +3161,7 @@ function executeClaudeTask(worktree, task, onActivity, continueSession) {
|
|
|
3123
3161
|
stdio: ["ignore", "pipe", "pipe"]
|
|
3124
3162
|
}
|
|
3125
3163
|
);
|
|
3126
|
-
const promise = new Promise((
|
|
3164
|
+
const promise = new Promise((resolve5) => {
|
|
3127
3165
|
let stderr = "";
|
|
3128
3166
|
let finalResult = null;
|
|
3129
3167
|
const lineBuffer = createLineBuffer();
|
|
@@ -3159,7 +3197,7 @@ function executeClaudeTask(worktree, task, onActivity, continueSession) {
|
|
|
3159
3197
|
if (finalResult) {
|
|
3160
3198
|
success = !finalResult.is_error;
|
|
3161
3199
|
}
|
|
3162
|
-
|
|
3200
|
+
resolve5({
|
|
3163
3201
|
task,
|
|
3164
3202
|
branch: worktree.branch,
|
|
3165
3203
|
worktreePath: worktree.path,
|
|
@@ -3169,7 +3207,7 @@ function executeClaudeTask(worktree, task, onActivity, continueSession) {
|
|
|
3169
3207
|
});
|
|
3170
3208
|
});
|
|
3171
3209
|
child.on("error", (err) => {
|
|
3172
|
-
|
|
3210
|
+
resolve5({
|
|
3173
3211
|
task,
|
|
3174
3212
|
branch: worktree.branch,
|
|
3175
3213
|
worktreePath: worktree.path,
|
|
@@ -3228,7 +3266,7 @@ async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, s
|
|
|
3228
3266
|
const results = new Array(total);
|
|
3229
3267
|
let nextIndex = 0;
|
|
3230
3268
|
let completedCount = 0;
|
|
3231
|
-
return new Promise((
|
|
3269
|
+
return new Promise((resolve5) => {
|
|
3232
3270
|
function launchNext() {
|
|
3233
3271
|
if (nextIndex >= total || isInterrupted()) return;
|
|
3234
3272
|
const index = nextIndex;
|
|
@@ -3249,7 +3287,7 @@ async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, s
|
|
|
3249
3287
|
}
|
|
3250
3288
|
launchNext();
|
|
3251
3289
|
if (completedCount === total) {
|
|
3252
|
-
|
|
3290
|
+
resolve5(results);
|
|
3253
3291
|
}
|
|
3254
3292
|
});
|
|
3255
3293
|
}
|
|
@@ -3309,11 +3347,11 @@ async function executeBatchTasks(worktrees, tasks, concurrency, continueFlags) {
|
|
|
3309
3347
|
printWarning(MESSAGES.INTERRUPTED);
|
|
3310
3348
|
killAllChildProcesses(childProcesses);
|
|
3311
3349
|
await Promise.allSettled(childProcesses.map(
|
|
3312
|
-
(cp) => new Promise((
|
|
3350
|
+
(cp) => new Promise((resolve5) => {
|
|
3313
3351
|
if (cp.exitCode !== null) {
|
|
3314
|
-
|
|
3352
|
+
resolve5();
|
|
3315
3353
|
} else {
|
|
3316
|
-
cp.on("close", () =>
|
|
3354
|
+
cp.on("close", () => resolve5());
|
|
3317
3355
|
}
|
|
3318
3356
|
})
|
|
3319
3357
|
));
|
|
@@ -3339,7 +3377,7 @@ async function executeBatchTasks(worktrees, tasks, concurrency, continueFlags) {
|
|
|
3339
3377
|
|
|
3340
3378
|
// src/utils/dry-run.ts
|
|
3341
3379
|
import chalk6 from "chalk";
|
|
3342
|
-
import { join as
|
|
3380
|
+
import { join as join9 } from "path";
|
|
3343
3381
|
var DRY_RUN_TASK_DESC_MAX_LENGTH = 80;
|
|
3344
3382
|
function truncateTaskDesc(task) {
|
|
3345
3383
|
const oneLine = task.replace(/\n/g, " ").trim();
|
|
@@ -3367,7 +3405,7 @@ function printDryRunPreview(branchNames, tasks, concurrency) {
|
|
|
3367
3405
|
let hasConflict = false;
|
|
3368
3406
|
for (let i = 0; i < branchNames.length; i++) {
|
|
3369
3407
|
const branch = branchNames[i];
|
|
3370
|
-
const worktreePath =
|
|
3408
|
+
const worktreePath = join9(projectDir, branch);
|
|
3371
3409
|
const exists = checkBranchExists(branch);
|
|
3372
3410
|
if (exists) hasConflict = true;
|
|
3373
3411
|
const indexLabel = `[${i + 1}/${branchNames.length}]`;
|
|
@@ -3559,7 +3597,7 @@ function isNewerVersion(latest, current) {
|
|
|
3559
3597
|
return false;
|
|
3560
3598
|
}
|
|
3561
3599
|
function fetchLatestVersion() {
|
|
3562
|
-
return new Promise((
|
|
3600
|
+
return new Promise((resolve5) => {
|
|
3563
3601
|
const req = request(NPM_REGISTRY_URL, { timeout: NPM_REGISTRY_TIMEOUT_MS }, (res) => {
|
|
3564
3602
|
let data = "";
|
|
3565
3603
|
res.on("data", (chunk) => {
|
|
@@ -3568,16 +3606,16 @@ function fetchLatestVersion() {
|
|
|
3568
3606
|
res.on("end", () => {
|
|
3569
3607
|
try {
|
|
3570
3608
|
const parsed = JSON.parse(data);
|
|
3571
|
-
|
|
3609
|
+
resolve5(parsed.version ?? null);
|
|
3572
3610
|
} catch {
|
|
3573
|
-
|
|
3611
|
+
resolve5(null);
|
|
3574
3612
|
}
|
|
3575
3613
|
});
|
|
3576
3614
|
});
|
|
3577
|
-
req.on("error", () =>
|
|
3615
|
+
req.on("error", () => resolve5(null));
|
|
3578
3616
|
req.on("timeout", () => {
|
|
3579
3617
|
req.destroy();
|
|
3580
|
-
|
|
3618
|
+
resolve5(null);
|
|
3581
3619
|
});
|
|
3582
3620
|
req.end();
|
|
3583
3621
|
});
|
|
@@ -4230,8 +4268,8 @@ var InteractivePanel = class {
|
|
|
4230
4268
|
return;
|
|
4231
4269
|
}
|
|
4232
4270
|
this.stateManager.updateData(await this.collectStatusFn());
|
|
4233
|
-
return new Promise((
|
|
4234
|
-
this.resolveStart =
|
|
4271
|
+
return new Promise((resolve5) => {
|
|
4272
|
+
this.resolveStart = resolve5;
|
|
4235
4273
|
this.initTerminal();
|
|
4236
4274
|
this.keyboardController.start();
|
|
4237
4275
|
this.startAutoRefresh();
|
|
@@ -4508,14 +4546,14 @@ var InteractivePanel = class {
|
|
|
4508
4546
|
* @returns {Promise<void>} 用户按回车时 resolve
|
|
4509
4547
|
*/
|
|
4510
4548
|
waitForEnter() {
|
|
4511
|
-
return new Promise((
|
|
4549
|
+
return new Promise((resolve5) => {
|
|
4512
4550
|
const rl = createInterface2({
|
|
4513
4551
|
input: process.stdin,
|
|
4514
4552
|
output: process.stdout
|
|
4515
4553
|
});
|
|
4516
4554
|
rl.once("line", () => {
|
|
4517
4555
|
rl.close();
|
|
4518
|
-
|
|
4556
|
+
resolve5();
|
|
4519
4557
|
});
|
|
4520
4558
|
});
|
|
4521
4559
|
}
|
|
@@ -4608,7 +4646,7 @@ async function handleMergeConflict(currentBranch, incomingBranch, cwd, autoFlag)
|
|
|
4608
4646
|
// src/hooks/post-create.ts
|
|
4609
4647
|
import { existsSync as existsSync10, accessSync, chmodSync, constants as fsConstants } from "fs";
|
|
4610
4648
|
import { spawn as spawn2 } from "child_process";
|
|
4611
|
-
import { join as
|
|
4649
|
+
import { join as join10 } from "path";
|
|
4612
4650
|
var POST_CREATE_SCRIPT_RELATIVE_PATH = ".clawt/postCreate.sh";
|
|
4613
4651
|
function isExecutable(filePath) {
|
|
4614
4652
|
try {
|
|
@@ -4640,7 +4678,7 @@ function resolvePostCreateHook() {
|
|
|
4640
4678
|
}
|
|
4641
4679
|
}
|
|
4642
4680
|
const mainWorktreePath = getMainWorktreePath();
|
|
4643
|
-
const scriptPath =
|
|
4681
|
+
const scriptPath = join10(mainWorktreePath, POST_CREATE_SCRIPT_RELATIVE_PATH);
|
|
4644
4682
|
if (existsSync10(scriptPath)) {
|
|
4645
4683
|
if (!isExecutable(scriptPath)) {
|
|
4646
4684
|
autoFixExecutablePermission(scriptPath);
|
|
@@ -4653,7 +4691,7 @@ function getSourceLabel(hook) {
|
|
|
4653
4691
|
return hook.source === "projectConfig" ? "\u9879\u76EE\u914D\u7F6E (postCreate)" : ".clawt/postCreate.sh";
|
|
4654
4692
|
}
|
|
4655
4693
|
function executeOneHook(worktree, hook) {
|
|
4656
|
-
return new Promise((
|
|
4694
|
+
return new Promise((resolve5) => {
|
|
4657
4695
|
const result = {
|
|
4658
4696
|
worktreePath: worktree.path,
|
|
4659
4697
|
branch: worktree.branch,
|
|
@@ -4670,7 +4708,7 @@ function executeOneHook(worktree, hook) {
|
|
|
4670
4708
|
result.success = false;
|
|
4671
4709
|
result.error = err.message;
|
|
4672
4710
|
logger.error(`postCreate hook \u5F02\u5E38: ${hook.command} @ ${worktree.path}: ${result.error}`);
|
|
4673
|
-
|
|
4711
|
+
resolve5(result);
|
|
4674
4712
|
});
|
|
4675
4713
|
child.on("close", (code) => {
|
|
4676
4714
|
if (code !== null && code !== 0) {
|
|
@@ -4680,13 +4718,13 @@ function executeOneHook(worktree, hook) {
|
|
|
4680
4718
|
} else {
|
|
4681
4719
|
logger.info(`postCreate hook \u6210\u529F: ${hook.command} @ ${worktree.path}`);
|
|
4682
4720
|
}
|
|
4683
|
-
|
|
4721
|
+
resolve5(result);
|
|
4684
4722
|
});
|
|
4685
4723
|
} catch (err) {
|
|
4686
4724
|
result.success = false;
|
|
4687
4725
|
result.error = err instanceof Error ? err.message : String(err);
|
|
4688
4726
|
logger.error(`postCreate hook \u542F\u52A8\u5931\u8D25: ${hook.command} @ ${worktree.path}: ${result.error}`);
|
|
4689
|
-
|
|
4727
|
+
resolve5(result);
|
|
4690
4728
|
}
|
|
4691
4729
|
});
|
|
4692
4730
|
}
|
|
@@ -5249,6 +5287,10 @@ async function handleValidate(options) {
|
|
|
5249
5287
|
const branchName = worktree.branch;
|
|
5250
5288
|
const targetWorktreePath = worktree.path;
|
|
5251
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
|
+
}
|
|
5252
5294
|
const hasUncommitted = !isWorkingDirClean(targetWorktreePath);
|
|
5253
5295
|
const hasCommitted = hasLocalCommits(branchName, mainWorktreePath);
|
|
5254
5296
|
if (!hasUncommitted && !hasCommitted) {
|
|
@@ -5882,8 +5924,8 @@ function registerAliasCommand(program2) {
|
|
|
5882
5924
|
}
|
|
5883
5925
|
|
|
5884
5926
|
// src/commands/projects.ts
|
|
5885
|
-
import { existsSync as existsSync11, readdirSync as
|
|
5886
|
-
import { join as
|
|
5927
|
+
import { existsSync as existsSync11, readdirSync as readdirSync6, statSync as statSync4 } from "fs";
|
|
5928
|
+
import { join as join11 } from "path";
|
|
5887
5929
|
import chalk13 from "chalk";
|
|
5888
5930
|
function registerProjectsCommand(program2) {
|
|
5889
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) => {
|
|
@@ -5907,7 +5949,7 @@ function handleProjectsOverview(json) {
|
|
|
5907
5949
|
printProjectsOverviewAsText(result);
|
|
5908
5950
|
}
|
|
5909
5951
|
function handleProjectDetail(name, json) {
|
|
5910
|
-
const projectDir =
|
|
5952
|
+
const projectDir = join11(WORKTREES_DIR, name);
|
|
5911
5953
|
if (!existsSync11(projectDir)) {
|
|
5912
5954
|
printError(MESSAGES.PROJECTS_NOT_FOUND(name));
|
|
5913
5955
|
process.exit(1);
|
|
@@ -5924,13 +5966,13 @@ function collectProjectsOverview() {
|
|
|
5924
5966
|
if (!existsSync11(WORKTREES_DIR)) {
|
|
5925
5967
|
return { projects: [], totalProjects: 0, totalDiskUsage: 0 };
|
|
5926
5968
|
}
|
|
5927
|
-
const entries =
|
|
5969
|
+
const entries = readdirSync6(WORKTREES_DIR, { withFileTypes: true });
|
|
5928
5970
|
const projects = [];
|
|
5929
5971
|
for (const entry of entries) {
|
|
5930
5972
|
if (!entry.isDirectory()) {
|
|
5931
5973
|
continue;
|
|
5932
5974
|
}
|
|
5933
|
-
const projectDir =
|
|
5975
|
+
const projectDir = join11(WORKTREES_DIR, entry.name);
|
|
5934
5976
|
const overview = collectSingleProjectOverview(entry.name, projectDir);
|
|
5935
5977
|
projects.push(overview);
|
|
5936
5978
|
}
|
|
@@ -5943,11 +5985,11 @@ function collectProjectsOverview() {
|
|
|
5943
5985
|
};
|
|
5944
5986
|
}
|
|
5945
5987
|
function collectSingleProjectOverview(name, projectDir) {
|
|
5946
|
-
const subEntries =
|
|
5988
|
+
const subEntries = readdirSync6(projectDir, { withFileTypes: true });
|
|
5947
5989
|
const worktreeDirs = subEntries.filter((e) => e.isDirectory());
|
|
5948
5990
|
const worktreeCount = worktreeDirs.length;
|
|
5949
5991
|
const diskUsage = calculateDirSize(projectDir);
|
|
5950
|
-
const lastActiveTime = resolveProjectLastActiveTime(projectDir, worktreeDirs.map((e) =>
|
|
5992
|
+
const lastActiveTime = resolveProjectLastActiveTime(projectDir, worktreeDirs.map((e) => join11(projectDir, e.name)));
|
|
5951
5993
|
return {
|
|
5952
5994
|
name,
|
|
5953
5995
|
worktreeCount,
|
|
@@ -5956,13 +5998,13 @@ function collectSingleProjectOverview(name, projectDir) {
|
|
|
5956
5998
|
};
|
|
5957
5999
|
}
|
|
5958
6000
|
function collectProjectDetail(name, projectDir) {
|
|
5959
|
-
const subEntries =
|
|
6001
|
+
const subEntries = readdirSync6(projectDir, { withFileTypes: true });
|
|
5960
6002
|
const worktrees = [];
|
|
5961
6003
|
for (const entry of subEntries) {
|
|
5962
6004
|
if (!entry.isDirectory()) {
|
|
5963
6005
|
continue;
|
|
5964
6006
|
}
|
|
5965
|
-
const wtPath =
|
|
6007
|
+
const wtPath = join11(projectDir, entry.name);
|
|
5966
6008
|
const detail = collectSingleWorktreeDetail(entry.name, wtPath);
|
|
5967
6009
|
worktrees.push(detail);
|
|
5968
6010
|
}
|
|
@@ -6062,7 +6104,7 @@ function printWorktreeDetailItem(wt) {
|
|
|
6062
6104
|
|
|
6063
6105
|
// src/commands/completion.ts
|
|
6064
6106
|
import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync13 } from "fs";
|
|
6065
|
-
import { resolve as
|
|
6107
|
+
import { resolve as resolve3 } from "path";
|
|
6066
6108
|
import { homedir as homedir2 } from "os";
|
|
6067
6109
|
|
|
6068
6110
|
// src/utils/completion-scripts.ts
|
|
@@ -6112,23 +6154,23 @@ compdef _clawt_completion clawt
|
|
|
6112
6154
|
}
|
|
6113
6155
|
|
|
6114
6156
|
// src/utils/completion-engine.ts
|
|
6115
|
-
import { existsSync as existsSync12, readdirSync as
|
|
6116
|
-
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";
|
|
6117
6159
|
function completeFilePath(partial) {
|
|
6118
6160
|
const cwd = process.cwd();
|
|
6119
6161
|
const hasDir = partial.includes("/");
|
|
6120
|
-
const searchDir = hasDir ?
|
|
6162
|
+
const searchDir = hasDir ? join12(cwd, dirname(partial)) : cwd;
|
|
6121
6163
|
const prefix = hasDir ? basename2(partial) : partial;
|
|
6122
6164
|
if (!existsSync12(searchDir)) {
|
|
6123
6165
|
return [];
|
|
6124
6166
|
}
|
|
6125
|
-
const entries =
|
|
6167
|
+
const entries = readdirSync7(searchDir);
|
|
6126
6168
|
const results = [];
|
|
6127
6169
|
const dirPrefix = hasDir ? dirname(partial) + "/" : "";
|
|
6128
6170
|
for (const entry of entries) {
|
|
6129
6171
|
if (!entry.startsWith(prefix)) continue;
|
|
6130
6172
|
if (entry.startsWith(".")) continue;
|
|
6131
|
-
const fullPath =
|
|
6173
|
+
const fullPath = join12(searchDir, entry);
|
|
6132
6174
|
try {
|
|
6133
6175
|
const stat = statSync5(fullPath);
|
|
6134
6176
|
if (stat.isDirectory()) {
|
|
@@ -6234,14 +6276,14 @@ function installCompletions() {
|
|
|
6234
6276
|
const home = homedir2();
|
|
6235
6277
|
try {
|
|
6236
6278
|
if (shell.includes("zsh")) {
|
|
6237
|
-
const rcPath =
|
|
6279
|
+
const rcPath = resolve3(home, ".zshrc");
|
|
6238
6280
|
const script = `
|
|
6239
6281
|
# clawt completion
|
|
6240
6282
|
source <(clawt completion zsh)
|
|
6241
6283
|
`;
|
|
6242
6284
|
appendToFile(rcPath, script);
|
|
6243
6285
|
} else if (shell.includes("bash")) {
|
|
6244
|
-
const rcPath =
|
|
6286
|
+
const rcPath = resolve3(home, ".bashrc");
|
|
6245
6287
|
const script = `
|
|
6246
6288
|
# clawt completion
|
|
6247
6289
|
eval "$(clawt completion bash)"
|
|
@@ -6251,7 +6293,7 @@ eval "$(clawt completion bash)"
|
|
|
6251
6293
|
printWarning(MESSAGES.COMPLETION_INSTALL_UNKNOWN_SHELL);
|
|
6252
6294
|
}
|
|
6253
6295
|
} catch (error) {
|
|
6254
|
-
const filePath = shell.includes("zsh") ?
|
|
6296
|
+
const filePath = shell.includes("zsh") ? resolve3(home, ".zshrc") : resolve3(home, ".bashrc");
|
|
6255
6297
|
printError(MESSAGES.COMPLETION_INSTALL_WRITE_ERROR(filePath));
|
|
6256
6298
|
}
|
|
6257
6299
|
}
|
|
@@ -6353,17 +6395,17 @@ async function handleHome() {
|
|
|
6353
6395
|
}
|
|
6354
6396
|
|
|
6355
6397
|
// src/commands/tasks.ts
|
|
6356
|
-
import { resolve as
|
|
6398
|
+
import { resolve as resolve4, dirname as dirname2, join as join13 } from "path";
|
|
6357
6399
|
import { existsSync as existsSync14, writeFileSync as writeFileSync6 } from "fs";
|
|
6358
6400
|
function registerTasksCommand(program2) {
|
|
6359
6401
|
const taskCmd = program2.command("tasks").description("\u4EFB\u52A1\u6587\u4EF6\u7BA1\u7406");
|
|
6360
6402
|
taskCmd.command("init").description("\u751F\u6210\u4EFB\u52A1\u6A21\u677F\u6587\u4EF6").argument("[path]", "\u8F93\u51FA\u6587\u4EF6\u8DEF\u5F84").action(async (path2) => {
|
|
6361
|
-
const filePath = path2 ??
|
|
6403
|
+
const filePath = path2 ?? join13(TASK_TEMPLATE_OUTPUT_DIR, generateTaskFilename(TASK_TEMPLATE_FILENAME_PREFIX));
|
|
6362
6404
|
await handleTasksInit(filePath);
|
|
6363
6405
|
});
|
|
6364
6406
|
}
|
|
6365
6407
|
async function handleTasksInit(filePath) {
|
|
6366
|
-
const absolutePath =
|
|
6408
|
+
const absolutePath = resolve4(filePath);
|
|
6367
6409
|
logger.info(`tasks init \u547D\u4EE4\u6267\u884C\uFF0C\u76EE\u6807\u6587\u4EF6: ${absolutePath}`);
|
|
6368
6410
|
if (existsSync14(absolutePath)) {
|
|
6369
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/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);
|
|
@@ -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/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
|
+
}
|
|
@@ -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,12 +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
46
|
import { createWorktreeInfo } from '../../helpers/fixtures.js';
|
|
37
47
|
|
|
38
48
|
const mockedSpawnSync = vi.mocked(spawnSync);
|
|
39
49
|
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
50
|
+
const mockedResolveClaudeCodeCommand = vi.mocked(resolveClaudeCodeCommand);
|
|
40
51
|
const mockedPrintInfo = vi.mocked(printInfo);
|
|
41
52
|
const mockedPrintWarning = vi.mocked(printWarning);
|
|
42
53
|
const mockedExistsSync = vi.mocked(existsSync);
|
|
@@ -86,7 +97,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
86
97
|
});
|
|
87
98
|
|
|
88
99
|
it('正常启动 Claude Code(退出码为 0)', () => {
|
|
89
|
-
|
|
100
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
90
101
|
mockedExistsSync.mockReturnValue(false);
|
|
91
102
|
mockedSpawnSync.mockReturnValue({
|
|
92
103
|
status: 0,
|
|
@@ -100,7 +111,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
100
111
|
|
|
101
112
|
launchInteractiveClaude(worktree);
|
|
102
113
|
|
|
103
|
-
expect(
|
|
114
|
+
expect(mockedResolveClaudeCodeCommand).toHaveBeenCalled();
|
|
104
115
|
expect(mockedSpawnSync).toHaveBeenCalledWith(
|
|
105
116
|
'claude',
|
|
106
117
|
expect.any(Array),
|
|
@@ -112,7 +123,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
112
123
|
});
|
|
113
124
|
|
|
114
125
|
it('输出分支和路径信息', () => {
|
|
115
|
-
|
|
126
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
116
127
|
mockedExistsSync.mockReturnValue(false);
|
|
117
128
|
mockedSpawnSync.mockReturnValue({
|
|
118
129
|
status: 0,
|
|
@@ -131,7 +142,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
131
142
|
});
|
|
132
143
|
|
|
133
144
|
it('支持带参数的命令(如 npx claude)', () => {
|
|
134
|
-
|
|
145
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('npx claude');
|
|
135
146
|
mockedExistsSync.mockReturnValue(false);
|
|
136
147
|
mockedSpawnSync.mockReturnValue({
|
|
137
148
|
status: 0,
|
|
@@ -153,7 +164,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
153
164
|
});
|
|
154
165
|
|
|
155
166
|
it('spawnSync 返回 error 时抛出 ClawtError', () => {
|
|
156
|
-
|
|
167
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
157
168
|
mockedExistsSync.mockReturnValue(false);
|
|
158
169
|
mockedSpawnSync.mockReturnValue({
|
|
159
170
|
status: null,
|
|
@@ -170,7 +181,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
170
181
|
});
|
|
171
182
|
|
|
172
183
|
it('非零退出码时调用 printWarning', () => {
|
|
173
|
-
|
|
184
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
174
185
|
mockedExistsSync.mockReturnValue(false);
|
|
175
186
|
mockedSpawnSync.mockReturnValue({
|
|
176
187
|
status: 1,
|
|
@@ -188,7 +199,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
188
199
|
});
|
|
189
200
|
|
|
190
201
|
it('退出码为 null 时不调用 printWarning', () => {
|
|
191
|
-
|
|
202
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
192
203
|
mockedExistsSync.mockReturnValue(false);
|
|
193
204
|
mockedSpawnSync.mockReturnValue({
|
|
194
205
|
status: null,
|
|
@@ -206,7 +217,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
206
217
|
});
|
|
207
218
|
|
|
208
219
|
it('退出码为 0 时不调用 printWarning', () => {
|
|
209
|
-
|
|
220
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
210
221
|
mockedExistsSync.mockReturnValue(false);
|
|
211
222
|
mockedSpawnSync.mockReturnValue({
|
|
212
223
|
status: 0,
|
|
@@ -224,7 +235,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
224
235
|
});
|
|
225
236
|
|
|
226
237
|
it('autoContinue 启用且有会话历史时追加 --continue 参数', () => {
|
|
227
|
-
|
|
238
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
228
239
|
mockedExistsSync.mockReturnValue(true);
|
|
229
240
|
mockedReaddirSync.mockReturnValue(['session-abc.jsonl'] as unknown as ReturnType<typeof readdirSync>);
|
|
230
241
|
mockedSpawnSync.mockReturnValue({
|
|
@@ -245,7 +256,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
245
256
|
});
|
|
246
257
|
|
|
247
258
|
it('autoContinue 启用但无会话历史时不追加 --continue 参数', () => {
|
|
248
|
-
|
|
259
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
249
260
|
mockedExistsSync.mockReturnValue(false);
|
|
250
261
|
mockedSpawnSync.mockReturnValue({
|
|
251
262
|
status: 0,
|
|
@@ -265,7 +276,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
265
276
|
});
|
|
266
277
|
|
|
267
278
|
it('不传 autoContinue 时即使有会话历史也不追加 --continue', () => {
|
|
268
|
-
|
|
279
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
269
280
|
mockedExistsSync.mockReturnValue(true);
|
|
270
281
|
mockedReaddirSync.mockReturnValue(['session-abc.jsonl'] as unknown as ReturnType<typeof readdirSync>);
|
|
271
282
|
mockedSpawnSync.mockReturnValue({
|
|
@@ -285,7 +296,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
285
296
|
});
|
|
286
297
|
|
|
287
298
|
it('不包含 --append-system-prompt 参数', () => {
|
|
288
|
-
|
|
299
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
289
300
|
mockedExistsSync.mockReturnValue(false);
|
|
290
301
|
mockedSpawnSync.mockReturnValue({
|
|
291
302
|
status: 0,
|
|
@@ -311,7 +322,7 @@ describe('buildClaudeCommand', () => {
|
|
|
311
322
|
});
|
|
312
323
|
|
|
313
324
|
it('生成包含 cd 和 claude 命令的字符串', () => {
|
|
314
|
-
|
|
325
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
315
326
|
|
|
316
327
|
const cmd = buildClaudeCommand(worktree, false);
|
|
317
328
|
|
|
@@ -320,7 +331,7 @@ describe('buildClaudeCommand', () => {
|
|
|
320
331
|
});
|
|
321
332
|
|
|
322
333
|
it('hasPreviousSession 为 true 时包含 --continue', () => {
|
|
323
|
-
|
|
334
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
324
335
|
|
|
325
336
|
const cmd = buildClaudeCommand(worktree, true);
|
|
326
337
|
|
|
@@ -328,7 +339,7 @@ describe('buildClaudeCommand', () => {
|
|
|
328
339
|
});
|
|
329
340
|
|
|
330
341
|
it('hasPreviousSession 为 false 时不包含 --continue', () => {
|
|
331
|
-
|
|
342
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
332
343
|
|
|
333
344
|
const cmd = buildClaudeCommand(worktree, false);
|
|
334
345
|
|
|
@@ -336,7 +347,7 @@ describe('buildClaudeCommand', () => {
|
|
|
336
347
|
});
|
|
337
348
|
|
|
338
349
|
it('不包含 --append-system-prompt 参数', () => {
|
|
339
|
-
|
|
350
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
340
351
|
|
|
341
352
|
const cmd = buildClaudeCommand(worktree, false);
|
|
342
353
|
|
|
@@ -344,7 +355,7 @@ describe('buildClaudeCommand', () => {
|
|
|
344
355
|
});
|
|
345
356
|
|
|
346
357
|
it('路径中的单引号被正确转义', () => {
|
|
347
|
-
|
|
358
|
+
mockedResolveClaudeCodeCommand.mockReturnValue('claude');
|
|
348
359
|
const wtWithQuote = createWorktreeInfo({
|
|
349
360
|
path: "/tmp/test's-worktree",
|
|
350
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
|
+
});
|