ccmanager 4.3.1 → 4.4.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 (51) hide show
  1. package/README.md +54 -0
  2. package/dist/components/App.js +92 -30
  3. package/dist/components/App.test.js +86 -0
  4. package/dist/components/Dashboard.js +10 -3
  5. package/dist/components/DeleteWorktree.js +2 -13
  6. package/dist/components/Menu.js +28 -17
  7. package/dist/components/Menu.test.js +37 -1
  8. package/dist/components/RestoreSessions.d.ts +19 -0
  9. package/dist/components/RestoreSessions.js +28 -0
  10. package/dist/components/SessionActions.d.ts +17 -2
  11. package/dist/components/SessionActions.js +19 -17
  12. package/dist/components/SessionActions.test.d.ts +1 -0
  13. package/dist/components/SessionActions.test.js +94 -0
  14. package/dist/hooks/useAvailableLabelWidth.d.ts +6 -0
  15. package/dist/hooks/useAvailableLabelWidth.js +15 -0
  16. package/dist/services/config/globalConfigManager.js +3 -12
  17. package/dist/services/globalSessionOrchestrator.d.ts +7 -0
  18. package/dist/services/globalSessionOrchestrator.js +40 -0
  19. package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
  20. package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
  21. package/dist/services/projectManager.js +3 -11
  22. package/dist/services/sessionManager.d.ts +6 -0
  23. package/dist/services/sessionManager.js +16 -0
  24. package/dist/services/sessionRestoreStore.d.ts +75 -0
  25. package/dist/services/sessionRestoreStore.js +138 -0
  26. package/dist/services/sessionRestoreStore.test.d.ts +1 -0
  27. package/dist/services/sessionRestoreStore.test.js +92 -0
  28. package/dist/services/sessionRestorer.d.ts +46 -0
  29. package/dist/services/sessionRestorer.js +123 -0
  30. package/dist/services/sessionRestorer.test.d.ts +1 -0
  31. package/dist/services/sessionRestorer.test.js +163 -0
  32. package/dist/services/worktreeService.d.ts +12 -0
  33. package/dist/services/worktreeService.js +30 -0
  34. package/dist/services/worktreeService.test.js +55 -1
  35. package/dist/types/index.d.ts +3 -2
  36. package/dist/utils/configDir.d.ts +10 -0
  37. package/dist/utils/configDir.js +31 -0
  38. package/dist/utils/errorMessage.d.ts +7 -0
  39. package/dist/utils/errorMessage.js +19 -0
  40. package/dist/utils/filterByQuery.test.js +2 -0
  41. package/dist/utils/gitUtils.d.ts +1 -0
  42. package/dist/utils/gitUtils.js +15 -0
  43. package/dist/utils/hookExecutor.test.js +4 -0
  44. package/dist/utils/worktreeInclude.d.ts +33 -0
  45. package/dist/utils/worktreeInclude.js +100 -0
  46. package/dist/utils/worktreeInclude.test.d.ts +1 -0
  47. package/dist/utils/worktreeInclude.test.js +91 -0
  48. package/dist/utils/worktreeUtils.d.ts +38 -4
  49. package/dist/utils/worktreeUtils.js +76 -17
  50. package/dist/utils/worktreeUtils.test.js +82 -5
  51. package/package.json +6 -6
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @fileoverview Durable record of the sessions ccmanager currently has open,
3
+ * so that a later ccmanager run can offer to start them again.
4
+ *
5
+ * Only what is needed to launch the same command again is stored (which
6
+ * worktree, which command preset, the user-assigned session name). The
7
+ * terminal output and the conversation held inside the launched CLI are
8
+ * deliberately not stored: restoring means "run the launch command in that
9
+ * worktree again", not "bring back what was on screen".
10
+ *
11
+ * Every mutation is written to disk immediately and synchronously rather than
12
+ * on shutdown, so a crash, a `kill -9`, or a closed terminal still leaves an
13
+ * accurate record behind.
14
+ *
15
+ * Each mutation also re-reads the file before rewriting it. Two ccmanager
16
+ * processes share this one file, and a read-modify-write keeps one process's
17
+ * write from discarding sessions the other process recorded in the meantime.
18
+ */
19
+ import path from 'path';
20
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
21
+ import { ensureConfigDir } from '../utils/configDir.js';
22
+ import { logger } from '../utils/logger.js';
23
+ /** Format version of the on-disk file, so future changes can be detected. */
24
+ export const SESSION_RECORD_VERSION = 1;
25
+ export const SESSION_RECORD_FILE_NAME = 'sessions.json';
26
+ export class SessionRestoreStore {
27
+ explicitFilePath;
28
+ /**
29
+ * While true, mutations are ignored. Set just before ccmanager tears its
30
+ * sessions down on exit: those sessions are exactly the ones the next run
31
+ * should offer to restore, so their records must survive the teardown.
32
+ */
33
+ trackingSuspended = false;
34
+ constructor(filePath) {
35
+ this.explicitFilePath = filePath;
36
+ }
37
+ /**
38
+ * Resolved lazily rather than in the constructor so that merely importing
39
+ * this module does not create the configuration directory.
40
+ */
41
+ get filePath() {
42
+ return (this.explicitFilePath ??
43
+ path.join(ensureConfigDir(), SESSION_RECORD_FILE_NAME));
44
+ }
45
+ /** All recorded sessions, including ones owned by other ccmanager runs. */
46
+ list() {
47
+ return this.read();
48
+ }
49
+ /** Add a session to the record, replacing any earlier record with the same id. */
50
+ record(session) {
51
+ if (this.trackingSuspended) {
52
+ return;
53
+ }
54
+ this.write([...this.read().filter(s => s.id !== session.id), session]);
55
+ }
56
+ /**
57
+ * Drop sessions from the record, so they are not offered on the next run.
58
+ * Called when a session ends for a reason that means it should stay ended:
59
+ * the user killed it, or the launched command exited by itself.
60
+ */
61
+ forget(...ids) {
62
+ if (this.trackingSuspended || ids.length === 0) {
63
+ return;
64
+ }
65
+ const dropped = new Set(ids);
66
+ const remaining = this.read().filter(session => !dropped.has(session.id));
67
+ this.write(remaining);
68
+ }
69
+ /**
70
+ * Keep a renamed session's name in the record. An absent name means the
71
+ * user cleared it.
72
+ */
73
+ rename(id, sessionName) {
74
+ if (this.trackingSuspended) {
75
+ return;
76
+ }
77
+ const sessions = this.read();
78
+ const target = sessions.find(session => session.id === id);
79
+ if (!target) {
80
+ return;
81
+ }
82
+ target.sessionName = sessionName;
83
+ this.write(sessions);
84
+ }
85
+ /**
86
+ * Stop applying further mutations. Used when ccmanager is shutting down and
87
+ * destroys its sessions: without this the teardown would erase precisely the
88
+ * records the next run needs.
89
+ */
90
+ suspendTracking() {
91
+ this.trackingSuspended = true;
92
+ }
93
+ /** Resume applying mutations. Counterpart of {@link suspendTracking}. */
94
+ resumeTracking() {
95
+ this.trackingSuspended = false;
96
+ }
97
+ isTrackingSuspended() {
98
+ return this.trackingSuspended;
99
+ }
100
+ read() {
101
+ if (!existsSync(this.filePath)) {
102
+ return [];
103
+ }
104
+ try {
105
+ const parsed = JSON.parse(readFileSync(this.filePath, 'utf-8'));
106
+ if (!Array.isArray(parsed.sessions)) {
107
+ return [];
108
+ }
109
+ return parsed.sessions.filter(session => typeof session?.id === 'string' &&
110
+ typeof session?.projectPath === 'string' &&
111
+ typeof session?.worktreePath === 'string');
112
+ }
113
+ catch (error) {
114
+ // A truncated or hand-edited file must not stop ccmanager from
115
+ // starting; the worst outcome of ignoring it is no restore offer.
116
+ logger.warn(`Failed to read session records from ${this.filePath}: ${String(error)}`);
117
+ return [];
118
+ }
119
+ }
120
+ write(sessions) {
121
+ const contents = {
122
+ version: SESSION_RECORD_VERSION,
123
+ sessions,
124
+ };
125
+ try {
126
+ // Write to a sibling file and rename over the target: a crash midway
127
+ // through then leaves the previous complete file rather than a
128
+ // half-written one.
129
+ const tempPath = `${this.filePath}.${process.pid}.tmp`;
130
+ writeFileSync(tempPath, JSON.stringify(contents, null, 2));
131
+ renameSync(tempPath, this.filePath);
132
+ }
133
+ catch (error) {
134
+ logger.warn(`Failed to write session records to ${this.filePath}: ${String(error)}`);
135
+ }
136
+ }
137
+ }
138
+ export const sessionRestoreStore = new SessionRestoreStore();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import path from 'path';
5
+ import { SessionRestoreStore, SESSION_RECORD_VERSION, } from './sessionRestoreStore.js';
6
+ describe('SessionRestoreStore', () => {
7
+ let directory;
8
+ let filePath;
9
+ let store;
10
+ const record = (overrides = {}) => ({
11
+ id: 'session-1',
12
+ projectPath: '/repo',
13
+ worktreePath: '/repo/worktrees/feature',
14
+ presetId: 'preset-1',
15
+ ownerPid: 1234,
16
+ createdAt: 1,
17
+ ...overrides,
18
+ });
19
+ beforeEach(() => {
20
+ directory = mkdtempSync(path.join(tmpdir(), 'ccmanager-records-'));
21
+ filePath = path.join(directory, 'sessions.json');
22
+ store = new SessionRestoreStore(filePath);
23
+ });
24
+ afterEach(() => {
25
+ rmSync(directory, { recursive: true, force: true });
26
+ });
27
+ it('returns no sessions when nothing has been recorded yet', () => {
28
+ expect(store.list()).toEqual([]);
29
+ });
30
+ it('writes each recorded session to disk immediately', () => {
31
+ store.record(record());
32
+ const contents = JSON.parse(readFileSync(filePath, 'utf-8'));
33
+ expect(contents.version).toBe(SESSION_RECORD_VERSION);
34
+ expect(contents.sessions).toHaveLength(1);
35
+ expect(contents.sessions[0].worktreePath).toBe('/repo/worktrees/feature');
36
+ });
37
+ it('replaces an existing record with the same id instead of duplicating it', () => {
38
+ store.record(record());
39
+ store.record(record({ presetId: 'preset-2' }));
40
+ expect(store.list()).toHaveLength(1);
41
+ expect(store.list()[0]?.presetId).toBe('preset-2');
42
+ });
43
+ it('forgets the requested sessions and keeps the others', () => {
44
+ store.record(record({ id: 'session-1' }));
45
+ store.record(record({ id: 'session-2' }));
46
+ store.record(record({ id: 'session-3' }));
47
+ store.forget('session-1', 'session-3');
48
+ expect(store.list().map(session => session.id)).toEqual(['session-2']);
49
+ });
50
+ it('keeps a renamed session name, and clears it when the name is removed', () => {
51
+ store.record(record());
52
+ store.rename('session-1', 'review');
53
+ expect(store.list()[0]?.sessionName).toBe('review');
54
+ store.rename('session-1', undefined);
55
+ expect(store.list()[0]?.sessionName).toBeUndefined();
56
+ });
57
+ it('keeps records while tracking is suspended, so a shutdown does not erase them', () => {
58
+ store.record(record());
59
+ store.suspendTracking();
60
+ store.forget('session-1');
61
+ store.record(record({ id: 'session-2' }));
62
+ expect(store.list().map(session => session.id)).toEqual(['session-1']);
63
+ store.resumeTracking();
64
+ store.forget('session-1');
65
+ expect(store.list()).toEqual([]);
66
+ });
67
+ it('picks up records another process wrote instead of overwriting them', () => {
68
+ // Stands in for a second ccmanager writing to the shared file between
69
+ // this store's own writes.
70
+ store.record(record({ id: 'session-1' }));
71
+ const other = new SessionRestoreStore(filePath);
72
+ other.record(record({ id: 'other-session' }));
73
+ store.record(record({ id: 'session-2' }));
74
+ expect(store
75
+ .list()
76
+ .map(session => session.id)
77
+ .sort()).toEqual(['other-session', 'session-1', 'session-2']);
78
+ });
79
+ it('ignores a corrupted file rather than failing', () => {
80
+ writeFileSync(filePath, '{not json');
81
+ expect(store.list()).toEqual([]);
82
+ store.record(record());
83
+ expect(store.list()).toHaveLength(1);
84
+ });
85
+ it('ignores entries that are missing the fields needed to launch them', () => {
86
+ writeFileSync(filePath, JSON.stringify({
87
+ version: SESSION_RECORD_VERSION,
88
+ sessions: [record(), { id: 'broken' }],
89
+ }));
90
+ expect(store.list().map(session => session.id)).toEqual(['session-1']);
91
+ });
92
+ });
@@ -0,0 +1,46 @@
1
+ import { SessionRecord } from './sessionRestoreStore.js';
2
+ export interface RestoreFailure {
3
+ record: SessionRecord;
4
+ message: string;
5
+ }
6
+ export interface RestoreOutcome {
7
+ restored: number;
8
+ failures: RestoreFailure[];
9
+ }
10
+ /**
11
+ * Recorded sessions that this ccmanager run may offer to launch again.
12
+ *
13
+ * @param options.projectPath - When given, only sessions of that repository are
14
+ * returned (single-project mode). Omit it to consider every recorded project,
15
+ * which is what multi-project mode does.
16
+ */
17
+ export declare function listRestorableSessions(options?: {
18
+ projectPath?: string;
19
+ }): SessionRecord[];
20
+ /**
21
+ * Name of the command preset a record will be launched with, for display.
22
+ * Resolved from the current configuration rather than stored alongside the
23
+ * record, so a renamed preset shows its current name. Records whose preset no
24
+ * longer exists fall back to the default preset, as launching does.
25
+ */
26
+ export declare function describeRecordPreset(record: SessionRecord): string;
27
+ /**
28
+ * Launch each recorded session again, in the session manager the running
29
+ * ccmanager will look at for its project.
30
+ *
31
+ * Sessions are started one at a time: each one is numbered relative to the
32
+ * sessions already present in its worktree, which only holds if they are not
33
+ * created concurrently.
34
+ *
35
+ * Every record is dropped from the durable store as it is processed — a
36
+ * successfully restored session records itself anew under its new id, and a
37
+ * failed one must not keep being offered on every subsequent start.
38
+ */
39
+ export declare function restoreSessions(records: SessionRecord[], options: {
40
+ multiProject: boolean;
41
+ }): Promise<RestoreOutcome>;
42
+ /**
43
+ * Forget the offered sessions without launching them, so declining the offer
44
+ * does not make it come back on the next start.
45
+ */
46
+ export declare function discardRestorableSessions(records: SessionRecord[]): void;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @fileoverview Turns the durable session record written by
3
+ * {@link SessionRestoreStore} back into running sessions.
4
+ *
5
+ * Restoring a session means launching its command preset in its worktree
6
+ * again — nothing of the previous run's terminal output or conversation is
7
+ * brought back. An initial prompt, if the session was originally started with
8
+ * one, is deliberately not replayed: it was a one-off instruction, not part of
9
+ * the session's identity.
10
+ */
11
+ import { existsSync } from 'fs';
12
+ import { Effect, Either } from 'effect';
13
+ import { globalSessionOrchestrator } from './globalSessionOrchestrator.js';
14
+ import { sessionRestoreStore } from './sessionRestoreStore.js';
15
+ import { configReader } from './config/configReader.js';
16
+ import { formatErrorMessage } from '../utils/errorMessage.js';
17
+ import { logger } from '../utils/logger.js';
18
+ /**
19
+ * Whether a process with this id is currently running. Used to leave alone the
20
+ * sessions of another ccmanager that is still open.
21
+ *
22
+ * A process id can be reused after a reboot, in which case an unrelated live
23
+ * process makes a record look owned and its session is silently not offered.
24
+ * That is the cheaper mistake: the opposite error would start a second copy of
25
+ * a session that is already running in another ccmanager window.
26
+ */
27
+ function isProcessAlive(pid) {
28
+ if (!Number.isInteger(pid) || pid <= 0) {
29
+ return false;
30
+ }
31
+ try {
32
+ // Signal 0 performs the permission and existence checks without
33
+ // delivering a signal.
34
+ process.kill(pid, 0);
35
+ return true;
36
+ }
37
+ catch (error) {
38
+ // EPERM means the process exists but belongs to another user.
39
+ return (typeof error === 'object' &&
40
+ error !== null &&
41
+ 'code' in error &&
42
+ error.code === 'EPERM');
43
+ }
44
+ }
45
+ /**
46
+ * Recorded sessions that this ccmanager run may offer to launch again.
47
+ *
48
+ * @param options.projectPath - When given, only sessions of that repository are
49
+ * returned (single-project mode). Omit it to consider every recorded project,
50
+ * which is what multi-project mode does.
51
+ */
52
+ export function listRestorableSessions(options = {}) {
53
+ return sessionRestoreStore
54
+ .list()
55
+ .filter(record => {
56
+ if (options.projectPath && record.projectPath !== options.projectPath) {
57
+ return false;
58
+ }
59
+ // The worktree may have been deleted while ccmanager was not running.
60
+ if (!existsSync(record.worktreePath)) {
61
+ logger.info(`Skipping session restore for missing worktree: ${record.worktreePath}`);
62
+ return false;
63
+ }
64
+ return !isProcessAlive(record.ownerPid);
65
+ })
66
+ .sort((a, b) => a.createdAt - b.createdAt);
67
+ }
68
+ /**
69
+ * Name of the command preset a record will be launched with, for display.
70
+ * Resolved from the current configuration rather than stored alongside the
71
+ * record, so a renamed preset shows its current name. Records whose preset no
72
+ * longer exists fall back to the default preset, as launching does.
73
+ */
74
+ export function describeRecordPreset(record) {
75
+ const preset = record.presetId
76
+ ? Either.getOrElse(configReader.getPresetByIdEffect(record.presetId), () => undefined)
77
+ : undefined;
78
+ return preset?.name ?? configReader.getDefaultPreset()?.name ?? 'default';
79
+ }
80
+ /**
81
+ * Launch each recorded session again, in the session manager the running
82
+ * ccmanager will look at for its project.
83
+ *
84
+ * Sessions are started one at a time: each one is numbered relative to the
85
+ * sessions already present in its worktree, which only holds if they are not
86
+ * created concurrently.
87
+ *
88
+ * Every record is dropped from the durable store as it is processed — a
89
+ * successfully restored session records itself anew under its new id, and a
90
+ * failed one must not keep being offered on every subsequent start.
91
+ */
92
+ export async function restoreSessions(records, options) {
93
+ const failures = [];
94
+ let restored = 0;
95
+ for (const record of records) {
96
+ sessionRestoreStore.forget(record.id);
97
+ // Single-project mode keeps every session in the one global manager;
98
+ // multi-project mode keeps a manager per project.
99
+ const manager = globalSessionOrchestrator.getManagerForProject(options.multiProject ? record.projectPath : undefined);
100
+ const sessionEffect = record.devcontainerConfig
101
+ ? manager.createSessionWithDevcontainerEffect(record.worktreePath, record.devcontainerConfig, record.presetId)
102
+ : manager.createSessionWithPresetEffect(record.worktreePath, record.presetId);
103
+ const result = await Effect.runPromise(Effect.either(sessionEffect));
104
+ if (result._tag === 'Left') {
105
+ const message = formatErrorMessage(result.left);
106
+ logger.error(`Failed to restore session in ${record.worktreePath}: ${message}`);
107
+ failures.push({ record, message });
108
+ continue;
109
+ }
110
+ if (record.sessionName) {
111
+ manager.renameSession(result.right.id, record.sessionName);
112
+ }
113
+ restored++;
114
+ }
115
+ return { restored, failures };
116
+ }
117
+ /**
118
+ * Forget the offered sessions without launching them, so declining the offer
119
+ * does not make it come back on the next start.
120
+ */
121
+ export function discardRestorableSessions(records) {
122
+ sessionRestoreStore.forget(...records.map(record => record.id));
123
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { Effect, Either } from 'effect';
3
+ import { mkdtempSync, mkdirSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ import path from 'path';
6
+ import { ProcessError, ValidationError } from '../types/errors.js';
7
+ const storedRecords = [];
8
+ const forgetMock = vi.fn((...ids) => {
9
+ for (const id of ids) {
10
+ const index = storedRecords.findIndex(record => record.id === id);
11
+ if (index !== -1) {
12
+ storedRecords.splice(index, 1);
13
+ }
14
+ }
15
+ });
16
+ vi.mock('./sessionRestoreStore.js', () => ({
17
+ sessionRestoreStore: {
18
+ list: () => [...storedRecords],
19
+ forget: (...ids) => forgetMock(...ids),
20
+ },
21
+ }));
22
+ class MockSessionManager {
23
+ createSessionWithPresetEffect = vi.fn((_worktreePath, _presetId) => Effect.succeed({ id: 'new-session' }));
24
+ createSessionWithDevcontainerEffect = vi.fn((_worktreePath, _devcontainerConfig, _presetId) => Effect.succeed({ id: 'new-session' }));
25
+ renameSession = vi.fn((_id, _name) => { });
26
+ }
27
+ const managersByProject = new Map();
28
+ const getManagerForProjectMock = vi.fn((projectPath) => {
29
+ let manager = managersByProject.get(projectPath);
30
+ if (!manager) {
31
+ manager = new MockSessionManager();
32
+ managersByProject.set(projectPath, manager);
33
+ }
34
+ return manager;
35
+ });
36
+ vi.mock('./globalSessionOrchestrator.js', () => ({
37
+ globalSessionOrchestrator: {
38
+ getManagerForProject: (projectPath) => getManagerForProjectMock(projectPath),
39
+ },
40
+ }));
41
+ vi.mock('./config/configReader.js', () => ({
42
+ configReader: {
43
+ getPresetByIdEffect: (id) => id === 'preset-1'
44
+ ? Either.right({ id: 'preset-1', name: 'Main' })
45
+ : Either.left(new ValidationError({
46
+ field: 'presetId',
47
+ constraint: 'Preset not found',
48
+ receivedValue: id,
49
+ })),
50
+ getDefaultPreset: () => ({ id: 'default', name: 'Default' }),
51
+ },
52
+ }));
53
+ const { listRestorableSessions, restoreSessions, discardRestorableSessions, describeRecordPreset, } = await import('./sessionRestorer.js');
54
+ describe('sessionRestorer', () => {
55
+ let existingWorktree;
56
+ const record = (overrides = {}) => ({
57
+ id: 'session-1',
58
+ projectPath: '/repo',
59
+ worktreePath: existingWorktree,
60
+ presetId: 'preset-1',
61
+ // A process id that is not running, so the record counts as restorable.
62
+ ownerPid: 999999,
63
+ createdAt: 1,
64
+ ...overrides,
65
+ });
66
+ beforeEach(() => {
67
+ const directory = mkdtempSync(path.join(tmpdir(), 'ccmanager-restore-'));
68
+ existingWorktree = path.join(directory, 'feature');
69
+ mkdirSync(existingWorktree);
70
+ storedRecords.length = 0;
71
+ forgetMock.mockClear();
72
+ managersByProject.clear();
73
+ getManagerForProjectMock.mockClear();
74
+ });
75
+ describe('listRestorableSessions', () => {
76
+ it('offers a recorded session whose worktree still exists', () => {
77
+ storedRecords.push(record());
78
+ expect(listRestorableSessions().map(r => r.id)).toEqual(['session-1']);
79
+ });
80
+ it('skips a session whose worktree has been deleted', () => {
81
+ storedRecords.push(record({ worktreePath: '/gone/worktree' }));
82
+ expect(listRestorableSessions()).toEqual([]);
83
+ });
84
+ it('skips a session still owned by a running ccmanager', () => {
85
+ storedRecords.push(record({ ownerPid: process.pid }));
86
+ expect(listRestorableSessions()).toEqual([]);
87
+ });
88
+ it('only offers the requested project when a project path is given', () => {
89
+ storedRecords.push(record({ id: 'mine', projectPath: '/repo' }));
90
+ storedRecords.push(record({ id: 'other', projectPath: '/elsewhere' }));
91
+ expect(listRestorableSessions({ projectPath: '/repo' }).map(r => r.id)).toEqual(['mine']);
92
+ });
93
+ it('returns oldest first so sessions come back in the order they were opened', () => {
94
+ storedRecords.push(record({ id: 'second', createdAt: 20 }));
95
+ storedRecords.push(record({ id: 'first', createdAt: 10 }));
96
+ expect(listRestorableSessions().map(r => r.id)).toEqual([
97
+ 'first',
98
+ 'second',
99
+ ]);
100
+ });
101
+ });
102
+ describe('restoreSessions', () => {
103
+ it('launches the recorded preset in the recorded worktree', async () => {
104
+ const target = record();
105
+ const outcome = await restoreSessions([target], { multiProject: false });
106
+ const manager = managersByProject.get(undefined);
107
+ expect(manager.createSessionWithPresetEffect).toHaveBeenCalledWith(existingWorktree, 'preset-1');
108
+ expect(outcome).toEqual({ restored: 1, failures: [] });
109
+ });
110
+ it('drops the old record so the same session is not offered twice', async () => {
111
+ storedRecords.push(record());
112
+ await restoreSessions([record()], { multiProject: false });
113
+ expect(forgetMock).toHaveBeenCalledWith('session-1');
114
+ expect(storedRecords).toEqual([]);
115
+ });
116
+ it('restores the session name the user had given', async () => {
117
+ await restoreSessions([record({ sessionName: 'review' })], {
118
+ multiProject: false,
119
+ });
120
+ const manager = managersByProject.get(undefined);
121
+ expect(manager.renameSession).toHaveBeenCalledWith('new-session', 'review');
122
+ });
123
+ it('uses the manager of the recorded project in multi-project mode', async () => {
124
+ await restoreSessions([record()], { multiProject: true });
125
+ expect(getManagerForProjectMock).toHaveBeenCalledWith('/repo');
126
+ });
127
+ it('relaunches through the devcontainer when the session used one', async () => {
128
+ const devcontainerConfig = {
129
+ upCommand: 'devcontainer up',
130
+ execCommand: 'devcontainer exec',
131
+ };
132
+ await restoreSessions([record({ devcontainerConfig })], {
133
+ multiProject: false,
134
+ });
135
+ const manager = managersByProject.get(undefined);
136
+ expect(manager.createSessionWithDevcontainerEffect).toHaveBeenCalledWith(existingWorktree, devcontainerConfig, 'preset-1');
137
+ });
138
+ it('reports a failed session and carries on with the rest', async () => {
139
+ const failing = record({ id: 'failing' });
140
+ const succeeding = record({ id: 'succeeding' });
141
+ const manager = getManagerForProjectMock(undefined);
142
+ manager.createSessionWithPresetEffect.mockImplementationOnce(() => Effect.fail(new ProcessError({ command: 'claude', message: 'spawn failed' })));
143
+ const outcome = await restoreSessions([failing, succeeding], {
144
+ multiProject: false,
145
+ });
146
+ expect(outcome.restored).toBe(1);
147
+ expect(outcome.failures).toHaveLength(1);
148
+ expect(outcome.failures[0]?.record.id).toBe('failing');
149
+ expect(outcome.failures[0]?.message).toContain('spawn failed');
150
+ });
151
+ });
152
+ it('describes a record by its current preset name, falling back to the default', () => {
153
+ expect(describeRecordPreset(record())).toBe('Main');
154
+ expect(describeRecordPreset(record({ presetId: 'removed' }))).toBe('Default');
155
+ expect(describeRecordPreset(record({ presetId: undefined }))).toBe('Default');
156
+ });
157
+ it('forgets the sessions the user declined to restore', () => {
158
+ storedRecords.push(record({ id: 'a' }), record({ id: 'b' }));
159
+ discardRestorableSessions([record({ id: 'a' }), record({ id: 'b' })]);
160
+ expect(forgetMock).toHaveBeenCalledWith('a', 'b');
161
+ expect(storedRecords).toEqual([]);
162
+ });
163
+ });
@@ -155,6 +155,18 @@ export declare class WorktreeService {
155
155
  * @throws {FileSystemError} When copying the directory fails
156
156
  */
157
157
  private copyClaudeDirectoryFromBaseBranchEffect;
158
+ /**
159
+ * Effect-based copyWorktreeIncludeFiles operation.
160
+ * Copies the gitignored files a `.worktreeinclude` file at the repository
161
+ * root selects (see src/utils/worktreeInclude.ts) into the new worktree.
162
+ * A no-op when no `.worktreeinclude` file exists, so this always runs
163
+ * unconditionally rather than being gated by a config flag.
164
+ *
165
+ * @param {string} gitRoot - Absolute path to the main checkout
166
+ * @param {string} targetWorktreePath - Path of the newly created worktree
167
+ * @returns {Effect.Effect<void, FileSystemError, never>} Effect that completes successfully or fails with FileSystemError
168
+ */
169
+ private copyWorktreeIncludeFilesEffect;
158
170
  /**
159
171
  * Effect-based getDefaultBranch operation
160
172
  * Returns Effect that may fail with GitError
@@ -7,6 +7,7 @@ import { GitError, FileSystemError } from '../types/errors.js';
7
7
  import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
8
8
  import { getClaudeProjectsDir, pathToClaudeProjectName, } from '../utils/claudeDir.js';
9
9
  import { executeWorktreePostCreationHook, executeWorktreePreCreationHook, } from '../utils/hookExecutor.js';
10
+ import { copyWorktreeIncludeFiles } from '../utils/worktreeInclude.js';
10
11
  import { configReader } from './config/configReader.js';
11
12
  import { logger } from '../utils/logger.js';
12
13
  const CLAUDE_DIR = '.claude';
@@ -448,6 +449,27 @@ export class WorktreeService {
448
449
  });
449
450
  });
450
451
  }
452
+ /**
453
+ * Effect-based copyWorktreeIncludeFiles operation.
454
+ * Copies the gitignored files a `.worktreeinclude` file at the repository
455
+ * root selects (see src/utils/worktreeInclude.ts) into the new worktree.
456
+ * A no-op when no `.worktreeinclude` file exists, so this always runs
457
+ * unconditionally rather than being gated by a config flag.
458
+ *
459
+ * @param {string} gitRoot - Absolute path to the main checkout
460
+ * @param {string} targetWorktreePath - Path of the newly created worktree
461
+ * @returns {Effect.Effect<void, FileSystemError, never>} Effect that completes successfully or fails with FileSystemError
462
+ */
463
+ copyWorktreeIncludeFilesEffect(gitRoot, targetWorktreePath) {
464
+ return Effect.try({
465
+ try: () => copyWorktreeIncludeFiles(gitRoot, targetWorktreePath),
466
+ catch: (error) => new FileSystemError({
467
+ operation: 'write',
468
+ path: targetWorktreePath,
469
+ cause: String(error),
470
+ }),
471
+ });
472
+ }
451
473
  /**
452
474
  * Effect-based getDefaultBranch operation
453
475
  * Returns Effect that may fail with GitError
@@ -967,6 +989,14 @@ export class WorktreeService {
967
989
  return Effect.succeed(undefined);
968
990
  });
969
991
  }
992
+ // Copy files selected by a .worktreeinclude file, if one exists at
993
+ // the repository root. Runs unconditionally (no config flag) and
994
+ // before the post-creation hook, so hook commands can rely on the
995
+ // copied files (e.g. .env) already being in place.
996
+ yield* Effect.catchAll(self.copyWorktreeIncludeFilesEffect(absoluteGitRoot, resolvedPath), (error) => {
997
+ console.error('Warning: Failed to copy .worktreeinclude files:', error);
998
+ return Effect.succeed(undefined);
999
+ });
970
1000
  // Execute post-creation hook if configured
971
1001
  const worktreeHooks = configReader.getWorktreeHooks();
972
1002
  logger.info('Worktree hook config after creation', {