claude-code-session-manager 0.76.0 → 0.77.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/assets/{AgentLibrary-CBx9l4zN.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-CpdtWhC8.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CQmo4QVC.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-CLarzwqA.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-B0sCdIy1.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-BhWPVOCD.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-2Vx3X5SI.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-Cmrz_LeN.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-B_4PNh9T.js → index-BDRSqBl3.js} +175 -175
- package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-6IOLjZZN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +115 -1
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +138 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/config.cjs +4 -1
- package/src/main/index.cjs +8 -1
- package/src/main/ipcSchemas.cjs +51 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +152 -2
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/childWithLog.cjs +40 -5
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +115 -9
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +464 -19
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/mcpToolCatalog.cjs +87 -2
- package/src/main/lib/opsOwnership.cjs +12 -0
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +57 -1
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +9 -2
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +53 -3
- package/src/main/lib/reaperHelpers.cjs +18 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +133 -29
- package/src/main/lib/schedulerConfig.cjs +19 -0
- package/src/main/projectPages.cjs +160 -2
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1371 -97
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +118 -2
- package/src/preload/index.cjs +7 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
- package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
- package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
|
@@ -14,9 +14,11 @@ const path = require('node:path');
|
|
|
14
14
|
const {
|
|
15
15
|
checkDelegationReadiness,
|
|
16
16
|
installPrdWriteGuard,
|
|
17
|
+
installDestructiveGitGuard,
|
|
17
18
|
probeSchedulerMcpLive,
|
|
18
19
|
clearLiveProbeCache,
|
|
19
20
|
PRD_WRITE_GUARD_SCRIPT,
|
|
21
|
+
DESTRUCTIVE_GIT_GUARD_SCRIPT,
|
|
20
22
|
} = require('../delegationReadiness.cjs');
|
|
21
23
|
|
|
22
24
|
const REQUIRED_TOOLS = ['scheduler_create_prd', 'session_manager_help'];
|
|
@@ -97,6 +99,10 @@ async function makeGreenFixtures() {
|
|
|
97
99
|
matcher: 'Write|Edit|NotebookEdit',
|
|
98
100
|
hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
|
|
99
101
|
},
|
|
102
|
+
{
|
|
103
|
+
matcher: 'Bash',
|
|
104
|
+
hooks: [{ type: 'command', command: `node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}` }],
|
|
105
|
+
},
|
|
100
106
|
],
|
|
101
107
|
},
|
|
102
108
|
});
|
|
@@ -104,12 +110,12 @@ async function makeGreenFixtures() {
|
|
|
104
110
|
return { homeDir, cwd, scriptPath };
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
test('all
|
|
113
|
+
test('all seven checks pass on a fully-configured project', async () => {
|
|
108
114
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
109
115
|
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
110
116
|
|
|
111
117
|
expect(result.ok).toBe(true);
|
|
112
|
-
expect(result.checks).toHaveLength(
|
|
118
|
+
expect(result.checks).toHaveLength(7);
|
|
113
119
|
expect(result.checks.every((c) => c.ok)).toBe(true);
|
|
114
120
|
expect(result.checks.map((c) => c.id)).toEqual([
|
|
115
121
|
'scheduler-mcp',
|
|
@@ -118,6 +124,7 @@ test('all six checks pass on a fully-configured project', async () => {
|
|
|
118
124
|
'dev-plugin',
|
|
119
125
|
'agent-personas',
|
|
120
126
|
'prd-write-guard',
|
|
127
|
+
'destructive-git-guard',
|
|
121
128
|
]);
|
|
122
129
|
}, 15_000);
|
|
123
130
|
|
|
@@ -382,3 +389,146 @@ test('the installed command actually DENIES a scheduler PRD write and allows a n
|
|
|
382
389
|
const allowed = run({ file_path: path.join(cwd, 'src', 'x.ts'), content: 'x' });
|
|
383
390
|
expect(allowed.hookSpecificOutput?.permissionDecision).not.toBe('deny');
|
|
384
391
|
});
|
|
392
|
+
|
|
393
|
+
// ─────────────────────────────── destructive-git-guard / installDestructiveGitGuard
|
|
394
|
+
|
|
395
|
+
test('destructive-git-guard: fails independently when the hook is missing', async () => {
|
|
396
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
397
|
+
await writeJson(path.join(cwd, '.claude', 'settings.json'), {
|
|
398
|
+
hooks: {
|
|
399
|
+
PreToolUse: [
|
|
400
|
+
{
|
|
401
|
+
matcher: 'Write|Edit|NotebookEdit',
|
|
402
|
+
hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
409
|
+
const check = result.checks.find((c) => c.id === 'destructive-git-guard');
|
|
410
|
+
expect(check.ok).toBe(false);
|
|
411
|
+
expect(result.ok).toBe(false);
|
|
412
|
+
// the other checks still pass independently
|
|
413
|
+
expect(result.checks.filter((c) => c.id !== 'destructive-git-guard').every((c) => c.ok)).toBe(true);
|
|
414
|
+
}, 15_000);
|
|
415
|
+
|
|
416
|
+
test('destructive-git-guard: fails when the hook names a script that does not exist', async () => {
|
|
417
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
418
|
+
await writeJson(path.join(cwd, '.claude', 'settings.json'), {
|
|
419
|
+
hooks: {
|
|
420
|
+
PreToolUse: [
|
|
421
|
+
{
|
|
422
|
+
matcher: 'Write|Edit|NotebookEdit',
|
|
423
|
+
hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
matcher: 'Bash',
|
|
427
|
+
hooks: [{ type: 'command', command: 'node /does/not/exist/guard-destructive-git.cjs' }],
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
434
|
+
const check = result.checks.find((c) => c.id === 'destructive-git-guard');
|
|
435
|
+
expect(check.ok).toBe(false);
|
|
436
|
+
expect(check.detail).toMatch(/does not exist/);
|
|
437
|
+
}, 15_000);
|
|
438
|
+
|
|
439
|
+
test('installDestructiveGitGuard: writes the canonical ABSOLUTE-path entry and turns the check green', async () => {
|
|
440
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
441
|
+
await writeJson(path.join(cwd, '.claude', 'settings.json'), {
|
|
442
|
+
hooks: {
|
|
443
|
+
PreToolUse: [
|
|
444
|
+
{
|
|
445
|
+
matcher: 'Write|Edit|NotebookEdit',
|
|
446
|
+
hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const r = await installDestructiveGitGuard({ cwd });
|
|
453
|
+
expect(r.ok).toBe(true);
|
|
454
|
+
expect(r.action).toBe('installed');
|
|
455
|
+
expect(r.command).toBe(`node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}`);
|
|
456
|
+
|
|
457
|
+
const written = JSON.parse(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8'));
|
|
458
|
+
// The pre-existing Write|Edit|NotebookEdit matcher (and its hook) survives untouched.
|
|
459
|
+
expect(written.hooks.PreToolUse).toContainEqual(
|
|
460
|
+
{ matcher: 'Write|Edit|NotebookEdit', hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }] },
|
|
461
|
+
);
|
|
462
|
+
expect(written.hooks.PreToolUse).toContainEqual(
|
|
463
|
+
{ matcher: 'Bash', hooks: [{ type: 'command', command: `node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}` }] },
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
467
|
+
expect(result.checks.find((c) => c.id === 'destructive-git-guard').ok).toBe(true);
|
|
468
|
+
expect(result.checks.find((c) => c.id === 'prd-write-guard').ok).toBe(true);
|
|
469
|
+
}, 15_000);
|
|
470
|
+
|
|
471
|
+
test('installDestructiveGitGuard: is idempotent — a healthy guard is a no-op', async () => {
|
|
472
|
+
const { cwd } = await makeGreenFixtures();
|
|
473
|
+
const before = fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8');
|
|
474
|
+
|
|
475
|
+
const again = await installDestructiveGitGuard({ cwd });
|
|
476
|
+
expect(again.action).toBe('already-installed');
|
|
477
|
+
expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe(before);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('installDestructiveGitGuard: repairs a broken entry in place rather than duplicating it', async () => {
|
|
481
|
+
const { cwd } = await makeGreenFixtures();
|
|
482
|
+
await writeJson(path.join(cwd, '.claude', 'settings.json'), {
|
|
483
|
+
hooks: {
|
|
484
|
+
PreToolUse: [
|
|
485
|
+
{
|
|
486
|
+
matcher: 'Write|Edit|NotebookEdit',
|
|
487
|
+
hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
matcher: 'Bash',
|
|
491
|
+
hooks: [{ type: 'command', command: 'node /does/not/exist/guard-destructive-git.cjs' }],
|
|
492
|
+
},
|
|
493
|
+
],
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const r = await installDestructiveGitGuard({ cwd });
|
|
498
|
+
expect(r.action).toBe('repaired');
|
|
499
|
+
|
|
500
|
+
const written = JSON.parse(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8'));
|
|
501
|
+
const bashMatchers = written.hooks.PreToolUse.filter((m) => m.matcher === 'Bash');
|
|
502
|
+
expect(bashMatchers).toHaveLength(1);
|
|
503
|
+
expect(bashMatchers[0].hooks).toEqual([{ type: 'command', command: `node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}` }]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('installDestructiveGitGuard: refuses on unparseable settings rather than discarding them', async () => {
|
|
507
|
+
const { cwd } = await makeGreenFixtures();
|
|
508
|
+
await fsp.writeFile(path.join(cwd, '.claude', 'settings.json'), '{ not valid json', 'utf8');
|
|
509
|
+
|
|
510
|
+
const r = await installDestructiveGitGuard({ cwd });
|
|
511
|
+
expect(r.ok).toBe(false);
|
|
512
|
+
expect(r.action).toBe('error');
|
|
513
|
+
expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe('{ not valid json');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// The guard script is EXERCISED here too, same rationale as the
|
|
517
|
+
// installPrdWriteGuard end-to-end test above.
|
|
518
|
+
test('the installed destructive-git command actually DENIES a shared-tree stash and allows it inside an sm-job/ worktree', async () => {
|
|
519
|
+
const { cwd } = await makeGreenFixtures();
|
|
520
|
+
const { execFileSync } = require('node:child_process');
|
|
521
|
+
const os2 = require('node:os');
|
|
522
|
+
|
|
523
|
+
const run = (command, runCwd) => JSON.parse(execFileSync('node', [DESTRUCTIVE_GIT_GUARD_SCRIPT], {
|
|
524
|
+
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd: runCwd }),
|
|
525
|
+
encoding: 'utf8',
|
|
526
|
+
}));
|
|
527
|
+
|
|
528
|
+
const denied = run('git stash', cwd);
|
|
529
|
+
expect(denied.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
530
|
+
|
|
531
|
+
const worktreeCwd = path.join(os2.tmpdir(), 'session-manager-job-worktrees', 'somehash', 'some-slug');
|
|
532
|
+
const allowed = run('git stash', worktreeCwd);
|
|
533
|
+
expect(allowed.hookSpecificOutput?.permissionDecision).not.toBe('deny');
|
|
534
|
+
});
|
|
@@ -80,12 +80,14 @@ test('a non-git cwd resolves to null rather than throwing (Epic proceeds unisola
|
|
|
80
80
|
expect(result).toBeNull();
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
test('a dirty base tree
|
|
83
|
+
test('a dirty base tree still creates the worktree, carrying the uncommitted edit in rather than falling back unisolated', async () => {
|
|
84
84
|
const cwd = await mkRepoCwd();
|
|
85
85
|
fs.writeFileSync(path.join(cwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
86
86
|
|
|
87
87
|
const result = await handler(null, { cwd, epicId: 'epic-mint-3' });
|
|
88
|
-
expect(result).toBeNull();
|
|
88
|
+
expect(result).not.toBeNull();
|
|
89
|
+
expect(fs.readFileSync(path.join(result.dir, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
|
|
90
|
+
expect(fs.readFileSync(path.join(cwd, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
|
|
89
91
|
});
|
|
90
92
|
|
|
91
93
|
test('SM_EPIC_WORKTREE_DISABLE=1 resolves to null rather than throwing', async () => {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fixChainDepth.test.cjs — unit tests for the pure fix-chain depth counter
|
|
3
|
+
* (PRD 1113). Run: timeout 120 npx vitest run src/main/lib/__tests__/fixChainDepth.test.cjs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
import { test, expect } from 'vitest';
|
|
9
|
+
const { fixChainDepthOf, baseSlugOf } = require('../fixChainDepth.cjs');
|
|
10
|
+
|
|
11
|
+
test('fixChainDepthOf counts consecutive -fix- segments after the leading NN- prefix', () => {
|
|
12
|
+
const cases = [
|
|
13
|
+
// [slug, expected depth]
|
|
14
|
+
['113-foo', 0],
|
|
15
|
+
['113-fix-foo', 1],
|
|
16
|
+
['113-fix-fix-foo', 2],
|
|
17
|
+
['113-fix-fix-fix-foo', 3],
|
|
18
|
+
// false-positive bait: a later segment merely reading 'fix' does not
|
|
19
|
+
// extend the run once a non-'fix' segment has broken it.
|
|
20
|
+
['113-fix-loop-extraction-and-round-trip', 1],
|
|
21
|
+
['22-fix-loop-galaxy-round-trip', 1],
|
|
22
|
+
// false-positive bait: a segment that only contains 'fix' as a substring
|
|
23
|
+
// (not an exact 'fix' segment) is never counted at all.
|
|
24
|
+
['113-prefix-foo', 0],
|
|
25
|
+
['113-prefix-fix-foo', 0],
|
|
26
|
+
// no fix chain, multi-digit NN, single-segment slug.
|
|
27
|
+
['1-widget', 0],
|
|
28
|
+
];
|
|
29
|
+
for (const [slug, expected] of cases) {
|
|
30
|
+
expect(fixChainDepthOf(slug)).toBe(expected);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('baseSlugOf strips the leading fix chain, keeping the NN prefix', () => {
|
|
35
|
+
expect(baseSlugOf('113-foo')).toBe('113-foo');
|
|
36
|
+
expect(baseSlugOf('113-fix-foo')).toBe('113-foo');
|
|
37
|
+
expect(baseSlugOf('113-fix-fix-foo')).toBe('113-foo');
|
|
38
|
+
expect(baseSlugOf('113-fix-fix-fix-foo')).toBe('113-foo');
|
|
39
|
+
expect(baseSlugOf('113-fix-loop-extraction-and-round-trip')).toBe('113-loop-extraction-and-round-trip');
|
|
40
|
+
});
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
'use strict';
|
|
13
13
|
|
|
14
|
-
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
14
|
+
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
15
15
|
const fs = require('node:fs');
|
|
16
16
|
const os = require('node:os');
|
|
17
17
|
const path = require('node:path');
|
|
@@ -160,6 +160,90 @@ test('[job] merge-commit message text is byte-for-byte unchanged from the pre-ge
|
|
|
160
160
|
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
test('[job] integration is skipped (carried-wip-only) when the branch only commits the carried base WIP, and the base tree stays untouched', async () => {
|
|
164
|
+
const slug = 'test-slug-carried-wip-only';
|
|
165
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
166
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
167
|
+
expect(worktree.ok).toBe(true);
|
|
168
|
+
expect(worktree.carriedPaths).toEqual(['README.md']);
|
|
169
|
+
|
|
170
|
+
// The job commits exactly the carried content, doing no other work.
|
|
171
|
+
git(['add', '-A'], worktree.dir);
|
|
172
|
+
git(['commit', '-q', '-m', 'commit carried README'], worktree.dir);
|
|
173
|
+
|
|
174
|
+
const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
175
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
|
|
176
|
+
expect(outcome.ok).toBe(true);
|
|
177
|
+
expect(outcome.integrated).toBe(false);
|
|
178
|
+
expect(outcome.reason).toBe('carried-wip-only');
|
|
179
|
+
|
|
180
|
+
const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
181
|
+
expect(headAfter).toBe(headBefore);
|
|
182
|
+
// The base tree's own uncommitted README edit is untouched — never
|
|
183
|
+
// discarded, never re-applied.
|
|
184
|
+
expect(git(['status', '--porcelain'], repoCwd)).toContain('README.md');
|
|
185
|
+
|
|
186
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('[job] a branch that commits carried paths ALONGSIDE its own work still attempts integration normally (no silent drop)', async () => {
|
|
190
|
+
const slug = 'test-slug-carried-plus-own-work';
|
|
191
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
192
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
193
|
+
expect(worktree.ok).toBe(true);
|
|
194
|
+
expect(worktree.carriedPaths).toEqual(['README.md']);
|
|
195
|
+
|
|
196
|
+
fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'job output\n', 'utf8');
|
|
197
|
+
git(['add', '-A'], worktree.dir);
|
|
198
|
+
git(['commit', '-q', '-m', 'job commit touching carried path + own work'], worktree.dir);
|
|
199
|
+
|
|
200
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
|
|
201
|
+
// The base tree still holds README.md dirty, so a real merge attempt here
|
|
202
|
+
// is expected to fail exactly like the existing worktreeIntegrationFailure
|
|
203
|
+
// path — branch preserved, not merged, not silently discarded.
|
|
204
|
+
expect(outcome.ok).toBe(false);
|
|
205
|
+
expect(outcome.reason).toBeTruthy();
|
|
206
|
+
|
|
207
|
+
const branches = git(['branch', '--list', worktree.branch], repoCwd);
|
|
208
|
+
expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
|
|
209
|
+
|
|
210
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('[job] a branch that ADDS its own edit on top of the carried content on the SAME path is never classified carried-wip-only (content-verify, not path-only)', async () => {
|
|
214
|
+
const slug = 'test-slug-carried-same-path-plus-edit';
|
|
215
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
216
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
217
|
+
expect(worktree.ok).toBe(true);
|
|
218
|
+
expect(worktree.carriedPaths).toEqual(['README.md']);
|
|
219
|
+
|
|
220
|
+
// The job's real work lands on the SAME path the base tree had WIP on —
|
|
221
|
+
// committing MORE than just the carried content. A path-only check would
|
|
222
|
+
// wrongly see "only README.md changed, and README.md was carried" and
|
|
223
|
+
// classify this as carried-wip-only, causing the caller to delete this
|
|
224
|
+
// branch (and the job's real commit with it) instead of attempting a
|
|
225
|
+
// normal merge.
|
|
226
|
+
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'uncommitted tracked edit\njob added this line\n', 'utf8');
|
|
227
|
+
git(['add', '-A'], worktree.dir);
|
|
228
|
+
git(['commit', '-q', '-m', 'job extends the carried README further'], worktree.dir);
|
|
229
|
+
|
|
230
|
+
const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
231
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
|
|
232
|
+
// The base tree still holds README.md dirty, so a real merge attempt here
|
|
233
|
+
// is expected to fail exactly like the existing worktreeIntegrationFailure
|
|
234
|
+
// path — branch preserved, not merged, not silently discarded.
|
|
235
|
+
expect(outcome.ok).toBe(false);
|
|
236
|
+
expect(outcome.reason).toBeTruthy();
|
|
237
|
+
expect(outcome.reason).not.toBe('carried-wip-only');
|
|
238
|
+
|
|
239
|
+
const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
240
|
+
expect(headAfter).toBe(headBefore);
|
|
241
|
+
const branches = git(['branch', '--list', worktree.branch], repoCwd);
|
|
242
|
+
expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
|
|
243
|
+
|
|
244
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
245
|
+
});
|
|
246
|
+
|
|
163
247
|
test('[job] falls back with a reason for a non-git cwd', async () => {
|
|
164
248
|
const nonGitDir = path.join(tmpRoot, 'not-a-repo');
|
|
165
249
|
fs.mkdirSync(nonGitDir, { recursive: true });
|
|
@@ -168,11 +252,47 @@ test('[job] falls back with a reason for a non-git cwd', async () => {
|
|
|
168
252
|
expect(result.reason).toMatch(/not a git repository/);
|
|
169
253
|
});
|
|
170
254
|
|
|
171
|
-
test('[job]
|
|
255
|
+
test('[job] a dirty tracked base file is carried into the worktree with identical content, and the base tree is never written to', async () => {
|
|
172
256
|
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
257
|
+
const statusBefore = git(['status', '--porcelain'], repoCwd);
|
|
258
|
+
const stashBefore = git(['stash', 'list'], repoCwd);
|
|
259
|
+
|
|
173
260
|
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
174
|
-
expect(result.ok).toBe(
|
|
175
|
-
expect(result.
|
|
261
|
+
expect(result.ok).toBe(true);
|
|
262
|
+
expect(result.carriedPaths).toEqual(['README.md']);
|
|
263
|
+
expect(fs.readFileSync(path.join(result.dir, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
|
|
264
|
+
|
|
265
|
+
const statusAfter = git(['status', '--porcelain'], repoCwd);
|
|
266
|
+
const stashAfter = git(['stash', 'list'], repoCwd);
|
|
267
|
+
expect(statusAfter).toBe(statusBefore);
|
|
268
|
+
expect(stashAfter).toBe(stashBefore);
|
|
269
|
+
|
|
270
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('[job] an untracked-only dirty base file is NOT carried and stays on the existing clean-create path', async () => {
|
|
274
|
+
fs.writeFileSync(path.join(repoCwd, 'scratch.txt'), 'stray scratch file\n', 'utf8');
|
|
275
|
+
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
276
|
+
expect(result.ok).toBe(true);
|
|
277
|
+
expect(result.carriedPaths).toEqual([]);
|
|
278
|
+
expect(fs.existsSync(path.join(result.dir, 'scratch.txt'))).toBe(false);
|
|
279
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('[job] carry-over failure (git apply cannot apply) degrades cleanly: worktree torn down, ok:false, active count released', async () => {
|
|
283
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
284
|
+
const spy = vi.spyOn(gitWorktree, 'captureAndCarryBaseDiff').mockResolvedValue({ ok: false, reason: 'simulated git apply failure' });
|
|
285
|
+
try {
|
|
286
|
+
const before = gitWorktree._getActiveWorktreeCountForTests('job');
|
|
287
|
+
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
288
|
+
expect(result.ok).toBe(false);
|
|
289
|
+
expect(result.reason).toMatch(/carry-over of base WIP failed/);
|
|
290
|
+
expect(gitWorktree._getActiveWorktreeCountForTests('job')).toBe(before);
|
|
291
|
+
const list = git(['worktree', 'list', '--porcelain'], repoCwd);
|
|
292
|
+
expect(list).not.toContain('sm-job/x');
|
|
293
|
+
} finally {
|
|
294
|
+
spy.mockRestore();
|
|
295
|
+
}
|
|
176
296
|
});
|
|
177
297
|
|
|
178
298
|
test('[job] falls back once the concurrency cap is reached, and recovers after cleanup', async () => {
|
|
@@ -191,6 +311,46 @@ test('[job] falls back once the concurrency cap is reached, and recovers after c
|
|
|
191
311
|
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: third.dir, branch: third.branch });
|
|
192
312
|
});
|
|
193
313
|
|
|
314
|
+
test('[job] getMaxConcurrentWorktrees floors the job cap at the sessionSlots pool size (PRD 1112)', () => {
|
|
315
|
+
const originalSlots = process.env.SM_SESSION_SLOTS;
|
|
316
|
+
try {
|
|
317
|
+
process.env.SM_SESSION_SLOTS = '10';
|
|
318
|
+
expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(10);
|
|
319
|
+
|
|
320
|
+
// The floor never drops BELOW the existing default (4) even when the
|
|
321
|
+
// slot pool is smaller — this cap only ever widens for isolation, never
|
|
322
|
+
// narrows it below what already shipped.
|
|
323
|
+
process.env.SM_SESSION_SLOTS = '3';
|
|
324
|
+
expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(4);
|
|
325
|
+
} finally {
|
|
326
|
+
if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
|
|
327
|
+
else process.env.SM_SESSION_SLOTS = originalSlots;
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('[job] an explicit SM_JOB_WORKTREE_MAX wins verbatim over the sessionSlots floor', () => {
|
|
332
|
+
const originalSlots = process.env.SM_SESSION_SLOTS;
|
|
333
|
+
try {
|
|
334
|
+
process.env.SM_SESSION_SLOTS = '10';
|
|
335
|
+
process.env.SM_JOB_WORKTREE_MAX = '2';
|
|
336
|
+
expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(2);
|
|
337
|
+
} finally {
|
|
338
|
+
if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
|
|
339
|
+
else process.env.SM_SESSION_SLOTS = originalSlots;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('[epic] getMaxConcurrentWorktrees is unaffected by the sessionSlots pool size', () => {
|
|
344
|
+
const originalSlots = process.env.SM_SESSION_SLOTS;
|
|
345
|
+
try {
|
|
346
|
+
process.env.SM_SESSION_SLOTS = '10';
|
|
347
|
+
expect(gitWorktree.getMaxConcurrentWorktrees('epic')).toBe(50);
|
|
348
|
+
} finally {
|
|
349
|
+
if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
|
|
350
|
+
else process.env.SM_SESSION_SLOTS = originalSlots;
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
194
354
|
test('[job] SM_JOB_WORKTREE_DISABLE=1 restores in-place behaviour', async () => {
|
|
195
355
|
process.env.SM_JOB_WORKTREE_DISABLE = '1';
|
|
196
356
|
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
@@ -410,3 +570,116 @@ test('isBaseTreeClean still reports dirty for a modified TRACKED file', async ()
|
|
|
410
570
|
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'modified tracked content\n', 'utf8');
|
|
411
571
|
expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(false);
|
|
412
572
|
});
|
|
573
|
+
|
|
574
|
+
// ──────────────────────────────────────────── orphaned on-disk checkouts (PRD 1108)
|
|
575
|
+
|
|
576
|
+
test('[job] cleanupWorktree rmdirs the now-empty parent hash dir, but a sibling checkout in the same hash dir survives', async () => {
|
|
577
|
+
const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'sibling-a' });
|
|
578
|
+
const second = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'sibling-b' });
|
|
579
|
+
expect(first.ok).toBe(true);
|
|
580
|
+
expect(second.ok).toBe(true);
|
|
581
|
+
const hashDir = path.dirname(first.dir);
|
|
582
|
+
expect(path.dirname(second.dir)).toBe(hashDir);
|
|
583
|
+
|
|
584
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
|
|
585
|
+
expect(fs.existsSync(first.dir)).toBe(false);
|
|
586
|
+
// The hash dir must survive — the sibling checkout still lives under it.
|
|
587
|
+
expect(fs.existsSync(hashDir)).toBe(true);
|
|
588
|
+
expect(fs.existsSync(second.dir)).toBe(true);
|
|
589
|
+
|
|
590
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: second.dir, branch: second.branch });
|
|
591
|
+
expect(fs.existsSync(second.dir)).toBe(false);
|
|
592
|
+
// Now that the last sibling is gone, the empty hash dir is reclaimed too.
|
|
593
|
+
expect(fs.existsSync(hashDir)).toBe(false);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('[job] reconcileWorktreesOnBoot reclaims an orphaned on-disk checkout for a project cwd it was never handed', async () => {
|
|
597
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-old' });
|
|
598
|
+
expect(worktree.ok).toBe(true);
|
|
599
|
+
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
600
|
+
// Simulate age: back-date the checkout dir's mtime well past the 1s
|
|
601
|
+
// threshold used below, without touching anything about its content.
|
|
602
|
+
const old = new Date(Date.now() - 60_000);
|
|
603
|
+
fs.utimesSync(worktree.dir, old, old);
|
|
604
|
+
|
|
605
|
+
// No cwd handed in at all — this is the "project no longer tracked" case
|
|
606
|
+
// the per-cwd loop can never reach on its own.
|
|
607
|
+
await gitWorktree.reconcileWorktreesOnBoot([], { kind: 'job', staleAgeMs: 1_000 });
|
|
608
|
+
|
|
609
|
+
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
610
|
+
// The hash dir it lived under is empty now and must be reclaimed too.
|
|
611
|
+
expect(fs.existsSync(path.dirname(worktree.dir))).toBe(false);
|
|
612
|
+
// The main tree's own worktree registration must not be left dangling.
|
|
613
|
+
const list = git(['worktree', 'list', '--porcelain'], repoCwd);
|
|
614
|
+
expect(list).not.toContain(worktree.dir);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test('[job] reconcileWorktreesOnBoot never reclaims a fresh, in-use checkout younger than the age threshold', async () => {
|
|
618
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'fresh-in-use' });
|
|
619
|
+
expect(worktree.ok).toBe(true);
|
|
620
|
+
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
621
|
+
|
|
622
|
+
await gitWorktree.reconcileWorktreesOnBoot([], { kind: 'job', staleAgeMs: 24 * 60 * 60 * 1000 });
|
|
623
|
+
|
|
624
|
+
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
625
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test('[epic] the orphan sweep age threshold defaults much higher (7 days) than the job kind (24h)', () => {
|
|
629
|
+
expect(gitWorktree.getStaleSweepAgeMs('job')).toBe(24 * 60 * 60 * 1000);
|
|
630
|
+
expect(gitWorktree.getStaleSweepAgeMs('epic')).toBe(7 * 24 * 60 * 60 * 1000);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test('[epic] SM_EPIC_WORKTREE_STALE_MS overrides the epic orphan-sweep age threshold independently of the job env var', () => {
|
|
634
|
+
const original = process.env.SM_EPIC_WORKTREE_STALE_MS;
|
|
635
|
+
try {
|
|
636
|
+
process.env.SM_EPIC_WORKTREE_STALE_MS = '12345';
|
|
637
|
+
expect(gitWorktree.getStaleSweepAgeMs('epic')).toBe(12345);
|
|
638
|
+
expect(gitWorktree.getStaleSweepAgeMs('job')).toBe(24 * 60 * 60 * 1000);
|
|
639
|
+
} finally {
|
|
640
|
+
if (original === undefined) delete process.env.SM_EPIC_WORKTREE_STALE_MS;
|
|
641
|
+
else process.env.SM_EPIC_WORKTREE_STALE_MS = original;
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test('[job] sweepStaleWorktreeCheckouts treats staleAgeMs: 0 as "reclaim immediately", not as "unset — use the default"', async () => {
|
|
646
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'zero-age' });
|
|
647
|
+
expect(worktree.ok).toBe(true);
|
|
648
|
+
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
649
|
+
|
|
650
|
+
const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0 });
|
|
651
|
+
|
|
652
|
+
expect(result.checkoutsRemoved).toBe(1);
|
|
653
|
+
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test('[job] sweepStaleWorktreeCheckouts never deletes anything outside its own kind root, even via a symlink shaped like a traversal', async () => {
|
|
657
|
+
const outsideDir = path.join(tmpRoot, 'outside-root');
|
|
658
|
+
fs.mkdirSync(outsideDir, { recursive: true });
|
|
659
|
+
const markerFile = path.join(outsideDir, 'do-not-delete.txt');
|
|
660
|
+
fs.writeFileSync(markerFile, 'still here\n', 'utf8');
|
|
661
|
+
|
|
662
|
+
const root = gitWorktree.worktreeRootFor('job');
|
|
663
|
+
const hashDir = path.join(root, 'traversal-hash-dir');
|
|
664
|
+
fs.mkdirSync(hashDir, { recursive: true });
|
|
665
|
+
const escapePath = path.join(hashDir, 'escape');
|
|
666
|
+
fs.symlinkSync(outsideDir, escapePath, 'dir');
|
|
667
|
+
|
|
668
|
+
const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0 });
|
|
669
|
+
|
|
670
|
+
expect(fs.existsSync(markerFile)).toBe(true);
|
|
671
|
+
expect(fs.existsSync(outsideDir)).toBe(true);
|
|
672
|
+
expect(result.checkoutsRemoved).toBe(0);
|
|
673
|
+
|
|
674
|
+
fs.rmSync(hashDir, { recursive: true, force: true });
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('[job] reconcileWorktreesOnBoot logs a one-shot report line with reclaimed counts', async () => {
|
|
678
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
679
|
+
try {
|
|
680
|
+
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' });
|
|
681
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/boot sweep \(kind:job\): reclaimed \d+ checkout\(s\), \d+ empty root dir\(s\)/));
|
|
682
|
+
} finally {
|
|
683
|
+
logSpy.mockRestore();
|
|
684
|
+
}
|
|
685
|
+
});
|