cclaw-cli 7.2.0 → 7.3.0
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/dist/config.d.ts +6 -1
- package/dist/config.js +46 -4
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -0
- package/dist/content/core-agents.js +1 -0
- package/dist/content/hooks.js +137 -1
- package/dist/delegation.d.ts +5 -0
- package/dist/install.js +1 -1
- package/dist/internal/slice-commit.js +179 -10
- package/dist/runtime/run-hook.mjs +1 -0
- package/dist/types.d.ts +12 -0
- package/dist/worktree-manager.d.ts +20 -0
- package/dist/worktree-manager.js +108 -0
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import type { CclawConfig, FlowTrack, HarnessId, LanguageRulePack, TddCommitMode } from "./types.js";
|
|
1
|
+
import type { CclawConfig, FlowTrack, HarnessId, LanguageRulePack, TddCommitMode, TddIsolationMode } from "./types.js";
|
|
2
2
|
export declare const TDD_COMMIT_MODES: readonly ["managed-per-slice", "agent-required", "checkpoint-only", "off"];
|
|
3
3
|
export declare const DEFAULT_TDD_COMMIT_MODE: TddCommitMode;
|
|
4
|
+
export declare const TDD_ISOLATION_MODES: readonly ["worktree", "in-place", "auto"];
|
|
5
|
+
export declare const DEFAULT_TDD_ISOLATION_MODE: TddIsolationMode;
|
|
6
|
+
export declare const DEFAULT_TDD_WORKTREE_ROOT = ".cclaw/worktrees";
|
|
4
7
|
export declare const DEFAULT_TDD_TEST_PATH_PATTERNS: readonly string[];
|
|
5
8
|
export declare const DEFAULT_TDD_TEST_GLOBS: readonly string[];
|
|
6
9
|
export declare const DEFAULT_TDD_PRODUCTION_PATH_PATTERNS: readonly string[];
|
|
@@ -19,6 +22,8 @@ export declare class InvalidConfigError extends Error {
|
|
|
19
22
|
export declare function configPath(projectRoot: string): string;
|
|
20
23
|
export declare function createDefaultConfig(harnesses?: HarnessId[], _defaultTrack?: FlowTrack): CclawConfig;
|
|
21
24
|
export declare function resolveTddCommitMode(config: Pick<CclawConfig, "tdd"> | null | undefined): TddCommitMode;
|
|
25
|
+
export declare function resolveTddIsolationMode(config: Pick<CclawConfig, "tdd"> | null | undefined): TddIsolationMode;
|
|
26
|
+
export declare function resolveTddWorktreeRoot(config: Pick<CclawConfig, "tdd"> | null | undefined): string;
|
|
22
27
|
export declare function detectLanguageRulePacks(_projectRoot: string): Promise<LanguageRulePack[]>;
|
|
23
28
|
export declare function readConfig(projectRoot: string, _options?: ReadConfigOptions): Promise<CclawConfig>;
|
|
24
29
|
export interface WriteConfigOptions {
|
package/dist/config.js
CHANGED
|
@@ -16,6 +16,10 @@ export const TDD_COMMIT_MODES = [
|
|
|
16
16
|
];
|
|
17
17
|
const TDD_COMMIT_MODE_SET = new Set(TDD_COMMIT_MODES);
|
|
18
18
|
export const DEFAULT_TDD_COMMIT_MODE = "managed-per-slice";
|
|
19
|
+
export const TDD_ISOLATION_MODES = ["worktree", "in-place", "auto"];
|
|
20
|
+
const TDD_ISOLATION_MODE_SET = new Set(TDD_ISOLATION_MODES);
|
|
21
|
+
export const DEFAULT_TDD_ISOLATION_MODE = "worktree";
|
|
22
|
+
export const DEFAULT_TDD_WORKTREE_ROOT = `${RUNTIME_ROOT}/worktrees`;
|
|
19
23
|
// Kept for runtime modules that use these defaults directly.
|
|
20
24
|
export const DEFAULT_TDD_TEST_PATH_PATTERNS = [
|
|
21
25
|
"**/*.test.*",
|
|
@@ -40,7 +44,9 @@ function configFixExample() {
|
|
|
40
44
|
- claude
|
|
41
45
|
- cursor
|
|
42
46
|
tdd:
|
|
43
|
-
commitMode: managed-per-slice
|
|
47
|
+
commitMode: managed-per-slice
|
|
48
|
+
isolationMode: worktree
|
|
49
|
+
worktreeRoot: .cclaw/worktrees`;
|
|
44
50
|
}
|
|
45
51
|
function configValidationError(configFilePath, reason) {
|
|
46
52
|
return new InvalidConfigError(`Invalid cclaw config at ${configFilePath}: ${reason}\n` +
|
|
@@ -60,7 +66,9 @@ export function createDefaultConfig(harnesses = DEFAULT_HARNESSES, _defaultTrack
|
|
|
60
66
|
flowVersion: FLOW_VERSION,
|
|
61
67
|
harnesses: [...new Set(harnesses)],
|
|
62
68
|
tdd: {
|
|
63
|
-
commitMode: DEFAULT_TDD_COMMIT_MODE
|
|
69
|
+
commitMode: DEFAULT_TDD_COMMIT_MODE,
|
|
70
|
+
isolationMode: DEFAULT_TDD_ISOLATION_MODE,
|
|
71
|
+
worktreeRoot: DEFAULT_TDD_WORKTREE_ROOT
|
|
64
72
|
}
|
|
65
73
|
};
|
|
66
74
|
}
|
|
@@ -71,6 +79,20 @@ export function resolveTddCommitMode(config) {
|
|
|
71
79
|
}
|
|
72
80
|
return DEFAULT_TDD_COMMIT_MODE;
|
|
73
81
|
}
|
|
82
|
+
export function resolveTddIsolationMode(config) {
|
|
83
|
+
const raw = config?.tdd?.isolationMode;
|
|
84
|
+
if (typeof raw === "string" && TDD_ISOLATION_MODE_SET.has(raw)) {
|
|
85
|
+
return raw;
|
|
86
|
+
}
|
|
87
|
+
return DEFAULT_TDD_ISOLATION_MODE;
|
|
88
|
+
}
|
|
89
|
+
export function resolveTddWorktreeRoot(config) {
|
|
90
|
+
const raw = config?.tdd?.worktreeRoot;
|
|
91
|
+
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
92
|
+
return raw.trim();
|
|
93
|
+
}
|
|
94
|
+
return DEFAULT_TDD_WORKTREE_ROOT;
|
|
95
|
+
}
|
|
74
96
|
function assertOnlySupportedKeys(parsed, fullPath) {
|
|
75
97
|
const unknownKeys = Object.keys(parsed).filter((key) => !ALLOWED_CONFIG_KEYS.has(key));
|
|
76
98
|
if (unknownKeys.length === 0)
|
|
@@ -129,19 +151,37 @@ export async function readConfig(projectRoot, _options = {}) {
|
|
|
129
151
|
: FLOW_VERSION;
|
|
130
152
|
const parsedTdd = isRecord(parsed.tdd) ? parsed.tdd : {};
|
|
131
153
|
const rawCommitMode = parsedTdd.commitMode;
|
|
154
|
+
const rawIsolationMode = parsedTdd.isolationMode;
|
|
155
|
+
const rawWorktreeRoot = parsedTdd.worktreeRoot;
|
|
132
156
|
if (rawCommitMode !== undefined &&
|
|
133
157
|
(typeof rawCommitMode !== "string" || !TDD_COMMIT_MODE_SET.has(rawCommitMode))) {
|
|
134
158
|
throw configValidationError(fullPath, `"tdd.commitMode" must be one of: ${TDD_COMMIT_MODES.join(", ")}`);
|
|
135
159
|
}
|
|
160
|
+
if (rawIsolationMode !== undefined &&
|
|
161
|
+
(typeof rawIsolationMode !== "string" || !TDD_ISOLATION_MODE_SET.has(rawIsolationMode))) {
|
|
162
|
+
throw configValidationError(fullPath, `"tdd.isolationMode" must be one of: ${TDD_ISOLATION_MODES.join(", ")}`);
|
|
163
|
+
}
|
|
164
|
+
if (rawWorktreeRoot !== undefined &&
|
|
165
|
+
(typeof rawWorktreeRoot !== "string" || rawWorktreeRoot.trim().length === 0)) {
|
|
166
|
+
throw configValidationError(fullPath, `"tdd.worktreeRoot" must be a non-empty string when provided`);
|
|
167
|
+
}
|
|
136
168
|
const commitMode = typeof rawCommitMode === "string"
|
|
137
169
|
? rawCommitMode
|
|
138
170
|
: DEFAULT_TDD_COMMIT_MODE;
|
|
171
|
+
const isolationMode = typeof rawIsolationMode === "string"
|
|
172
|
+
? rawIsolationMode
|
|
173
|
+
: DEFAULT_TDD_ISOLATION_MODE;
|
|
174
|
+
const worktreeRoot = typeof rawWorktreeRoot === "string" && rawWorktreeRoot.trim().length > 0
|
|
175
|
+
? rawWorktreeRoot.trim()
|
|
176
|
+
: DEFAULT_TDD_WORKTREE_ROOT;
|
|
139
177
|
return {
|
|
140
178
|
version,
|
|
141
179
|
flowVersion,
|
|
142
180
|
harnesses: normalizedHarnesses,
|
|
143
181
|
tdd: {
|
|
144
|
-
commitMode
|
|
182
|
+
commitMode,
|
|
183
|
+
isolationMode,
|
|
184
|
+
worktreeRoot
|
|
145
185
|
}
|
|
146
186
|
};
|
|
147
187
|
}
|
|
@@ -151,7 +191,9 @@ export async function writeConfig(projectRoot, config, _options = {}) {
|
|
|
151
191
|
flowVersion: config.flowVersion,
|
|
152
192
|
harnesses: config.harnesses,
|
|
153
193
|
tdd: {
|
|
154
|
-
commitMode: resolveTddCommitMode(config)
|
|
194
|
+
commitMode: resolveTddCommitMode(config),
|
|
195
|
+
isolationMode: resolveTddIsolationMode(config),
|
|
196
|
+
worktreeRoot: resolveTddWorktreeRoot(config)
|
|
155
197
|
}
|
|
156
198
|
};
|
|
157
199
|
await writeFileSafe(configPath(projectRoot), stringify(serialisable));
|
package/dist/constants.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare const FLOW_VERSION = "1.0.0";
|
|
|
10
10
|
export declare const SHIP_FINALIZATION_MODES: readonly ["FINALIZE_MERGE_LOCAL", "FINALIZE_OPEN_PR", "FINALIZE_KEEP_BRANCH", "FINALIZE_DISCARD_BRANCH", "FINALIZE_NO_VCS"];
|
|
11
11
|
export type ShipFinalizationMode = (typeof SHIP_FINALIZATION_MODES)[number];
|
|
12
12
|
export declare const DEFAULT_HARNESSES: HarnessId[];
|
|
13
|
-
export declare const REQUIRED_DIRS: readonly [".cclaw", ".cclaw/commands", ".cclaw/skills", ".cclaw/templates", ".cclaw/templates/state-contracts", ".cclaw/artifacts", ".cclaw/wave-plans", ".cclaw/archive", ".cclaw/state", ".cclaw/rules", ".cclaw/agents", ".cclaw/hooks", ".cclaw/skills/review-prompts"];
|
|
13
|
+
export declare const REQUIRED_DIRS: readonly [".cclaw", ".cclaw/commands", ".cclaw/skills", ".cclaw/templates", ".cclaw/templates/state-contracts", ".cclaw/artifacts", ".cclaw/wave-plans", ".cclaw/archive", ".cclaw/worktrees", ".cclaw/state", ".cclaw/rules", ".cclaw/agents", ".cclaw/hooks", ".cclaw/skills/review-prompts"];
|
|
14
14
|
export declare const REQUIRED_GITIGNORE_PATTERNS: readonly ["# cclaw generated artifacts", ".cclaw/", ".claude/commands/cc-*.md", ".claude/commands/cc.md", ".cursor/commands/cc-*.md", ".cursor/commands/cc.md", ".opencode/commands/cc-*.md", ".opencode/commands/cc.md", ".agents/skills/cc/SKILL.md", ".agents/skills/cc-*/SKILL.md", ".claude/hooks/hooks.json", ".cursor/hooks.json", ".codex/hooks.json", ".opencode/plugins/cclaw-plugin.mjs", ".cursor/rules/cclaw-workflow.mdc"];
|
|
15
15
|
/**
|
|
16
16
|
* Canonical stage -> skill folder mapping.
|
package/dist/constants.js
CHANGED
|
@@ -156,6 +156,7 @@ export function sliceBuilderProtocol() {
|
|
|
156
156
|
"### Invariants",
|
|
157
157
|
"- Produce failing RED evidence (or cite the delegated RED artifact) **before** production edits.",
|
|
158
158
|
"- Stay inside the slice contract: `claimedPaths`, acceptance mapping, and forbidden-change lists from the parent.",
|
|
159
|
+
"- When `tdd.isolationMode=worktree|auto`, run the slice inside the assigned worktree path (never in the shared repo root) so filesystem isolation enforces the claimed-path fence.",
|
|
159
160
|
"- When `tdd.commitMode=managed-per-slice`, do **not** hand-edit git state for slice files (no manual `git add/commit` on claimed paths). Let `.cclaw/hooks/slice-commit.mjs` own per-slice commits.",
|
|
160
161
|
"- After GREEN, refactor inline **or** record deferred refactor via the same `--refactor-outcome` mechanics the controller specifies.",
|
|
161
162
|
"- Own the prose slice summary at `<artifacts-dir>/tdd-slices/S-<id>.md` yourself.",
|
package/dist/content/hooks.js
CHANGED
|
@@ -614,6 +614,10 @@ function buildRow(args, status, runId, now, options) {
|
|
|
614
614
|
riskTierRaw === "low" || riskTierRaw === "medium" || riskTierRaw === "high"
|
|
615
615
|
? riskTierRaw
|
|
616
616
|
: undefined;
|
|
617
|
+
const worktreePath =
|
|
618
|
+
typeof args["worktree-path"] === "string" && args["worktree-path"].trim().length > 0
|
|
619
|
+
? args["worktree-path"].trim()
|
|
620
|
+
: undefined;
|
|
617
621
|
return {
|
|
618
622
|
stage: args.stage,
|
|
619
623
|
agent: args.agent,
|
|
@@ -637,6 +641,7 @@ function buildRow(args, status, runId, now, options) {
|
|
|
637
641
|
schemaVersion: LEDGER_SCHEMA_VERSION,
|
|
638
642
|
allowParallel: args["allow-parallel"] === true ? true : undefined,
|
|
639
643
|
claimedPaths: claimedPaths.length > 0 ? claimedPaths : undefined,
|
|
644
|
+
worktreePath,
|
|
640
645
|
sliceId,
|
|
641
646
|
phase,
|
|
642
647
|
refactorOutcome,
|
|
@@ -1251,6 +1256,24 @@ async function runSliceCommitIfNeeded(root, row, runId) {
|
|
|
1251
1256
|
"--span-id=" + spanId,
|
|
1252
1257
|
"--run-id=" + runId
|
|
1253
1258
|
];
|
|
1259
|
+
let explicitWorktreePath =
|
|
1260
|
+
typeof row.worktreePath === "string" && row.worktreePath.trim().length > 0
|
|
1261
|
+
? row.worktreePath.trim()
|
|
1262
|
+
: "";
|
|
1263
|
+
if (explicitWorktreePath.length === 0) {
|
|
1264
|
+
const priorLedger = await readDelegationLedgerEntries(root);
|
|
1265
|
+
const priorSpanPath = priorLedger
|
|
1266
|
+
.filter((entry) => entry && entry.spanId === spanId && entry.runId === runId)
|
|
1267
|
+
.map((entry) =>
|
|
1268
|
+
entry && typeof entry.worktreePath === "string" ? entry.worktreePath.trim() : "")
|
|
1269
|
+
.find((value) => value.length > 0);
|
|
1270
|
+
if (priorSpanPath) {
|
|
1271
|
+
explicitWorktreePath = priorSpanPath;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
if (explicitWorktreePath.length > 0) {
|
|
1275
|
+
helperArgs.push("--worktree-path=" + explicitWorktreePath);
|
|
1276
|
+
}
|
|
1254
1277
|
if (typeof row.taskId === "string" && row.taskId.trim().length > 0) {
|
|
1255
1278
|
helperArgs.push("--task-id=" + row.taskId.trim());
|
|
1256
1279
|
}
|
|
@@ -1320,6 +1343,91 @@ async function runSliceCommitIfNeeded(root, row, runId) {
|
|
|
1320
1343
|
});
|
|
1321
1344
|
}
|
|
1322
1345
|
|
|
1346
|
+
async function runSliceWorktreePrepareIfNeeded(root, row, runId) {
|
|
1347
|
+
if (
|
|
1348
|
+
row.stage !== "tdd" ||
|
|
1349
|
+
row.agent !== "slice-builder" ||
|
|
1350
|
+
row.status !== "scheduled"
|
|
1351
|
+
) {
|
|
1352
|
+
return { ok: true, skipped: true };
|
|
1353
|
+
}
|
|
1354
|
+
const sliceId = typeof row.sliceId === "string" ? row.sliceId.trim() : "";
|
|
1355
|
+
const spanId = typeof row.spanId === "string" ? row.spanId.trim() : "";
|
|
1356
|
+
if (sliceId.length === 0 || spanId.length === 0) {
|
|
1357
|
+
return { ok: true, skipped: true };
|
|
1358
|
+
}
|
|
1359
|
+
const helperPath = path.join(root, RUNTIME_ROOT, "hooks", "slice-commit.mjs");
|
|
1360
|
+
if (!(await exists(helperPath))) {
|
|
1361
|
+
return { ok: true, skipped: true };
|
|
1362
|
+
}
|
|
1363
|
+
const helperArgs = [
|
|
1364
|
+
helperPath,
|
|
1365
|
+
"--json",
|
|
1366
|
+
"--quiet",
|
|
1367
|
+
"--prepare-worktree",
|
|
1368
|
+
"--slice=" + sliceId,
|
|
1369
|
+
"--span-id=" + spanId,
|
|
1370
|
+
"--run-id=" + runId
|
|
1371
|
+
];
|
|
1372
|
+
if (Array.isArray(row.claimedPaths) && row.claimedPaths.length > 0) {
|
|
1373
|
+
helperArgs.push("--claimed-paths=" + row.claimedPaths.join(","));
|
|
1374
|
+
}
|
|
1375
|
+
return await new Promise((resolve) => {
|
|
1376
|
+
const child = spawn(process.execPath, helperArgs, {
|
|
1377
|
+
cwd: root,
|
|
1378
|
+
env: process.env,
|
|
1379
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1380
|
+
});
|
|
1381
|
+
let out = "";
|
|
1382
|
+
let err = "";
|
|
1383
|
+
child.stdout.on("data", (chunk) => {
|
|
1384
|
+
out += String(chunk ?? "");
|
|
1385
|
+
});
|
|
1386
|
+
child.stderr.on("data", (chunk) => {
|
|
1387
|
+
err += String(chunk ?? "");
|
|
1388
|
+
});
|
|
1389
|
+
child.on("error", (error) => {
|
|
1390
|
+
resolve({
|
|
1391
|
+
ok: false,
|
|
1392
|
+
errorCode: "worktree_prepare_failed",
|
|
1393
|
+
details: {
|
|
1394
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1395
|
+
}
|
|
1396
|
+
});
|
|
1397
|
+
});
|
|
1398
|
+
child.on("close", (code) => {
|
|
1399
|
+
let payload = null;
|
|
1400
|
+
const trimmed = out.trim();
|
|
1401
|
+
if (trimmed.length > 0) {
|
|
1402
|
+
try {
|
|
1403
|
+
payload = JSON.parse(trimmed);
|
|
1404
|
+
} catch {
|
|
1405
|
+
payload = null;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
if (code === 0) {
|
|
1409
|
+
resolve({ ok: true, payload });
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const payloadCode =
|
|
1413
|
+
payload && typeof payload === "object" && typeof payload.errorCode === "string"
|
|
1414
|
+
? payload.errorCode
|
|
1415
|
+
: "worktree_prepare_failed";
|
|
1416
|
+
resolve({
|
|
1417
|
+
ok: false,
|
|
1418
|
+
errorCode: payloadCode,
|
|
1419
|
+
details:
|
|
1420
|
+
payload && typeof payload === "object"
|
|
1421
|
+
? payload
|
|
1422
|
+
: {
|
|
1423
|
+
stderr: err.trim(),
|
|
1424
|
+
stdout: out.trim()
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
});
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1323
1431
|
async function main() {
|
|
1324
1432
|
const args = parseArgs(process.argv.slice(2));
|
|
1325
1433
|
const json = args.json !== undefined;
|
|
@@ -1510,6 +1618,16 @@ async function main() {
|
|
|
1510
1618
|
const status = args.status;
|
|
1511
1619
|
const priorLedger = await readDelegationLedgerEntries(root);
|
|
1512
1620
|
const priorForSpan = priorLedger.filter((e) => e && e.spanId === args["span-id"]);
|
|
1621
|
+
const inheritedWorktreePath = priorForSpan
|
|
1622
|
+
.map((entry) =>
|
|
1623
|
+
entry && typeof entry.worktreePath === "string" ? entry.worktreePath.trim() : "")
|
|
1624
|
+
.find((value) => value.length > 0);
|
|
1625
|
+
if (
|
|
1626
|
+
inheritedWorktreePath &&
|
|
1627
|
+
(typeof args["worktree-path"] !== "string" || args["worktree-path"].trim().length === 0)
|
|
1628
|
+
) {
|
|
1629
|
+
args["worktree-path"] = inheritedWorktreePath;
|
|
1630
|
+
}
|
|
1513
1631
|
const inheritedStartTs = priorForSpan
|
|
1514
1632
|
.map((e) => e.startTs)
|
|
1515
1633
|
.filter((ts) => typeof ts === "string" && ts.length > 0)
|
|
@@ -1572,6 +1690,24 @@ async function main() {
|
|
|
1572
1690
|
emitErrorJson("dispatch_cap", capViolation, json);
|
|
1573
1691
|
return;
|
|
1574
1692
|
}
|
|
1693
|
+
const preparedWorktree = await runSliceWorktreePrepareIfNeeded(root, clean, runId);
|
|
1694
|
+
if (!preparedWorktree.ok) {
|
|
1695
|
+
emitErrorJson(
|
|
1696
|
+
preparedWorktree.errorCode || "worktree_prepare_failed",
|
|
1697
|
+
preparedWorktree.details || {},
|
|
1698
|
+
json
|
|
1699
|
+
);
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
if (
|
|
1703
|
+
preparedWorktree.payload &&
|
|
1704
|
+
typeof preparedWorktree.payload === "object" &&
|
|
1705
|
+
typeof preparedWorktree.payload.worktreePath === "string" &&
|
|
1706
|
+
preparedWorktree.payload.worktreePath.trim().length > 0
|
|
1707
|
+
) {
|
|
1708
|
+
clean.worktreePath = preparedWorktree.payload.worktreePath.trim();
|
|
1709
|
+
event.worktreePath = clean.worktreePath;
|
|
1710
|
+
}
|
|
1575
1711
|
}
|
|
1576
1712
|
const dedupViolation = enforceDispatchDedupInline(clean, priorLedger, args);
|
|
1577
1713
|
if (dedupViolation) {
|
|
@@ -1758,7 +1894,7 @@ void main();
|
|
|
1758
1894
|
`;
|
|
1759
1895
|
}
|
|
1760
1896
|
export function sliceCommitScript() {
|
|
1761
|
-
return internalHelperScript("slice-commit", "slice-commit", "Usage: node " + RUNTIME_ROOT + "/hooks/slice-commit.mjs --slice=<S-N> --span-id=<span-id> [--task-id=<T-id>] [--title=<text>] [--run-id=<run-id>] [--claimed-paths=<path1,path2,...>] [--claimed-path=<path> ...] [--json] [--quiet]");
|
|
1897
|
+
return internalHelperScript("slice-commit", "slice-commit", "Usage: node " + RUNTIME_ROOT + "/hooks/slice-commit.mjs --slice=<S-N> --span-id=<span-id> [--task-id=<T-id>] [--title=<text>] [--run-id=<run-id>] [--worktree-path=<abs-or-rel-path>] [--prepare-worktree] [--claimed-paths=<path1,path2,...>] [--claimed-path=<path> ...] [--json] [--quiet]");
|
|
1762
1898
|
}
|
|
1763
1899
|
export function runHookCmdScript() {
|
|
1764
1900
|
return `: << 'CMDBLOCK'
|
package/dist/delegation.d.ts
CHANGED
|
@@ -142,6 +142,11 @@ export type DelegationEntry = {
|
|
|
142
142
|
* `src/content/hooks.ts::delegationRecordScript`.
|
|
143
143
|
*/
|
|
144
144
|
claimedPaths?: string[];
|
|
145
|
+
/**
|
|
146
|
+
* Absolute path of the isolated git worktree assigned to this span when
|
|
147
|
+
* `tdd.isolationMode=worktree|auto`.
|
|
148
|
+
*/
|
|
149
|
+
worktreePath?: string;
|
|
145
150
|
/**
|
|
146
151
|
* TDD slice identifier, e.g. `"S-1"`. Recorded by the controller when
|
|
147
152
|
* dispatching `slice-builder` so the artifact linter can auto-derive the
|
package/dist/install.js
CHANGED
|
@@ -190,7 +190,7 @@ const DEPRECATED_HOOK_FILES = [
|
|
|
190
190
|
"context-monitor.sh"
|
|
191
191
|
];
|
|
192
192
|
const DEPRECATED_RUNTIME_ROOT_FILES = ["learnings.jsonl", "observations.jsonl"];
|
|
193
|
-
const DEPRECATED_RUNTIME_DIRS = ["evals", "
|
|
193
|
+
const DEPRECATED_RUNTIME_DIRS = ["evals", "references", "contexts"];
|
|
194
194
|
async function resolveGitHooksDir(projectRoot) {
|
|
195
195
|
try {
|
|
196
196
|
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", "hooks"], {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
|
-
import { readConfig, resolveTddCommitMode } from "../config.js";
|
|
4
|
+
import { readConfig, resolveTddCommitMode, resolveTddIsolationMode, resolveTddWorktreeRoot } from "../config.js";
|
|
5
5
|
import { readDelegationLedger } from "../delegation.js";
|
|
6
6
|
import { exists } from "../fs-utils.js";
|
|
7
|
+
import { cleanupWorktree, commitAndMergeBack, createSliceWorktree, WorktreeMergeConflictError, WorktreeUnsupportedError } from "../worktree-manager.js";
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
function parseCsv(raw) {
|
|
9
10
|
return raw
|
|
@@ -22,7 +23,9 @@ function parseSliceCommitArgs(tokens) {
|
|
|
22
23
|
let taskId;
|
|
23
24
|
let title;
|
|
24
25
|
let runId;
|
|
26
|
+
let worktreePath;
|
|
25
27
|
const claimedPaths = [];
|
|
28
|
+
let prepareWorktree = false;
|
|
26
29
|
let json = false;
|
|
27
30
|
let quiet = false;
|
|
28
31
|
for (let i = 0; i < tokens.length; i += 1) {
|
|
@@ -45,6 +48,10 @@ function parseSliceCommitArgs(tokens) {
|
|
|
45
48
|
quiet = true;
|
|
46
49
|
continue;
|
|
47
50
|
}
|
|
51
|
+
if (token === "--prepare-worktree") {
|
|
52
|
+
prepareWorktree = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
48
55
|
if (token.startsWith("--slice=") || token === "--slice") {
|
|
49
56
|
sliceId = valueFrom("--slice").trim();
|
|
50
57
|
continue;
|
|
@@ -65,6 +72,13 @@ function parseSliceCommitArgs(tokens) {
|
|
|
65
72
|
runId = valueFrom("--run-id").trim();
|
|
66
73
|
continue;
|
|
67
74
|
}
|
|
75
|
+
if (token.startsWith("--worktree-path=") || token === "--worktree-path") {
|
|
76
|
+
const resolved = valueFrom("--worktree-path").trim();
|
|
77
|
+
if (resolved.length > 0) {
|
|
78
|
+
worktreePath = resolved;
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
68
82
|
if (token.startsWith("--claimed-paths=") || token === "--claimed-paths") {
|
|
69
83
|
claimedPaths.push(...parseCsv(valueFrom("--claimed-paths")));
|
|
70
84
|
continue;
|
|
@@ -89,7 +103,9 @@ function parseSliceCommitArgs(tokens) {
|
|
|
89
103
|
taskId,
|
|
90
104
|
title,
|
|
91
105
|
runId,
|
|
106
|
+
worktreePath,
|
|
92
107
|
claimedPaths,
|
|
108
|
+
prepareWorktree,
|
|
93
109
|
json,
|
|
94
110
|
quiet
|
|
95
111
|
};
|
|
@@ -132,6 +148,12 @@ function parsePorcelainPaths(raw) {
|
|
|
132
148
|
}
|
|
133
149
|
return [...new Set(out)];
|
|
134
150
|
}
|
|
151
|
+
async function gitChangedPaths(cwd) {
|
|
152
|
+
const { stdout: statusRaw } = await execFileAsync("git", ["status", "--porcelain", "-uall"], {
|
|
153
|
+
cwd
|
|
154
|
+
});
|
|
155
|
+
return parsePorcelainPaths(statusRaw);
|
|
156
|
+
}
|
|
135
157
|
function matchesClaimedPath(changedPath, claimedPaths) {
|
|
136
158
|
const changed = normalizePathLike(changedPath);
|
|
137
159
|
return claimedPaths.some((rawClaimed) => {
|
|
@@ -171,6 +193,67 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
|
|
|
171
193
|
}
|
|
172
194
|
const config = await readConfig(projectRoot).catch(() => null);
|
|
173
195
|
const commitMode = resolveTddCommitMode(config);
|
|
196
|
+
const isolationMode = resolveTddIsolationMode(config);
|
|
197
|
+
const worktreeRoot = resolveTddWorktreeRoot(config);
|
|
198
|
+
const gitPresent = await exists(path.join(projectRoot, ".git"));
|
|
199
|
+
if (args.prepareWorktree) {
|
|
200
|
+
if (!gitPresent) {
|
|
201
|
+
output(io, args, {
|
|
202
|
+
ok: true,
|
|
203
|
+
skipped: true,
|
|
204
|
+
reason: "no-git",
|
|
205
|
+
message: "slice-worktree skipped: .git is missing"
|
|
206
|
+
});
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
if (isolationMode === "in-place") {
|
|
210
|
+
output(io, args, {
|
|
211
|
+
ok: true,
|
|
212
|
+
skipped: true,
|
|
213
|
+
reason: "isolation-in-place",
|
|
214
|
+
isolationMode,
|
|
215
|
+
message: "slice-worktree skipped: tdd.isolationMode=in-place"
|
|
216
|
+
});
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: projectRoot });
|
|
221
|
+
const prepared = await createSliceWorktree(args.sliceId, stdout.trim(), args.claimedPaths, {
|
|
222
|
+
projectRoot,
|
|
223
|
+
worktreeRoot
|
|
224
|
+
});
|
|
225
|
+
output(io, args, {
|
|
226
|
+
ok: true,
|
|
227
|
+
prepared: true,
|
|
228
|
+
sliceId: args.sliceId,
|
|
229
|
+
spanId: args.spanId,
|
|
230
|
+
worktreePath: prepared.path,
|
|
231
|
+
baseRef: prepared.ref
|
|
232
|
+
});
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (error instanceof WorktreeUnsupportedError) {
|
|
237
|
+
output(io, args, {
|
|
238
|
+
ok: true,
|
|
239
|
+
skipped: true,
|
|
240
|
+
reason: "worktree-unavailable",
|
|
241
|
+
degradedCommitMode: "agent-required",
|
|
242
|
+
message: error.message
|
|
243
|
+
});
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
output(io, args, {
|
|
247
|
+
ok: false,
|
|
248
|
+
errorCode: "worktree_prepare_failed",
|
|
249
|
+
details: {
|
|
250
|
+
message: error instanceof Error ? error.message : String(error)
|
|
251
|
+
},
|
|
252
|
+
message: `worktree_prepare_failed: ${error instanceof Error ? error.message : String(error)}`
|
|
253
|
+
}, "stderr");
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
174
257
|
if (commitMode !== "managed-per-slice") {
|
|
175
258
|
output(io, args, {
|
|
176
259
|
ok: true,
|
|
@@ -181,7 +264,6 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
|
|
|
181
264
|
});
|
|
182
265
|
return 0;
|
|
183
266
|
}
|
|
184
|
-
const gitPresent = await exists(path.join(projectRoot, ".git"));
|
|
185
267
|
if (!gitPresent) {
|
|
186
268
|
output(io, args, {
|
|
187
269
|
ok: true,
|
|
@@ -206,11 +288,64 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
|
|
|
206
288
|
}, "stderr");
|
|
207
289
|
return 2;
|
|
208
290
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
291
|
+
let managedWorktreePath = null;
|
|
292
|
+
let activeCwd = projectRoot;
|
|
293
|
+
let degradedToInPlace = false;
|
|
294
|
+
const requestedWorktreePath = typeof args.worktreePath === "string" && args.worktreePath.trim().length > 0
|
|
295
|
+
? path.resolve(projectRoot, args.worktreePath.trim())
|
|
296
|
+
: null;
|
|
297
|
+
if (requestedWorktreePath && await exists(requestedWorktreePath)) {
|
|
298
|
+
managedWorktreePath = requestedWorktreePath;
|
|
299
|
+
activeCwd = requestedWorktreePath;
|
|
300
|
+
}
|
|
301
|
+
else if (isolationMode !== "in-place") {
|
|
302
|
+
try {
|
|
303
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: projectRoot });
|
|
304
|
+
const prepared = await createSliceWorktree(args.sliceId, stdout.trim(), claimedPaths, {
|
|
305
|
+
projectRoot,
|
|
306
|
+
worktreeRoot
|
|
307
|
+
});
|
|
308
|
+
managedWorktreePath = prepared.path;
|
|
309
|
+
activeCwd = prepared.path;
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (error instanceof WorktreeUnsupportedError) {
|
|
313
|
+
output(io, args, {
|
|
314
|
+
ok: true,
|
|
315
|
+
skipped: true,
|
|
316
|
+
reason: "worktree-unavailable",
|
|
317
|
+
degradedCommitMode: "agent-required",
|
|
318
|
+
message: error.message
|
|
319
|
+
});
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
output(io, args, {
|
|
323
|
+
ok: false,
|
|
324
|
+
errorCode: "worktree_prepare_failed",
|
|
325
|
+
details: {
|
|
326
|
+
message: error instanceof Error ? error.message : String(error)
|
|
327
|
+
},
|
|
328
|
+
message: `worktree_prepare_failed: ${error instanceof Error ? error.message : String(error)}`
|
|
329
|
+
}, "stderr");
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const cleanupManagedWorktree = async () => {
|
|
334
|
+
if (!managedWorktreePath)
|
|
335
|
+
return;
|
|
336
|
+
await cleanupWorktree(managedWorktreePath, { projectRoot }).catch(() => undefined);
|
|
337
|
+
};
|
|
338
|
+
let changedPaths = await gitChangedPaths(activeCwd);
|
|
339
|
+
if (changedPaths.length === 0 && managedWorktreePath && activeCwd !== projectRoot) {
|
|
340
|
+
const rootChangedPaths = await gitChangedPaths(projectRoot);
|
|
341
|
+
if (rootChangedPaths.length > 0) {
|
|
342
|
+
activeCwd = projectRoot;
|
|
343
|
+
changedPaths = rootChangedPaths;
|
|
344
|
+
degradedToInPlace = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
213
347
|
if (changedPaths.length === 0) {
|
|
348
|
+
await cleanupManagedWorktree();
|
|
214
349
|
output(io, args, {
|
|
215
350
|
ok: true,
|
|
216
351
|
skipped: true,
|
|
@@ -236,6 +371,7 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
|
|
|
236
371
|
}
|
|
237
372
|
const changedInClaim = changedPaths.filter((p) => matchesClaimedPath(p, claimedPaths));
|
|
238
373
|
if (changedInClaim.length === 0) {
|
|
374
|
+
await cleanupManagedWorktree();
|
|
239
375
|
output(io, args, {
|
|
240
376
|
ok: true,
|
|
241
377
|
skipped: true,
|
|
@@ -246,7 +382,7 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
|
|
|
246
382
|
}
|
|
247
383
|
try {
|
|
248
384
|
await execFileAsync("git", ["add", "--", ...claimedPaths], {
|
|
249
|
-
cwd:
|
|
385
|
+
cwd: activeCwd
|
|
250
386
|
});
|
|
251
387
|
const taskPart = args.taskId && args.taskId.length > 0 ? args.taskId : "task";
|
|
252
388
|
const titlePart = args.title && args.title.length > 0 ? args.title : "slice update";
|
|
@@ -257,12 +393,13 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
|
|
|
257
393
|
"phase-cycle: red->green->refactor->doc"
|
|
258
394
|
].join("\n");
|
|
259
395
|
await execFileAsync("git", ["commit", "-m", header, "-m", body], {
|
|
260
|
-
cwd:
|
|
396
|
+
cwd: activeCwd
|
|
261
397
|
});
|
|
262
398
|
}
|
|
263
399
|
catch (err) {
|
|
264
400
|
const message = err instanceof Error ? err.message : String(err);
|
|
265
401
|
if (/nothing to commit/iu.test(message)) {
|
|
402
|
+
await cleanupManagedWorktree();
|
|
266
403
|
output(io, args, {
|
|
267
404
|
ok: true,
|
|
268
405
|
skipped: true,
|
|
@@ -280,9 +417,39 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
|
|
|
280
417
|
return 1;
|
|
281
418
|
}
|
|
282
419
|
const { stdout: shaStdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
|
|
283
|
-
cwd:
|
|
420
|
+
cwd: activeCwd
|
|
284
421
|
});
|
|
285
|
-
|
|
422
|
+
let commitSha = shaStdout.trim();
|
|
423
|
+
if (managedWorktreePath && activeCwd !== projectRoot) {
|
|
424
|
+
try {
|
|
425
|
+
const merged = await commitAndMergeBack(activeCwd, `merge ${args.sliceId}`, { projectRoot });
|
|
426
|
+
commitSha = merged.commitSha;
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
if (error instanceof WorktreeMergeConflictError) {
|
|
430
|
+
output(io, args, {
|
|
431
|
+
ok: false,
|
|
432
|
+
errorCode: "worktree_merge_conflict",
|
|
433
|
+
details: {
|
|
434
|
+
sliceId: args.sliceId,
|
|
435
|
+
spanId: args.spanId,
|
|
436
|
+
worktreePath: activeCwd,
|
|
437
|
+
message: error.message
|
|
438
|
+
},
|
|
439
|
+
message: error.message
|
|
440
|
+
}, "stderr");
|
|
441
|
+
return 2;
|
|
442
|
+
}
|
|
443
|
+
output(io, args, {
|
|
444
|
+
ok: false,
|
|
445
|
+
errorCode: "slice_commit_failed",
|
|
446
|
+
details: { message: error instanceof Error ? error.message : String(error) },
|
|
447
|
+
message: `slice_commit_failed: ${error instanceof Error ? error.message : String(error)}`
|
|
448
|
+
}, "stderr");
|
|
449
|
+
return 1;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
await cleanupManagedWorktree();
|
|
286
453
|
output(io, args, {
|
|
287
454
|
ok: true,
|
|
288
455
|
commitSha,
|
|
@@ -290,6 +457,8 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
|
|
|
290
457
|
spanId: args.spanId,
|
|
291
458
|
claimedPaths,
|
|
292
459
|
changedPaths: changedInClaim,
|
|
460
|
+
worktreePath: managedWorktreePath ?? undefined,
|
|
461
|
+
degradedToInPlace: degradedToInPlace || undefined,
|
|
293
462
|
message: `slice commit created for ${args.sliceId}: ${commitSha}`
|
|
294
463
|
});
|
|
295
464
|
return 0;
|
package/dist/types.d.ts
CHANGED
|
@@ -162,6 +162,7 @@ export interface ReviewLoopConfig {
|
|
|
162
162
|
}
|
|
163
163
|
export type VcsMode = "git-with-remote" | "git-local-only" | "none";
|
|
164
164
|
export type TddCommitMode = "managed-per-slice" | "agent-required" | "checkpoint-only" | "off";
|
|
165
|
+
export type TddIsolationMode = "worktree" | "in-place" | "auto";
|
|
165
166
|
export interface TddConfig {
|
|
166
167
|
/**
|
|
167
168
|
* Commit ownership model for closed TDD slices.
|
|
@@ -171,6 +172,17 @@ export interface TddConfig {
|
|
|
171
172
|
* - off: skip commit-shape enforcement.
|
|
172
173
|
*/
|
|
173
174
|
commitMode?: TddCommitMode;
|
|
175
|
+
/**
|
|
176
|
+
* Slice execution isolation model.
|
|
177
|
+
* - worktree: default; allocate one git worktree per slice span.
|
|
178
|
+
* - in-place: run in the main working tree.
|
|
179
|
+
* - auto: prefer worktree, degrade to in-place when unavailable.
|
|
180
|
+
*/
|
|
181
|
+
isolationMode?: TddIsolationMode;
|
|
182
|
+
/**
|
|
183
|
+
* Repo-relative root used for managed slice worktrees.
|
|
184
|
+
*/
|
|
185
|
+
worktreeRoot?: string;
|
|
174
186
|
}
|
|
175
187
|
export interface CclawConfig {
|
|
176
188
|
version: string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface WorktreeManagerOptions {
|
|
2
|
+
projectRoot?: string;
|
|
3
|
+
worktreeRoot?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare class WorktreeUnsupportedError extends Error {
|
|
6
|
+
readonly code = "worktree_unavailable";
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
export declare class WorktreeMergeConflictError extends Error {
|
|
10
|
+
readonly code = "worktree_merge_conflict";
|
|
11
|
+
constructor(message: string);
|
|
12
|
+
}
|
|
13
|
+
export declare function createSliceWorktree(sliceId: string, baseRef: string, _claimedPaths: string[], options?: WorktreeManagerOptions): Promise<{
|
|
14
|
+
path: string;
|
|
15
|
+
ref: string;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function commitAndMergeBack(worktreePath: string, _message: string, options?: WorktreeManagerOptions): Promise<{
|
|
18
|
+
commitSha: string;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function cleanupWorktree(worktreePath: string, options?: WorktreeManagerOptions): Promise<void>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { DEFAULT_TDD_WORKTREE_ROOT } from "./config.js";
|
|
6
|
+
import { exists } from "./fs-utils.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
export class WorktreeUnsupportedError extends Error {
|
|
9
|
+
code = "worktree_unavailable";
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "WorktreeUnsupportedError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class WorktreeMergeConflictError extends Error {
|
|
16
|
+
code = "worktree_merge_conflict";
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "WorktreeMergeConflictError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function sanitizeSliceId(sliceId) {
|
|
23
|
+
return sliceId.trim().replace(/[^A-Za-z0-9._-]+/gu, "-");
|
|
24
|
+
}
|
|
25
|
+
function resolveProjectRoot(options) {
|
|
26
|
+
return options?.projectRoot ?? process.cwd();
|
|
27
|
+
}
|
|
28
|
+
function resolveWorktreeRoot(projectRoot, options) {
|
|
29
|
+
const root = typeof options?.worktreeRoot === "string" && options.worktreeRoot.trim().length > 0
|
|
30
|
+
? options.worktreeRoot.trim()
|
|
31
|
+
: DEFAULT_TDD_WORKTREE_ROOT;
|
|
32
|
+
return path.resolve(projectRoot, root);
|
|
33
|
+
}
|
|
34
|
+
async function resolveMainRepoRootFromWorktree(worktreePath) {
|
|
35
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: worktreePath });
|
|
36
|
+
const commonDir = stdout.trim();
|
|
37
|
+
if (commonDir.length === 0) {
|
|
38
|
+
throw new Error(`Cannot resolve git common-dir from worktree ${worktreePath}`);
|
|
39
|
+
}
|
|
40
|
+
return path.dirname(commonDir);
|
|
41
|
+
}
|
|
42
|
+
export async function createSliceWorktree(sliceId, baseRef, _claimedPaths, options = {}) {
|
|
43
|
+
const projectRoot = resolveProjectRoot(options);
|
|
44
|
+
const ref = baseRef.trim().length > 0 ? baseRef.trim() : "HEAD";
|
|
45
|
+
const worktreeRoot = resolveWorktreeRoot(projectRoot, options);
|
|
46
|
+
const safeSliceId = sanitizeSliceId(sliceId);
|
|
47
|
+
if (safeSliceId.length === 0) {
|
|
48
|
+
throw new WorktreeUnsupportedError("Cannot create worktree: empty slice id.");
|
|
49
|
+
}
|
|
50
|
+
const worktreePath = path.join(worktreeRoot, safeSliceId);
|
|
51
|
+
try {
|
|
52
|
+
await execFileAsync("git", ["rev-parse", "--git-dir"], { cwd: projectRoot });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new WorktreeUnsupportedError("Cannot create worktree: repository has no .git metadata.");
|
|
56
|
+
}
|
|
57
|
+
await fs.mkdir(worktreeRoot, { recursive: true });
|
|
58
|
+
if (await exists(worktreePath)) {
|
|
59
|
+
if (await exists(path.join(worktreePath, ".git"))) {
|
|
60
|
+
return { path: worktreePath, ref };
|
|
61
|
+
}
|
|
62
|
+
await fs.rm(worktreePath, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
await execFileAsync("git", ["worktree", "add", "--detach", worktreePath, ref], {
|
|
66
|
+
cwd: projectRoot
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
throw new WorktreeUnsupportedError(`Cannot create worktree for ${sliceId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
71
|
+
}
|
|
72
|
+
return { path: worktreePath, ref };
|
|
73
|
+
}
|
|
74
|
+
export async function commitAndMergeBack(worktreePath, _message, options = {}) {
|
|
75
|
+
const projectRoot = options.projectRoot ?? await resolveMainRepoRootFromWorktree(worktreePath);
|
|
76
|
+
try {
|
|
77
|
+
const { stdout: mainHeadStdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
|
|
78
|
+
cwd: projectRoot
|
|
79
|
+
});
|
|
80
|
+
const mainHead = mainHeadStdout.trim();
|
|
81
|
+
await execFileAsync("git", ["rebase", mainHead], { cwd: worktreePath });
|
|
82
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: worktreePath });
|
|
83
|
+
const commitSha = stdout.trim();
|
|
84
|
+
await execFileAsync("git", ["fetch", worktreePath, "HEAD"], { cwd: projectRoot });
|
|
85
|
+
await execFileAsync("git", ["merge", "--ff-only", "FETCH_HEAD"], { cwd: projectRoot });
|
|
86
|
+
return { commitSha };
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
throw new WorktreeMergeConflictError(`worktree_merge_conflict: ${error instanceof Error ? error.message : String(error)}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function cleanupWorktree(worktreePath, options = {}) {
|
|
93
|
+
if (!(await exists(worktreePath)))
|
|
94
|
+
return;
|
|
95
|
+
const projectRoot = options.projectRoot ?? await resolveMainRepoRootFromWorktree(worktreePath).catch(() => null);
|
|
96
|
+
if (projectRoot) {
|
|
97
|
+
try {
|
|
98
|
+
await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], {
|
|
99
|
+
cwd: projectRoot
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// fall through to rm fallback below
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await fs.rm(worktreePath, { recursive: true, force: true });
|
|
108
|
+
}
|