claude-code-session-manager 0.71.0 → 0.73.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.
Files changed (60) hide show
  1. package/dist/assets/{AgentLibrary-B1lh6YEM.js → AgentLibrary-BowrgaXK.js} +1 -1
  2. package/dist/assets/{History-B1jNdKFk.js → History-B8rJXcxk.js} +1 -1
  3. package/dist/assets/{Hooks-BWHFkF7z.js → Hooks-D-KzXjDk.js} +1 -1
  4. package/dist/assets/{HostBilko-CYQt0b-t.js → HostBilko-BSJhBE9I.js} +1 -1
  5. package/dist/assets/{Library-DZVKlOBQ.js → Library-CtqgqUc6.js} +1 -1
  6. package/dist/assets/{ListDetail-rBF1ZQv8.js → ListDetail-D7k4TXvO.js} +1 -1
  7. package/dist/assets/{MarkdownEditor-zS0QmHxL.js → MarkdownEditor-DFm0I9B_.js} +1 -1
  8. package/dist/assets/{McpServers-BA54nZee.js → McpServers-DDK04FMq.js} +1 -1
  9. package/dist/assets/{Memory-DN97rjG5.js → Memory-B1p59Mas.js} +1 -1
  10. package/dist/assets/{Panel-DLn9L0Cv.js → Panel-BJpqcDzp.js} +1 -1
  11. package/dist/assets/{Permissions-BnDRq-EM.js → Permissions-CFC3RusZ.js} +1 -1
  12. package/dist/assets/{Plugins-aPC4a4Bi.js → Plugins-TeCQMr6X.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-Bc1gskID.js → ProvenanceBadge-BfIzSRrY.js} +1 -1
  14. package/dist/assets/{SaveBar-CjvWkRaQ.js → SaveBar-7SmNcZB-.js} +1 -1
  15. package/dist/assets/{Scheduler-86oyi_P2.js → Scheduler-B9ovEniK.js} +1 -1
  16. package/dist/assets/{ScopeSwitcher-DJvnhSk3.js → ScopeSwitcher-KmemfTLa.js} +1 -1
  17. package/dist/assets/Settings-CxZijd66.js +3 -0
  18. package/dist/assets/{SkillReferenceGraph-CZjMRk3z.js → SkillReferenceGraph-BhACzgW7.js} +1 -1
  19. package/dist/assets/{Skills-EueUDa9X.js → Skills-CFZU6ltE.js} +1 -1
  20. package/dist/assets/{SystemPrompt-BQQudmyk.js → SystemPrompt-3tHpyoge.js} +1 -1
  21. package/dist/assets/{TagLibrary-BEAqaJlp.js → TagLibrary-BoJHe-aY.js} +1 -1
  22. package/dist/assets/{TiptapBody-DKmVDGB5.js → TiptapBody-DzlxVXaw.js} +1 -1
  23. package/dist/assets/{Toggle-B7Qb5e2O.js → Toggle-DKmoif0X.js} +1 -1
  24. package/dist/assets/{index-l-hKv9ou.js → index-Dtv0whpT.js} +671 -671
  25. package/dist/assets/{index-CNmekpRK.css → index-N_v_4fqC.css} +1 -1
  26. package/dist/assets/{settingsSchema-DEW4x9LG.js → settingsSchema-CW-0QfXi.js} +1 -1
  27. package/dist/index.html +2 -2
  28. package/package.json +1 -1
  29. package/scripts/lib/activeSessions.cjs +10 -1
  30. package/src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs +125 -0
  31. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +156 -0
  32. package/src/main/__tests__/queueOpsArchiveDestination.test.cjs +65 -0
  33. package/src/main/__tests__/stop-signal-anchor.test.cjs +75 -0
  34. package/src/main/chatRunner.cjs +11 -1
  35. package/src/main/index.cjs +12 -6
  36. package/src/main/ipcSchemas.cjs +35 -0
  37. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +193 -0
  38. package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +136 -0
  39. package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +130 -0
  40. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +115 -0
  41. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +79 -0
  42. package/src/main/lib/__tests__/gitWorktree.test.cjs +400 -0
  43. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +126 -0
  44. package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +82 -0
  45. package/src/main/lib/epicSpawnCwd.cjs +95 -0
  46. package/src/main/lib/epicWorktreeBoot.cjs +49 -0
  47. package/src/main/lib/epicWorktreeMerge.cjs +63 -0
  48. package/src/main/lib/epicWorktreeMint.cjs +54 -0
  49. package/src/main/lib/epicWorktreeProjectConfig.cjs +102 -0
  50. package/src/main/lib/gitWorktree.cjs +446 -0
  51. package/src/main/lib/jobWorktree.cjs +36 -293
  52. package/src/main/lib/promptSessionSchema.cjs +15 -0
  53. package/src/main/lib/queueStore.cjs +18 -0
  54. package/src/main/lib/terminalRunOutcome.cjs +14 -2
  55. package/src/main/pty.cjs +11 -1
  56. package/src/main/queueOps.cjs +40 -12
  57. package/src/main/scheduler.cjs +14 -0
  58. package/src/preload/api.d.ts +59 -0
  59. package/src/preload/index.cjs +22 -0
  60. package/dist/assets/Settings-B7_1p_xA.js +0 -3
