@agou/dsh-worktrees 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +40 -0
- package/README.md +86 -2
- package/client.js +958 -0
- package/cordis.patch.yml +6 -0
- package/discovery.js +232 -0
- package/host.js +412 -0
- package/icon.svg +18 -0
- package/index.js +215 -0
- package/lib/git.js +186 -0
- package/lib/manager.js +674 -0
- package/lib/snapshot.js +211 -0
- package/lib/state.js +97 -0
- package/locale/en.json +67 -0
- package/locale/zh.json +67 -0
- package/package.json +41 -4
- package/transport.js +30 -0
package/lib/manager.js
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { bounded, canonical, commitAt, exists, fail, filterOverrides, git, inside, repository, samePath, WorktreeError } from './git.js';
|
|
5
|
+
import { assertSnapshotUnchanged, makeSnapshot, materialize } from './snapshot.js';
|
|
6
|
+
import { freeze, requireIdentifier, validateState } from './state.js';
|
|
7
|
+
|
|
8
|
+
export { createInitialState, validateState } from './state.js';
|
|
9
|
+
|
|
10
|
+
const OWNER_GRACE = 60 * 60 * 1000;
|
|
11
|
+
const NEWBORN_GRACE = 60 * 1000;
|
|
12
|
+
const CLEANUP_LIMIT = 8;
|
|
13
|
+
const CLEANUP_TIME = 30_000;
|
|
14
|
+
|
|
15
|
+
function message(error) {
|
|
16
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 8192) || 'Unknown worktree error';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function selected(choice) {
|
|
20
|
+
if (!choice || !['local', 'new'].includes(choice.mode)) throw new TypeError('Choice must specify local or new mode.');
|
|
21
|
+
return choice.mode === 'local' ? { mode: 'local' } : { mode: 'new', baseRef: choice.baseRef };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function has(object, key) {
|
|
25
|
+
return Object.hasOwn(object, key) ? object[key] : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function markerOf(record) {
|
|
29
|
+
return {
|
|
30
|
+
version: 1, owner: 'dsh-worktrees', id: record.id, token: record.token,
|
|
31
|
+
path: record.path, commonDir: record.commonDir, managedRoot: record.managedRoot,
|
|
32
|
+
sourceRoot: record.sourceRoot, baseCommit: record.baseCommit,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One instance owns one profile's aggregate. All storage writes are serialized independently of Git work. */
|
|
37
|
+
export class WorktreeManager {
|
|
38
|
+
constructor({ storage, getConfig, setDirectory, isProtected = async () => false, archiveSessions = async () => {}, now = Date.now, onChange }) {
|
|
39
|
+
if (!storage || typeof storage.get !== 'function' || typeof storage.set !== 'function') throw new TypeError('storage.get and storage.set are required.');
|
|
40
|
+
if (typeof getConfig !== 'function' || typeof setDirectory !== 'function') throw new TypeError('getConfig and setDirectory are required.');
|
|
41
|
+
this.storage = storage;
|
|
42
|
+
this.getConfig = getConfig;
|
|
43
|
+
this.setDirectory = setDirectory;
|
|
44
|
+
this.isProtected = isProtected;
|
|
45
|
+
this.archiveSessions = archiveSessions;
|
|
46
|
+
this.now = now;
|
|
47
|
+
this.onChange = onChange;
|
|
48
|
+
this.state = freeze(validateState(storage.get()));
|
|
49
|
+
this.writes = Promise.resolve();
|
|
50
|
+
this.sessions = new Map();
|
|
51
|
+
this.records = new Map();
|
|
52
|
+
this.pending = new Set();
|
|
53
|
+
this.inUse = new Map();
|
|
54
|
+
this.lifetime = new AbortController();
|
|
55
|
+
this.cleaning = false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
list() {
|
|
59
|
+
return this.state;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_run(callback, signal) {
|
|
63
|
+
const operationSignal = signal ? AbortSignal.any([this.lifetime.signal, signal]) : this.lifetime.signal;
|
|
64
|
+
const operation = Promise.resolve().then(() => {
|
|
65
|
+
operationSignal.throwIfAborted();
|
|
66
|
+
return callback(operationSignal);
|
|
67
|
+
});
|
|
68
|
+
this.pending.add(operation);
|
|
69
|
+
operation.then(() => this.pending.delete(operation), () => this.pending.delete(operation));
|
|
70
|
+
return operation;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_lock(map, key, callback) {
|
|
74
|
+
const previous = map.get(key) ?? Promise.resolve();
|
|
75
|
+
const operation = previous.catch(() => {}).then(callback);
|
|
76
|
+
map.set(key, operation);
|
|
77
|
+
operation.then(() => { if (map.get(key) === operation) map.delete(key); }, () => { if (map.get(key) === operation) map.delete(key); });
|
|
78
|
+
return operation;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_save(update) {
|
|
82
|
+
const operation = this.writes.catch(() => {}).then(async () => {
|
|
83
|
+
const next = structuredClone(this.state);
|
|
84
|
+
const result = update(next);
|
|
85
|
+
const valid = freeze(validateState(next));
|
|
86
|
+
await this.storage.set(valid);
|
|
87
|
+
this.state = valid;
|
|
88
|
+
this.onChange?.();
|
|
89
|
+
return result;
|
|
90
|
+
});
|
|
91
|
+
this.writes = operation;
|
|
92
|
+
return operation;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_record(id) {
|
|
96
|
+
const record = has(this.state.worktrees, id);
|
|
97
|
+
if (!record) fail('NOT_FOUND', 'The managed worktree does not exist.');
|
|
98
|
+
return record;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async _failure(id, error, status = 'error') {
|
|
102
|
+
try {
|
|
103
|
+
await this._save(state => {
|
|
104
|
+
const record = state.worktrees[id];
|
|
105
|
+
if (record) {
|
|
106
|
+
record.status = status;
|
|
107
|
+
record.error = message(error);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
} catch (storageError) {
|
|
111
|
+
throw new AggregateError([error, storageError], `${message(error)} Metadata could not be saved: ${message(storageError)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async _workspace(workspace) {
|
|
116
|
+
requireIdentifier(workspace?.workspaceId, 'workspaceId');
|
|
117
|
+
if (typeof workspace.title !== 'string' && workspace.title !== undefined) throw new TypeError('Workspace title must be text.');
|
|
118
|
+
return { workspaceId: workspace.workspaceId, path: await canonical(workspace.path), title: workspace.title ?? '' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async _inspect(workspace, signal) {
|
|
122
|
+
const original = await this._workspace(workspace);
|
|
123
|
+
const preference = has(this.state.preferences, original.workspaceId);
|
|
124
|
+
let repo;
|
|
125
|
+
try {
|
|
126
|
+
repo = await repository(original.path, signal);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
signal.throwIfAborted();
|
|
129
|
+
const unavailable = error.code === 'ENOENT' && (error.path === 'git' || error.syscall?.startsWith('spawn'));
|
|
130
|
+
if (!unavailable && (error.code !== 'GIT_FAILED' || !/not a git repository|must be run in a work tree/i.test(error.message))) throw error;
|
|
131
|
+
return { workspaceId: original.workspaceId, isGit: false, repositoryRoot: null, currentBranch: null, head: null, defaultBase: null, branches: [], choice: { mode: 'local' }, reason: unavailable ? 'git-unavailable' : 'not-git' };
|
|
132
|
+
}
|
|
133
|
+
const refs = await git(repo.root, ['for-each-ref', '--format=%(refname)%00%(symref)', 'refs/heads/', 'refs/remotes/'], { signal });
|
|
134
|
+
const branches = [];
|
|
135
|
+
const remoteHeads = [];
|
|
136
|
+
for (const line of refs.stdout.split('\n').filter(Boolean)) {
|
|
137
|
+
const [ref, symbolic] = line.replace(/\r$/, '').split('\0');
|
|
138
|
+
if (symbolic) {
|
|
139
|
+
if (/^refs\/remotes\/[^/]+\/HEAD$/.test(ref)) remoteHeads.push({ ref, target: symbolic });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (ref.startsWith('refs/heads/')) branches.push({ name: ref.slice(11), ref, remote: null });
|
|
143
|
+
else if (ref.startsWith('refs/remotes/')) {
|
|
144
|
+
const name = ref.slice(13);
|
|
145
|
+
branches.push({ name, ref, remote: name.split('/')[0] });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const symbolic = await git(repo.root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { signal, allowed: [0, 1] });
|
|
149
|
+
const currentBranch = symbolic.code === 0 ? symbolic.stdout.trim() : null;
|
|
150
|
+
const head = await commitAt(repo.root, 'HEAD', signal, true);
|
|
151
|
+
const localFor = ref => {
|
|
152
|
+
const match = /^refs\/remotes\/[^/]+\/(.+)$/.exec(ref);
|
|
153
|
+
return match && branches.some(branch => branch.ref === `refs/heads/${match[1]}`) ? `refs/heads/${match[1]}` : ref;
|
|
154
|
+
};
|
|
155
|
+
const remoteDefault = remoteHeads.find(item => item.ref === 'refs/remotes/origin/HEAD') ?? (remoteHeads.length === 1 ? remoteHeads[0] : undefined);
|
|
156
|
+
let defaultBase = remoteDefault && branches.some(branch => branch.ref === remoteDefault.target) ? localFor(remoteDefault.target) : null;
|
|
157
|
+
if (!defaultBase) defaultBase = ['refs/heads/main', 'refs/heads/master', currentBranch ? `refs/heads/${currentBranch}` : null].find(ref => branches.some(branch => branch.ref === ref)) ?? null;
|
|
158
|
+
const rememberedBase = branches.some(branch => branch.ref === preference?.baseRef) || preference?.baseRef === 'HEAD' && head ? preference.baseRef : null;
|
|
159
|
+
const choice = preference?.mode === 'new' ? { mode: 'new', baseRef: rememberedBase ?? defaultBase } : { mode: 'local', ...(rememberedBase ? { baseRef: rememberedBase } : {}) };
|
|
160
|
+
return { workspaceId: original.workspaceId, isGit: true, repositoryRoot: repo.root, currentBranch, head, defaultBase, branches, choice, ...(head ? {} : { reason: 'unborn-head' }) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
inspectWorkspace(workspace, signal) {
|
|
164
|
+
return this._run(operationSignal => this._inspect(workspace, operationSignal), signal);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
checkout(workspace, branch, signal) {
|
|
168
|
+
return this._run(operationSignal => this._lock(this.sessions, `workspace:${workspace?.workspaceId}`, async () => {
|
|
169
|
+
const inspected = await this._inspect(workspace, operationSignal);
|
|
170
|
+
if (!inspected.isGit) fail('NOT_GIT', 'Local checkout requires a Git repository.');
|
|
171
|
+
const match = inspected.branches.find(item => !item.remote && (item.ref === branch || item.name === branch));
|
|
172
|
+
if (!match || match.name.startsWith('-')) fail('INVALID_BRANCH', 'Local checkout requires an existing local branch whose name does not begin with "-".');
|
|
173
|
+
const overrides = await filterOverrides(inspected.repositoryRoot, operationSignal);
|
|
174
|
+
await git(inspected.repositoryRoot, [...overrides, 'checkout', '--no-guess', '--no-recurse-submodules', match.name, '--'], { signal: operationSignal });
|
|
175
|
+
return this._inspect(workspace, operationSignal);
|
|
176
|
+
}), signal);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async _marker(record, create = false) {
|
|
180
|
+
const common = await canonical(record.commonDir);
|
|
181
|
+
if (!samePath(common, record.commonDir) || (await fs.lstat(record.commonDir)).isSymbolicLink()) fail('IDENTITY_MISMATCH', 'The Git common directory changed identity.');
|
|
182
|
+
const directory = path.join(record.commonDir, 'dsh-worktrees');
|
|
183
|
+
if (create) await fs.mkdir(directory, { recursive: true });
|
|
184
|
+
const directoryStat = await fs.lstat(directory);
|
|
185
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) fail('IDENTITY_MISMATCH', 'The worktree ownership directory is unsafe.');
|
|
186
|
+
const target = path.join(directory, `${record.id}.json`);
|
|
187
|
+
if (create && !(await exists(target))) await fs.writeFile(target, `${JSON.stringify(markerOf(record))}\n`, { flag: 'wx', mode: 0o600 });
|
|
188
|
+
const stat = await fs.lstat(target);
|
|
189
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16_384) fail('IDENTITY_MISMATCH', 'The worktree ownership marker is unsafe.');
|
|
190
|
+
let value;
|
|
191
|
+
try { value = JSON.parse(await fs.readFile(target, 'utf8')); } catch (error) { throw new WorktreeError('IDENTITY_MISMATCH', 'The worktree ownership marker cannot be read.', error); }
|
|
192
|
+
if (JSON.stringify(value) !== JSON.stringify(markerOf(record))) fail('IDENTITY_MISMATCH', 'The worktree ownership marker does not match its saved identity.');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async _repoGit(record, args, signal, allowed = [0]) {
|
|
196
|
+
return git(record.commonDir, [`--git-dir=${record.commonDir}`, ...args], { signal, allowed });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async _verify(record, signal) {
|
|
200
|
+
await this._marker(record);
|
|
201
|
+
const root = await canonical(record.managedRoot);
|
|
202
|
+
if (!samePath(root, record.managedRoot) || !samePath(path.dirname(record.path), root)) fail('IDENTITY_MISMATCH', 'The managed worktree parent changed identity.');
|
|
203
|
+
const stat = await fs.lstat(record.path);
|
|
204
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || !samePath(await canonical(record.path), record.path)) fail('IDENTITY_MISMATCH', 'The managed checkout path changed identity.');
|
|
205
|
+
const gitFile = path.join(record.path, '.git');
|
|
206
|
+
const gitStat = await fs.lstat(gitFile);
|
|
207
|
+
if (!gitStat.isFile() || gitStat.isSymbolicLink() || gitStat.size > 16_384) fail('IDENTITY_MISMATCH', 'The managed checkout Git pointer is unsafe.');
|
|
208
|
+
const pointer = /^gitdir: (.+)\r?\n?$/.exec(await fs.readFile(gitFile, 'utf8'));
|
|
209
|
+
if (!pointer) fail('IDENTITY_MISMATCH', 'The managed checkout Git pointer is invalid.');
|
|
210
|
+
const admin = await canonical(path.resolve(record.path, pointer[1].replace(/\r$/, '')));
|
|
211
|
+
if (!samePath(path.dirname(admin), path.join(record.commonDir, 'worktrees')) || record.gitDir && !samePath(admin, record.gitDir)) fail('IDENTITY_MISMATCH', 'The checkout has a different Git administration directory.');
|
|
212
|
+
const backlink = (await fs.readFile(path.join(admin, 'gitdir'), 'utf8')).replace(/\r?\n$/, '');
|
|
213
|
+
if (!samePath(path.resolve(admin, backlink), gitFile)) fail('IDENTITY_MISMATCH', 'The Git worktree backlink changed.');
|
|
214
|
+
const repo = await repository(record.path, signal);
|
|
215
|
+
if (!samePath(repo.root, record.path) || !samePath(repo.commonDir, record.commonDir)) fail('IDENTITY_MISMATCH', 'The checkout belongs to a different repository.');
|
|
216
|
+
const registered = (await this._repoGit(record, ['worktree', 'list', '--porcelain', '-z'], signal)).stdout.split('\0').some(field => field.startsWith('worktree ') && samePath(field.slice(9), record.path));
|
|
217
|
+
if (!registered) fail('IDENTITY_MISMATCH', 'Git does not register this managed checkout.');
|
|
218
|
+
const sparse = await git(record.path, ['config', '--bool', 'core.sparseCheckout'], { signal, allowed: [0, 1] });
|
|
219
|
+
if (sparse.stdout.trim() === 'true') fail('SPARSE_CHECKOUT', 'Sparse checkouts cannot be snapshotted or managed safely.');
|
|
220
|
+
return admin;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async _configuredRoot() {
|
|
224
|
+
const config = this.getConfig();
|
|
225
|
+
if (!config || typeof config.directory !== 'string' || !path.isAbsolute(config.directory) || config.directory.includes('\0')) fail('INVALID_CONFIG', 'Worktree directory must be an absolute path.');
|
|
226
|
+
const resolved = path.resolve(config.directory);
|
|
227
|
+
if (samePath(resolved, path.parse(resolved).root)) fail('INVALID_CONFIG', 'A filesystem root cannot be the managed worktree directory.');
|
|
228
|
+
await fs.mkdir(resolved, { recursive: true });
|
|
229
|
+
return canonical(resolved);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async _allocate(sessionId, workspace, choice, operationId, signal) {
|
|
233
|
+
const inspected = await this._inspect(workspace, signal);
|
|
234
|
+
if (!inspected.isGit || !inspected.head) fail('NO_BASE', 'A committed Git repository is required to create a worktree.');
|
|
235
|
+
const baseRef = choice.baseRef ?? inspected.defaultBase;
|
|
236
|
+
if (baseRef !== 'HEAD' && !inspected.branches.some(branch => branch.ref === baseRef || branch.name === baseRef)) fail('INVALID_BASE', 'The selected source ref is unavailable locally.');
|
|
237
|
+
const branch = inspected.branches.find(item => item.ref === baseRef) ?? inspected.branches.find(item => item.name === baseRef && !item.remote) ?? inspected.branches.find(item => item.name === baseRef);
|
|
238
|
+
const normalizedRef = branch?.ref ?? baseRef;
|
|
239
|
+
const repo = await repository(workspace.path, signal);
|
|
240
|
+
const baseCommit = await commitAt(repo.root, normalizedRef, signal);
|
|
241
|
+
const root = await this._configuredRoot();
|
|
242
|
+
const id = randomUUID();
|
|
243
|
+
const target = path.join(root, `dsh-${id}`);
|
|
244
|
+
if (await exists(target)) fail('OCCUPIED_PATH', 'The allocated worktree path is already occupied.');
|
|
245
|
+
const time = this.now();
|
|
246
|
+
const record = {
|
|
247
|
+
id, workspaceId: workspace.workspaceId, workspaceTitle: workspace.title,
|
|
248
|
+
sourceRoot: repo.root, commonDir: repo.commonDir, managedRoot: root, path: target, originalPath: target,
|
|
249
|
+
gitDir: null, baseRef: normalizedRef, baseCommit, token: randomBytes(32).toString('hex'),
|
|
250
|
+
createdAt: time, lastUsedAt: time, ownerlessAt: time, currentBranch: null, status: 'creating', sessionIds: [sessionId],
|
|
251
|
+
snapshotRef: `refs/dsh-worktrees/snapshots/${id}`, snapshotCommit: null, snapshotPendingCommit: null,
|
|
252
|
+
error: null, materialized: false, archivePending: false, ownerSessionId: sessionId, operationId: operationId ?? null,
|
|
253
|
+
};
|
|
254
|
+
const binding = this._binding(sessionId, workspace, { mode: 'new', baseRef: normalizedRef }, record);
|
|
255
|
+
await this._save(state => {
|
|
256
|
+
this._detachPrevious(state, sessionId);
|
|
257
|
+
state.worktrees[id] = record;
|
|
258
|
+
state.bindings[sessionId] = binding;
|
|
259
|
+
});
|
|
260
|
+
return this._record(id);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_binding(sessionId, workspace, choice, record) {
|
|
264
|
+
return {
|
|
265
|
+
sessionId, workspaceId: workspace.workspaceId, workspacePath: workspace.path, workspaceTitle: workspace.title,
|
|
266
|
+
mode: choice.mode, ...(choice.mode === 'new' ? { baseRef: record.baseRef } : {}),
|
|
267
|
+
worktreeId: record?.id ?? null, path: record?.path ?? workspace.path,
|
|
268
|
+
preparedAt: this.now(), acceptedAt: null, status: 'pending', error: null,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_detachPrevious(state, sessionId) {
|
|
273
|
+
const previous = has(state.bindings, sessionId);
|
|
274
|
+
if (previous?.worktreeId) {
|
|
275
|
+
const record = state.worktrees[previous.worktreeId];
|
|
276
|
+
record.sessionIds = record.sessionIds.filter(id => id !== sessionId);
|
|
277
|
+
if (!record.sessionIds.length) record.ownerlessAt = this.now();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async _finishCreate(record, signal, restore = false) {
|
|
282
|
+
await this._marker(record, !restore);
|
|
283
|
+
const present = await exists(record.path);
|
|
284
|
+
let needsAdd = !present;
|
|
285
|
+
if (present?.isDirectory() && !present.isSymbolicLink() && !restore && !record.materialized && !record.gitDir && (await fs.readdir(record.path)).length === 0) {
|
|
286
|
+
const registered = (await this._repoGit(record, ['worktree', 'list', '--porcelain', '-z'], signal)).stdout.split('\0').some(field => field.startsWith('worktree ') && samePath(field.slice(9), record.path));
|
|
287
|
+
if (registered) fail('IDENTITY_MISMATCH', 'The empty reservation has inconsistent Git administration metadata.');
|
|
288
|
+
needsAdd = true;
|
|
289
|
+
}
|
|
290
|
+
if (needsAdd) {
|
|
291
|
+
if (!samePath(await canonical(record.managedRoot), record.managedRoot)) fail('IDENTITY_MISMATCH', 'The managed directory changed identity.');
|
|
292
|
+
await this._repoGit(record, ['worktree', 'add', '--detach', '--no-checkout', '--', record.path, restore ? record.snapshotRef : record.baseCommit], signal);
|
|
293
|
+
}
|
|
294
|
+
const admin = await this._verify(record, signal);
|
|
295
|
+
const expected = restore ? record.snapshotCommit : record.baseCommit;
|
|
296
|
+
if (await commitAt(record.path, 'HEAD', signal) !== expected) fail('IDENTITY_MISMATCH', 'The incomplete checkout has moved away from its expected commit.');
|
|
297
|
+
await this._save(state => { state.worktrees[record.id].gitDir = admin; });
|
|
298
|
+
record = this._record(record.id);
|
|
299
|
+
await materialize(record, expected, signal);
|
|
300
|
+
await this._save(state => {
|
|
301
|
+
const item = state.worktrees[record.id];
|
|
302
|
+
item.materialized = true;
|
|
303
|
+
item.currentBranch = null;
|
|
304
|
+
item.status = 'ready';
|
|
305
|
+
item.error = null;
|
|
306
|
+
item.lastUsedAt = this.now();
|
|
307
|
+
});
|
|
308
|
+
return this._record(record.id);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async _publish(sessionId, signal) {
|
|
312
|
+
let binding = has(this.state.bindings, sessionId);
|
|
313
|
+
try {
|
|
314
|
+
signal.throwIfAborted();
|
|
315
|
+
await bounded(this.setDirectory(sessionId, binding.path), signal);
|
|
316
|
+
await this._save(state => {
|
|
317
|
+
const next = state.bindings[sessionId];
|
|
318
|
+
next.status = 'ready';
|
|
319
|
+
next.error = null;
|
|
320
|
+
next.preparedAt = this.now();
|
|
321
|
+
if (next.worktreeId) state.worktrees[next.worktreeId].lastUsedAt = this.now();
|
|
322
|
+
});
|
|
323
|
+
} catch (error) {
|
|
324
|
+
await this._save(state => { state.bindings[sessionId].status = 'error'; state.bindings[sessionId].error = message(error); });
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
binding = has(this.state.bindings, sessionId);
|
|
328
|
+
return { binding, ...(binding.worktreeId ? { worktree: this._record(binding.worktreeId) } : {}), requiresRestore: false };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
prepare({ sessionId, workspace, choice = { mode: 'local' }, operationId }, signal) {
|
|
332
|
+
requireIdentifier(sessionId, 'sessionId');
|
|
333
|
+
if (operationId !== undefined) requireIdentifier(operationId, 'operationId');
|
|
334
|
+
return this._run(operationSignal => this._lock(this.sessions, sessionId, async () => {
|
|
335
|
+
operationSignal.throwIfAborted();
|
|
336
|
+
const original = await this._workspace(workspace);
|
|
337
|
+
let binding = has(this.state.bindings, sessionId);
|
|
338
|
+
if (binding && (binding.workspaceId !== original.workspaceId || !samePath(binding.workspacePath, original.path))) fail('WORKSPACE_CHANGED', 'A session cannot change its original workspace membership.');
|
|
339
|
+
let wanted = binding?.acceptedAt !== null && binding?.acceptedAt !== undefined ? selected(binding) : selected(choice);
|
|
340
|
+
if (wanted.mode === 'new' && binding?.acceptedAt == null) {
|
|
341
|
+
const inspected = await this._inspect(original, operationSignal);
|
|
342
|
+
const ref = wanted.baseRef ?? inspected.defaultBase;
|
|
343
|
+
const branch = inspected.branches.find(item => item.ref === ref) ?? inspected.branches.find(item => item.name === ref && !item.remote) ?? inspected.branches.find(item => item.name === ref);
|
|
344
|
+
wanted = { mode: 'new', baseRef: branch?.ref ?? ref };
|
|
345
|
+
}
|
|
346
|
+
const matches = binding && binding.mode === wanted.mode && (wanted.mode === 'local' || binding.baseRef === wanted.baseRef);
|
|
347
|
+
if (!matches) {
|
|
348
|
+
if (wanted.mode === 'local') {
|
|
349
|
+
const next = this._binding(sessionId, original, wanted);
|
|
350
|
+
await this._save(state => { this._detachPrevious(state, sessionId); state.bindings[sessionId] = next; });
|
|
351
|
+
} else {
|
|
352
|
+
let reusable = operationId ? Object.values(this.state.worktrees).find(record => record.ownerSessionId === sessionId && record.operationId === operationId) : undefined;
|
|
353
|
+
if (reusable && (reusable.workspaceId !== original.workspaceId || reusable.baseRef !== wanted.baseRef)) fail('OPERATION_CONFLICT', 'This preparation id was already used with a different source.');
|
|
354
|
+
if (reusable) {
|
|
355
|
+
const next = this._binding(sessionId, original, wanted, reusable);
|
|
356
|
+
await this._save(state => {
|
|
357
|
+
this._detachPrevious(state, sessionId);
|
|
358
|
+
if (!state.worktrees[reusable.id].sessionIds.includes(sessionId)) state.worktrees[reusable.id].sessionIds.push(sessionId);
|
|
359
|
+
state.worktrees[reusable.id].ownerlessAt = null;
|
|
360
|
+
state.bindings[sessionId] = next;
|
|
361
|
+
});
|
|
362
|
+
} else await this._allocate(sessionId, original, wanted, operationId, operationSignal);
|
|
363
|
+
}
|
|
364
|
+
binding = has(this.state.bindings, sessionId);
|
|
365
|
+
}
|
|
366
|
+
if (binding.mode === 'local') return this._publish(sessionId, operationSignal);
|
|
367
|
+
const id = binding.worktreeId;
|
|
368
|
+
this.inUse.set(id, (this.inUse.get(id) ?? 0) + 1);
|
|
369
|
+
try {
|
|
370
|
+
return await this._lock(this.records, id, async () => {
|
|
371
|
+
let record = this._record(id);
|
|
372
|
+
if (record.status === 'removed' || record.status === 'missing' || record.materialized && !(await exists(record.path))) {
|
|
373
|
+
if (record.status !== 'removed' && record.status !== 'missing') await this._save(state => { state.worktrees[id].status = 'missing'; });
|
|
374
|
+
return { binding: has(this.state.bindings, sessionId), worktree: this._record(id), requiresRestore: true };
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
if (!record.materialized) record = await this._finishCreate(record, operationSignal, Boolean(record.snapshotCommit));
|
|
378
|
+
else await this._verify(record, operationSignal);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
await this._failure(id, error);
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
return this._publish(sessionId, operationSignal);
|
|
384
|
+
});
|
|
385
|
+
} finally {
|
|
386
|
+
const remaining = this.inUse.get(id) - 1;
|
|
387
|
+
if (remaining) this.inUse.set(id, remaining);
|
|
388
|
+
else this.inUse.delete(id);
|
|
389
|
+
}
|
|
390
|
+
}), signal);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
remember({ sessionId, requestId, acceptedAt = this.now() }) {
|
|
394
|
+
requireIdentifier(sessionId, 'sessionId');
|
|
395
|
+
requireIdentifier(requestId, 'requestId');
|
|
396
|
+
if (!Number.isSafeInteger(acceptedAt) || acceptedAt < 0) throw new TypeError('acceptedAt must be a timestamp in milliseconds.');
|
|
397
|
+
return this._run(() => this._save(state => {
|
|
398
|
+
const binding = has(state.bindings, sessionId);
|
|
399
|
+
const key = JSON.stringify([sessionId, requestId]);
|
|
400
|
+
// Queue edits and steering reuse request ids with new timestamps. Receipts must
|
|
401
|
+
// survive archive/restore so these repeats cannot replace newer preferences.
|
|
402
|
+
if (!binding || has(state.accepted, key)) return false;
|
|
403
|
+
if (binding.status !== 'ready') fail('NOT_PREPARED', 'The session directory was not prepared for this accepted message.');
|
|
404
|
+
binding.acceptedAt = Math.max(binding.acceptedAt ?? 0, acceptedAt);
|
|
405
|
+
state.accepted[key] = { sessionId, requestId, acceptedAt };
|
|
406
|
+
const prior = has(state.preferences, binding.workspaceId);
|
|
407
|
+
if (!prior || prior.acceptedAt <= acceptedAt) {
|
|
408
|
+
const choice = selected(binding);
|
|
409
|
+
if (choice.mode === 'local' && prior?.baseRef) choice.baseRef = prior.baseRef;
|
|
410
|
+
state.preferences[binding.workspaceId] = { ...choice, sessionId, requestId, acceptedAt };
|
|
411
|
+
}
|
|
412
|
+
if (binding.worktreeId) {
|
|
413
|
+
state.worktrees[binding.worktreeId].lastUsedAt = Math.max(state.worktrees[binding.worktreeId].lastUsedAt, acceptedAt);
|
|
414
|
+
state.worktrees[binding.worktreeId].ownerlessAt = null;
|
|
415
|
+
}
|
|
416
|
+
return true;
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
bindInheritedSession(childId, parentId, workspace, actualDirectory, signal) {
|
|
421
|
+
requireIdentifier(childId, 'childId');
|
|
422
|
+
requireIdentifier(parentId, 'parentId');
|
|
423
|
+
return this._run(operationSignal => this._lock(this.sessions, childId, async () => {
|
|
424
|
+
if (has(this.state.bindings, childId)) return has(this.state.bindings, childId);
|
|
425
|
+
const parent = has(this.state.bindings, parentId);
|
|
426
|
+
if (!parent?.worktreeId) return undefined;
|
|
427
|
+
const record = this._record(parent.worktreeId);
|
|
428
|
+
const actual = await canonical(actualDirectory);
|
|
429
|
+
if (!inside(record.path, actual)) return undefined;
|
|
430
|
+
await this._verify(record, operationSignal);
|
|
431
|
+
const original = await this._workspace(workspace);
|
|
432
|
+
const binding = this._binding(childId, original, selected(parent), record);
|
|
433
|
+
binding.status = 'ready';
|
|
434
|
+
// Inheritance is fixed immediately; it is not an accepted user message or a remembered preference.
|
|
435
|
+
binding.acceptedAt = parent.acceptedAt ?? this.now();
|
|
436
|
+
await this._save(state => {
|
|
437
|
+
state.bindings[childId] = binding;
|
|
438
|
+
state.worktrees[record.id].sessionIds.push(childId);
|
|
439
|
+
});
|
|
440
|
+
return has(this.state.bindings, childId);
|
|
441
|
+
}), signal);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
assertAvailable(sessionId, signal) {
|
|
445
|
+
requireIdentifier(sessionId, 'sessionId');
|
|
446
|
+
return this._run(async operationSignal => {
|
|
447
|
+
const binding = has(this.state.bindings, sessionId);
|
|
448
|
+
if (!binding?.worktreeId) return binding;
|
|
449
|
+
const record = this._record(binding.worktreeId);
|
|
450
|
+
if (['removed', 'missing'].includes(record.status) || !(await exists(record.path))) fail('RESTORE_REQUIRED', 'Restore this managed worktree before continuing the session.');
|
|
451
|
+
if (!record.materialized) fail('INCOMPLETE_CHECKOUT', 'The managed worktree has not finished preparation.');
|
|
452
|
+
await this._verify(record, operationSignal);
|
|
453
|
+
return binding;
|
|
454
|
+
}, signal);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
_ownerless(record) {
|
|
458
|
+
return !record.sessionIds.some(id => has(this.state.bindings, id)?.acceptedAt != null);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async _protection(record, signal, automatic = false) {
|
|
462
|
+
const reasons = [];
|
|
463
|
+
if (this.inUse.has(record.id)) reasons.push('pending');
|
|
464
|
+
if (['creating', 'restoring', 'snapshotting', 'removing'].includes(record.status)) reasons.push('pending');
|
|
465
|
+
if (automatic && this.now() - record.createdAt < NEWBORN_GRACE) reasons.push('newborn');
|
|
466
|
+
if (automatic && this._ownerless(record) && this.now() - (record.ownerlessAt ?? record.createdAt) < OWNER_GRACE) reasons.push('ownerless-grace');
|
|
467
|
+
const external = await bounded(this.isProtected(record), signal);
|
|
468
|
+
if (external === true) reasons.push('protected');
|
|
469
|
+
else if (Array.isArray(external)) reasons.push(...external.map(String));
|
|
470
|
+
else if (external !== false) fail('PROTECTION_FAILED', 'The protection callback returned an invalid result.');
|
|
471
|
+
return [...new Set(reasons)];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async _snapshotRef(record, signal) {
|
|
475
|
+
const result = await this._repoGit(record, ['rev-parse', '--verify', '--end-of-options', `${record.snapshotRef}^{commit}`], signal, [0, 128]);
|
|
476
|
+
return result.code === 0 ? result.stdout.trim() : null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async _archive(record, signal) {
|
|
480
|
+
if (!record.archivePending) return;
|
|
481
|
+
try {
|
|
482
|
+
await bounded(this.archiveSessions([...record.sessionIds]), signal);
|
|
483
|
+
await this._save(state => { state.worktrees[record.id].archivePending = false; state.worktrees[record.id].error = null; });
|
|
484
|
+
} catch (error) {
|
|
485
|
+
await this._failure(record.id, error, 'removed');
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async _remove(id, signal, automatic) {
|
|
491
|
+
signal.throwIfAborted();
|
|
492
|
+
let record = this._record(id);
|
|
493
|
+
if (record.status === 'removed') {
|
|
494
|
+
if (!automatic) {
|
|
495
|
+
const protection = await this._protection(record, signal, false);
|
|
496
|
+
if (protection.length) fail('PROTECTED', `The checkout is protected: ${protection.join(', ')}.`);
|
|
497
|
+
if (!record.archivePending) await this._save(state => { state.worktrees[id].archivePending = true; });
|
|
498
|
+
await this._archive(this._record(id), signal);
|
|
499
|
+
}
|
|
500
|
+
return this._record(id);
|
|
501
|
+
}
|
|
502
|
+
const protection = await this._protection(record, signal, automatic);
|
|
503
|
+
if (protection.length) fail('PROTECTED', `The checkout is protected: ${protection.join(', ')}.`);
|
|
504
|
+
try {
|
|
505
|
+
await this._verify(record, signal);
|
|
506
|
+
if (!record.materialized) fail('INCOMPLETE_CHECKOUT', 'The checkout is incomplete and must be recovered before removal.');
|
|
507
|
+
await this._save(state => { state.worktrees[id].status = 'snapshotting'; state.worktrees[id].error = null; });
|
|
508
|
+
const snapshot = await makeSnapshot(record, signal);
|
|
509
|
+
const existingRef = await this._snapshotRef(record, signal);
|
|
510
|
+
if (existingRef !== record.snapshotCommit && !(record.snapshotPendingCommit && existingRef === record.snapshotPendingCommit)) fail('SNAPSHOT_CHANGED', 'The snapshot ref changed outside this manager.');
|
|
511
|
+
await this._save(state => { state.worktrees[id].snapshotPendingCommit = snapshot.commit; });
|
|
512
|
+
await this._repoGit(record, ['update-ref', record.snapshotRef, snapshot.commit, existingRef ?? '0'.repeat(snapshot.commit.length)], signal);
|
|
513
|
+
await this._save(state => {
|
|
514
|
+
state.worktrees[id].snapshotCommit = snapshot.commit;
|
|
515
|
+
state.worktrees[id].snapshotPendingCommit = null;
|
|
516
|
+
state.worktrees[id].status = 'removing';
|
|
517
|
+
});
|
|
518
|
+
record = this._record(id);
|
|
519
|
+
await this._verify(record, signal);
|
|
520
|
+
await assertSnapshotUnchanged(record, snapshot, signal);
|
|
521
|
+
// Recheck external protection after the snapshot, immediately before the destructive Git operation.
|
|
522
|
+
const external = await bounded(this.isProtected(record), signal);
|
|
523
|
+
if (external === true || Array.isArray(external) && external.length) fail('PROTECTED', 'The checkout became protected while it was being snapshotted.');
|
|
524
|
+
if (external !== false && !Array.isArray(external)) fail('PROTECTION_FAILED', 'The protection callback returned an invalid result.');
|
|
525
|
+
if (this.inUse.has(id)) fail('PROTECTED', 'The checkout is being prepared for a session.');
|
|
526
|
+
if (await this._snapshotRef(record, signal) !== snapshot.commit) fail('SNAPSHOT_CHANGED', 'The snapshot ref changed before removal. The checkout was kept.');
|
|
527
|
+
await this._repoGit(record, ['worktree', 'remove', '--force', '--', record.path], signal);
|
|
528
|
+
await this._save(state => {
|
|
529
|
+
const item = state.worktrees[id];
|
|
530
|
+
item.status = 'removed';
|
|
531
|
+
item.error = null;
|
|
532
|
+
item.archivePending = !automatic;
|
|
533
|
+
});
|
|
534
|
+
if (!automatic) await this._archive(this._record(id), signal);
|
|
535
|
+
return this._record(id);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (this._record(id).status !== 'removed') await this._failure(id, error);
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
remove(worktreeId, signal) {
|
|
543
|
+
return this._run(operationSignal => this._lock(this.records, worktreeId, () => this._remove(worktreeId, operationSignal, false)), signal);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
restore(worktreeId, signal) {
|
|
547
|
+
return this._run(operationSignal => this._lock(this.records, worktreeId, async () => {
|
|
548
|
+
let record = this._record(worktreeId);
|
|
549
|
+
try {
|
|
550
|
+
if (record.status === 'ready') { await this._verify(record, operationSignal); return record; }
|
|
551
|
+
await this._marker(record);
|
|
552
|
+
const snapshot = await this._snapshotRef(record, operationSignal);
|
|
553
|
+
if (!snapshot || snapshot !== record.snapshotCommit && snapshot !== record.snapshotPendingCommit) fail('NO_SNAPSHOT', 'A verified snapshot is unavailable for this checkout.');
|
|
554
|
+
const present = await exists(record.path);
|
|
555
|
+
if (present && record.status !== 'restoring' && !(record.status === 'error' && !record.materialized && record.snapshotCommit)) fail('OCCUPIED_PATH', 'The original worktree path is occupied. Nothing was overwritten.');
|
|
556
|
+
await this._save(state => {
|
|
557
|
+
const item = state.worktrees[worktreeId];
|
|
558
|
+
item.status = 'restoring';
|
|
559
|
+
item.snapshotCommit = snapshot;
|
|
560
|
+
item.snapshotPendingCommit = null;
|
|
561
|
+
item.materialized = false;
|
|
562
|
+
item.gitDir = null;
|
|
563
|
+
item.error = null;
|
|
564
|
+
});
|
|
565
|
+
record = await this._finishCreate(this._record(worktreeId), operationSignal, true);
|
|
566
|
+
return record;
|
|
567
|
+
} catch (error) {
|
|
568
|
+
await this._failure(worktreeId, error);
|
|
569
|
+
throw error;
|
|
570
|
+
}
|
|
571
|
+
}), signal);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
refresh(signal) {
|
|
575
|
+
return this._run(async operationSignal => {
|
|
576
|
+
for (const record of Object.values(this.state.worktrees)) {
|
|
577
|
+
operationSignal.throwIfAborted();
|
|
578
|
+
if (this.records.has(record.id) || this.inUse.has(record.id)) continue;
|
|
579
|
+
await this._lock(this.records, record.id, async () => {
|
|
580
|
+
try {
|
|
581
|
+
const present = await exists(record.path);
|
|
582
|
+
if (!present) {
|
|
583
|
+
const snapshot = await this._snapshotRef(record, operationSignal);
|
|
584
|
+
await this._save(state => {
|
|
585
|
+
const item = state.worktrees[record.id];
|
|
586
|
+
if (snapshot && (snapshot === item.snapshotCommit || snapshot === item.snapshotPendingCommit)) {
|
|
587
|
+
item.snapshotCommit = snapshot;
|
|
588
|
+
item.snapshotPendingCommit = null;
|
|
589
|
+
}
|
|
590
|
+
if (item.status !== 'removed') item.status = !item.materialized && !item.snapshotCommit ? 'creating' : 'missing';
|
|
591
|
+
});
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const admin = await this._verify(record, operationSignal);
|
|
595
|
+
const branch = await git(record.path, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { signal: operationSignal, allowed: [0, 1] });
|
|
596
|
+
await this._save(state => {
|
|
597
|
+
const item = state.worktrees[record.id];
|
|
598
|
+
item.gitDir = admin;
|
|
599
|
+
item.currentBranch = branch.code === 0 ? branch.stdout.trim() : null;
|
|
600
|
+
if (item.materialized && !['removed', 'error'].includes(item.status)) item.status = 'ready';
|
|
601
|
+
});
|
|
602
|
+
} catch (error) {
|
|
603
|
+
operationSignal.throwIfAborted();
|
|
604
|
+
await this._failure(record.id, error);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
return this.list();
|
|
609
|
+
}, signal);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
cleanup() {
|
|
613
|
+
if (this.cleaning) return Promise.resolve({ status: 'busy', eligible: 0, retained: 0, removed: [], skipped: [], errors: [], remaining: 0 });
|
|
614
|
+
this.cleaning = true;
|
|
615
|
+
return this._run(async lifetimeSignal => {
|
|
616
|
+
const controller = new AbortController();
|
|
617
|
+
const timer = setTimeout(() => controller.abort(new WorktreeError('CLEANUP_TIMEOUT', 'Background cleanup reached its time limit.')), CLEANUP_TIME);
|
|
618
|
+
timer.unref();
|
|
619
|
+
const signal = AbortSignal.any([lifetimeSignal, controller.signal]);
|
|
620
|
+
const report = { status: 'complete', eligible: 0, retained: 0, removed: [], skipped: [], errors: [], remaining: 0, skippedCount: 0, errorCount: 0 };
|
|
621
|
+
const skipped = item => { report.skippedCount++; if (report.skipped.length < 100) report.skipped.push(item); };
|
|
622
|
+
const failed = item => { report.errorCount++; if (report.errors.length < 100) report.errors.push(item); };
|
|
623
|
+
try {
|
|
624
|
+
const config = this.getConfig();
|
|
625
|
+
const maximum = config.maxRetained ?? 15;
|
|
626
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1) fail('INVALID_CONFIG', 'maxRetained must be a positive integer.');
|
|
627
|
+
const candidates = [];
|
|
628
|
+
for (const record of Object.values(this.state.worktrees)) {
|
|
629
|
+
if (!record.materialized || ['removed', 'missing'].includes(record.status)) continue;
|
|
630
|
+
try {
|
|
631
|
+
const reasons = await this._protection(record, signal, true);
|
|
632
|
+
if (reasons.length) skipped({ id: record.id, reasons });
|
|
633
|
+
else candidates.push(record);
|
|
634
|
+
} catch (error) {
|
|
635
|
+
failed({ id: record.id, message: message(error) });
|
|
636
|
+
if (signal.aborted) break;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
candidates.sort((a, b) => {
|
|
640
|
+
const aOwnerless = this._ownerless(a);
|
|
641
|
+
const bOwnerless = this._ownerless(b);
|
|
642
|
+
if (aOwnerless !== bOwnerless) return aOwnerless ? -1 : 1;
|
|
643
|
+
return (aOwnerless ? a.createdAt - b.createdAt : a.lastUsedAt - b.lastUsedAt) || a.createdAt - b.createdAt || a.id.localeCompare(b.id);
|
|
644
|
+
});
|
|
645
|
+
report.eligible = candidates.length;
|
|
646
|
+
const excess = Math.max(0, candidates.length - maximum);
|
|
647
|
+
for (const record of candidates.slice(0, Math.min(excess, CLEANUP_LIMIT))) {
|
|
648
|
+
if (signal.aborted) break;
|
|
649
|
+
try {
|
|
650
|
+
await bounded(this._lock(this.records, record.id, () => this._remove(record.id, signal, true)), signal);
|
|
651
|
+
report.removed.push(record.id);
|
|
652
|
+
} catch (error) {
|
|
653
|
+
failed({ id: record.id, message: message(error) });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
report.retained = report.eligible - report.removed.length;
|
|
657
|
+
report.remaining = Math.max(0, report.retained - maximum);
|
|
658
|
+
if (report.errors.length || report.remaining || signal.aborted) report.status = 'partial';
|
|
659
|
+
} catch (error) {
|
|
660
|
+
report.status = 'partial';
|
|
661
|
+
failed({ id: null, message: message(error) });
|
|
662
|
+
} finally {
|
|
663
|
+
clearTimeout(timer);
|
|
664
|
+
}
|
|
665
|
+
return report;
|
|
666
|
+
}).finally(() => { this.cleaning = false; });
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async dispose() {
|
|
670
|
+
this.lifetime.abort(new WorktreeError('DISPOSED', 'The worktree manager was disposed.'));
|
|
671
|
+
await Promise.allSettled([...this.pending, ...this.records.values(), ...this.sessions.values()]);
|
|
672
|
+
await this.writes.catch(() => {});
|
|
673
|
+
}
|
|
674
|
+
}
|