@ulysses-ai/create-workspace 0.22.0-beta.0 → 0.23.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/payload.mjs +170 -2
- package/lib/payload.test.mjs +158 -3
- package/lib/upgrade.mjs +148 -6
- package/lib/upgrade.test.mjs +319 -15
- package/package.json +1 -1
- package/template/_claude/rules/forge-operations.md +27 -6
- package/template/_claude/scripts/chat-record.mjs +51 -4
- package/template/_claude/scripts/classify-update.mjs +201 -21
- package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
- package/template/_claude/scripts/forges/gitlab.mjs +450 -18
- package/template/_claude/scripts/forges/interface.mjs +39 -6
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +232 -24
- package/template/_claude/scripts/task-pr.mjs +52 -13
- package/template/_claude/scripts/task-worktree.mjs +79 -10
- package/template/_claude/scripts/template-baseline.mjs +33 -9
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
- package/template/_claude/scripts/trackers/interface.mjs +3 -0
- package/template/_claude/skills/complete-work/SKILL.md +7 -4
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -4
- package/template/_claude/skills/release/SKILL.md +24 -8
- package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
- package/template/_claude/skills/start-work/SKILL.md +15 -2
- package/template/_claude/skills/workspace-update/SKILL.md +34 -22
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
// can batch the safe cases and ask only where a decision is needed.
|
|
4
4
|
//
|
|
5
5
|
// Usage:
|
|
6
|
-
// node classify-update.mjs [--root <dir>] [--payload <dir>]
|
|
6
|
+
// node classify-update.mjs [--root <dir>] [--payload <dir>] [--baseline <file>]
|
|
7
7
|
// node classify-update.mjs --root <dir> --payload <dir> --write-baseline
|
|
8
8
|
// node classify-update.mjs --root <dir> --payload <dir> --merge-claude-md
|
|
9
9
|
//
|
|
10
|
-
// --root
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// --payload
|
|
10
|
+
// --root workspace root; defaults to the current working directory (never
|
|
11
|
+
// derived from this script's location — the upgrade payload runs
|
|
12
|
+
// this file from <workspace>/.workspace-update/.claude/scripts/)
|
|
13
|
+
// --payload the staged payload; defaults to <root>/.workspace-update
|
|
14
|
+
// --baseline the baseline to classify against; defaults to
|
|
15
|
+
// <root>/.claude/.template-baseline.json, falling back to
|
|
16
|
+
// <payload>/.template-baseline.reconstructed.json (what --upgrade
|
|
17
|
+
// reconstructs for pre-baseline workspaces) when the root has none.
|
|
18
|
+
// Pass it explicitly in the worktree flow, where <root> is the
|
|
19
|
+
// worktree and the launcher's baseline may not be reachable.
|
|
14
20
|
//
|
|
15
21
|
// The default mode prints JSON with these lists:
|
|
16
22
|
// new — no installed counterpart and no baseline entry; safe to
|
|
@@ -24,6 +30,23 @@
|
|
|
24
30
|
// edit AND a template change — the one case that needs a
|
|
25
31
|
// per-file decision (or the workspace predates baselines and
|
|
26
32
|
// has no entry to compare).
|
|
33
|
+
// config — .mcp.json and .claude/settings.json: JSON the workspace
|
|
34
|
+
// owns jointly with the template (its own MCP servers and
|
|
35
|
+
// settings live beside template keys). Never classified by
|
|
36
|
+
// content and never batch-copied — instead each entry carries
|
|
37
|
+
// a key-level diff (`added` keys the template ships, keys
|
|
38
|
+
// `workspaceOnly`, keys `changed` in both, nested paths joined
|
|
39
|
+
// with '/'), and /workspace-update merges key by key: add
|
|
40
|
+
// template keys, keep workspace-only keys, ask on conflicting
|
|
41
|
+
// keys. Array-valued keys (hooks event lists,
|
|
42
|
+
// permissions.allow/deny) diff by ELEMENT instead of whole:
|
|
43
|
+
// each `arrays` entry is { path, added, workspaceOnly } with
|
|
44
|
+
// the element lists, and the skill merges arrays as a union —
|
|
45
|
+
// the workspace's elements kept, the template's new ones
|
|
46
|
+
// appended — so only true scalar conflicts ask. Entries flag
|
|
47
|
+
// `notInstalled` (no workspace file — ask once whether to
|
|
48
|
+
// install the payload's copy) or `unparseable` (broken JSON on
|
|
49
|
+
// either side — ask, never merge blind).
|
|
27
50
|
// localOnly — installed file differs from the payload, but the payload
|
|
28
51
|
// equals the baseline: the template hasn't touched the file
|
|
29
52
|
// since the last update, so the difference is purely local.
|
|
@@ -38,6 +61,8 @@
|
|
|
38
61
|
// active rule stays (gh:180)
|
|
39
62
|
// removed — installed file with no payload counterpart: the template
|
|
40
63
|
// stopped shipping it. Excludes what the workspace owns:
|
|
64
|
+
// the config files above (the template dropping one hands it
|
|
65
|
+
// to the workspace, it never deletes user content),
|
|
41
66
|
// *.test.mjs (see staleTests), anything gitignored
|
|
42
67
|
// (machine-local), paths under .claude/worktrees/, and entries
|
|
43
68
|
// of workspace.json → workspace.localFiles (array of
|
|
@@ -48,21 +73,25 @@
|
|
|
48
73
|
// checkout and are never updated by /workspace-update; the
|
|
49
74
|
// skill offers to remove them (tests live in the template repo)
|
|
50
75
|
//
|
|
51
|
-
// Plus `hasBaseline`: whether
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
76
|
+
// Plus `hasBaseline`: whether a usable baseline was found, `baselineSource`
|
|
77
|
+
// (which file it came from) and `baselineReconstructed`. The default
|
|
78
|
+
// resolution is <root>/.claude/.template-baseline.json, then the payload's
|
|
79
|
+
// .template-baseline.reconstructed.json (both unparseable-as-absent); without
|
|
80
|
+
// either, template changes cannot be told from local edits, so they land in
|
|
81
|
+
// `differs` — the first update asks per file; once it writes the baseline,
|
|
82
|
+
// later updates won't.
|
|
56
83
|
//
|
|
57
84
|
// Content comparisons hash with CRLF normalized to LF on both sides (binary
|
|
58
85
|
// files hash byte-exact), so a git autocrlf checkout that stores CRLF where
|
|
59
86
|
// the payload ships LF classifies as identical rather than locally modified.
|
|
60
87
|
//
|
|
61
|
-
// Only verbatim-installed files are classified: everything under .claude
|
|
62
|
-
// plus .mcp.json and .claudeignore
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
88
|
+
// Only verbatim-installed files are classified: everything under .claude/
|
|
89
|
+
// except .claude/settings.json, plus .mcp.json and .claudeignore — the two
|
|
90
|
+
// JSON configs route to `config` instead of the content lists. The payload's
|
|
91
|
+
// templates (*.tmpl, which install with {{project-name}} substitution),
|
|
92
|
+
// _gitignore (merged line-by-line into the workspace's .gitignore), and
|
|
93
|
+
// .manifest.json (payload metadata) are handled by their own steps in
|
|
94
|
+
// /workspace-update and are excluded here.
|
|
66
95
|
//
|
|
67
96
|
// The other two modes are /workspace-update bookends:
|
|
68
97
|
// --write-baseline write .claude/.template-baseline.json recording the
|
|
@@ -89,7 +118,13 @@ import {
|
|
|
89
118
|
import { basename, join, resolve } from 'node:path';
|
|
90
119
|
import { fileURLToPath } from 'node:url';
|
|
91
120
|
import { gitIgnoredPaths } from './build-workspace-context.mjs';
|
|
92
|
-
import {
|
|
121
|
+
import {
|
|
122
|
+
BASELINE_PATH,
|
|
123
|
+
RECONSTRUCTED_BASELINE_NAME,
|
|
124
|
+
hashBytes,
|
|
125
|
+
readBaselineFile,
|
|
126
|
+
writeBaseline,
|
|
127
|
+
} from './template-baseline.mjs';
|
|
93
128
|
|
|
94
129
|
function isMainModule(metaUrl) {
|
|
95
130
|
if (!process.argv[1]) return false;
|
|
@@ -99,11 +134,12 @@ function isMainModule(metaUrl) {
|
|
|
99
134
|
}
|
|
100
135
|
|
|
101
136
|
function parseArgs(argv) {
|
|
102
|
-
const args = { root: process.cwd(), payload: null, writeBaseline: false, mergeClaudeMd: false };
|
|
137
|
+
const args = { root: process.cwd(), payload: null, baseline: null, writeBaseline: false, mergeClaudeMd: false };
|
|
103
138
|
for (let i = 2; i < argv.length; i++) {
|
|
104
139
|
const a = argv[i];
|
|
105
140
|
if (a === '--root') args.root = argv[++i];
|
|
106
141
|
else if (a === '--payload') args.payload = argv[++i];
|
|
142
|
+
else if (a === '--baseline') args.baseline = argv[++i];
|
|
107
143
|
else if (a === '--write-baseline') args.writeBaseline = true;
|
|
108
144
|
else if (a === '--merge-claude-md') args.mergeClaudeMd = true;
|
|
109
145
|
else throw new Error(`Unknown arg: ${a}`);
|
|
@@ -115,6 +151,104 @@ function parseArgs(argv) {
|
|
|
115
151
|
// Everything else in the payload is a template or metadata handled elsewhere.
|
|
116
152
|
const VERBATIM_ROOTS = ['.claude', '.mcp.json', '.claudeignore'];
|
|
117
153
|
|
|
154
|
+
// JSON configs the workspace owns jointly with the template: its own MCP
|
|
155
|
+
// servers sit inside .mcp.json's mcpServers, its own settings beside the
|
|
156
|
+
// template's keys in .claude/settings.json. Content classification would
|
|
157
|
+
// file every one of them as `differs` the moment the workspace adds
|
|
158
|
+
// anything, and a batch copy would wipe the workspace's entries — so they
|
|
159
|
+
// are reported in `config` with a key-level diff and merged key by key,
|
|
160
|
+
// never compared by bytes and never copied wholesale (gh:186).
|
|
161
|
+
const CONFIG_PATHS = new Set(['.mcp.json', '.claude/settings.json']);
|
|
162
|
+
|
|
163
|
+
function isPlainObject(value) {
|
|
164
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Key-level diff between the payload's config object and the workspace's.
|
|
169
|
+
* Paths join keys with '/' (mcpServers/playwright) and stop at two
|
|
170
|
+
* segments: these configs are maps of named units — mcpServers/{server},
|
|
171
|
+
* permissions/{allow} — and a unit's own internals (a server's args vs
|
|
172
|
+
* command) merge as one decision, not as separate asks. Arrays the key
|
|
173
|
+
* carries on both sides diff by ELEMENT (a union merge needs no decision),
|
|
174
|
+
* and any other non-object value compares by JSON value and reports at its
|
|
175
|
+
* unit's path.
|
|
176
|
+
*/
|
|
177
|
+
const CONFIG_DIFF_DEPTH = 2;
|
|
178
|
+
|
|
179
|
+
function arrayElementDiff(payloadArr, workspaceArr) {
|
|
180
|
+
const wsSet = new Set(workspaceArr.map((e) => JSON.stringify(e)));
|
|
181
|
+
const plSet = new Set(payloadArr.map((e) => JSON.stringify(e)));
|
|
182
|
+
return {
|
|
183
|
+
added: payloadArr.filter((e) => !wsSet.has(JSON.stringify(e))),
|
|
184
|
+
workspaceOnly: workspaceArr.filter((e) => !plSet.has(JSON.stringify(e))),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function configKeyDiff(payloadObj, workspaceObj, prefix = '') {
|
|
189
|
+
const added = [];
|
|
190
|
+
const workspaceOnly = [];
|
|
191
|
+
const changed = [];
|
|
192
|
+
const arrays = [];
|
|
193
|
+
const keys = new Set([...Object.keys(payloadObj), ...Object.keys(workspaceObj)]);
|
|
194
|
+
for (const key of [...keys].sort()) {
|
|
195
|
+
const path = prefix ? `${prefix}/${key}` : key;
|
|
196
|
+
const inPayload = Object.prototype.hasOwnProperty.call(payloadObj, key);
|
|
197
|
+
const inWorkspace = Object.prototype.hasOwnProperty.call(workspaceObj, key);
|
|
198
|
+
if (inPayload && !inWorkspace) { added.push(path); continue; }
|
|
199
|
+
if (!inPayload && inWorkspace) { workspaceOnly.push(path); continue; }
|
|
200
|
+
const pv = payloadObj[key];
|
|
201
|
+
const wv = workspaceObj[key];
|
|
202
|
+
if (
|
|
203
|
+
prefix.split('/').filter(Boolean).length + 1 < CONFIG_DIFF_DEPTH
|
|
204
|
+
&& isPlainObject(pv) && isPlainObject(wv)
|
|
205
|
+
) {
|
|
206
|
+
const sub = configKeyDiff(pv, wv, path);
|
|
207
|
+
added.push(...sub.added);
|
|
208
|
+
workspaceOnly.push(...sub.workspaceOnly);
|
|
209
|
+
changed.push(...sub.changed);
|
|
210
|
+
arrays.push(...sub.arrays);
|
|
211
|
+
} else if (Array.isArray(pv) && Array.isArray(wv)) {
|
|
212
|
+
// An array both sides hold is a set the workspace extends: element
|
|
213
|
+
// lists let the skill union-merge instead of choosing one side whole.
|
|
214
|
+
const diff = arrayElementDiff(pv, wv);
|
|
215
|
+
if (diff.added.length > 0 || diff.workspaceOnly.length > 0) {
|
|
216
|
+
arrays.push({ path, ...diff });
|
|
217
|
+
}
|
|
218
|
+
} else if (JSON.stringify(pv) !== JSON.stringify(wv)) {
|
|
219
|
+
changed.push(path);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { added, workspaceOnly, changed, arrays };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One `config` entry: the key-level diff for a payload-shipped config file
|
|
227
|
+
* against the workspace's copy, or a flag when no diff is possible —
|
|
228
|
+
* `notInstalled` (no workspace file; the skill asks once whether to install
|
|
229
|
+
* the payload's copy) and `unparseable` (broken JSON on either side; the
|
|
230
|
+
* skill asks rather than merging blind).
|
|
231
|
+
*/
|
|
232
|
+
function configEntry(absRoot, absPayload, rel) {
|
|
233
|
+
let payloadJson;
|
|
234
|
+
try {
|
|
235
|
+
payloadJson = JSON.parse(readFileSync(join(absPayload, rel), 'utf8'));
|
|
236
|
+
} catch {
|
|
237
|
+
return { path: rel, unparseable: true };
|
|
238
|
+
}
|
|
239
|
+
if (!isPlainObject(payloadJson)) return { path: rel, unparseable: true };
|
|
240
|
+
const installed = join(absRoot, rel);
|
|
241
|
+
if (!existsSync(installed)) return { path: rel, notInstalled: true };
|
|
242
|
+
let workspaceJson;
|
|
243
|
+
try {
|
|
244
|
+
workspaceJson = JSON.parse(readFileSync(installed, 'utf8'));
|
|
245
|
+
} catch {
|
|
246
|
+
return { path: rel, unparseable: true };
|
|
247
|
+
}
|
|
248
|
+
if (!isPlainObject(workspaceJson)) return { path: rel, unparseable: true };
|
|
249
|
+
return { path: rel, ...configKeyDiff(payloadJson, workspaceJson) };
|
|
250
|
+
}
|
|
251
|
+
|
|
118
252
|
function isClassified(payloadRelPath) {
|
|
119
253
|
const first = payloadRelPath.split('/')[0];
|
|
120
254
|
return VERBATIM_ROOTS.includes(first);
|
|
@@ -196,7 +330,31 @@ function isOwnedByWorkspace(rel, localFiles) {
|
|
|
196
330
|
return localFiles.some((pattern) => globMatches(pattern, claudeRel));
|
|
197
331
|
}
|
|
198
332
|
|
|
199
|
-
|
|
333
|
+
/**
|
|
334
|
+
* Which baseline the classification runs against. An explicit --baseline
|
|
335
|
+
* wins; otherwise the workspace's own <root>/.claude/.template-baseline.json
|
|
336
|
+
* is tried first, then the payload's .template-baseline.reconstructed.json
|
|
337
|
+
* (staged by --upgrade for workspaces that predate baselines). The fallback
|
|
338
|
+
* matters in the worktree flow: <root> is the task worktree, which cannot
|
|
339
|
+
* see launcher-only files, while the payload travels there by absolute path.
|
|
340
|
+
* A file that exists but does not parse counts as absent — a corrupt
|
|
341
|
+
* baseline must not block the reconstructed one (gh:186).
|
|
342
|
+
*/
|
|
343
|
+
export function resolveBaseline({ root, payload, baseline = null }) {
|
|
344
|
+
const candidates = baseline !== null
|
|
345
|
+
? [{ path: resolve(baseline), label: baseline }]
|
|
346
|
+
: [
|
|
347
|
+
{ path: join(resolve(root), BASELINE_PATH), label: BASELINE_PATH },
|
|
348
|
+
{ path: join(resolve(payload), RECONSTRUCTED_BASELINE_NAME), label: `.workspace-update/${RECONSTRUCTED_BASELINE_NAME}` },
|
|
349
|
+
];
|
|
350
|
+
for (const candidate of candidates) {
|
|
351
|
+
const parsed = readBaselineFile(candidate.path);
|
|
352
|
+
if (parsed !== null) return { baseline: parsed, source: candidate.label };
|
|
353
|
+
}
|
|
354
|
+
return { baseline: null, source: null };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function classifyUpdate({ root, payload, baseline: baselineArg = null }) {
|
|
200
358
|
const absRoot = resolve(root);
|
|
201
359
|
const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
|
|
202
360
|
if (!existsSync(absPayload)) {
|
|
@@ -205,21 +363,30 @@ export function classifyUpdate({ root, payload }) {
|
|
|
205
363
|
|
|
206
364
|
const payloadFiles = [...walkFiles(absPayload)].filter(isClassified);
|
|
207
365
|
const payloadSet = new Set(payloadFiles);
|
|
208
|
-
const baseline =
|
|
366
|
+
const { baseline, source } = resolveBaseline({ root: absRoot, payload: absPayload, baseline: baselineArg });
|
|
209
367
|
|
|
210
368
|
const result = {
|
|
211
369
|
new: [],
|
|
212
370
|
identical: [],
|
|
213
371
|
updated: [],
|
|
214
372
|
differs: [],
|
|
373
|
+
config: [],
|
|
215
374
|
localOnly: [],
|
|
216
375
|
deletedLocally: [],
|
|
217
376
|
activated: [],
|
|
218
377
|
removed: [],
|
|
219
378
|
staleTests: [],
|
|
220
379
|
hasBaseline: baseline !== null,
|
|
380
|
+
baselineSource: source,
|
|
381
|
+
baselineReconstructed: baseline !== null && baseline.reconstructed === true,
|
|
221
382
|
};
|
|
222
383
|
for (const rel of payloadFiles) {
|
|
384
|
+
// Jointly-owned JSON configs never compare by content — the config
|
|
385
|
+
// list carries a key-level diff for the skill to merge instead.
|
|
386
|
+
if (CONFIG_PATHS.has(rel)) {
|
|
387
|
+
result.config.push(configEntry(absRoot, absPayload, rel));
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
223
390
|
// A .skip rule whose active counterpart is installed was deliberately
|
|
224
391
|
// activated by this workspace: report it as activated, not new.
|
|
225
392
|
if (rel.startsWith('.claude/rules/') && rel.endsWith('.md.skip')) {
|
|
@@ -270,6 +437,9 @@ export function classifyUpdate({ root, payload }) {
|
|
|
270
437
|
const gitignored = gitIgnoredPaths(absRoot, installedFiles);
|
|
271
438
|
for (const rel of installedFiles) {
|
|
272
439
|
if (skipSet.has(rel)) continue;
|
|
440
|
+
// A config file the payload dropped stays with the workspace: it holds
|
|
441
|
+
// user content the template never deletes.
|
|
442
|
+
if (CONFIG_PATHS.has(rel)) continue;
|
|
273
443
|
// An active rule whose .skip twin is in the payload is an activated rule,
|
|
274
444
|
// not a removed one.
|
|
275
445
|
if (rel.startsWith('.claude/rules/') && rel.endsWith('.md') && skipSet.has(`${rel}.skip`)) continue;
|
|
@@ -417,7 +587,17 @@ function resolvePayload(args) {
|
|
|
417
587
|
}
|
|
418
588
|
|
|
419
589
|
function writeBaselineMode(args) {
|
|
420
|
-
const
|
|
590
|
+
const absPayload = resolvePayload(args);
|
|
591
|
+
// The previous baseline decides which declined updates keep their old
|
|
592
|
+
// entry — resolve it exactly as classification does, so the worktree flow
|
|
593
|
+
// (no baseline of its own yet) carries over from the payload's
|
|
594
|
+
// reconstructed one instead of starting from nothing.
|
|
595
|
+
const { baseline: previous } = resolveBaseline({
|
|
596
|
+
root: args.root,
|
|
597
|
+
payload: absPayload,
|
|
598
|
+
baseline: args.baseline,
|
|
599
|
+
});
|
|
600
|
+
const baseline = writeBaseline(args.root, absPayload, { previous });
|
|
421
601
|
process.stdout.write(JSON.stringify({
|
|
422
602
|
written: true,
|
|
423
603
|
path: BASELINE_PATH,
|
|
@@ -453,7 +633,7 @@ function main() {
|
|
|
453
633
|
} else if (args.mergeClaudeMd) {
|
|
454
634
|
mergeClaudeMdMode(args);
|
|
455
635
|
} else {
|
|
456
|
-
const result = classifyUpdate({ root: args.root, payload: args.payload });
|
|
636
|
+
const result = classifyUpdate({ root: args.root, payload: args.payload, baseline: args.baseline });
|
|
457
637
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
458
638
|
}
|
|
459
639
|
}
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// Teardown order is MANDATORY:
|
|
5
5
|
// 1. Remove each project worktree from its project repo
|
|
6
6
|
// 2. Remove the workspace worktree from the workspace repo
|
|
7
|
-
// 3. Prune each project repo (belt-and-suspenders
|
|
7
|
+
// 3. Prune each project repo (belt-and-suspenders — and only when every
|
|
8
|
+
// prunable record in the repo belongs to this session; a repo also
|
|
9
|
+
// holding an unrelated external worktree is never pruned, so its
|
|
10
|
+
// record survives gh:187)
|
|
8
11
|
// 4. Delete all local branches
|
|
9
12
|
// 5. Remove the whole work-sessions/{name}/ folder
|
|
10
13
|
//
|
|
@@ -83,6 +86,25 @@ function isRepoSegment(name) {
|
|
|
83
86
|
return !/^\.+$/.test(segs[0]);
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
// `worktree list --porcelain` stanzas reduced to what prune scoping needs:
|
|
90
|
+
// each stanza's worktree path and whether git marked it prunable. Paths
|
|
91
|
+
// are kept exactly as git printed them (realOf is applied by callers, so
|
|
92
|
+
// a record whose directory is gone survives the comparison).
|
|
93
|
+
function worktreeStanzas(porcelain) {
|
|
94
|
+
const stanzas = [];
|
|
95
|
+
let cur = null;
|
|
96
|
+
for (const line of String(porcelain).split(/\r?\n/)) {
|
|
97
|
+
if (line.startsWith('worktree ')) {
|
|
98
|
+
if (cur) stanzas.push(cur);
|
|
99
|
+
cur = { path: line.slice(9), prunable: false };
|
|
100
|
+
} else if (cur && line.startsWith('prunable')) {
|
|
101
|
+
cur.prunable = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (cur) stanzas.push(cur);
|
|
105
|
+
return stanzas;
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
const args = process.argv.slice(2);
|
|
87
109
|
const getArg = (name) => {
|
|
88
110
|
const idx = args.indexOf(`--${name}`);
|
|
@@ -335,9 +357,42 @@ if (existsSync(wsWorktree)) {
|
|
|
335
357
|
}
|
|
336
358
|
|
|
337
359
|
// === Step 3: Prune each project repo to mop up orphans ===
|
|
360
|
+
//
|
|
361
|
+
// `git worktree prune` has no path filter: one invocation drops EVERY
|
|
362
|
+
// prunable record the repo holds, including records of worktrees that
|
|
363
|
+
// have nothing to do with this workspace — a scratch checkout in tmp, a
|
|
364
|
+
// directory on another drive (gh:187). Those belong to no session, so
|
|
365
|
+
// prune runs only when every prunable record in the repo sits under this
|
|
366
|
+
// session's folder; otherwise it is skipped and the records are named in
|
|
367
|
+
// the output, left registered for whoever owns them.
|
|
368
|
+
const sessionFolderReal = realOf(sessionFolder);
|
|
369
|
+
const sessionFolderAbs = resolve(sessionFolder);
|
|
370
|
+
const underSessionFolder = (recordedPath) => {
|
|
371
|
+
const normalized = realOf(recordedPath);
|
|
372
|
+
return [sessionFolderReal, sessionFolderAbs].some(
|
|
373
|
+
(base) => normalized === base || normalized.startsWith(base + sep)
|
|
374
|
+
|| recordedPath === base || recordedPath.startsWith(base + sep),
|
|
375
|
+
);
|
|
376
|
+
};
|
|
338
377
|
for (const repo of repos) {
|
|
339
378
|
const repoDir = join(reposDir, repo);
|
|
340
379
|
if (!existsSync(repoDir)) continue;
|
|
380
|
+
const listRes = git(repoDir, ['worktree', 'list', '--porcelain']);
|
|
381
|
+
if (!listRes.ok) {
|
|
382
|
+
errors.push(`Could not list worktrees in ${repo}: ${listRes.err || listRes.out}`);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const foreign = worktreeStanzas(listRes.out)
|
|
386
|
+
.filter((s) => s.prunable && !underSessionFolder(s.path))
|
|
387
|
+
.map((s) => s.path);
|
|
388
|
+
if (foreign.length > 0) {
|
|
389
|
+
skipped.push({
|
|
390
|
+
step: 'prune',
|
|
391
|
+
repo,
|
|
392
|
+
reason: `prune skipped: ${repo} has prunable worktree records outside this session (${foreign.join(', ')}) — a blanket prune would drop them; they were left registered for their owner`,
|
|
393
|
+
});
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
341
396
|
const res = git(repoDir, ['worktree', 'prune']);
|
|
342
397
|
if (!res.ok) {
|
|
343
398
|
// Prune is a safety net, but if it fails on a repo we touched, surface
|
|
@@ -407,8 +462,14 @@ for (const repo of repos) {
|
|
|
407
462
|
continue;
|
|
408
463
|
}
|
|
409
464
|
const wtList = listRes.out;
|
|
410
|
-
|
|
411
|
-
|
|
465
|
+
// Only this session's own prunable records are a leftover gh:119 orphan;
|
|
466
|
+
// a prunable record elsewhere in the repo is someone else's worktree and
|
|
467
|
+
// stays exactly as found (step 3 refused to prune it for that reason).
|
|
468
|
+
const leftover = worktreeStanzas(wtList)
|
|
469
|
+
.filter((s) => s.prunable && underSessionFolder(s.path))
|
|
470
|
+
.map((s) => s.path);
|
|
471
|
+
if (leftover.length > 0) {
|
|
472
|
+
errors.push(`Prunable worktree record(s) remain in ${repo} after cleanup (${leftover.join(', ')}) — the gh:119 symptom; inspect them, then run git -C ${repoDir} worktree prune yourself`);
|
|
412
473
|
}
|
|
413
474
|
if (wtList.includes(wsPath)) {
|
|
414
475
|
errors.push(`${repo} still has a worktree record referencing the session path`);
|