@@ -0,0 +1,193 @@
1
+ /**
2
+ * epicSpawnCwd.test.cjs — unit tests for resolveEpicSpawnCwd, the shared
3
+ * spawn-cwd resolver both pty.cjs's Terminal spawn and chatRunner.cjs's
4
+ * headless spawn use to point an Epic's actual child process at its
5
+ * isolated git worktree (PRD 1033), while every other cwd usage stays the
6
+ * real project cwd (see the module's own "ops-root hazard" header comment).
7
+ *
8
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/epicSpawnCwd.test.cjs
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import { test, expect } from 'vitest';
14
+ const { resolveEpicSpawnCwd } = require('../epicSpawnCwd.cjs');
15
+
16
+ function stubReadActiveIndex(sessions) {
17
+ return () => ({ sessions });
18
+ }
19
+
20
+ // resolveEpicSpawnCwd stats worktree.dir before returning it (a persisted
21
+ // path outlives its checkout across a reboot/tmp sweep, a clone onto another
22
+ // machine, or a manual prune). These fixtures name paths that don't exist on
23
+ // the test host, so inject a stat stub saying they do; the "vanished dir"
24
+ // tests at the bottom exercise the real fallback.
25
+ const statOk = () => ({ isDirectory: () => true });
26
+ const statGone = () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); };
27
+
28
+ test('returns worktree.dir when the matching Epic (by claudeSessionId) has one', () => {
29
+ const result = resolveEpicSpawnCwd({
30
+ cwd: '/projects/foo',
31
+ claudeSessionId: 'sess-1',
32
+ deps: {
33
+ readActiveIndex: stubReadActiveIndex({
34
+ 'epic-1': {
35
+ id: 'epic-1',
36
+ claudeSessionId: 'sess-1',
37
+ worktree: { dir: '/tmp/sm-epic-worktrees/abc/epic-1', branch: 'sm-epic/epic-1', baseCwd: '/projects/foo', status: 'active' },
38
+ },
39
+ }),
40
+ statSync: statOk,
41
+ },
42
+ });
43
+ expect(result).toBe('/tmp/sm-epic-worktrees/abc/epic-1');
44
+ });
45
+
46
+ test('falls back to cwd unchanged when the matching Epic has no worktree field', () => {
47
+ const result = resolveEpicSpawnCwd({
48
+ cwd: '/projects/foo',
49
+ claudeSessionId: 'sess-1',
50
+ deps: {
51
+ readActiveIndex: stubReadActiveIndex({
52
+ 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-1' },
53
+ }),
54
+ },
55
+ });
56
+ expect(result).toBe('/projects/foo');
57
+ });
58
+
59
+ test('falls back to cwd unchanged when no session matches claudeSessionId (non-Epic tab)', () => {
60
+ const result = resolveEpicSpawnCwd({
61
+ cwd: '/projects/foo',
62
+ claudeSessionId: 'some-other-session',
63
+ deps: {
64
+ readActiveIndex: stubReadActiveIndex({
65
+ 'epic-1': {
66
+ id: 'epic-1',
67
+ claudeSessionId: 'sess-1',
68
+ worktree: { dir: '/tmp/sm-epic-worktrees/abc/epic-1', branch: 'sm-epic/epic-1', baseCwd: '/projects/foo', status: 'active' },
69
+ },
70
+ }),
71
+ statSync: statOk,
72
+ },
73
+ });
74
+ expect(result).toBe('/projects/foo');
75
+ });
76
+
77
+ test('falls back to cwd unchanged when cwd or claudeSessionId is missing', () => {
78
+ expect(resolveEpicSpawnCwd({ cwd: '/projects/foo', claudeSessionId: '' })).toBe('/projects/foo');
79
+ expect(resolveEpicSpawnCwd({ cwd: '', claudeSessionId: 'sess-1' })).toBe('');
80
+ expect(resolveEpicSpawnCwd({})).toBeUndefined();
81
+ });
82
+
83
+ test('returns worktree.dir for a conflicted (needs_merge_resolution) Epic too — same PTY-into-worktree path a human uses to resolve it manually (PRD 1034)', () => {
84
+ const result = resolveEpicSpawnCwd({
85
+ cwd: '/projects/foo',
86
+ claudeSessionId: 'sess-1',
87
+ deps: {
88
+ readActiveIndex: stubReadActiveIndex({
89
+ 'epic-1': {
90
+ id: 'epic-1',
91
+ claudeSessionId: 'sess-1',
92
+ worktree: {
93
+ dir: '/tmp/sm-epic-worktrees/abc/epic-1',
94
+ branch: 'sm-epic/epic-1',
95
+ baseCwd: '/projects/foo',
96
+ status: 'needs_merge_resolution',
97
+ },
98
+ },
99
+ }),
100
+ statSync: statOk,
101
+ },
102
+ });
103
+ expect(result).toBe('/tmp/sm-epic-worktrees/abc/epic-1');
104
+ });
105
+
106
+ test('never throws when the active-index read itself throws — falls back to cwd', () => {
107
+ const result = resolveEpicSpawnCwd({
108
+ cwd: '/projects/foo',
109
+ claudeSessionId: 'sess-1',
110
+ deps: {
111
+ readActiveIndex: () => { throw new Error('disk read failed'); },
112
+ },
113
+ });
114
+ expect(result).toBe('/projects/foo');
115
+ });
116
+
117
+ // ─── persisted worktree.dir outliving its checkout (2026-08-13) ─────────────
118
+ //
119
+ // worktree.dir is an absolute os.tmpdir() path persisted onto the Epic record
120
+ // in active-index.json — a git-TRACKED file. It therefore routinely names a
121
+ // directory that no longer exists: /tmp is cleared on boot on most Linux
122
+ // distros and macOS (this host: `D /tmp 1777 root root 30d`), Epics are
123
+ // deliberately long-lived, and a clone on another machine carries a path built
124
+ // from the ORIGINAL machine's tmpdir and a sha1 of its project cwd. Handing
125
+ // that to node-pty/child_process as a spawn cwd fails the spawn outright, so
126
+ // the resolver falls back to the shared project tree instead.
127
+
128
+ const WORKTREE_SESSIONS = {
129
+ 'epic-1': {
130
+ id: 'epic-1',
131
+ claudeSessionId: 'sess-1',
132
+ worktree: {
133
+ dir: '/tmp/session-manager-epic-worktrees/deadbeef/epic-1',
134
+ branch: 'sm-epic/epic-1',
135
+ baseCwd: '/projects/foo',
136
+ status: 'active',
137
+ },
138
+ },
139
+ };
140
+
141
+ test('falls back to the project cwd when the persisted worktree dir no longer exists (reboot / tmp sweep)', () => {
142
+ const result = resolveEpicSpawnCwd({
143
+ cwd: '/projects/foo',
144
+ claudeSessionId: 'sess-1',
145
+ deps: { readActiveIndex: stubReadActiveIndex(WORKTREE_SESSIONS), statSync: statGone },
146
+ });
147
+ expect(result).toBe('/projects/foo');
148
+ });
149
+
150
+ test('falls back when worktree.dir exists but is a FILE, not a directory', () => {
151
+ const result = resolveEpicSpawnCwd({
152
+ cwd: '/projects/foo',
153
+ claudeSessionId: 'sess-1',
154
+ deps: {
155
+ readActiveIndex: stubReadActiveIndex(WORKTREE_SESSIONS),
156
+ statSync: () => ({ isDirectory: () => false }),
157
+ },
158
+ });
159
+ expect(result).toBe('/projects/foo');
160
+ });
161
+
162
+ test('a stat that throws something other than ENOENT still falls back rather than propagating', () => {
163
+ const result = resolveEpicSpawnCwd({
164
+ cwd: '/projects/foo',
165
+ claudeSessionId: 'sess-1',
166
+ deps: {
167
+ readActiveIndex: stubReadActiveIndex(WORKTREE_SESSIONS),
168
+ statSync: () => { throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); },
169
+ },
170
+ });
171
+ expect(result).toBe('/projects/foo');
172
+ });
173
+
174
+ test('a real, existing directory IS returned — the check gates on disk state, not on the field being set', () => {
175
+ const os = require('node:os');
176
+ const fs = require('node:fs');
177
+ const realDir = fs.mkdtempSync(require('node:path').join(os.tmpdir(), 'sm-epicspawn-real-'));
178
+ try {
179
+ const result = resolveEpicSpawnCwd({
180
+ cwd: '/projects/foo',
181
+ claudeSessionId: 'sess-1',
182
+ deps: {
183
+ readActiveIndex: stubReadActiveIndex({
184
+ 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-1', worktree: { dir: realDir, branch: 'b', baseCwd: '/projects/foo', status: 'active' } },
185
+ }),
186
+ // no statSync stub — exercises the real fs.statSync default
187
+ },
188
+ });
189
+ expect(result).toBe(realDir);
190
+ } finally {
191
+ fs.rmSync(realDir, { recursive: true, force: true });
192
+ }
193
+ });
@@ -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
+ });