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.
- package/README.md +54 -0
- package/dist/components/App.js +92 -30
- package/dist/components/App.test.js +86 -0
- package/dist/components/Dashboard.js +10 -3
- package/dist/components/DeleteWorktree.js +2 -13
- package/dist/components/Menu.js +28 -17
- package/dist/components/Menu.test.js +37 -1
- package/dist/components/RestoreSessions.d.ts +19 -0
- package/dist/components/RestoreSessions.js +28 -0
- package/dist/components/SessionActions.d.ts +17 -2
- package/dist/components/SessionActions.js +19 -17
- package/dist/components/SessionActions.test.d.ts +1 -0
- package/dist/components/SessionActions.test.js +94 -0
- package/dist/hooks/useAvailableLabelWidth.d.ts +6 -0
- package/dist/hooks/useAvailableLabelWidth.js +15 -0
- package/dist/services/config/globalConfigManager.js +3 -12
- package/dist/services/globalSessionOrchestrator.d.ts +7 -0
- package/dist/services/globalSessionOrchestrator.js +40 -0
- package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
- package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
- package/dist/services/projectManager.js +3 -11
- package/dist/services/sessionManager.d.ts +6 -0
- package/dist/services/sessionManager.js +16 -0
- package/dist/services/sessionRestoreStore.d.ts +75 -0
- package/dist/services/sessionRestoreStore.js +138 -0
- package/dist/services/sessionRestoreStore.test.d.ts +1 -0
- package/dist/services/sessionRestoreStore.test.js +92 -0
- package/dist/services/sessionRestorer.d.ts +46 -0
- package/dist/services/sessionRestorer.js +123 -0
- package/dist/services/sessionRestorer.test.d.ts +1 -0
- package/dist/services/sessionRestorer.test.js +163 -0
- package/dist/services/worktreeService.d.ts +12 -0
- package/dist/services/worktreeService.js +30 -0
- package/dist/services/worktreeService.test.js +55 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/utils/configDir.d.ts +10 -0
- package/dist/utils/configDir.js +31 -0
- package/dist/utils/errorMessage.d.ts +7 -0
- package/dist/utils/errorMessage.js +19 -0
- package/dist/utils/filterByQuery.test.js +2 -0
- package/dist/utils/gitUtils.d.ts +1 -0
- package/dist/utils/gitUtils.js +15 -0
- package/dist/utils/hookExecutor.test.js +4 -0
- package/dist/utils/worktreeInclude.d.ts +33 -0
- package/dist/utils/worktreeInclude.js +100 -0
- package/dist/utils/worktreeInclude.test.d.ts +1 -0
- package/dist/utils/worktreeInclude.test.js +91 -0
- package/dist/utils/worktreeUtils.d.ts +38 -4
- package/dist/utils/worktreeUtils.js +76 -17
- package/dist/utils/worktreeUtils.test.js +82 -5
- 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', {
|