@ran-sh/dsh-crew 1.9.1 → 1.10.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/.claude-plugin/plugin.json +1 -1
- package/package.json +3 -2
- package/src/history/service.mjs +7 -2
- package/src/hub/entry.mjs +34 -5
- package/src/install/npx-lifecycle.mjs +16 -4
- package/src/release-in-use.mjs +109 -21
- package/src/runtime-identity.mjs +1 -1
- package/src/workspace-isolation.mjs +548 -57
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ran-sh/dsh-crew",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/hub/entry.mjs",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"build:client": "tsdown src/client/entry.tsx --format cjs --platform browser --target es2022 --tsconfig tsconfig.client.json --deps.never-bundle react --deps.never-bundle react/jsx-runtime --deps.never-bundle react-dom --deps.never-bundle react-dom/client --out-dir .client-build --clean --logLevel warn && tsdown src/client/quick-entry.tsx --format cjs --platform browser --target es2022 --tsconfig tsconfig.client.json --deps.never-bundle react --deps.never-bundle react/jsx-runtime --deps.never-bundle react-dom --deps.never-bundle react-dom/client --out-dir .client-build --no-clean --logLevel warn && node scripts/build-client.mjs",
|
|
30
30
|
"verify:npm-install": "node scripts/verify-npm-install.mjs",
|
|
31
31
|
"verify:npm-install:official": "node scripts/verify-npm-install.mjs --with-official-dsh",
|
|
32
|
-
"verify:official-bridge": "node scripts/verify-official-bridge-e2e.mjs --diagnostic-only"
|
|
32
|
+
"verify:official-bridge": "node scripts/verify-official-bridge-e2e.mjs --diagnostic-only",
|
|
33
|
+
"test": "node --test \"test/*.test.mjs\""
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"@modelcontextprotocol/sdk": "^1.25.4",
|
package/src/history/service.mjs
CHANGED
|
@@ -8,7 +8,12 @@ import { readHistoryState, writeHistoryState, historyPending, publicHistoryState
|
|
|
8
8
|
import { readSessionOrigins } from '../session-origins.mjs';
|
|
9
9
|
import { defaultWorktreeRoot } from '../workspace-isolation.mjs';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// `readOrigins` is injectable because the provenance ledger it reads by default
|
|
12
|
+
// is machine-global, and the plan revision hashes the whole set: anything that
|
|
13
|
+
// appends to that file between a preview and its execute invalidates the plan.
|
|
14
|
+
// That is correct in production, where the ledger is this machine's own record,
|
|
15
|
+
// but a caller that cannot control the ledger cannot control its own revisions.
|
|
16
|
+
export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now, readOrigins = readSessionOrigins }) {
|
|
12
17
|
const gate = installHistoryAdmissionGate(agents, () => historyPending(crewRoot));
|
|
13
18
|
const plans = new Map();
|
|
14
19
|
let entering = false;
|
|
@@ -67,7 +72,7 @@ export function createHistoryService({ crewRoot, agents, persistence, runtimeId,
|
|
|
67
72
|
});
|
|
68
73
|
// A retained child keeps its ancestor chain; do not leave a newer fork orphaned.
|
|
69
74
|
const workspaces = Object.entries(store.tables.workspaces).map(([id, row]) => ({ id, ...row }));
|
|
70
|
-
const crewSessionIds = [...
|
|
75
|
+
const crewSessionIds = [...readOrigins()];
|
|
71
76
|
const crewSet = new Set(crewSessionIds);
|
|
72
77
|
for (const row of sessions) if (crewSet.has(row.id)) row.crew = true;
|
|
73
78
|
// The isolated-workspace marker: the hub stamps every worktree session with a
|
package/src/hub/entry.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { apply as applyHub, inject, name, WorkerRegistry } from './index.mjs';
|
|
11
11
|
import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
|
|
12
12
|
import { getProcessAdaptiveHealthStore } from '../adaptive-routing.mjs';
|
|
13
|
-
import { claimReleaseInUse } from '../release-in-use.mjs';
|
|
13
|
+
import { claimReleaseInUse, clearReleaseClaim } from '../release-in-use.mjs';
|
|
14
14
|
|
|
15
15
|
const RUNTIME_PATH = '/_dsh/dsh-crew/runtime';
|
|
16
16
|
const ADAPTIVE_OBSERVER_INSTALLED = Symbol.for('@ran-sh/dsh-crew/adaptive-observer-installed');
|
|
@@ -97,8 +97,37 @@ export async function apply(ctx) {
|
|
|
97
97
|
// modules lazily with a cache-busting query, so deleting the release under a
|
|
98
98
|
// running process would break those routes with no way to recover but a
|
|
99
99
|
// restart. Retention reads these claims and leaves a live release alone.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
//
|
|
101
|
+
// A claim that could not be written is worth saying out loud: retention cannot
|
|
102
|
+
// see an unclaimed release, so this process is then the one that a later update
|
|
103
|
+
// may delete from under itself.
|
|
104
|
+
const claimFile = claimReleaseInUse();
|
|
105
|
+
if (!claimFile) {
|
|
106
|
+
ctx.logger?.warn?.('dsh-crew: could not record this release as in use; a later update may prune it while this Hub is running');
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
registerRuntimeEndpoint(ctx);
|
|
110
|
+
installAdaptiveHealthObserver();
|
|
111
|
+
const disposeHub = await applyHub(ctx);
|
|
112
|
+
// Release the claim on disposal, and only once teardown has actually
|
|
113
|
+
// finished: clearing it first would say "this release is unused" while the
|
|
114
|
+
// Hub is still running. Nothing else may remove a claim file — the reader
|
|
115
|
+
// deliberately never unlinks one, because it cannot tell its object from a
|
|
116
|
+
// successor published at the same name — but the process that owns a claim
|
|
117
|
+
// may remove its own, and it removes *this* claim rather than any other this
|
|
118
|
+
// process happens to hold.
|
|
119
|
+
return async () => {
|
|
120
|
+
try { if (typeof disposeHub === 'function') await disposeHub(); }
|
|
121
|
+
// Only a claim this mount actually holds. A null handle means the claim was
|
|
122
|
+
// never acquired, and clearing something anyway could remove a sibling
|
|
123
|
+
// mount's protection.
|
|
124
|
+
finally { if (claimFile) { try { clearReleaseClaim({ file: claimFile }); } catch { /* best effort */ } } }
|
|
125
|
+
};
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// No disposer will ever be returned for a failed mount, so the claim has to
|
|
128
|
+
// be released here or the release stays pinned by a process that never
|
|
129
|
+
// provided anything.
|
|
130
|
+
if (claimFile) { try { clearReleaseClaim({ file: claimFile }); } catch { /* best effort */ } }
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
104
133
|
}
|
|
@@ -42,7 +42,7 @@ import { homedir } from 'node:os';
|
|
|
42
42
|
import * as realInstaller from './install.mjs';
|
|
43
43
|
import { samePayloadContent, capturePayloadContent } from './payload-content.mjs';
|
|
44
44
|
import { crewDshHome, crewProfileDir } from './install.mjs';
|
|
45
|
-
import {
|
|
45
|
+
import { releaseClaimsState } from '../release-in-use.mjs';
|
|
46
46
|
import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
|
|
47
47
|
import {
|
|
48
48
|
ensureOfficialWebIntegration,
|
|
@@ -1084,14 +1084,26 @@ const STALE_INCOMPLETE_MS = 24 * 60 * 60 * 1000;
|
|
|
1084
1084
|
function gcOldReleases({ home, keep = KEEP_RELEASES, protect = null }) {
|
|
1085
1085
|
const pointer = readCurrentPointer({ home });
|
|
1086
1086
|
const releasesDir = crewReleasesDir({ home });
|
|
1087
|
-
if (!existsSync(releasesDir)) return;
|
|
1087
|
+
if (!existsSync(releasesDir)) return [];
|
|
1088
1088
|
const removed = [];
|
|
1089
1089
|
// A running Hub keeps executing the release it started from and re-reads
|
|
1090
1090
|
// several modules from disk on every request, so removing that release breaks
|
|
1091
1091
|
// those routes with no recovery but a restart. Protect what live processes
|
|
1092
1092
|
// claim, not just what the pointer names.
|
|
1093
|
-
|
|
1094
|
-
|
|
1093
|
+
//
|
|
1094
|
+
// Liveness has to be known, not merely unrefuted: if the claim directory
|
|
1095
|
+
// cannot be read, "no live claims" is an absence of evidence, and deleting on
|
|
1096
|
+
// it reproduces the outage this protection exists to prevent. Pruning is
|
|
1097
|
+
// optional and a skipped pass costs disk; guessing wrong costs a broken Hub.
|
|
1098
|
+
const claims = releaseClaimsState({ home });
|
|
1099
|
+
if (!claims.reliable) {
|
|
1100
|
+
// Say so rather than passing over it in silence: nothing is pruned until the
|
|
1101
|
+
// claim directory can be read again, and an operator who never hears about
|
|
1102
|
+
// that finds out when the disk fills.
|
|
1103
|
+
process.emitWarning(`dsh-crew: release liveness could not be read (${claims.error ?? 'unknown cause'}); skipping release pruning this pass`);
|
|
1104
|
+
return removed;
|
|
1105
|
+
}
|
|
1106
|
+
const live = new Set([...(protect ? [resolve(protect)] : []), ...claims.live.map((dir) => resolve(dir))]);
|
|
1095
1107
|
const dirs = readdirSync(releasesDir)
|
|
1096
1108
|
.map((name) => join(releasesDir, name))
|
|
1097
1109
|
.filter((dir) => (!pointer || dir !== pointer.path) && !live.has(resolve(dir)));
|
package/src/release-in-use.mjs
CHANGED
|
@@ -16,13 +16,21 @@
|
|
|
16
16
|
// release with a live claim. Liveness is decided by the pid, not by the file, so
|
|
17
17
|
// a process that dies without cleaning up cannot pin a release forever.
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
21
|
import { homedir } from 'node:os';
|
|
21
22
|
import { dirname, join, resolve } from 'node:path';
|
|
22
23
|
import { fileURLToPath } from 'node:url';
|
|
23
24
|
|
|
24
25
|
export const IN_USE_DIRNAME = 'in-use';
|
|
25
26
|
|
|
27
|
+
// A claim file is `<pid>.json` or `<pid>-<nonce>.json`. The PID alone is not
|
|
28
|
+
// enough to identify a claim: two mounts inside one process would publish to the
|
|
29
|
+
// same pathname, and whichever disposed first would remove the other's
|
|
30
|
+
// protection. A claim is owned by the mount that wrote it, so it needs a name
|
|
31
|
+
// that says which one that was.
|
|
32
|
+
const CLAIM_NAME_RE = /^(\d+)(?:-[0-9a-f]+)?\.json$/;
|
|
33
|
+
|
|
26
34
|
export function releaseInUseDir({ home = homedir() } = {}) {
|
|
27
35
|
return join(home, '.config', 'dsh-crew', 'app', IN_USE_DIRNAME);
|
|
28
36
|
}
|
|
@@ -55,8 +63,13 @@ function isAlive(pid) {
|
|
|
55
63
|
|
|
56
64
|
/**
|
|
57
65
|
* Record that this process is running `releasePath`. Best effort: failing to
|
|
58
|
-
* claim must never stop the Hub from starting
|
|
59
|
-
*
|
|
66
|
+
* claim must never stop the Hub from starting.
|
|
67
|
+
*
|
|
68
|
+
* A null return is a real degradation, not a footnote. Retention cannot see a
|
|
69
|
+
* release that was never claimed, so an unclaimable release is one that a later
|
|
70
|
+
* update may delete from under this process — the 500-answering routes this
|
|
71
|
+
* module exists to prevent. Callers should surface it; `releaseClaimsState`
|
|
72
|
+
* gives retention the matching fail-closed signal.
|
|
60
73
|
*/
|
|
61
74
|
export function claimReleaseInUse({ moduleUrl = import.meta.url, releasePath, home = homedir(), pid = process.pid, now = Date.now() } = {}) {
|
|
62
75
|
try {
|
|
@@ -64,47 +77,122 @@ export function claimReleaseInUse({ moduleUrl = import.meta.url, releasePath, ho
|
|
|
64
77
|
if (!target) return null;
|
|
65
78
|
const dir = releaseInUseDir({ home });
|
|
66
79
|
mkdirSync(dir, { recursive: true });
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
// The nonce makes this claim the property of this call. Clearing by PID would
|
|
81
|
+
// let one mount remove a sibling mount's only protection.
|
|
82
|
+
const nonce = randomBytes(8).toString('hex');
|
|
83
|
+
const file = join(dir, `${pid}-${nonce}.json`);
|
|
84
|
+
// Write then rename: a reader that lists the directory can otherwise see this
|
|
85
|
+
// filename while its JSON is still empty or half-written, and an unparseable
|
|
86
|
+
// claim is indistinguishable from a dead one.
|
|
87
|
+
const pending = `${file}.tmp`;
|
|
88
|
+
writeFileSync(pending, JSON.stringify({ pid, nonce, release: resolve(target), claimed_at: now }) + '\n');
|
|
89
|
+
renameSync(pending, file);
|
|
69
90
|
return file;
|
|
70
91
|
} catch { return null; }
|
|
71
92
|
}
|
|
72
93
|
|
|
94
|
+
/** The unbranded per-PID claim path, as written by releases before claims were per-mount. */
|
|
73
95
|
export function releaseClaimFile({ home = homedir(), pid = process.pid } = {}) {
|
|
74
96
|
return join(releaseInUseDir({ home }), `${pid}.json`);
|
|
75
97
|
}
|
|
76
98
|
|
|
77
99
|
export function releaseClaimInUse({ home = homedir(), pid = process.pid } = {}) {
|
|
78
|
-
|
|
79
|
-
|
|
100
|
+
let names;
|
|
101
|
+
try { names = readdirSync(releaseInUseDir({ home })); } catch { return false; }
|
|
102
|
+
return names.some((name) => CLAIM_NAME_RE.exec(name)?.[1] === String(pid));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Remove the claim whose handle `claimReleaseInUse` returned.
|
|
107
|
+
*
|
|
108
|
+
* A missing handle is not permission to remove something: without one there is
|
|
109
|
+
* no way to know which claim this caller owns, and guessing by PID can remove a
|
|
110
|
+
* sibling mount's protection. Legacy unbranded claims are removed by
|
|
111
|
+
* `clearLegacyReleaseClaim`, which says so in its name.
|
|
112
|
+
*/
|
|
113
|
+
export function clearReleaseClaim({ file = null } = {}) {
|
|
114
|
+
if (!file) return false;
|
|
115
|
+
try { rmSync(file, { force: true }); return true; } catch { return false; }
|
|
80
116
|
}
|
|
81
117
|
|
|
82
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Remove the unbranded `<pid>.json` claim written by releases before claims were
|
|
120
|
+
* per-mount. Separate from `clearReleaseClaim` because it can take a claim this
|
|
121
|
+
* caller did not write, and that should never happen by falling through a
|
|
122
|
+
* missing argument.
|
|
123
|
+
*/
|
|
124
|
+
export function clearLegacyReleaseClaim({ home = homedir(), pid = process.pid } = {}) {
|
|
83
125
|
try { rmSync(releaseClaimFile({ home, pid }), { force: true }); return true; } catch { return false; }
|
|
84
126
|
}
|
|
85
127
|
|
|
86
128
|
/**
|
|
87
|
-
* Releases currently held by a live process
|
|
88
|
-
*
|
|
129
|
+
* Releases currently held by a live process, plus whether that answer is
|
|
130
|
+
* trustworthy.
|
|
131
|
+
*
|
|
132
|
+
* `reliable: false` means liveness could not be determined, so an empty `live`
|
|
133
|
+
* list is not evidence that nothing is running. A caller that deletes releases
|
|
134
|
+
* must treat unknown as "do not delete": reading an unreadable claim directory
|
|
135
|
+
* as "no claims" is exactly how a release disappears from under a running Hub,
|
|
136
|
+
* and the damage is to the process, not to the files.
|
|
137
|
+
*
|
|
138
|
+
* Nothing is deleted here. A claim is only ever a protection, so a reaper that
|
|
139
|
+
* unlinks one has to be certain the object it inspected is still the object it
|
|
140
|
+
* is removing — and it cannot be, because the pathname outlives the claim and a
|
|
141
|
+
* process restarting with a reused PID publishes a new file at the same one.
|
|
142
|
+
* Checking liveness and then unlinking is a check-then-act over a name, and
|
|
143
|
+
* losing that race deletes a live process's protection. Stale files therefore
|
|
144
|
+
* stay; a Hub clears its own claim on a clean shutdown, and one file per
|
|
145
|
+
* hard-killed process is not worth a race over a safety mechanism.
|
|
89
146
|
*/
|
|
90
|
-
export function
|
|
147
|
+
export function releaseClaimsState({ home = homedir(), alive = isAlive } = {}) {
|
|
91
148
|
const dir = releaseInUseDir({ home });
|
|
92
149
|
let names;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
150
|
+
try { names = readdirSync(dir); } catch (error) {
|
|
151
|
+
// A directory that does not exist yet reliably holds no claims; any other
|
|
152
|
+
// failure — permissions, a file where the directory should be — does not.
|
|
153
|
+
if (error?.code === 'ENOENT') return { live: [], reliable: true };
|
|
154
|
+
return { live: [], reliable: false, error: String(error?.message ?? error) };
|
|
155
|
+
}
|
|
97
156
|
const live = [];
|
|
157
|
+
let unknown = null;
|
|
98
158
|
for (const name of names) {
|
|
99
159
|
if (!name.endsWith('.json')) continue;
|
|
100
160
|
const file = join(dir, name);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
161
|
+
// The filename carries the PID, and the name is complete before any content
|
|
162
|
+
// exists — so a torn write still says whose claim it was, which is what
|
|
163
|
+
// keeps one old unusable file from disabling pruning forever.
|
|
164
|
+
const pidFromName = CLAIM_NAME_RE.exec(name) ? Number.parseInt(name, 10) : null;
|
|
165
|
+
|
|
166
|
+
let parsed = null;
|
|
167
|
+
try { parsed = JSON.parse(readFileSync(file, 'utf8')); } catch { parsed = null; }
|
|
168
|
+
|
|
169
|
+
const wellFormed = parsed && typeof parsed === 'object'
|
|
170
|
+
&& Number.isInteger(parsed.pid) && parsed.pid > 0
|
|
171
|
+
&& typeof parsed.release === 'string' && parsed.release !== ''
|
|
172
|
+
&& parsed.pid === pidFromName;
|
|
173
|
+
|
|
174
|
+
if (wellFormed) {
|
|
175
|
+
// A well-formed claim for a process that is gone simply protects nothing;
|
|
176
|
+
// it is inert, not a reason to distrust the rest.
|
|
177
|
+
if (alive(parsed.pid)) live.push(resolve(parsed.release));
|
|
105
178
|
continue;
|
|
106
179
|
}
|
|
107
|
-
|
|
180
|
+
|
|
181
|
+
// Anything else is not evidence of a dead process. Syntax that happens to
|
|
182
|
+
// parse is not a schema: `{}` and `{"pid":123}` say nothing about liveness.
|
|
183
|
+
// The filename is the only identity left, and a claim whose named process is
|
|
184
|
+
// positively gone is inert for the same reason.
|
|
185
|
+
if (pidFromName !== null && !alive(pidFromName)) continue;
|
|
186
|
+
// No usable PID, or the PID may still be running. A reused PID is
|
|
187
|
+
// indistinguishable from the original here, so this stays unknown rather
|
|
188
|
+
// than guessing; that is a fail-closed condition, not a resolved one.
|
|
189
|
+
unknown ??= `unusable claim: ${file}`;
|
|
108
190
|
}
|
|
109
|
-
return [...new Set(live)];
|
|
191
|
+
if (unknown) return { live: [...new Set(live)], reliable: false, error: unknown };
|
|
192
|
+
return { live: [...new Set(live)], reliable: true };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Releases currently held by a live process. See `releaseClaimsState`. */
|
|
196
|
+
export function liveReleaseClaims(opts = {}) {
|
|
197
|
+
return releaseClaimsState(opts).live;
|
|
110
198
|
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export {
|
|
|
36
36
|
// included in the identity contract.
|
|
37
37
|
const RUNTIME_ID = randomUUID();
|
|
38
38
|
|
|
39
|
-
export const RUNTIME_VERSION = '1.
|
|
39
|
+
export const RUNTIME_VERSION = '1.10.1';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
// lock blocks cleanup.
|
|
12
12
|
|
|
13
13
|
import { execFile } from 'node:child_process';
|
|
14
|
-
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
15
|
import { readFile, lstat } from 'node:fs/promises';
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
|
-
import { join, resolve, basename } from 'node:path';
|
|
17
|
+
import { join, resolve, basename, dirname } from 'node:path';
|
|
18
18
|
import { randomBytes, createHash } from 'node:crypto';
|
|
19
19
|
import { promisify } from 'node:util';
|
|
20
20
|
import { isSensitivePath, parseChanges, DIFF_LIMIT, GIT_TIMEOUT_MS } from './workspace-audit.mjs';
|
|
@@ -31,17 +31,22 @@ export const CANDIDATE_CAPTURE_FAILED = 'CANDIDATE_CAPTURE_FAILED';
|
|
|
31
31
|
export const MAX_PARALLEL_CAP = 16;
|
|
32
32
|
export const DEFAULT_MAX_PARALLEL = 3;
|
|
33
33
|
// Worktree names read `Crew_YYYYMMDD_HHMMSS_<purpose>` so an operator can tell
|
|
34
|
-
// from the directory alone when a job ran and what it was for.
|
|
35
|
-
//
|
|
36
|
-
// are still adopted and cleaned up rather than orphaned.
|
|
34
|
+
// from the directory alone when a job ran and what it was for. Worktrees from
|
|
35
|
+
// an earlier release used `dsh-crew-<job>-<hex>` and stay recognised, so old
|
|
36
|
+
// trees are still adopted and cleaned up rather than orphaned.
|
|
37
|
+
//
|
|
38
|
+
// The shapes are matched exactly rather than by prefix, because the prefix
|
|
39
|
+
// alone is not proof of ownership: cleanup `--force`-deletes what it adopts,
|
|
40
|
+
// and a directory a user happened to name Crew_manual-testing or
|
|
41
|
+
// dsh-crew-backup would go with its uncommitted contents.
|
|
37
42
|
const WORKTREE_PREFIX = 'Crew_';
|
|
38
|
-
const
|
|
43
|
+
const WORKTREE_NAME_RE = /^Crew_\d{8}_\d{6}_[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*(?:-\d+)?$/;
|
|
44
|
+
const LEGACY_WORKTREE_RE = /^dsh-crew-[A-Za-z0-9._-]+-[0-9a-f]{8}$/;
|
|
39
45
|
|
|
40
46
|
/** Whether a directory name is a worktree Crew created. */
|
|
41
47
|
export function isCrewWorktreeName(name) {
|
|
42
48
|
const value = String(name ?? '');
|
|
43
|
-
return value.
|
|
44
|
-
|| LEGACY_WORKTREE_PREFIXES.some((prefix) => value.startsWith(prefix));
|
|
49
|
+
return WORKTREE_NAME_RE.test(value) || LEGACY_WORKTREE_RE.test(value);
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
async function defaultRunner(args, { cwd }) {
|
|
@@ -80,23 +85,27 @@ function stamp(at) {
|
|
|
80
85
|
+ `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
/**
|
|
84
|
-
* `Crew_<date>_<time>_<purpose>`, with a numeric suffix only when that name is
|
|
85
|
-
* already taken. Parallel jobs can start inside the same second, so the suffix is
|
|
86
|
-
* what keeps the name unique without putting random noise in every name.
|
|
87
|
-
*/
|
|
88
88
|
/**
|
|
89
89
|
* `Crew_<date>_<time>_<purpose>`, reserved by creating the directory.
|
|
90
90
|
*
|
|
91
91
|
* The reservation is a mkdir, not a lookup: two jobs starting in the same second
|
|
92
92
|
* both probe before either has created anything, so a check-then-act name would
|
|
93
93
|
* hand them the same path and one `git worktree add` would fail. mkdir
|
|
94
|
-
* fails on EEXIST, which makes the suffix loop race-free
|
|
94
|
+
* fails on EEXIST, which makes the suffix loop race-free, and a numeric suffix
|
|
95
|
+
* is added only when the name is already taken.
|
|
95
96
|
*/
|
|
96
|
-
function reserveWorktreeDir({ root, purpose, at }) {
|
|
97
|
-
|
|
97
|
+
export function reserveWorktreeDir({ root, purpose, at }) {
|
|
98
|
+
try {
|
|
99
|
+
mkdirSync(root, { recursive: true });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return { ok: false, error: `cannot create worktree root: ${error?.message ?? error}` };
|
|
102
|
+
}
|
|
103
|
+
// Truncate before trimming. Trimming first lets a 32-character slice end on
|
|
104
|
+
// the separator it just created, and the resulting name fails
|
|
105
|
+
// `isCrewWorktreeName` — so Crew would create a worktree it no longer
|
|
106
|
+
// recognises as its own, and stale pruning would never adopt it again.
|
|
98
107
|
const safe = String(purpose ?? 'job').replace(/[^A-Za-z0-9]+/g, '-')
|
|
99
|
-
.replace(/^-+|-+$/g, '')
|
|
108
|
+
.slice(0, PURPOSE_MAX).replace(/^-+|-+$/g, '') || 'job';
|
|
100
109
|
const base = `${WORKTREE_PREFIX}${stamp(at)}_${safe}`;
|
|
101
110
|
for (let n = 1; n <= 100; n += 1) {
|
|
102
111
|
const name = n === 1 ? base : `${base}-${n}`;
|
|
@@ -114,6 +123,138 @@ export function defaultWorktreeRoot() {
|
|
|
114
123
|
return join(tmpdir(), 'dsh-crew-worktrees');
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
// ---------- ownership record ----------
|
|
127
|
+
//
|
|
128
|
+
// A name is not proof that Crew made a worktree. `dsh-crew-backup-deadbeef`
|
|
129
|
+
// satisfies the legacy grammar, and `detached` describes HEAD rather than who
|
|
130
|
+
// created the tree, so both can be true of a worktree a user made — and cleanup
|
|
131
|
+
// `--force` deletes what it adopts. Crew therefore records what it creates,
|
|
132
|
+
// beside the worktrees it creates it in, and only ever adopts what a valid
|
|
133
|
+
// record claims. Anything else that looks like a leftover is reported for a
|
|
134
|
+
// human instead of removed.
|
|
135
|
+
//
|
|
136
|
+
// The record lives under the worktree root rather than in the Crew home so it
|
|
137
|
+
// travels with the trees it describes: if the root is wiped, the trees are gone
|
|
138
|
+
// with it and there is nothing left to adopt.
|
|
139
|
+
//
|
|
140
|
+
// A record is evidence only when it can be read and matches: its shape is
|
|
141
|
+
// validated, its name has to be this worktree's name, and its repository has to
|
|
142
|
+
// be the repository being scanned. An unreadable, truncated, malformed or
|
|
143
|
+
// mismatched record reads as "not owned", which withholds deletion rather than
|
|
144
|
+
// granting it — the one direction where being wrong is survivable.
|
|
145
|
+
|
|
146
|
+
const OWNED_DIRNAME = '.crew-owned';
|
|
147
|
+
const OWNED_SCHEMA = 2;
|
|
148
|
+
// Stored inside the worktree's git administrative directory, which git destroys
|
|
149
|
+
// with the worktree.
|
|
150
|
+
const INCARNATION_FILE = 'crew-owned';
|
|
151
|
+
|
|
152
|
+
function ownedMarkerPath(worktreePath) {
|
|
153
|
+
const abs = resolve(worktreePath);
|
|
154
|
+
return join(dirname(abs), OWNED_DIRNAME, `${basename(abs)}.json`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Prove which *incarnation* of a worktree this is.
|
|
159
|
+
*
|
|
160
|
+
* A path can be reused. Git removes a worktree and later another one is created
|
|
161
|
+
* in the same place — same name, same repository — so a record keyed on those
|
|
162
|
+
* alone can be inherited by a successor that Crew never created, and inherited
|
|
163
|
+
* again after Crew's own cleanup if releasing the record failed. The worktree's
|
|
164
|
+
* git administrative directory does not have that problem: git deletes it along
|
|
165
|
+
* with the worktree, so a value kept there cannot outlive the tree it describes.
|
|
166
|
+
*/
|
|
167
|
+
async function gitDirOf(run, cwd) {
|
|
168
|
+
const abs = await runGit(run, ['rev-parse', '--path-format=absolute', '--git-dir'], { cwd });
|
|
169
|
+
if (abs.ok && abs.stdout.trim()) return resolve(abs.stdout.trim());
|
|
170
|
+
const rel = await runGit(run, ['rev-parse', '--git-dir'], { cwd });
|
|
171
|
+
if (!rel.ok || !rel.stdout.trim()) return null;
|
|
172
|
+
return resolve(cwd, rel.stdout.trim());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The nonce this worktree incarnation was stamped with, if any. */
|
|
176
|
+
async function incarnationOf(run, worktreePath) {
|
|
177
|
+
const gitDir = await gitDirOf(run, worktreePath);
|
|
178
|
+
if (!gitDir) return null;
|
|
179
|
+
let nonce;
|
|
180
|
+
try { nonce = readFileSync(join(gitDir, INCARNATION_FILE), 'utf8').trim(); } catch { return null; }
|
|
181
|
+
return nonce ? { gitDir, nonce } : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Stamp a freshly created worktree so its record cannot be inherited later. */
|
|
185
|
+
async function claimIncarnation(run, worktreePath) {
|
|
186
|
+
const gitDir = await gitDirOf(run, worktreePath);
|
|
187
|
+
if (!gitDir) return null;
|
|
188
|
+
const nonce = randomBytes(16).toString('hex');
|
|
189
|
+
try { writeFileSync(join(gitDir, INCARNATION_FILE), `${nonce}\n`); } catch { return null; }
|
|
190
|
+
return { gitDir, nonce };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function recordOwnership({ worktreePath, repo, incarnation, head, purpose, at }) {
|
|
194
|
+
const marker = ownedMarkerPath(worktreePath);
|
|
195
|
+
const record = {
|
|
196
|
+
schemaVersion: OWNED_SCHEMA,
|
|
197
|
+
name: basename(resolve(worktreePath)),
|
|
198
|
+
worktree: pathIdentity(worktreePath),
|
|
199
|
+
// Binding the record to a repository is what stops a stale record from
|
|
200
|
+
// licensing a delete for a worktree of some other repository that later
|
|
201
|
+
// occupies the same path. The identity is the git *common* directory, not
|
|
202
|
+
// `--show-toplevel`: that returns the linked worktree's own path when run
|
|
203
|
+
// inside one, so it names different directories for the same repository
|
|
204
|
+
// depending on where it is asked.
|
|
205
|
+
repo: pathIdentity(repo),
|
|
206
|
+
git_dir: pathIdentity(incarnation.gitDir),
|
|
207
|
+
nonce: incarnation.nonce,
|
|
208
|
+
// The revision Crew left the worktree at. An operator who commits or checks
|
|
209
|
+
// out something else has taken the worktree over, and moving HEAD is how
|
|
210
|
+
// that shows up: being on a branch is only one way to do it.
|
|
211
|
+
head,
|
|
212
|
+
purpose: purpose ?? null,
|
|
213
|
+
created_at: at ?? Date.now(),
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
mkdirSync(dirname(marker), { recursive: true });
|
|
217
|
+
const pending = `${marker}.${process.pid}.tmp`;
|
|
218
|
+
writeFileSync(pending, JSON.stringify(record) + '\n');
|
|
219
|
+
renameSync(pending, marker);
|
|
220
|
+
return true;
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function releaseOwnership({ worktreePath }) {
|
|
227
|
+
try { rmSync(ownedMarkerPath(worktreePath), { force: true }); return true; } catch { return false; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The validated ownership record for `worktreePath`, or null when there is no
|
|
232
|
+
* usable evidence that Crew created *this* worktree in `repo`.
|
|
233
|
+
*
|
|
234
|
+
* This answers identity only — was it Crew's worktree? — and deliberately not
|
|
235
|
+
* state, which is a question for the caller: `head` and `detached` describe what
|
|
236
|
+
* the worktree looks like now, and a worktree whose operator has moved HEAD is
|
|
237
|
+
* still Crew's worktree, just no longer a disposable one. All of shape, name,
|
|
238
|
+
* path, repository and incarnation have to match; every failure reads as "not
|
|
239
|
+
* owned", which withholds deletion rather than granting it.
|
|
240
|
+
*/
|
|
241
|
+
function readOwnership({ worktreePath, repo, incarnation }) {
|
|
242
|
+
let raw;
|
|
243
|
+
try { raw = readFileSync(ownedMarkerPath(worktreePath), 'utf8'); } catch { return null; }
|
|
244
|
+
let record;
|
|
245
|
+
try { record = JSON.parse(raw); } catch { return null; }
|
|
246
|
+
if (!record || typeof record !== 'object' || record.schemaVersion !== OWNED_SCHEMA) return null;
|
|
247
|
+
if (typeof record.name !== 'string' || record.name !== basename(resolve(worktreePath))) return null;
|
|
248
|
+
if (typeof record.worktree !== 'string' || record.worktree !== pathIdentity(worktreePath)) return null;
|
|
249
|
+
// An unknown identity on either side cannot be matched, so it cannot be
|
|
250
|
+
// adopted: an unverifiable record is not evidence.
|
|
251
|
+
if (!repo || typeof record.repo !== 'string' || record.repo !== pathIdentity(repo)) return null;
|
|
252
|
+
if (!incarnation) return null;
|
|
253
|
+
if (typeof record.nonce !== 'string' || record.nonce !== incarnation.nonce) return null;
|
|
254
|
+
if (typeof record.git_dir !== 'string' || record.git_dir !== pathIdentity(incarnation.gitDir)) return null;
|
|
255
|
+
return record;
|
|
256
|
+
}
|
|
257
|
+
|
|
117
258
|
/**
|
|
118
259
|
* Resolve repository root + HEAD. Dirty detection is advisory: inability to
|
|
119
260
|
* read `git status` must not turn an otherwise valid repository into a hard
|
|
@@ -140,6 +281,10 @@ export async function inspectRepository({ cwd, git, runner } = {}) {
|
|
|
140
281
|
|
|
141
282
|
export async function createIsolatedWorkspace({ cwd, jobId, purpose, baseRevision, at, root = defaultWorktreeRoot(), git } = {}) {
|
|
142
283
|
const run = git ?? defaultRunner;
|
|
284
|
+
// A relative root would be resolved against this process's cwd for the
|
|
285
|
+
// mkdir reservation but against the repo root for the git invocation, so the
|
|
286
|
+
// two could land in different places. Anchor it once.
|
|
287
|
+
root = resolve(root);
|
|
143
288
|
const repo = await inspectRepository({ cwd, git: run });
|
|
144
289
|
if (!repo.ok) return { ok: false, reason: repo.reason, error: repo.error };
|
|
145
290
|
const rev = baseRevision ?? repo.baseRevision;
|
|
@@ -148,11 +293,44 @@ export async function createIsolatedWorkspace({ cwd, jobId, purpose, baseRevisio
|
|
|
148
293
|
const { dir, name } = reserved;
|
|
149
294
|
const res = await runGit(run, ['worktree', 'add', '--detach', dir, rev], { cwd: repo.repoRoot });
|
|
150
295
|
if (!res.ok) {
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
296
|
+
// A nonzero exit does not mean git did nothing: a failing post-checkout hook
|
|
297
|
+
// leaves the worktree registered and populated, and a transient Windows lock
|
|
298
|
+
// can defeat the unregister. Go through the same verified, retrying cleanup
|
|
299
|
+
// the normal path uses, and report a blocked reservation rather than
|
|
300
|
+
// pretending the name was released — a stranded registration makes every
|
|
301
|
+
// later attempt on this name fail with no way for the caller to tell why.
|
|
302
|
+
const released = await cleanupIsolatedWorkspace({ worktreePath: dir, repoRoot: repo.repoRoot, git: run, reservation: true });
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
reason: res.reason,
|
|
306
|
+
error: res.error,
|
|
307
|
+
...(released.ok ? {} : { cleanupBlocked: true, cleanupError: released.error }),
|
|
308
|
+
};
|
|
154
309
|
}
|
|
155
|
-
|
|
310
|
+
// Recorded only after git succeeded, so a failed creation never leaves a claim
|
|
311
|
+
// on a tree that does not exist. A record that cannot be written costs the
|
|
312
|
+
// automatic prune of this one worktree; it never costs the worktree itself.
|
|
313
|
+
//
|
|
314
|
+
// `owned` is only true when a record that `readOwnership` would actually
|
|
315
|
+
// accept was written: a record missing its repository or incarnation identity
|
|
316
|
+
// can never authorize cleanup, and reporting it as owned would be a leak
|
|
317
|
+
// dressed up as success.
|
|
318
|
+
// The revision Git actually left the worktree at, not the string we asked for:
|
|
319
|
+
// a caller may pass a branch or tag, and recording that name would never match
|
|
320
|
+
// the commit OID `worktree list` reports, so the worktree could never be
|
|
321
|
+
// recognized as untouched.
|
|
322
|
+
const headRes = await runGit(run, ['rev-parse', 'HEAD'], { cwd: dir });
|
|
323
|
+
const created = headRes.ok ? headRes.stdout.trim() : '';
|
|
324
|
+
|
|
325
|
+
const commonDir = await commonDirOf(run, repo.repoRoot);
|
|
326
|
+
const incarnation = commonDir ? await claimIncarnation(run, dir) : null;
|
|
327
|
+
// All four identities are required, and an unresolvable HEAD is not a fallback
|
|
328
|
+
// to the requested string: recording a symbolic name would produce a record
|
|
329
|
+
// that can never match, which is a worktree that can never be cleaned up
|
|
330
|
+
// reported as owned.
|
|
331
|
+
const owned = Boolean(commonDir && incarnation && created)
|
|
332
|
+
&& recordOwnership({ worktreePath: dir, repo: commonDir, incarnation, head: created, purpose, at });
|
|
333
|
+
return { ok: true, worktreePath: dir, baseRevision: rev, repoRoot: repo.repoRoot, name, owned };
|
|
156
334
|
}
|
|
157
335
|
|
|
158
336
|
function splitFirstTab(line) {
|
|
@@ -328,30 +506,114 @@ function trackedIn(nameStatus, untracked) {
|
|
|
328
506
|
return out;
|
|
329
507
|
}
|
|
330
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Parse `git worktree list --porcelain` into records. Git documents that the
|
|
511
|
+
* main working tree is the first record; `detached` marks the ones Crew creates,
|
|
512
|
+
* since every Crew worktree is added with `--detach`.
|
|
513
|
+
*/
|
|
514
|
+
function parseWorktrees(stdout) {
|
|
515
|
+
const out = [];
|
|
516
|
+
for (const block of String(stdout ?? '').split('\n\n')) {
|
|
517
|
+
const lines = block.split('\n');
|
|
518
|
+
const path = lines.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)?.trim();
|
|
519
|
+
if (!path) continue;
|
|
520
|
+
out.push({
|
|
521
|
+
path: resolve(path),
|
|
522
|
+
detached: lines.includes('detached'),
|
|
523
|
+
head: lines.find((line) => line.startsWith('HEAD '))?.slice('HEAD '.length)?.trim() ?? null,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
|
|
331
529
|
async function mainRepoRoot(run, worktreePath) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
530
|
+
// The main working tree is the first record of `git worktree list`; deriving
|
|
531
|
+
// it from `--git-common-dir` breaks for a bare main repository, where that
|
|
532
|
+
// path is the bare repository itself and its parent is not a git directory at
|
|
533
|
+
// all. The first record is the same ordering guarantee the stale-list scan
|
|
534
|
+
// already relies on.
|
|
535
|
+
const list = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: worktreePath });
|
|
536
|
+
if (!list.ok) return null;
|
|
537
|
+
return parseWorktrees(list.stdout)[0]?.path ?? null;
|
|
337
538
|
}
|
|
338
539
|
|
|
339
540
|
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
340
541
|
|
|
542
|
+
/**
|
|
543
|
+
* A path identity safe to compare for equality.
|
|
544
|
+
*
|
|
545
|
+
* `realpathSync` returns the on-disk spelling, so two aliases of one directory —
|
|
546
|
+
* a symlinked repo root, a mapped drive, a differently cased path — collapse to
|
|
547
|
+
* the same string without guessing. Lower-casing on Windows used to stand in for
|
|
548
|
+
* this, but Windows supports per-directory case sensitivity, where `Foo` and
|
|
549
|
+
* `foo` really are two directories and folding them together would license a
|
|
550
|
+
* delete against the wrong repository.
|
|
551
|
+
*/
|
|
341
552
|
const pathIdentity = (value) => {
|
|
342
553
|
const resolved = resolve(String(value));
|
|
343
|
-
return
|
|
554
|
+
try { return realpathSync.native ? realpathSync.native(resolved) : realpathSync(resolved); }
|
|
555
|
+
catch { return resolved; }
|
|
344
556
|
};
|
|
345
557
|
|
|
346
558
|
async function worktreeRegistered({ worktreePath, git, cwd }) {
|
|
347
559
|
const res = await runGit(git, ['worktree', 'list', '--porcelain'], { cwd });
|
|
348
560
|
if (!res.ok) return null;
|
|
349
561
|
const target = pathIdentity(worktreePath);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
562
|
+
return parseWorktrees(res.stdout).some((record) => pathIdentity(record.path) === target);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The repository's common git directory as seen from `cwd`, or null when git
|
|
567
|
+
* cannot say. `--path-format=absolute` needs git 2.31+, so the relative form is
|
|
568
|
+
* resolved against `cwd` for older installations.
|
|
569
|
+
*/
|
|
570
|
+
async function commonDirOf(run, cwd) {
|
|
571
|
+
const abs = await runGit(run, ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd });
|
|
572
|
+
if (abs.ok && abs.stdout.trim()) return resolve(abs.stdout.trim());
|
|
573
|
+
const rel = await runGit(run, ['rev-parse', '--git-common-dir'], { cwd });
|
|
574
|
+
if (!rel.ok || !rel.stdout.trim()) return null;
|
|
575
|
+
return resolve(cwd, rel.stdout.trim());
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Whether `worktreePath` carries git metadata for the same repository as `root`.
|
|
580
|
+
*
|
|
581
|
+
* A directory name is not proof of ownership, and the filesystem fallback below
|
|
582
|
+
* force-deletes what it adopts — so the last resort asks git which repository the
|
|
583
|
+
* directory belongs to and refuses when the answer is unavailable or different.
|
|
584
|
+
* An unrelated directory that merely happens to be named like a Crew worktree
|
|
585
|
+
* therefore survives.
|
|
586
|
+
*/
|
|
587
|
+
async function sameRepository(run, worktreePath, root) {
|
|
588
|
+
const [a, b] = await Promise.all([commonDirOf(run, worktreePath), commonDirOf(run, root)]);
|
|
589
|
+
return Boolean(a && b && pathIdentity(a) === pathIdentity(b));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function emptyDirectory(path) {
|
|
593
|
+
try { return readdirSync(path).length === 0; } catch { return false; }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Why `worktreePath` is no longer the worktree the caller validated, or null when
|
|
598
|
+
* it still is.
|
|
599
|
+
*
|
|
600
|
+
* Checking ownership and then deleting by pathname is check-then-act: whatever
|
|
601
|
+
* occupies the path when the delete runs is what gets deleted. Re-reading the
|
|
602
|
+
* incarnation, registration, HEAD and detached state immediately before the
|
|
603
|
+
* removal narrows that window to the removal itself.
|
|
604
|
+
*/
|
|
605
|
+
async function ownershipDrift(run, worktreePath, expect) {
|
|
606
|
+
const incarnation = await incarnationOf(run, worktreePath);
|
|
607
|
+
if (!incarnation) return 'its incarnation stamp is gone';
|
|
608
|
+
if (incarnation.nonce !== expect.incarnation?.nonce) return 'its incarnation nonce changed';
|
|
609
|
+
if (pathIdentity(incarnation.gitDir) !== pathIdentity(expect.incarnation?.gitDir)) return 'its git directory changed';
|
|
610
|
+
const listed = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: worktreePath });
|
|
611
|
+
if (!listed.ok) return 'it is no longer registered';
|
|
612
|
+
const record = parseWorktrees(listed.stdout).find((r) => pathIdentity(r.path) === pathIdentity(worktreePath));
|
|
613
|
+
if (!record) return 'it is no longer registered';
|
|
614
|
+
if (!record.detached) return 'it is no longer detached';
|
|
615
|
+
if (expect.head && record.head !== expect.head) return `its HEAD moved from ${expect.head} to ${record.head}`;
|
|
616
|
+
return null;
|
|
355
617
|
}
|
|
356
618
|
|
|
357
619
|
/**
|
|
@@ -372,36 +634,140 @@ export async function cleanupIsolatedWorkspace({
|
|
|
372
634
|
git,
|
|
373
635
|
retries = WORKTREE_CLEANUP_RETRIES,
|
|
374
636
|
backoffMs = WORKTREE_CLEANUP_BACKOFF_MS,
|
|
637
|
+
reservation = false,
|
|
638
|
+
// Callers that own the worktree (the Hub allocated it, this process holds the
|
|
639
|
+
// job) may force it away. Callers that merely recognised it — background
|
|
640
|
+
// pruning — must not: forcing discards any changes the tree now holds, which is
|
|
641
|
+
// precisely what a takeover looks like.
|
|
642
|
+
force = true,
|
|
643
|
+
// What the caller validated before deciding to delete. Re-checked here, because
|
|
644
|
+
// deciding on one observation and acting on a pathname is a different object.
|
|
645
|
+
expect = null,
|
|
375
646
|
} = {}) {
|
|
376
647
|
const run = git ?? defaultRunner;
|
|
377
648
|
if (!worktreePath) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path required' };
|
|
378
649
|
const root = repoRoot ?? (await mainRepoRoot(run, worktreePath));
|
|
379
650
|
const owned = isCrewWorktreeName(basename(resolve(worktreePath)));
|
|
380
651
|
|
|
652
|
+
if (expect) {
|
|
653
|
+
const drift = await ownershipDrift(run, worktreePath, expect);
|
|
654
|
+
if (drift) {
|
|
655
|
+
return { ok: false, reason: WORKTREE_LOCKED, error: `refusing to remove ${worktreePath}: ${drift}`, cleanupBlocked: true };
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!force) {
|
|
660
|
+
// Measured, not assumed: git refuses modified, staged and untracked files
|
|
661
|
+
// without `--force`, but it removes a worktree whose only remaining content
|
|
662
|
+
// is *ignored* — build output, a log, local config — and takes that content
|
|
663
|
+
// with it. Ask about ignored files directly, and treat an unanswerable
|
|
664
|
+
// question as a reason not to delete.
|
|
665
|
+
const ignored = await runGit(run, ['ls-files', '--others', '--ignored', '--exclude-standard'], { cwd: worktreePath });
|
|
666
|
+
if (!ignored.ok || ignored.stdout.trim() !== '') {
|
|
667
|
+
return {
|
|
668
|
+
ok: false,
|
|
669
|
+
reason: WORKTREE_LOCKED,
|
|
670
|
+
error: `refusing to remove ${worktreePath} without --force: it holds files that are not Crew's, or its contents could not be read`,
|
|
671
|
+
cleanupBlocked: true,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
// And the index flags, which are the same class of hole one level down: a
|
|
675
|
+
// tracked file with `assume-unchanged` or `skip-worktree` set is invisible to
|
|
676
|
+
// `status`, to the untracked query and to the ignored query alike, and git
|
|
677
|
+
// removes the worktree without complaint — taking the operator's edit with
|
|
678
|
+
// it. Measured on git 2.47.3. In `ls-files -v` a lowercase letter means
|
|
679
|
+
// assume-unchanged and `S` means skip-worktree; the ordinary state is `H`.
|
|
680
|
+
const flags = await runGit(run, ['ls-files', '-v'], { cwd: worktreePath });
|
|
681
|
+
const hidden = flags.ok
|
|
682
|
+
? flags.stdout.split('\n').find((line) => /^([a-z]|S)/.test(line)) ?? null
|
|
683
|
+
: 'unknown';
|
|
684
|
+
if (!flags.ok || hidden) {
|
|
685
|
+
return {
|
|
686
|
+
ok: false,
|
|
687
|
+
reason: WORKTREE_LOCKED,
|
|
688
|
+
error: `refusing to remove ${worktreePath} without --force: its index carries assume-unchanged or skip-worktree entries, which hide a modification from every status query`,
|
|
689
|
+
cleanupBlocked: true,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
381
694
|
if (root) {
|
|
382
|
-
// Preferred path: `git worktree remove
|
|
383
|
-
//
|
|
695
|
+
// Preferred path: `git worktree remove` (removes registration and directory
|
|
696
|
+
// atomically). Retry bounded times to recover transient locks.
|
|
697
|
+
//
|
|
698
|
+
// The ownership re-check above narrows the window between deciding and
|
|
699
|
+
// deleting, but it does not close it: git has no "remove only if HEAD and
|
|
700
|
+
// incarnation still equal X" primitive, so a worktree that changes in the
|
|
701
|
+
// moments between the two is removed anyway. That residual is accepted and
|
|
702
|
+
// stated rather than papered over; closing it needs a lease on the worktree,
|
|
703
|
+
// not another read.
|
|
704
|
+
const args = force
|
|
705
|
+
? ['worktree', 'remove', '--force', worktreePath]
|
|
706
|
+
: ['worktree', 'remove', worktreePath];
|
|
384
707
|
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
385
|
-
const res = await runGit(run,
|
|
386
|
-
if (res.ok)
|
|
708
|
+
const res = await runGit(run, args, { cwd: root });
|
|
709
|
+
if (res.ok) {
|
|
710
|
+
const released = releaseOwnership({ worktreePath });
|
|
711
|
+
return {
|
|
712
|
+
ok: true,
|
|
713
|
+
removed: true,
|
|
714
|
+
actions: [`removed worktree ${worktreePath}`],
|
|
715
|
+
// The worktree is gone but its record is not: worth saying, because the
|
|
716
|
+
// record is what would have authorized a later cleanup.
|
|
717
|
+
...(released ? {} : { ownershipRecordStale: true }),
|
|
718
|
+
};
|
|
719
|
+
}
|
|
387
720
|
if (attempt < retries - 1) await sleep(backoffMs);
|
|
388
721
|
}
|
|
722
|
+
if (!force) {
|
|
723
|
+
// Non-forced removal failed: git is refusing, or the tree is locked. The
|
|
724
|
+
// filesystem fallback deletes unconditionally, so it must not run here.
|
|
725
|
+
return { ok: false, reason: WORKTREE_LOCKED, error: `git declined to remove ${worktreePath} without --force`, cleanupBlocked: true };
|
|
726
|
+
}
|
|
389
727
|
}
|
|
390
728
|
|
|
391
729
|
// Last resort, only for Crew-owned disposable paths: remove the directory and
|
|
392
730
|
// verify the git registration actually went away before claiming success.
|
|
731
|
+
//
|
|
732
|
+
// Ownership here is not the name alone. An earlier revision adopted anything
|
|
733
|
+
// whose basename matched, so a directory the user had named (or a worktree they
|
|
734
|
+
// had made) could be force-deleted for resembling Crew's.
|
|
393
735
|
if (owned && root && pathIdentity(worktreePath) !== pathIdentity(root)) {
|
|
736
|
+
// Ask git BEFORE deleting anything. The reverse order — delete, then check —
|
|
737
|
+
// destroys the files of a worktree git still tracks and then reports the
|
|
738
|
+
// registration stranded, which is the outcome the caller was avoiding.
|
|
739
|
+
const registered = await worktreeRegistered({ worktreePath, git: run, cwd: root });
|
|
740
|
+
if (registered === true) {
|
|
741
|
+
return { ok: false, reason: WORKTREE_LOCKED, error: `worktree still registered after git refused to remove it: ${worktreePath}`, cleanupBlocked: true };
|
|
742
|
+
}
|
|
743
|
+
// The empty directory this call itself reserved is the one path that needs no
|
|
744
|
+
// repository provenance; anything else must belong to this repository.
|
|
745
|
+
const ownEmptyReservation = reservation && emptyDirectory(worktreePath);
|
|
746
|
+
const adoptable = ownEmptyReservation || (registered === false && await sameRepository(run, worktreePath, root));
|
|
747
|
+
if (!adoptable) {
|
|
748
|
+
return {
|
|
749
|
+
ok: false,
|
|
750
|
+
reason: WORKTREE_LOCKED,
|
|
751
|
+
error: `refusing to delete ${worktreePath}: not a worktree of ${root} (remove it manually if it is disposable)`,
|
|
752
|
+
cleanupBlocked: true,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
394
755
|
try {
|
|
395
|
-
|
|
756
|
+
// A reservation is empty by construction, so remove it non-recursively: if
|
|
757
|
+
// something appeared in it, failing is better than deleting that too.
|
|
758
|
+
if (ownEmptyReservation) rmdirSync(worktreePath);
|
|
759
|
+
else rmSync(worktreePath, { recursive: true, force: true });
|
|
396
760
|
} catch (err) {
|
|
397
761
|
return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed: ${err?.message ?? String(err)}`, cleanupBlocked: true };
|
|
398
762
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
return {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
763
|
+
if (!existsSync(worktreePath)) {
|
|
764
|
+
const released = releaseOwnership({ worktreePath });
|
|
765
|
+
return {
|
|
766
|
+
ok: true,
|
|
767
|
+
removed: true,
|
|
768
|
+
actions: [`cleaned worktree files ${worktreePath}`],
|
|
769
|
+
...(released ? {} : { ownershipRecordStale: true }),
|
|
770
|
+
};
|
|
405
771
|
}
|
|
406
772
|
return { ok: false, reason: WORKTREE_LOCKED, error: `could not verify worktree removal for ${worktreePath}`, cleanupBlocked: true };
|
|
407
773
|
}
|
|
@@ -409,36 +775,161 @@ export async function cleanupIsolatedWorkspace({
|
|
|
409
775
|
return { ok: false, reason: WORKTREE_LOCKED, error: `worktree cleanup failed while ${worktreePath} remains (${root ? 'not a Crew-owned disposable path' : 'main repository root unresolvable'})`, cleanupBlocked: true };
|
|
410
776
|
}
|
|
411
777
|
|
|
412
|
-
|
|
778
|
+
/**
|
|
779
|
+
* Split the linked worktrees of this repository into:
|
|
780
|
+
*
|
|
781
|
+
* - `stale`: Crew recorded creating it and it is still in the disposable state
|
|
782
|
+
* Crew leaves behind, so it may be removed automatically;
|
|
783
|
+
* - `retained`: Crew recorded creating it, but an operator has since attached a
|
|
784
|
+
* branch or checked something out, so it is no longer Crew's to discard;
|
|
785
|
+
* - `unowned`: Crew cannot show it created it at all.
|
|
786
|
+
*
|
|
787
|
+
* Only the first is ever deleted. The other two are reported so a human decides.
|
|
788
|
+
* Returned paths are canonical: git reports the spelling it resolved when the
|
|
789
|
+
* worktree was added, which on Windows can expand an 8.3 temp name (`RUNNER~1`)
|
|
790
|
+
* into its long form, so a caller comparing its own path string against the
|
|
791
|
+
* answer would miss — and a miss here decides whether a live worktree looks
|
|
792
|
+
* like a leftover.
|
|
793
|
+
*/
|
|
794
|
+
async function worktreeCandidates({ git, allowed = [] } = {}) {
|
|
413
795
|
const run = git ?? defaultRunner;
|
|
414
|
-
const set = new Set(allowed.map((p) =>
|
|
796
|
+
const set = new Set(allowed.map((p) => pathIdentity(p)));
|
|
415
797
|
const stale = [];
|
|
798
|
+
const retained = [];
|
|
799
|
+
const unowned = [];
|
|
416
800
|
try {
|
|
417
801
|
const root = await inspectRepository({ cwd: allowed[0] ?? process.cwd(), git: run });
|
|
418
802
|
if (root.ok) {
|
|
419
803
|
const res = await runGit(run, ['worktree', 'list', '--porcelain'], { cwd: root.repoRoot });
|
|
420
804
|
if (res.ok) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
805
|
+
// `git worktree list` puts the main working tree first. Inspecting from
|
|
806
|
+
// inside a linked worktree reports that worktree as the top level, so the
|
|
807
|
+
// repo root cannot be matched by path — the position in the list is what
|
|
808
|
+
// identifies the main tree.
|
|
809
|
+
const records = parseWorktrees(res.stdout);
|
|
810
|
+
const repo = await commonDirOf(run, root.repoRoot);
|
|
811
|
+
for (let index = 1; index < records.length; index += 1) {
|
|
812
|
+
const { path: abs, detached, head } = records[index];
|
|
813
|
+
// A repo whose directory happens to start with a Crew prefix (say, a
|
|
814
|
+
// checkout named dsh-crew-something) is not a disposable worktree, and
|
|
815
|
+
// treating it as stale would have the cleanup path fighting over it.
|
|
816
|
+
if (!isCrewWorktreeName(basename(abs)) || set.has(pathIdentity(abs))) continue;
|
|
817
|
+
const incarnation = await incarnationOf(run, abs);
|
|
818
|
+
const owned = readOwnership({ worktreePath: abs, repo, incarnation });
|
|
819
|
+
if (!owned) {
|
|
820
|
+
// The name is not proof: `dsh-crew-backup-deadbeef` is a name a user
|
|
821
|
+
// could plausibly pick. No valid record, no deletion.
|
|
822
|
+
unowned.push(pathIdentity(abs));
|
|
823
|
+
} else if (detached && owned.head === head) {
|
|
824
|
+
// Crew's worktree, untouched since Crew left it there. The incarnation
|
|
825
|
+
// and HEAD travel with it so the removal can re-check them rather
|
|
826
|
+
// than trusting that nothing changed since this scan.
|
|
827
|
+
stale.push({ path: pathIdentity(abs), incarnation, head });
|
|
828
|
+
} else {
|
|
829
|
+
// Crew's worktree, but no longer a disposable one. Being on a branch
|
|
830
|
+
// is only one way an operator takes a worktree over; committing on a
|
|
831
|
+
// detached HEAD, or checking out something else, moves it just as
|
|
832
|
+
// far out of Crew's hands.
|
|
833
|
+
retained.push(pathIdentity(abs));
|
|
834
|
+
}
|
|
426
835
|
}
|
|
427
836
|
}
|
|
428
837
|
}
|
|
429
838
|
} catch {}
|
|
430
|
-
return stale;
|
|
839
|
+
return { stale, retained, unowned };
|
|
431
840
|
}
|
|
432
841
|
|
|
433
|
-
|
|
842
|
+
/** Crew-created worktrees that are still disposable and belong to no active job. */
|
|
843
|
+
export async function staleWorktrees({ git, allowed = [] } = {}) {
|
|
844
|
+
return (await worktreeCandidates({ git, allowed })).stale.map((entry) => entry.path);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Linked worktrees that resemble Crew's but carry no usable record of it. */
|
|
848
|
+
export async function unownedWorktrees({ git, allowed = [] } = {}) {
|
|
849
|
+
return (await worktreeCandidates({ git, allowed })).unowned;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** Crew-created worktrees an operator has since taken over; never removed. */
|
|
853
|
+
export async function retainedWorktrees({ git, allowed = [] } = {}) {
|
|
854
|
+
return (await worktreeCandidates({ git, allowed })).retained;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Report what Crew could clean up, and only delete when explicitly asked.
|
|
859
|
+
*
|
|
860
|
+
* `remove` defaults to false, and the default is the point. Deleting from a
|
|
861
|
+
* background scan has been shown insufficient three separate ways: git removes a
|
|
862
|
+
* worktree whose only content is ignored; it removes one whose tracked file
|
|
863
|
+
* carries `assume-unchanged` or `skip-worktree`; and between validating a
|
|
864
|
+
* worktree and deleting it, the worktree can change. Each was measured against
|
|
865
|
+
* real git rather than reasoned about, and each ends with an operator's work
|
|
866
|
+
* gone. Nothing in Crew calls this path, so the automatic capability buys nothing
|
|
867
|
+
* and risks the one thing Crew must not lose.
|
|
868
|
+
*
|
|
869
|
+
* A caller that has decided to remove a specific worktree should pass
|
|
870
|
+
* `remove: true`, or better, use `cleanupIsolatedWorkspace` for a worktree it
|
|
871
|
+
* directly owns. Unattended deletion would need a lease on the worktree, not
|
|
872
|
+
* another pre-delete observation.
|
|
873
|
+
*/
|
|
874
|
+
export async function pruneWorktrees({ git, allowed = [], remove = false } = {}) {
|
|
434
875
|
const run = git ?? defaultRunner;
|
|
435
|
-
const stale = await
|
|
876
|
+
const { stale, retained, unowned } = await worktreeCandidates({ git: run, allowed });
|
|
436
877
|
const actions = [];
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
878
|
+
|
|
879
|
+
if (!remove) {
|
|
880
|
+
for (const entry of stale) actions.push(`stale Crew worktree, remove explicitly if it is finished with: ${entry.path}`);
|
|
881
|
+
for (const w of unowned) actions.push(`not Crew-owned, left in place: ${w}`);
|
|
882
|
+
for (const w of retained) actions.push(`taken over by an operator, left in place: ${w}`);
|
|
883
|
+
return {
|
|
884
|
+
ok: true,
|
|
885
|
+
reportOnly: true,
|
|
886
|
+
removed: 0,
|
|
887
|
+
candidates: stale.map((entry) => entry.path),
|
|
888
|
+
failed: [],
|
|
889
|
+
unowned,
|
|
890
|
+
retained,
|
|
891
|
+
staleRecords: [],
|
|
892
|
+
actions,
|
|
893
|
+
};
|
|
440
894
|
}
|
|
441
|
-
|
|
895
|
+
|
|
896
|
+
const failed = [];
|
|
897
|
+
const staleRecords = [];
|
|
898
|
+
let removed = 0;
|
|
899
|
+
for (const entry of stale) {
|
|
900
|
+
// Not forced, and re-checked against what was scanned: background pruning
|
|
901
|
+
// recognised this worktree from a name and a record, and neither survives the
|
|
902
|
+
// tree itself being taken over. `--force` would discard whatever an operator
|
|
903
|
+
// had put there; ownershipDrift would not notice if they had already.
|
|
904
|
+
const r = await cleanupIsolatedWorkspace({
|
|
905
|
+
worktreePath: entry.path,
|
|
906
|
+
git: run,
|
|
907
|
+
force: false,
|
|
908
|
+
expect: { incarnation: entry.incarnation, head: entry.head },
|
|
909
|
+
});
|
|
910
|
+
// A locked worktree is left on disk, so counting it as removed would tell an
|
|
911
|
+
// operator the machine is clean when it is not.
|
|
912
|
+
if (r.ok && r.removed) {
|
|
913
|
+
removed += 1;
|
|
914
|
+
actions.push(...(r.actions ?? []));
|
|
915
|
+
if (r.ownershipRecordStale) staleRecords.push(entry.path);
|
|
916
|
+
} else {
|
|
917
|
+
failed.push(entry.path);
|
|
918
|
+
actions.push(`failed to remove ${entry.path}: ${r.error ?? 'unknown reason'}`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
for (const w of unowned) actions.push(`not Crew-owned, left in place: ${w}`);
|
|
922
|
+
for (const w of retained) actions.push(`taken over by an operator, left in place: ${w}`);
|
|
923
|
+
return {
|
|
924
|
+
ok: failed.length === 0,
|
|
925
|
+
removed,
|
|
926
|
+
failed,
|
|
927
|
+
unowned,
|
|
928
|
+
retained,
|
|
929
|
+
staleRecords,
|
|
930
|
+
actions,
|
|
931
|
+
...(failed.length ? { cleanupBlocked: true } : {}),
|
|
932
|
+
};
|
|
442
933
|
}
|
|
443
934
|
|
|
444
935
|
export function clampMaxParallel(raw) {
|