claude-code-session-manager 0.75.3 → 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-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.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-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
- package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.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/lib/activeSessions.cjs +116 -6
- 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 +269 -96
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +247 -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 +58 -3
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -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 +207 -1
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -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/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +25 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +64 -5
- package/src/main/ipcSchemas.cjs +69 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
- 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__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -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 +133 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -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__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +69 -2
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +514 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +520 -21
- package/src/main/lib/jobWorktree.cjs +2 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +370 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +106 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +103 -15
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +40 -7
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +54 -4
- package/src/main/lib/reaperHelpers.cjs +64 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +301 -55
- package/src/main/lib/schedulerConfig.cjs +99 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +162 -3
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1701 -130
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/seedSchedulerMcp.cjs +58 -4
- 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 +187 -3
- package/src/preload/index.cjs +9 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
- package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
- package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
|
@@ -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]
|
|
172
|
-
fs.writeFileSync(path.join(repoCwd, '
|
|
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 () => {
|
|
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' });
|
|
@@ -398,3 +558,128 @@ test('keyFromBranch strips the kind-specific prefix and returns null for a forei
|
|
|
398
558
|
test('createWorktree throws on an unknown kind', async () => {
|
|
399
559
|
await expect(gitWorktree.createWorktree({ kind: 'bogus', cwd: repoCwd, key: 'x' })).rejects.toThrow(/unknown kind/);
|
|
400
560
|
});
|
|
561
|
+
|
|
562
|
+
// ──────────────────────────────────────────── isBaseTreeClean (PRD 1064)
|
|
563
|
+
|
|
564
|
+
test('isBaseTreeClean ignores untracked files — a single stray untracked file must not disable isolation', async () => {
|
|
565
|
+
fs.writeFileSync(path.join(repoCwd, 'scratch_untracked.txt'), 'not tracked\n', 'utf8');
|
|
566
|
+
expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(true);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test('isBaseTreeClean still reports dirty for a modified TRACKED file', async () => {
|
|
570
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'modified tracked content\n', 'utf8');
|
|
571
|
+
expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(false);
|
|
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
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gitWorktreeSalvage.test.cjs — PRD 1071: a job worktree's uncommitted diff
|
|
3
|
+
* (tracked + untracked) is salvaged to a patch file before teardown, so a
|
|
4
|
+
* job killed before its finish-protocol commit doesn't lose its work when
|
|
5
|
+
* cleanupWorktree removes the checkout right after.
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/gitWorktreeSalvage.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { execFileSync } = require('node:child_process');
|
|
17
|
+
const gitWorktree = require('../gitWorktree.cjs');
|
|
18
|
+
|
|
19
|
+
let tmpRoot;
|
|
20
|
+
let repoCwd;
|
|
21
|
+
// config.cjs's writeTextAtomic (used internally by salvageWorktreeDiff)
|
|
22
|
+
// restricts writes to a fixed allowlist — real prod call sites write under
|
|
23
|
+
// `~/.claude/session-manager/scheduled-plans/runs/...`, which is covered by
|
|
24
|
+
// config.cjs's WRITE_PREFIXES. Mirror that here instead of writing under
|
|
25
|
+
// os.tmpdir() (which validateWrite would reject) — same convention as
|
|
26
|
+
// coldBootPromptSessionsWrite.test.cjs.
|
|
27
|
+
let outDir;
|
|
28
|
+
|
|
29
|
+
function git(args, cwd) {
|
|
30
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function initRepo(dir) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
git(['init', '-q'], dir);
|
|
36
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
37
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
38
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
39
|
+
git(['add', '-A'], dir);
|
|
40
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gitworktree-salvage-'));
|
|
45
|
+
repoCwd = path.join(tmpRoot, 'repo');
|
|
46
|
+
initRepo(repoCwd);
|
|
47
|
+
outDir = fs.mkdtempSync(path.join(os.homedir(), '.claude', 'sm-test-salvage-'));
|
|
48
|
+
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
53
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('dirty worktree (tracked edit + untracked file) produces a non-empty patch with the untracked file\'s contents', async () => {
|
|
57
|
+
const slug = 'salvage-dirty';
|
|
58
|
+
const created = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
59
|
+
expect(created.ok).toBe(true);
|
|
60
|
+
|
|
61
|
+
fs.writeFileSync(path.join(created.dir, 'README.md'), 'hello\nedited\n', 'utf8');
|
|
62
|
+
fs.writeFileSync(path.join(created.dir, 'new-file.txt'), 'brand new untracked content\n', 'utf8');
|
|
63
|
+
|
|
64
|
+
const outFile = path.join(outDir, `${slug}.uncommitted.patch`);
|
|
65
|
+
const result = await gitWorktree.salvageWorktreeDiff({ cwd: created.dir, outFile });
|
|
66
|
+
|
|
67
|
+
expect(result.ok).toBe(true);
|
|
68
|
+
expect(result.bytes).toBeGreaterThan(0);
|
|
69
|
+
expect(fs.existsSync(outFile)).toBe(true);
|
|
70
|
+
const patch = fs.readFileSync(outFile, 'utf8');
|
|
71
|
+
expect(patch).toContain('new-file.txt');
|
|
72
|
+
expect(patch).toContain('brand new untracked content');
|
|
73
|
+
expect(patch).toContain('README.md');
|
|
74
|
+
expect(patch).toContain('edited');
|
|
75
|
+
|
|
76
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: created.dir, branch: created.branch });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('clean worktree produces no patch file (no 0-byte artifact)', async () => {
|
|
80
|
+
const slug = 'salvage-clean';
|
|
81
|
+
const created = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
82
|
+
expect(created.ok).toBe(true);
|
|
83
|
+
|
|
84
|
+
const outFile = path.join(outDir, `${slug}.uncommitted.patch`);
|
|
85
|
+
const result = await gitWorktree.salvageWorktreeDiff({ cwd: created.dir, outFile });
|
|
86
|
+
|
|
87
|
+
expect(result.ok).toBe(false);
|
|
88
|
+
expect(fs.existsSync(outFile)).toBe(false);
|
|
89
|
+
|
|
90
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: created.dir, branch: created.branch });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a git failure during salvage is swallowed (never throws) and cleanup still runs', async () => {
|
|
94
|
+
const notAGitRepo = path.join(tmpRoot, 'not-a-repo');
|
|
95
|
+
fs.mkdirSync(notAGitRepo, { recursive: true });
|
|
96
|
+
const outFile = path.join(outDir, 'salvage-failure.uncommitted.patch');
|
|
97
|
+
|
|
98
|
+
await expect(gitWorktree.salvageWorktreeDiff({ cwd: notAGitRepo, outFile })).resolves.toEqual({ ok: false });
|
|
99
|
+
expect(fs.existsSync(outFile)).toBe(false);
|
|
100
|
+
|
|
101
|
+
// Cleanup of an unrelated, never-created worktree is itself a no-op but
|
|
102
|
+
// must still resolve without throwing — mirrors the finally-block shape in
|
|
103
|
+
// scheduler.cjs where salvage failure must never block integration/cleanup.
|
|
104
|
+
await expect(
|
|
105
|
+
gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: path.join(tmpRoot, 'never-existed'), branch: 'sm-job/does-not-exist' }),
|
|
106
|
+
).resolves.toBeUndefined();
|
|
107
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gitWorktreeSalvageDelta.test.cjs — PRD 1098: `salvageDirtyDelta` is the
|
|
3
|
+
* shared-tree counterpart to `salvageWorktreeDiff` (gitWorktreeSalvage.test.cjs)
|
|
4
|
+
* — it salvages an IN-PLACE job's uncommitted work without a throwaway
|
|
5
|
+
* worktree checkout to diff and discard, so it must be strictly DELTA-scoped
|
|
6
|
+
* (only the caller-supplied paths, never a whole-tree dump) and must never
|
|
7
|
+
* mutate the shared tree (no add/stash/reset/checkout/clean) the way
|
|
8
|
+
* `salvageWorktreeDiff`'s `git add -A --intent-to-add` deliberately can
|
|
9
|
+
* against a disposable checkout.
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const { execFileSync } = require('node:child_process');
|
|
21
|
+
const gitWorktree = require('../gitWorktree.cjs');
|
|
22
|
+
|
|
23
|
+
let tmpRoot;
|
|
24
|
+
let repoCwd;
|
|
25
|
+
let outDir;
|
|
26
|
+
|
|
27
|
+
function git(args, cwd) {
|
|
28
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function initRepo(dir) {
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
git(['init', '-q'], dir);
|
|
34
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
35
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
36
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
37
|
+
fs.writeFileSync(path.join(dir, 'tracked-a.txt'), 'a\n', 'utf8');
|
|
38
|
+
fs.writeFileSync(path.join(dir, 'tracked-b.txt'), 'b\n', 'utf8');
|
|
39
|
+
git(['add', '-A'], dir);
|
|
40
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gitworktree-salvage-delta-'));
|
|
45
|
+
repoCwd = path.join(tmpRoot, 'repo');
|
|
46
|
+
initRepo(repoCwd);
|
|
47
|
+
outDir = fs.mkdtempSync(path.join(os.homedir(), '.claude', 'sm-test-salvage-delta-'));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
52
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('salvages only the given delta paths (tracked edit + untracked new file), never a pre-existing dirty file outside the delta', async () => {
|
|
56
|
+
// Pre-existing WIP that must NEVER appear in the patch — the whole point
|
|
57
|
+
// of delta-scoping.
|
|
58
|
+
fs.writeFileSync(path.join(repoCwd, 'tracked-b.txt'), 'b\npre-existing human WIP\n', 'utf8');
|
|
59
|
+
|
|
60
|
+
// This job's own delta: an edit to tracked-a.txt and a new untracked file.
|
|
61
|
+
fs.writeFileSync(path.join(repoCwd, 'tracked-a.txt'), 'a\nedited by job\n', 'utf8');
|
|
62
|
+
fs.writeFileSync(path.join(repoCwd, 'job-new-file.txt'), 'brand new job output\n', 'utf8');
|
|
63
|
+
|
|
64
|
+
const outFile = path.join(outDir, 'delta.uncommitted.patch');
|
|
65
|
+
const result = await gitWorktree.salvageDirtyDelta({
|
|
66
|
+
cwd: repoCwd,
|
|
67
|
+
paths: ['tracked-a.txt', 'job-new-file.txt'],
|
|
68
|
+
outFile,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.ok).toBe(true);
|
|
72
|
+
expect(result.bytes).toBeGreaterThan(0);
|
|
73
|
+
const patch = fs.readFileSync(outFile, 'utf8');
|
|
74
|
+
expect(patch).toContain('tracked-a.txt');
|
|
75
|
+
expect(patch).toContain('edited by job');
|
|
76
|
+
expect(patch).toContain('job-new-file.txt');
|
|
77
|
+
expect(patch).toContain('brand new job output');
|
|
78
|
+
// The sibling/human's pre-existing dirty file is NOT in the delta list —
|
|
79
|
+
// it must never leak into this job's patch.
|
|
80
|
+
expect(patch).not.toContain('pre-existing human WIP');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('applies cleanly with git apply --check against a fresh clone of the base tree', async () => {
|
|
84
|
+
fs.writeFileSync(path.join(repoCwd, 'tracked-a.txt'), 'a\nedited\n', 'utf8');
|
|
85
|
+
fs.writeFileSync(path.join(repoCwd, 'job-new-file.txt'), 'new content\n', 'utf8');
|
|
86
|
+
|
|
87
|
+
const outFile = path.join(outDir, 'apply-check.uncommitted.patch');
|
|
88
|
+
const result = await gitWorktree.salvageDirtyDelta({
|
|
89
|
+
cwd: repoCwd,
|
|
90
|
+
paths: ['tracked-a.txt', 'job-new-file.txt'],
|
|
91
|
+
outFile,
|
|
92
|
+
});
|
|
93
|
+
expect(result.ok).toBe(true);
|
|
94
|
+
|
|
95
|
+
// Fresh clone of the same commit, clean, to prove the patch is
|
|
96
|
+
// self-sufficient and applies against the pristine base state.
|
|
97
|
+
const cleanCheckout = path.join(tmpRoot, 'clean-checkout');
|
|
98
|
+
git(['clone', '-q', repoCwd, cleanCheckout], tmpRoot);
|
|
99
|
+
expect(() => git(['apply', '--check', outFile], cleanCheckout)).not.toThrow();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('never mutates the shared tree: git status and stash list are byte-identical before and after', async () => {
|
|
103
|
+
// A human's own pre-existing WIP, deliberately outside the delta.
|
|
104
|
+
fs.writeFileSync(path.join(repoCwd, 'tracked-b.txt'), 'b\nhuman wip\n', 'utf8');
|
|
105
|
+
fs.writeFileSync(path.join(repoCwd, 'tracked-a.txt'), 'a\njob edit\n', 'utf8');
|
|
106
|
+
fs.writeFileSync(path.join(repoCwd, 'job-new-file.txt'), 'job output\n', 'utf8');
|
|
107
|
+
|
|
108
|
+
const statusBefore = git(['status', '--porcelain'], repoCwd);
|
|
109
|
+
const stashBefore = git(['stash', 'list'], repoCwd);
|
|
110
|
+
|
|
111
|
+
const outFile = path.join(outDir, 'no-mutate.uncommitted.patch');
|
|
112
|
+
const result = await gitWorktree.salvageDirtyDelta({
|
|
113
|
+
cwd: repoCwd,
|
|
114
|
+
paths: ['tracked-a.txt', 'job-new-file.txt'],
|
|
115
|
+
outFile,
|
|
116
|
+
});
|
|
117
|
+
expect(result.ok).toBe(true);
|
|
118
|
+
|
|
119
|
+
const statusAfter = git(['status', '--porcelain'], repoCwd);
|
|
120
|
+
const stashAfter = git(['stash', 'list'], repoCwd);
|
|
121
|
+
expect(statusAfter).toBe(statusBefore);
|
|
122
|
+
expect(stashAfter).toBe(stashBefore);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('no delta paths dirty at call time produces no patch file (no 0-byte artifact)', async () => {
|
|
126
|
+
const outFile = path.join(outDir, 'clean.uncommitted.patch');
|
|
127
|
+
const result = await gitWorktree.salvageDirtyDelta({
|
|
128
|
+
cwd: repoCwd,
|
|
129
|
+
paths: ['tracked-a.txt'],
|
|
130
|
+
outFile,
|
|
131
|
+
});
|
|
132
|
+
expect(result.ok).toBe(false);
|
|
133
|
+
expect(fs.existsSync(outFile)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('empty paths list is a no-op', async () => {
|
|
137
|
+
const outFile = path.join(outDir, 'empty.uncommitted.patch');
|
|
138
|
+
const result = await gitWorktree.salvageDirtyDelta({ cwd: repoCwd, paths: [], outFile });
|
|
139
|
+
expect(result.ok).toBe(false);
|
|
140
|
+
expect(fs.existsSync(outFile)).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a git failure (not a repo) is swallowed and never throws', async () => {
|
|
144
|
+
const notAGitRepo = path.join(tmpRoot, 'not-a-repo');
|
|
145
|
+
fs.mkdirSync(notAGitRepo, { recursive: true });
|
|
146
|
+
fs.writeFileSync(path.join(notAGitRepo, 'x.txt'), 'x\n', 'utf8');
|
|
147
|
+
const outFile = path.join(outDir, 'failure.uncommitted.patch');
|
|
148
|
+
|
|
149
|
+
await expect(
|
|
150
|
+
gitWorktree.salvageDirtyDelta({ cwd: notAGitRepo, paths: ['x.txt'], outFile }),
|
|
151
|
+
).resolves.toEqual({ ok: false });
|
|
152
|
+
expect(fs.existsSync(outFile)).toBe(false);
|
|
153
|
+
});
|
|
@@ -142,11 +142,13 @@ test('falls back with a reason for a non-git cwd', async () => {
|
|
|
142
142
|
expect(result.reason).toMatch(/not a git repository/);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
-
test('
|
|
146
|
-
fs.writeFileSync(path.join(repoCwd, '
|
|
145
|
+
test('a dirty tracked base file is carried into the worktree instead of disabling isolation (never silently drops uncommitted human WIP)', async () => {
|
|
146
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
147
147
|
const result = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
148
|
-
expect(result.ok).toBe(
|
|
149
|
-
expect(result.
|
|
148
|
+
expect(result.ok).toBe(true);
|
|
149
|
+
expect(result.carriedPaths).toEqual(['README.md']);
|
|
150
|
+
expect(fs.readFileSync(path.join(result.dir, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
|
|
151
|
+
await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
150
152
|
});
|
|
151
153
|
|
|
152
154
|
test('falls back once the concurrency cap is reached, and recovers after cleanup', async () => {
|