claude-code-session-manager 0.72.0 → 0.73.1
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-CXw7Z5Iw.js → AgentLibrary-BKWnsq_l.js} +1 -1
- package/dist/assets/{History-D64U_aDf.js → History-C0X8QPtj.js} +1 -1
- package/dist/assets/{Hooks-BFVkM6QH.js → Hooks-CPlN3FZJ.js} +1 -1
- package/dist/assets/{HostBilko-BRERoCra.js → HostBilko-D89tQlRT.js} +1 -1
- package/dist/assets/{Library-C6shVsjv.js → Library-CPZItZXp.js} +1 -1
- package/dist/assets/{ListDetail-dXWSZuDV.js → ListDetail-BOPSKXb8.js} +1 -1
- package/dist/assets/{MarkdownEditor-D_akyPJ2.js → MarkdownEditor-COrqn6pG.js} +1 -1
- package/dist/assets/{McpServers-DxROkYGa.js → McpServers-CAJoECwZ.js} +1 -1
- package/dist/assets/{Memory-OqUyTRrr.js → Memory-k4nLGHes.js} +1 -1
- package/dist/assets/{Panel-Biw98xRO.js → Panel-5RBhh85Z.js} +1 -1
- package/dist/assets/{Permissions-CIdZOzK8.js → Permissions-saePXW0J.js} +1 -1
- package/dist/assets/{Plugins-Dt147wEE.js → Plugins-Dga7FHIF.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BUikIHf4.js → ProvenanceBadge-CHhfNxeY.js} +1 -1
- package/dist/assets/{SaveBar-BoHRDHlp.js → SaveBar-Ccf-qQGF.js} +1 -1
- package/dist/assets/{Scheduler-CipmAcy7.js → Scheduler-IP4JRLZM.js} +1 -1
- package/dist/assets/{ScopeSwitcher-CkCwUYU5.js → ScopeSwitcher-D6dgDA6U.js} +1 -1
- package/dist/assets/Settings-eu8Xc1Hi.js +3 -0
- package/dist/assets/{SkillReferenceGraph-BuSuA3B7.js → SkillReferenceGraph-Bw-Hr4Sl.js} +1 -1
- package/dist/assets/{Skills-BM8w5BGV.js → Skills-BYLJsGLc.js} +1 -1
- package/dist/assets/{SystemPrompt-BWOdtfze.js → SystemPrompt-BGRCOhlY.js} +1 -1
- package/dist/assets/{TagLibrary-cn3JECC5.js → TagLibrary-COX_bxyZ.js} +1 -1
- package/dist/assets/{TiptapBody-BwoXKY8J.js → TiptapBody-S2ygespF.js} +1 -1
- package/dist/assets/{Toggle-BlNVxkq-.js → Toggle-Cbz7S9OY.js} +1 -1
- package/dist/assets/{index-BpzvONtX.js → index-BoVqP3BD.js} +679 -679
- package/dist/assets/{index-DDpA9yBe.css → index-D9C8N3pn.css} +1 -1
- package/dist/assets/{settingsSchema-DePBZnaw.js → settingsSchema-PIoojYg_.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +10 -1
- package/src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs +125 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +156 -0
- package/src/main/__tests__/queueOpsArchiveDestination.test.cjs +65 -0
- package/src/main/chatRunner.cjs +11 -1
- package/src/main/index.cjs +6 -0
- package/src/main/ipcSchemas.cjs +35 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +193 -0
- package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +136 -0
- package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +130 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +115 -0
- package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +79 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +400 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +126 -0
- package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +82 -0
- package/src/main/lib/epicSpawnCwd.cjs +95 -0
- package/src/main/lib/epicWorktreeBoot.cjs +49 -0
- package/src/main/lib/epicWorktreeMerge.cjs +63 -0
- package/src/main/lib/epicWorktreeMint.cjs +54 -0
- package/src/main/lib/epicWorktreeProjectConfig.cjs +102 -0
- package/src/main/lib/gitWorktree.cjs +446 -0
- package/src/main/lib/jobWorktree.cjs +36 -293
- package/src/main/lib/promptSessionSchema.cjs +15 -0
- package/src/main/lib/queueStore.cjs +18 -0
- package/src/main/lib/terminalRunOutcome.cjs +14 -2
- package/src/main/pty.cjs +11 -1
- package/src/main/queueOps.cjs +40 -12
- package/src/main/scheduler.cjs +14 -0
- package/src/preload/api.d.ts +59 -0
- package/src/preload/index.cjs +22 -0
- package/dist/assets/Settings-CmU_7z2e.js +0 -3
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epicWorktreeBoot.test.cjs — unit tests for reconcileEpicWorktreesOnBoot
|
|
3
|
+
* (PRD 1033), which wires gitWorktree.cjs's `isLive` predicate (built but
|
|
4
|
+
* left unwired by PRD 1032) to a project's real active-index.json: a
|
|
5
|
+
* worktree survives boot reconciliation exactly when its owning Epic is
|
|
6
|
+
* still 'active' there.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/epicWorktreeBoot.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect } from 'vitest';
|
|
14
|
+
const { reconcileEpicWorktreesOnBoot } = require('../epicWorktreeBoot.cjs');
|
|
15
|
+
|
|
16
|
+
test('reaps a worktree whose owning Epic is not active (missing from the index)', async () => {
|
|
17
|
+
const calls = [];
|
|
18
|
+
const reconcileWorktreesOnBoot = async (cwds, opts) => {
|
|
19
|
+
calls.push({ cwds, kind: opts.kind });
|
|
20
|
+
// Simulate gitWorktree.cjs's real loop: it asks isLive per key found on disk.
|
|
21
|
+
expect(await opts.isLive('orphaned-epic')).toBe(false);
|
|
22
|
+
};
|
|
23
|
+
await reconcileEpicWorktreesOnBoot(['/projects/foo'], {
|
|
24
|
+
readActiveIndex: () => ({ sessions: {} }),
|
|
25
|
+
reconcileWorktreesOnBoot,
|
|
26
|
+
});
|
|
27
|
+
expect(calls).toHaveLength(1);
|
|
28
|
+
expect(calls[0].kind).toBe('epic');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a worktree belonging to a currently-active Epic survives (isLive returns true)', async () => {
|
|
32
|
+
const reconcileWorktreesOnBoot = async (cwds, opts) => {
|
|
33
|
+
expect(await opts.isLive('still-active-epic')).toBe(true);
|
|
34
|
+
expect(await opts.isLive('completed-epic')).toBe(false);
|
|
35
|
+
expect(await opts.isLive('unknown-epic')).toBe(false);
|
|
36
|
+
};
|
|
37
|
+
await reconcileEpicWorktreesOnBoot(['/projects/foo'], {
|
|
38
|
+
readActiveIndex: (cwd) => {
|
|
39
|
+
expect(cwd).toBe('/projects/foo');
|
|
40
|
+
return {
|
|
41
|
+
sessions: {
|
|
42
|
+
'still-active-epic': { id: 'still-active-epic', status: 'active' },
|
|
43
|
+
'completed-epic': { id: 'completed-epic', status: 'completed' },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
reconcileWorktreesOnBoot,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('sweeps every cwd given, each with its own active-index read', async () => {
|
|
52
|
+
const seenCwds = [];
|
|
53
|
+
await reconcileEpicWorktreesOnBoot(['/projects/a', '/projects/b'], {
|
|
54
|
+
readActiveIndex: (cwd) => {
|
|
55
|
+
seenCwds.push(cwd);
|
|
56
|
+
return { sessions: {} };
|
|
57
|
+
},
|
|
58
|
+
reconcileWorktreesOnBoot: async () => {},
|
|
59
|
+
});
|
|
60
|
+
expect(seenCwds).toEqual(['/projects/a', '/projects/b']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('never throws when a cwd has no active-index.json yet (readActiveIndex throws)', async () => {
|
|
64
|
+
const reconcileWorktreesOnBoot = async (cwds, opts) => {
|
|
65
|
+
expect(await opts.isLive('any-epic')).toBe(false);
|
|
66
|
+
};
|
|
67
|
+
await expect(
|
|
68
|
+
reconcileEpicWorktreesOnBoot(['/projects/no-index-yet'], {
|
|
69
|
+
readActiveIndex: () => { throw new Error('ENOENT'); },
|
|
70
|
+
reconcileWorktreesOnBoot,
|
|
71
|
+
}),
|
|
72
|
+
).resolves.toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('ignores falsy entries in the cwd list', async () => {
|
|
76
|
+
const seenCwds = [];
|
|
77
|
+
await reconcileEpicWorktreesOnBoot([null, '/projects/a', undefined, ''], {
|
|
78
|
+
readActiveIndex: (cwd) => { seenCwds.push(cwd); return { sessions: {} }; },
|
|
79
|
+
reconcileWorktreesOnBoot: async () => {},
|
|
80
|
+
});
|
|
81
|
+
expect(seenCwds).toEqual(['/projects/a']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ─── real git repo + real active-index.json, default (unstubbed) deps ─────
|
|
85
|
+
|
|
86
|
+
test('end-to-end with default deps: a worktree of a still-active Epic survives, an orphaned one is reaped', async () => {
|
|
87
|
+
const fs = require('node:fs');
|
|
88
|
+
const os = require('node:os');
|
|
89
|
+
const path = require('node:path');
|
|
90
|
+
const { execFileSync } = require('node:child_process');
|
|
91
|
+
const gitWorktree = require('../gitWorktree.cjs');
|
|
92
|
+
|
|
93
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-epicworktreeboot-e2e-'));
|
|
94
|
+
const repoCwd = path.join(tmpRoot, 'repo');
|
|
95
|
+
const git = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
96
|
+
fs.mkdirSync(repoCwd, { recursive: true });
|
|
97
|
+
git(['init', '-q'], repoCwd);
|
|
98
|
+
git(['config', 'user.email', 'test@example.com'], repoCwd);
|
|
99
|
+
git(['config', 'user.name', 'Test'], repoCwd);
|
|
100
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'hello\n', 'utf8');
|
|
101
|
+
git(['add', '-A'], repoCwd);
|
|
102
|
+
git(['commit', '-q', '-m', 'initial'], repoCwd);
|
|
103
|
+
gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const liveEpicId = 'e2e-live-epic';
|
|
107
|
+
const orphanedEpicId = 'e2e-orphaned-epic';
|
|
108
|
+
const live = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: liveEpicId });
|
|
109
|
+
const orphaned = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: orphanedEpicId });
|
|
110
|
+
expect(live.ok).toBe(true);
|
|
111
|
+
expect(orphaned.ok).toBe(true);
|
|
112
|
+
gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
|
|
113
|
+
|
|
114
|
+
const indexDir = path.join(repoCwd, 'session-manager-operations', 'prompt-sessions');
|
|
115
|
+
fs.mkdirSync(indexDir, { recursive: true });
|
|
116
|
+
fs.writeFileSync(
|
|
117
|
+
path.join(indexDir, 'active-index.json'),
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
sessions: {
|
|
120
|
+
[liveEpicId]: { id: liveEpicId, status: 'active' },
|
|
121
|
+
// orphanedEpicId deliberately absent — simulates a completed/deleted Epic.
|
|
122
|
+
},
|
|
123
|
+
events: {},
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
await reconcileEpicWorktreesOnBoot([repoCwd]);
|
|
128
|
+
|
|
129
|
+
expect(fs.existsSync(live.dir)).toBe(true);
|
|
130
|
+
expect(fs.existsSync(orphaned.dir)).toBe(false);
|
|
131
|
+
|
|
132
|
+
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: live.dir, branch: live.branch });
|
|
133
|
+
} finally {
|
|
134
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epicWorktreeMerge.test.cjs — unit tests for the
|
|
3
|
+
* 'promptSessions:merge-to-main' IPC handler (lib/epicWorktreeMerge.cjs),
|
|
4
|
+
* PRD 1034: the ONLY point where an Epic's isolated git worktree branch
|
|
5
|
+
* (PRD 1032/1033) resolves back into its owning project's main tree.
|
|
6
|
+
*
|
|
7
|
+
* Exercises the handler the same way index.cjs wires it — schema validation
|
|
8
|
+
* (ipcSchemas.cjs's `validated` wrapper) in front of the business logic,
|
|
9
|
+
* mirroring epicWorktreeMint.test.cjs's pattern.
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/epicWorktreeMerge.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { test, expect, afterEach } from 'vitest';
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const fsp = require('node:fs/promises');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { execFileSync } = require('node:child_process');
|
|
22
|
+
const { schemas, validated } = require('../../ipcSchemas.cjs');
|
|
23
|
+
const { mergeEpicToMainViaIpc } = require('../epicWorktreeMerge.cjs');
|
|
24
|
+
const gitWorktree = require('../gitWorktree.cjs');
|
|
25
|
+
const config = require('../../config.cjs');
|
|
26
|
+
|
|
27
|
+
const handler = validated(schemas.promptSessionsMergeToMain, ({ cwd, epicId, branch, dir }) =>
|
|
28
|
+
mergeEpicToMainViaIpc(cwd, epicId, branch, dir),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
function git(args, cwd) {
|
|
32
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function initRepo(dir) {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
git(['init', '-q'], dir);
|
|
38
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
39
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
40
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
41
|
+
git(['add', '-A'], dir);
|
|
42
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tmpDirs = [];
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
|
|
48
|
+
while (tmpDirs.length) {
|
|
49
|
+
const d = tmpDirs.pop();
|
|
50
|
+
try { await gitWorktree.reconcileWorktreesOnBoot([d], { kind: 'epic' }); } catch { /* ignore */ }
|
|
51
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function mkRepoCwd() {
|
|
56
|
+
const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-epicworktreemerge-'));
|
|
57
|
+
initRepo(cwd);
|
|
58
|
+
config.addAllowedRoot(cwd);
|
|
59
|
+
tmpDirs.push(cwd);
|
|
60
|
+
return cwd;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('a clean ff-only merge succeeds and cleans up the worktree checkout', async () => {
|
|
64
|
+
const cwd = await mkRepoCwd();
|
|
65
|
+
const created = await gitWorktree.createEpicWorktree({ cwd, epicId: 'epic-merge-clean' });
|
|
66
|
+
expect(created.ok).toBe(true);
|
|
67
|
+
|
|
68
|
+
fs.writeFileSync(path.join(created.dir, 'epic-note.txt'), 'epic work\n', 'utf8');
|
|
69
|
+
git(['add', '-A'], created.dir);
|
|
70
|
+
git(['commit', '-q', '-m', 'epic edits'], created.dir);
|
|
71
|
+
|
|
72
|
+
const result = await handler(null, { cwd, epicId: 'epic-merge-clean', branch: created.branch, dir: created.dir });
|
|
73
|
+
|
|
74
|
+
expect(result).toEqual({ ok: true, status: 'merged', integrated: true });
|
|
75
|
+
// Checkout removed, content landed on the main tree.
|
|
76
|
+
expect(fs.existsSync(created.dir)).toBe(false);
|
|
77
|
+
expect(fs.existsSync(path.join(cwd, 'epic-note.txt'))).toBe(true);
|
|
78
|
+
const branches = git(['branch', '--list', created.branch], cwd);
|
|
79
|
+
expect(branches.trim()).toBe('');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a real conflicting change leaves branch + worktree dir intact and returns needs_merge_resolution', async () => {
|
|
83
|
+
const cwd = await mkRepoCwd();
|
|
84
|
+
const created = await gitWorktree.createEpicWorktree({ cwd, epicId: 'epic-merge-conflict' });
|
|
85
|
+
expect(created.ok).toBe(true);
|
|
86
|
+
|
|
87
|
+
// Same line, two divergent edits — a genuine content conflict.
|
|
88
|
+
fs.writeFileSync(path.join(created.dir, 'README.md'), 'epic changed this line\n', 'utf8');
|
|
89
|
+
git(['add', '-A'], created.dir);
|
|
90
|
+
git(['commit', '-q', '-m', 'epic edits README'], created.dir);
|
|
91
|
+
|
|
92
|
+
fs.writeFileSync(path.join(cwd, 'README.md'), 'main tree changed this line\n', 'utf8');
|
|
93
|
+
git(['add', '-A'], cwd);
|
|
94
|
+
git(['commit', '-q', '-m', 'main tree edits README'], cwd);
|
|
95
|
+
|
|
96
|
+
const result = await handler(null, { cwd, epicId: 'epic-merge-conflict', branch: created.branch, dir: created.dir });
|
|
97
|
+
|
|
98
|
+
expect(result.ok).toBe(false);
|
|
99
|
+
expect(result.status).toBe('needs_merge_resolution');
|
|
100
|
+
expect(result.reason).toMatch(/conflict|merge failed/i);
|
|
101
|
+
|
|
102
|
+
// Never silently discarded: the checkout and its branch both survive.
|
|
103
|
+
expect(fs.existsSync(created.dir)).toBe(true);
|
|
104
|
+
const branches = git(['branch', '--list', created.branch], cwd);
|
|
105
|
+
expect(branches.trim()).not.toBe('');
|
|
106
|
+
// The main tree itself is left clean (no half-applied merge).
|
|
107
|
+
const status = git(['status', '--porcelain'], cwd);
|
|
108
|
+
expect(status.trim()).toBe('');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a cwd outside every allowed root is rejected before anything is merged', async () => {
|
|
112
|
+
const unregistered = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-epicworktreemerge-unregistered-'));
|
|
113
|
+
initRepo(unregistered);
|
|
114
|
+
tmpDirs.push(unregistered);
|
|
115
|
+
|
|
116
|
+
await expect(
|
|
117
|
+
handler(null, { cwd: unregistered, epicId: 'epic-merge-x', branch: 'sm-epic/epic-merge-x', dir: '/tmp/whatever' }),
|
|
118
|
+
).rejects.toThrow(/outside allowed boundaries/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a request with a malformed branch name is rejected by ipcSchemas validation', async () => {
|
|
122
|
+
const cwd = await mkRepoCwd();
|
|
123
|
+
// `validated()` parses BEFORE calling the handler, so a schema rejection is
|
|
124
|
+
// a synchronous throw, not a rejected promise (ipcSchemas.cjs:851-856 —
|
|
125
|
+
// Electron's IPC harness is what turns it into a rejection at the boundary).
|
|
126
|
+
// Same assertion style as promptSessionsCreateEpicHandler.test.cjs:86-106.
|
|
127
|
+
expect(
|
|
128
|
+
() => handler(null, { cwd, epicId: 'epic-merge-y', branch: 'not-an-epic-branch', dir: '/tmp/whatever' }),
|
|
129
|
+
).toThrow();
|
|
130
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epicWorktreeMint.test.cjs — unit tests for the
|
|
3
|
+
* 'promptSessions:create-worktree' IPC handler (lib/epicWorktreeMint.cjs),
|
|
4
|
+
* PRD 1033: the main-process entry point the renderer's approveProposed
|
|
5
|
+
* fires (fire-and-forget) at the Epic proposed->active transition to create
|
|
6
|
+
* its isolated git worktree.
|
|
7
|
+
*
|
|
8
|
+
* Exercises the handler the same way index.cjs wires it — schema validation
|
|
9
|
+
* (ipcSchemas.cjs's `validated` wrapper) in front of the business logic,
|
|
10
|
+
* mirroring promptSessionsCreateEpicHandler.test.cjs's pattern.
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/epicWorktreeMint.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect, afterEach } from 'vitest';
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const fsp = require('node:fs/promises');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { execFileSync } = require('node:child_process');
|
|
23
|
+
const { schemas, validated } = require('../../ipcSchemas.cjs');
|
|
24
|
+
const { createEpicWorktreeViaIpc } = require('../epicWorktreeMint.cjs');
|
|
25
|
+
const gitWorktree = require('../gitWorktree.cjs');
|
|
26
|
+
const config = require('../../config.cjs');
|
|
27
|
+
|
|
28
|
+
const handler = validated(schemas.promptSessionsCreateWorktree, ({ cwd, epicId }) => createEpicWorktreeViaIpc(cwd, epicId));
|
|
29
|
+
|
|
30
|
+
function git(args, cwd) {
|
|
31
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function initRepo(dir) {
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
git(['init', '-q'], dir);
|
|
37
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
38
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
39
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
40
|
+
git(['add', '-A'], dir);
|
|
41
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tmpDirs = [];
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
|
|
47
|
+
while (tmpDirs.length) {
|
|
48
|
+
const d = tmpDirs.pop();
|
|
49
|
+
try { await gitWorktree.reconcileWorktreesOnBoot([d], { kind: 'epic' }); } catch { /* ignore */ }
|
|
50
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
async function mkRepoCwd() {
|
|
55
|
+
const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-epicworktreemint-'));
|
|
56
|
+
initRepo(cwd);
|
|
57
|
+
config.addAllowedRoot(cwd);
|
|
58
|
+
tmpDirs.push(cwd);
|
|
59
|
+
return cwd;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('a valid request creates the Epic worktree and returns the field shape ready to persist', async () => {
|
|
63
|
+
const cwd = await mkRepoCwd();
|
|
64
|
+
const result = await handler(null, { cwd, epicId: 'epic-mint-1' });
|
|
65
|
+
|
|
66
|
+
expect(result).not.toBeNull();
|
|
67
|
+
expect(result.branch).toBe('sm-epic/epic-mint-1');
|
|
68
|
+
expect(result.baseCwd).toBe(cwd);
|
|
69
|
+
expect(result.status).toBe('active');
|
|
70
|
+
expect(fs.existsSync(result.dir)).toBe(true);
|
|
71
|
+
expect(fs.existsSync(path.join(result.dir, 'README.md'))).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a non-git cwd resolves to null rather than throwing (Epic proceeds unisolated)', async () => {
|
|
75
|
+
const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-epicworktreemint-nongit-'));
|
|
76
|
+
config.addAllowedRoot(cwd);
|
|
77
|
+
tmpDirs.push(cwd);
|
|
78
|
+
|
|
79
|
+
const result = await handler(null, { cwd, epicId: 'epic-mint-2' });
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a dirty base tree resolves to null rather than throwing', async () => {
|
|
84
|
+
const cwd = await mkRepoCwd();
|
|
85
|
+
fs.writeFileSync(path.join(cwd, 'dirty.txt'), 'uncommitted\n', 'utf8');
|
|
86
|
+
|
|
87
|
+
const result = await handler(null, { cwd, epicId: 'epic-mint-3' });
|
|
88
|
+
expect(result).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('SM_EPIC_WORKTREE_DISABLE=1 resolves to null rather than throwing', async () => {
|
|
92
|
+
const cwd = await mkRepoCwd();
|
|
93
|
+
const original = process.env.SM_EPIC_WORKTREE_DISABLE;
|
|
94
|
+
process.env.SM_EPIC_WORKTREE_DISABLE = '1';
|
|
95
|
+
try {
|
|
96
|
+
const result = await handler(null, { cwd, epicId: 'epic-mint-4' });
|
|
97
|
+
expect(result).toBeNull();
|
|
98
|
+
} finally {
|
|
99
|
+
if (original === undefined) delete process.env.SM_EPIC_WORKTREE_DISABLE;
|
|
100
|
+
else process.env.SM_EPIC_WORKTREE_DISABLE = original;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a cwd outside every allowed root is rejected before anything is created', async () => {
|
|
105
|
+
const unregistered = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-epicworktreemint-unregistered-'));
|
|
106
|
+
initRepo(unregistered);
|
|
107
|
+
tmpDirs.push(unregistered);
|
|
108
|
+
|
|
109
|
+
await expect(handler(null, { cwd: unregistered, epicId: 'epic-mint-5' })).rejects.toThrow(/outside allowed boundaries/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('a request missing epicId is rejected by ipcSchemas validation', async () => {
|
|
113
|
+
const cwd = await mkRepoCwd();
|
|
114
|
+
expect(() => handler(null, { cwd })).toThrow();
|
|
115
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epicWorktreeProjectConfig.test.cjs — PRD 1035: the per-project "disable
|
|
3
|
+
* Epic worktree isolation" UI toggle. Points the module at a throwaway
|
|
4
|
+
* tmpdir file via SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH so it never touches
|
|
5
|
+
* the real ~/.claude/session-manager/epic-worktree-project-config.json on
|
|
6
|
+
* the machine running the tests.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
let tmpRoot;
|
|
19
|
+
let configFile;
|
|
20
|
+
let originalOverride;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-epic-worktree-project-config-'));
|
|
24
|
+
configFile = path.join(tmpRoot, 'config.json');
|
|
25
|
+
originalOverride = process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
|
|
26
|
+
process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = configFile;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (originalOverride === undefined) delete process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
|
|
31
|
+
else process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = originalOverride;
|
|
32
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function freshModule() {
|
|
36
|
+
return require('../epicWorktreeProjectConfig.cjs');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('defaults to not disabled for a project never touched', () => {
|
|
40
|
+
const m = freshModule();
|
|
41
|
+
expect(m.isEpicWorktreeDisabledForProject('/some/project')).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('setEpicWorktreeDisabledForProject(true) persists across a fresh read', () => {
|
|
45
|
+
const m = freshModule();
|
|
46
|
+
m.setEpicWorktreeDisabledForProject('/proj/a', true);
|
|
47
|
+
expect(m.isEpicWorktreeDisabledForProject('/proj/a')).toBe(true);
|
|
48
|
+
expect(fs.existsSync(configFile)).toBe(true);
|
|
49
|
+
const onDisk = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
50
|
+
expect(onDisk).toEqual({ '/proj/a': true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('is scoped per cwd — a different project is unaffected', () => {
|
|
54
|
+
const m = freshModule();
|
|
55
|
+
m.setEpicWorktreeDisabledForProject('/proj/a', true);
|
|
56
|
+
expect(m.isEpicWorktreeDisabledForProject('/proj/b')).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('setEpicWorktreeDisabledForProject(false) clears the entry rather than writing false', () => {
|
|
60
|
+
const m = freshModule();
|
|
61
|
+
m.setEpicWorktreeDisabledForProject('/proj/a', true);
|
|
62
|
+
m.setEpicWorktreeDisabledForProject('/proj/a', false);
|
|
63
|
+
expect(m.isEpicWorktreeDisabledForProject('/proj/a')).toBe(false);
|
|
64
|
+
const onDisk = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
65
|
+
expect(onDisk).toEqual({});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a missing or corrupt config file reads as not-disabled rather than throwing', () => {
|
|
69
|
+
const m = freshModule();
|
|
70
|
+
fs.mkdirSync(path.dirname(configFile), { recursive: true });
|
|
71
|
+
fs.writeFileSync(configFile, '{ not valid json');
|
|
72
|
+
expect(m.isEpicWorktreeDisabledForProject('/proj/a')).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('isEpicWorktreeDisabledForProject rejects a non-string cwd instead of throwing', () => {
|
|
76
|
+
const m = freshModule();
|
|
77
|
+
expect(m.isEpicWorktreeDisabledForProject(null)).toBe(false);
|
|
78
|
+
expect(m.isEpicWorktreeDisabledForProject(undefined)).toBe(false);
|
|
79
|
+
});
|