specrails-desktop 2.29.4 → 2.29.5
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/docs/customizing.md +1 -1
- package/docs/internals/gemini-mcp-registration.md +1 -1
- package/docs/internals/global-artifacts-alignment-contract.md +4 -4
- package/docs/internals/safe-pr-review-flow.md +29 -19
- package/package.json +1 -1
- package/server/dist/chat-manager.js +309 -18
- package/server/dist/context-scope.js +6 -3
- package/server/dist/contract-refine-runner.js +143 -72
- package/server/dist/rail-isolated-launch.js +11 -1
- package/server/dist/workspace-manager.js +3 -1
- package/server/dist/worktree-overlay.js +233 -51
|
@@ -24,10 +24,24 @@ exports.applyWorktreeOverlay = applyWorktreeOverlay;
|
|
|
24
24
|
* (in-repo) projects have the sibling problem for their UNTRACKED `.claude`
|
|
25
25
|
* entries: present on disk in the repo, absent from the checkout.
|
|
26
26
|
*
|
|
27
|
+
* Sources are ORDERED roots: `sourceRoot` (the effective artifact root — the
|
|
28
|
+
* workspace for relocated projects, the repo for legacy ones) plus optional
|
|
29
|
+
* `fallbackSourceRoots`. Relocated launches pass the REPO as the fallback so
|
|
30
|
+
* repo-resident untracked carve-outs (OpenSpec's `/opsx:*` command dirs,
|
|
31
|
+
* `openspec-*` skills, user extras) reach the worktree exactly as they do for
|
|
32
|
+
* legacy projects — a relocated workspace links the framework `commands/`
|
|
33
|
+
* subtree, which ships only `specrails/`, so without the fallback the claude
|
|
34
|
+
* CLI reports `Unknown command: /opsx:ff` inside isolated rails.
|
|
35
|
+
*
|
|
27
36
|
* Mechanics — a MERGE-overlay under `<worktree>/<providerDir>/`:
|
|
28
37
|
* - For each source entry missing in the worktree, SYMLINK it (dir symlink when
|
|
29
38
|
* the whole dir is absent, per-file/per-child when partially present). The
|
|
30
39
|
* checkout's own content is NEVER overwritten — tracked files always win.
|
|
40
|
+
* - Earlier roots win per entry. When SEVERAL roots contribute children to the
|
|
41
|
+
* same directory (workspace `commands/specrails` + repo `commands/opsx`),
|
|
42
|
+
* the dir is materialized as a REAL directory of per-child links — a
|
|
43
|
+
* whole-dir link to either root would hide the other's children. A prior
|
|
44
|
+
* pass's own whole-dir link is upgraded in place on resume.
|
|
31
45
|
* - `agent-memory` is linked like everything else: all runs SHARE agent memory,
|
|
32
46
|
* exactly matching the pre-isolation shared-cwd semantics (deliberate).
|
|
33
47
|
* - The providerDir ROOT itself is never linked (a real dir is created) so
|
|
@@ -41,11 +55,11 @@ exports.applyWorktreeOverlay = applyWorktreeOverlay;
|
|
|
41
55
|
*
|
|
42
56
|
* Idempotent + resume-safe: every created entry is recorded in a manifest
|
|
43
57
|
* (`.sr-rail-overlay.json`, itself overlay-owned) whose UNION across passes is
|
|
44
|
-
* returned as `createdPaths`
|
|
45
|
-
*
|
|
46
|
-
* ticket branch / PR. Failures degrade
|
|
47
|
-
* proceeds — a partial surface beats an
|
|
48
|
-
* throws.
|
|
58
|
+
* returned as `createdPaths`. `commitWorktree` uses those paths for its index
|
|
59
|
+
* audit/reset and authoritative `git commit --only` literal exclusions, so
|
|
60
|
+
* overlay scaffolding NEVER lands on the ticket branch / PR. Failures degrade
|
|
61
|
+
* per entry into `warnings` (the spawn proceeds — a partial surface beats an
|
|
62
|
+
* aborted rail); this function never throws.
|
|
49
63
|
*/
|
|
50
64
|
const fs_1 = __importDefault(require("fs"));
|
|
51
65
|
const path_1 = __importDefault(require("path"));
|
|
@@ -102,17 +116,37 @@ function safeRelativePath(value) {
|
|
|
102
116
|
return null;
|
|
103
117
|
return normalized;
|
|
104
118
|
}
|
|
105
|
-
|
|
119
|
+
/** The configured source roots in priority order, deduped, never the worktree
|
|
120
|
+
* itself (self-overlay paranoia extends to fallback roots). */
|
|
121
|
+
function overlayRootsOf(input) {
|
|
122
|
+
const worktree = path_1.default.resolve(input.worktreePath);
|
|
123
|
+
const roots = [];
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
for (const root of [input.sourceRoot, ...(input.fallbackSourceRoots ?? [])]) {
|
|
126
|
+
if (!root)
|
|
127
|
+
continue;
|
|
128
|
+
const resolved = path_1.default.resolve(root);
|
|
129
|
+
if (resolved === worktree || seen.has(resolved))
|
|
130
|
+
continue;
|
|
131
|
+
seen.add(resolved);
|
|
132
|
+
roots.push(root);
|
|
133
|
+
}
|
|
134
|
+
return roots;
|
|
135
|
+
}
|
|
136
|
+
/** Candidate source paths (one per configured root, priority order) an overlay
|
|
137
|
+
* manifest entry may authenticate against. Empty for unauthorised rels. */
|
|
138
|
+
function sourceCandidatesForOverlayEntry(input, rel) {
|
|
139
|
+
const roots = overlayRootsOf(input);
|
|
106
140
|
if (rel === '.mcp.json' || rel === input.instructionsFilename) {
|
|
107
|
-
return path_1.default.join(
|
|
141
|
+
return roots.map((root) => path_1.default.join(root, rel));
|
|
108
142
|
}
|
|
109
143
|
const providerPrefix = `${input.providerDir}/`;
|
|
110
144
|
if (!rel.startsWith(providerPrefix))
|
|
111
|
-
return
|
|
145
|
+
return [];
|
|
112
146
|
const providerRel = rel.slice(providerPrefix.length);
|
|
113
147
|
if (!providerRel || providerRel === 'worktrees' || providerRel.startsWith('worktrees/'))
|
|
114
|
-
return
|
|
115
|
-
return path_1.default.join(
|
|
148
|
+
return [];
|
|
149
|
+
return roots.map((root) => path_1.default.join(root, ...rel.split('/')));
|
|
116
150
|
}
|
|
117
151
|
function sha256(parts) {
|
|
118
152
|
const hash = (0, crypto_1.createHash)('sha256');
|
|
@@ -188,25 +222,28 @@ function captureOverlayCleanupEvidence(input, candidates, includeManifest = fals
|
|
|
188
222
|
}
|
|
189
223
|
continue;
|
|
190
224
|
}
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
225
|
+
const sources = sourceCandidatesForOverlayEntry(input, rel);
|
|
226
|
+
if (sources.length === 0)
|
|
193
227
|
continue;
|
|
194
228
|
const destinationStat = lstatSafe(destination);
|
|
195
229
|
if (!destinationStat)
|
|
196
230
|
continue;
|
|
197
231
|
if (destinationStat.isSymbolicLink()) {
|
|
232
|
+
let target;
|
|
198
233
|
try {
|
|
199
|
-
|
|
200
|
-
continue;
|
|
234
|
+
target = path_1.default.resolve(path_1.default.dirname(destination), fs_1.default.readlinkSync(destination));
|
|
201
235
|
}
|
|
202
236
|
catch {
|
|
203
237
|
continue;
|
|
204
238
|
}
|
|
239
|
+
if (!sources.some((source) => path_1.default.resolve(source) === target))
|
|
240
|
+
continue;
|
|
205
241
|
}
|
|
206
242
|
else {
|
|
207
|
-
const sourceDigest = dereferencedDigest(source);
|
|
208
243
|
const destinationDigest = dereferencedDigest(destination);
|
|
209
|
-
if (!
|
|
244
|
+
if (!destinationDigest)
|
|
245
|
+
continue;
|
|
246
|
+
if (!sources.some((source) => dereferencedDigest(source) === destinationDigest))
|
|
210
247
|
continue;
|
|
211
248
|
}
|
|
212
249
|
const fingerprint = fingerprintOverlayCleanupPath(destination);
|
|
@@ -286,47 +323,175 @@ function linkOrCopyEntry(src, dest, rel, created, warnings) {
|
|
|
286
323
|
}
|
|
287
324
|
created.push(rel);
|
|
288
325
|
}
|
|
326
|
+
/** True when `secondary` contributes any entry name (recursively, by tree
|
|
327
|
+
* shape) that is not reachable through `primary`. Drives the resume-time
|
|
328
|
+
* upgrade of a prior whole-dir link into a merged real dir. Follows dir
|
|
329
|
+
* symlinks with a realpath ancestor guard (framework subtrees are links). */
|
|
330
|
+
function contributesExtraEntries(secondary, primary, ancestors = new Set()) {
|
|
331
|
+
let names;
|
|
332
|
+
try {
|
|
333
|
+
names = fs_1.default.readdirSync(secondary);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
let real;
|
|
339
|
+
try {
|
|
340
|
+
real = fs_1.default.realpathSync(secondary);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
if (ancestors.has(real))
|
|
346
|
+
return false;
|
|
347
|
+
const nextAncestors = new Set(ancestors).add(real);
|
|
348
|
+
for (const name of names) {
|
|
349
|
+
const primaryChild = path_1.default.join(primary, name);
|
|
350
|
+
if (!lstatSafe(primaryChild))
|
|
351
|
+
return true;
|
|
352
|
+
const secondaryChild = path_1.default.join(secondary, name);
|
|
353
|
+
if (isDir(secondaryChild) && isDir(primaryChild) &&
|
|
354
|
+
contributesExtraEntries(secondaryChild, primaryChild, nextAncestors)) {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
289
360
|
/**
|
|
290
|
-
* Merge
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* - dest
|
|
294
|
-
*
|
|
295
|
-
*
|
|
361
|
+
* Merge the ordered existing sources for ONE entry into `dest`
|
|
362
|
+
* non-destructively (`srcs` = the roots' paths that exist for this rel,
|
|
363
|
+
* priority order — earlier roots win):
|
|
364
|
+
* - dest missing, one contributing dir (or a file first) → link whole entry.
|
|
365
|
+
* - dest missing, several contributing dirs → REAL dir + per-child recursion
|
|
366
|
+
* (a whole-dir link to either root would hide the other's children).
|
|
367
|
+
* - dest is OUR prior link/copy → re-claim; rebuild from the current ordered
|
|
368
|
+
* sources when a higher-priority winner appears or another root contributes
|
|
369
|
+
* extra directory entries (resume path).
|
|
370
|
+
* - dest is a real dir AND the highest-priority src is a dir → recurse per
|
|
371
|
+
* child over the union (per-file links where checkout is partially present).
|
|
372
|
+
* - anything else → skip (the checkout's content always wins).
|
|
296
373
|
*/
|
|
297
|
-
function mergeLink(
|
|
374
|
+
function mergeLink(srcs, dest, rel, created, warnings, priorOwned, converted) {
|
|
375
|
+
if (srcs.length === 0)
|
|
376
|
+
return;
|
|
377
|
+
const primary = srcs[0];
|
|
378
|
+
const primaryIsDir = isDir(primary);
|
|
379
|
+
// A highest-priority FILE shadows every lower-priority entry, including
|
|
380
|
+
// directories. Only collect mergeable dirs when the primary itself is one.
|
|
381
|
+
const dirSrcs = primaryIsDir ? srcs.filter((src) => isDir(src)) : [];
|
|
298
382
|
const destSt = lstatSafe(dest);
|
|
299
383
|
if (!destSt) {
|
|
300
|
-
|
|
384
|
+
if (!primaryIsDir || dirSrcs.length <= 1) {
|
|
385
|
+
// A file in the highest-priority root shadows lower roots entirely; a
|
|
386
|
+
// dir contributed by a single root keeps the whole-dir link (status quo).
|
|
387
|
+
linkOrCopyEntry(primary, dest, rel, created, warnings);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
fs_1.default.mkdirSync(dest, { recursive: true });
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
warnings.push(`failed to create ${rel}: ${errMsg(err)}`);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// The merged real dir itself is NOT recorded (precedent: the providerDir
|
|
398
|
+
// root); only its leaf links carry manifest entries + cleanup evidence.
|
|
399
|
+
mergeChildren(dirSrcs, dest, rel, created, warnings, priorOwned, converted);
|
|
301
400
|
return;
|
|
302
401
|
}
|
|
303
402
|
if (destSt.isSymbolicLink()) {
|
|
304
403
|
// Idempotent re-claim of a link a prior pass created; a foreign symlink
|
|
305
404
|
// (brought by the checkout) is left alone and NOT claimed.
|
|
405
|
+
let target;
|
|
306
406
|
try {
|
|
307
|
-
|
|
308
|
-
created.push(rel);
|
|
407
|
+
target = path_1.default.resolve(path_1.default.dirname(dest), fs_1.default.readlinkSync(dest));
|
|
309
408
|
}
|
|
310
409
|
catch {
|
|
311
|
-
/* unreadable link — leave it */
|
|
410
|
+
return; /* unreadable link — leave it */
|
|
411
|
+
}
|
|
412
|
+
const owned = srcs.find((src) => path_1.default.resolve(src) === target);
|
|
413
|
+
// Target equality authenticates WHAT the link points at, but not WHO made
|
|
414
|
+
// it. Destructive resume conversion additionally requires authenticated
|
|
415
|
+
// prior-manifest ownership; a checkout/foreign link may legitimately point
|
|
416
|
+
// at the same configured source and must never be replaced or claimed.
|
|
417
|
+
if (!owned || !priorOwned.has(rel))
|
|
418
|
+
return;
|
|
419
|
+
const winnerChanged = path_1.default.resolve(owned) !== path_1.default.resolve(primary);
|
|
420
|
+
const needsDirectoryMerge = isDir(owned) && primaryIsDir && dirSrcs.length > 1 &&
|
|
421
|
+
dirSrcs.some((src) => src !== owned && contributesExtraEntries(src, owned));
|
|
422
|
+
if (winnerChanged || needsDirectoryMerge) {
|
|
423
|
+
// Resume upgrade: rebuild OUR authenticated entry when a higher-priority
|
|
424
|
+
// source appeared or another dir now contributes children. Re-entering
|
|
425
|
+
// mergeLink with a missing destination selects the current winner and
|
|
426
|
+
// materializes a merged REAL dir when required.
|
|
427
|
+
try {
|
|
428
|
+
fs_1.default.unlinkSync(dest);
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
warnings.push(`failed to upgrade ${rel}: ${errMsg(err)}`);
|
|
432
|
+
created.push(rel); // still ours — keep it commit-excluded
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
converted.add(rel);
|
|
436
|
+
mergeLink(srcs, dest, rel, created, warnings, priorOwned, converted);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
created.push(rel);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if ((destSt.isDirectory() || destSt.isFile()) && priorOwned.has(rel)) {
|
|
443
|
+
// Windows may have materialized a prior overlay as a COPY when link
|
|
444
|
+
// creation was unavailable. Rebuild an authenticated source-identical copy
|
|
445
|
+
// when a higher-priority source appeared or a directory needs children
|
|
446
|
+
// from another root. Merely recursing into a prior dir copy would leave its
|
|
447
|
+
// old children unrecorded after the parent digest stops matching.
|
|
448
|
+
const destinationDigest = dereferencedDigest(dest);
|
|
449
|
+
const owned = destinationDigest
|
|
450
|
+
? srcs.find((src) => dereferencedDigest(src) === destinationDigest)
|
|
451
|
+
: undefined;
|
|
452
|
+
const winnerChanged = !!owned && path_1.default.resolve(owned) !== path_1.default.resolve(primary);
|
|
453
|
+
const needsDirectoryMerge = !!owned && isDir(owned) && primaryIsDir && dirSrcs.length > 1 &&
|
|
454
|
+
dirSrcs.some((src) => src !== owned && contributesExtraEntries(src, owned));
|
|
455
|
+
if (owned && (winnerChanged || needsDirectoryMerge)) {
|
|
456
|
+
try {
|
|
457
|
+
// Recompute immediately before removal so a user modification between
|
|
458
|
+
// manifest authentication and conversion revokes destructive authority.
|
|
459
|
+
if (dereferencedDigest(dest) !== destinationDigest)
|
|
460
|
+
return;
|
|
461
|
+
fs_1.default.rmSync(dest, { recursive: destSt.isDirectory() });
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
warnings.push(`failed to upgrade ${rel}: ${errMsg(err)}`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
converted.add(rel);
|
|
468
|
+
mergeLink(srcs, dest, rel, created, warnings, priorOwned, converted);
|
|
469
|
+
return;
|
|
312
470
|
}
|
|
471
|
+
}
|
|
472
|
+
if (destSt.isDirectory() && primaryIsDir && dirSrcs.length > 0) {
|
|
473
|
+
mergeChildren(dirSrcs, dest, rel, created, warnings, priorOwned, converted);
|
|
313
474
|
return;
|
|
314
475
|
}
|
|
315
|
-
|
|
316
|
-
|
|
476
|
+
// dest exists as a file (or src is a file while dest is a dir) — never overwrite.
|
|
477
|
+
}
|
|
478
|
+
/** Recurse `mergeLink` per child over the UNION of children across the
|
|
479
|
+
* contributing dirs, preserving root priority order. */
|
|
480
|
+
function mergeChildren(dirSrcs, dest, rel, created, warnings, priorOwned, converted) {
|
|
481
|
+
const names = new Set();
|
|
482
|
+
for (const dir of dirSrcs) {
|
|
317
483
|
try {
|
|
318
|
-
|
|
484
|
+
for (const name of fs_1.default.readdirSync(dir))
|
|
485
|
+
names.add(name);
|
|
319
486
|
}
|
|
320
487
|
catch (err) {
|
|
321
488
|
warnings.push(`failed to read ${rel}: ${errMsg(err)}`);
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
for (const name of entries) {
|
|
325
|
-
mergeLink(path_1.default.join(src, name), path_1.default.join(dest, name), `${rel}/${name}`, created, warnings);
|
|
326
489
|
}
|
|
327
|
-
return;
|
|
328
490
|
}
|
|
329
|
-
|
|
491
|
+
for (const name of [...names].sort()) {
|
|
492
|
+
const childSrcs = dirSrcs.map((dir) => path_1.default.join(dir, name)).filter((p) => lstatSafe(p) !== null);
|
|
493
|
+
mergeLink(childSrcs, path_1.default.join(dest, name), `${rel}/${name}`, created, warnings, priorOwned, converted);
|
|
494
|
+
}
|
|
330
495
|
}
|
|
331
496
|
/** Copy `src` → `dest` only when `dest` does not exist. Records `rel`. */
|
|
332
497
|
function copyIfAbsent(src, dest, rel, created, warnings) {
|
|
@@ -351,6 +516,8 @@ function applyWorktreeOverlay(input) {
|
|
|
351
516
|
const created = [];
|
|
352
517
|
const manifestPath = path_1.default.join(worktreePath, exports.OVERLAY_MANIFEST);
|
|
353
518
|
const prior = captureOverlayCleanupEvidence(input, readManifest(manifestPath)).map((entry) => entry.path);
|
|
519
|
+
const priorOwned = new Set(prior);
|
|
520
|
+
const converted = new Set();
|
|
354
521
|
try {
|
|
355
522
|
if (!isDir(worktreePath)) {
|
|
356
523
|
warnings.push(`worktree dir missing: ${worktreePath}`);
|
|
@@ -360,9 +527,11 @@ function applyWorktreeOverlay(input) {
|
|
|
360
527
|
// Paranoia: never overlay a worktree onto itself.
|
|
361
528
|
return { createdPaths: [], cleanupEvidence: [], warnings };
|
|
362
529
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
530
|
+
const roots = overlayRootsOf(input);
|
|
531
|
+
// 1. providerDir merge-overlay (commands/agents/skills/rules/settings/…),
|
|
532
|
+
// over the UNION of entries across the configured roots (earlier wins).
|
|
533
|
+
const srcProviders = roots.map((root) => path_1.default.join(root, providerDir)).filter((p) => isDir(p));
|
|
534
|
+
if (srcProviders.length > 0) {
|
|
366
535
|
const destProvider = path_1.default.join(worktreePath, providerDir);
|
|
367
536
|
// The providerDir root is ALWAYS a real local dir (never a link) so
|
|
368
537
|
// nested `.claude/worktrees/**` stay local — see the module header.
|
|
@@ -382,32 +551,45 @@ function applyWorktreeOverlay(input) {
|
|
|
382
551
|
providerReady = false;
|
|
383
552
|
}
|
|
384
553
|
if (providerReady) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
554
|
+
const names = new Set();
|
|
555
|
+
for (const srcProvider of srcProviders) {
|
|
556
|
+
try {
|
|
557
|
+
for (const name of fs_1.default.readdirSync(srcProvider))
|
|
558
|
+
names.add(name);
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
warnings.push(`failed to read source ${providerDir}: ${errMsg(err)}`);
|
|
562
|
+
}
|
|
388
563
|
}
|
|
389
|
-
|
|
390
|
-
warnings.push(`failed to read source ${providerDir}: ${errMsg(err)}`);
|
|
391
|
-
}
|
|
392
|
-
for (const name of entries) {
|
|
564
|
+
for (const name of [...names].sort()) {
|
|
393
565
|
if (SKIP_PROVIDER_ENTRIES.has(name))
|
|
394
566
|
continue;
|
|
395
|
-
|
|
567
|
+
const childSrcs = srcProviders.map((srcProvider) => path_1.default.join(srcProvider, name)).filter((p) => lstatSafe(p) !== null);
|
|
568
|
+
mergeLink(childSrcs, path_1.default.join(destProvider, name), `${providerDir}/${name}`, created, warnings, priorOwned, converted);
|
|
396
569
|
}
|
|
397
570
|
}
|
|
398
571
|
}
|
|
399
572
|
// 2. `.mcp.json` — COPY (spawn-local), only when the checkout lacks one.
|
|
400
|
-
|
|
401
|
-
|
|
573
|
+
// First root that has one wins.
|
|
574
|
+
const mcpSrc = roots.map((root) => path_1.default.join(root, '.mcp.json')).find((p) => fs_1.default.existsSync(p));
|
|
575
|
+
if (mcpSrc)
|
|
576
|
+
copyIfAbsent(mcpSrc, path_1.default.join(worktreePath, '.mcp.json'), '.mcp.json', created, warnings);
|
|
577
|
+
// 3. Provider instruction file — COPY when a source has one and the
|
|
402
578
|
// checkout doesn't (a repo-tracked CLAUDE.md always wins).
|
|
403
|
-
|
|
579
|
+
const instructionsSrc = roots.map((root) => path_1.default.join(root, instructionsFilename)).find((p) => fs_1.default.existsSync(p));
|
|
580
|
+
if (instructionsSrc) {
|
|
581
|
+
copyIfAbsent(instructionsSrc, path_1.default.join(worktreePath, instructionsFilename), instructionsFilename, created, warnings);
|
|
582
|
+
}
|
|
404
583
|
}
|
|
405
584
|
catch (err) {
|
|
406
585
|
warnings.push(`overlay failed: ${errMsg(err)}`);
|
|
407
586
|
}
|
|
408
587
|
// Union with the prior manifest so a RESUMED worktree keeps every overlay
|
|
409
588
|
// entry excluded from commits, then persist (the manifest is overlay-owned).
|
|
410
|
-
|
|
589
|
+
// A converted whole-dir link is no longer an overlay leaf. Drop it even if
|
|
590
|
+
// the merged real directory happens to have the same dereferenced digest as
|
|
591
|
+
// one source root (e.g. a fallback root already contains the full superset).
|
|
592
|
+
const all = [...new Set([...prior.filter((rel) => !converted.has(rel)), ...created])];
|
|
411
593
|
if (all.length > 0) {
|
|
412
594
|
try {
|
|
413
595
|
fs_1.default.writeFileSync(manifestPath, JSON.stringify({ version: 1, paths: all }, null, 2));
|