specrails-desktop 2.29.2 → 2.29.4

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.
Files changed (101) hide show
  1. package/client/dist/assets/{ActivityFeedPage-ClpD5CFj.js → ActivityFeedPage-CDMzpGXd.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-CSPs9Ofc.js → AgentBrowserCapture-BVTacapZ.js} +1 -1
  3. package/client/dist/assets/{AgentModeCodePane-DwcyklA5.js → AgentModeCodePane-D9bhFyuy.js} +2 -2
  4. package/client/dist/assets/{AgentModeJobsPane-C69bqlMa.js → AgentModeJobsPane-DAjN8Au5.js} +1 -1
  5. package/client/dist/assets/{AgentsPage-pRiRr9-W.js → AgentsPage-CebykajE.js} +1 -1
  6. package/client/dist/assets/{AnalyticsPage-C14exKsC.js → AnalyticsPage-BiPrVvgv.js} +1 -1
  7. package/client/dist/assets/{CodePage-CK76JKm9.js → CodePage-Dw9eQFRS.js} +1 -1
  8. package/client/dist/assets/{DesktopAnalyticsPage-Cnv0f3XQ.js → DesktopAnalyticsPage-cCG_JAEi.js} +1 -1
  9. package/client/dist/assets/{DocsDialog-CHUm_Ym0.js → DocsDialog-C1kZ_mRP.js} +1 -1
  10. package/client/dist/assets/{DocsPage-COI-oDok.js → DocsPage-3GivxWJm.js} +1 -1
  11. package/client/dist/assets/{InteractiveJobComposer-CypM7R1F.js → InteractiveJobComposer-KuZXGdr8.js} +1 -1
  12. package/client/dist/assets/{JobDetailModal-B_W0H9dw.js → JobDetailModal-DWpRALkB.js} +1 -1
  13. package/client/dist/assets/{JobDetailPage-CeeXOoKS.js → JobDetailPage-mrXDKafj.js} +1 -1
  14. package/client/dist/assets/{JobsPage-CwhJG9jw.js → JobsPage-CUYXjcoc.js} +1 -1
  15. package/client/dist/assets/{LoopBuilderPage-C51t1WOM.js → LoopBuilderPage-CwjBD5Gm.js} +1 -1
  16. package/client/dist/assets/{LoopPreviewModal-Y2HgtIJu.js → LoopPreviewModal-CO9ton0-.js} +1 -1
  17. package/client/dist/assets/{LoopsPage-Bvn1Kw5V.js → LoopsPage-CLL5UuvN.js} +1 -1
  18. package/client/dist/assets/{MinimizedChatsContext-DiFvZDPW.js → MinimizedChatsContext-DJAkRYps.js} +1 -1
  19. package/client/dist/assets/{PluginsPage-BGPRJpdf.js → PluginsPage-DZvCC5Ri.js} +1 -1
  20. package/client/dist/assets/{ProjectSettingsDialog-D_GTyGLc.js → ProjectSettingsDialog-C7Hesadg.js} +1 -1
  21. package/client/dist/assets/{TemplatePreviewModal-szwBhd7h.js → TemplatePreviewModal-DczCzUzP.js} +1 -1
  22. package/client/dist/assets/{TicketDetailModal-9UEyK974.js → TicketDetailModal-CFh7GOv5.js} +4 -4
  23. package/client/dist/assets/{Trans-B_vKZ-Hb.js → Trans-Cm9rhdnG.js} +1 -1
  24. package/client/dist/assets/agent-BXc59ARK2.js +1 -0
  25. package/client/dist/assets/agent-BbIFXdDq.js +1 -0
  26. package/client/dist/assets/agent-BqEfA5u5.js +1 -0
  27. package/client/dist/assets/agent-BtGzgTC4.js +1 -0
  28. package/client/dist/assets/agent-CIhys_5d.js +1 -0
  29. package/client/dist/assets/agent-CJPaqsVR.js +1 -0
  30. package/client/dist/assets/agent-n-hY4GuL.js +1 -0
  31. package/client/dist/assets/agent-tJ-efa3_2.js +1 -0
  32. package/client/dist/assets/common-6MLbD-jM.js +1 -0
  33. package/client/dist/assets/common-BKGEu4rm.js +1 -0
  34. package/client/dist/assets/common-BNbnv8cE2.js +1 -0
  35. package/client/dist/assets/common-BqGWtDbt.js +1 -0
  36. package/client/dist/assets/common-DBtTzeYm2.js +1 -0
  37. package/client/dist/assets/common-DEW3Nr8n.js +1 -0
  38. package/client/dist/assets/{common-DVQuhs-m.js → common-DHsv9N5d.js} +1 -1
  39. package/client/dist/assets/common-mkrEcW-Y.js +1 -0
  40. package/client/dist/assets/dashboard-CHCEkds4.js +1 -0
  41. package/client/dist/assets/dashboard-Cqmmbh3l2.js +1 -0
  42. package/client/dist/assets/dashboard-CrlTV1ms.js +1 -0
  43. package/client/dist/assets/dashboard-Cslu_GCH.js +1 -0
  44. package/client/dist/assets/dashboard-DUU8-_Rg.js +1 -0
  45. package/client/dist/assets/dashboard-DweT3Y4R.js +1 -0
  46. package/client/dist/assets/dashboard-Dy2Um-US2.js +1 -0
  47. package/client/dist/assets/dashboard-WhzOQw1S.js +1 -0
  48. package/client/dist/assets/{dist-js-VwztRkGj.js → dist-js-BERxOT0X.js} +1 -1
  49. package/client/dist/assets/{dist-js-CcBCbb32.js → dist-js-Di8lflPo.js} +1 -1
  50. package/client/dist/assets/{formatDistanceToNow-ByHIauTQ.js → formatDistanceToNow-BAXZRbEO.js} +1 -1
  51. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BxHAksaU.js → getTimezoneOffsetInMilliseconds-DwzZXHtg.js} +1 -1
  52. package/client/dist/assets/{i18n-CgaRxpII.js → i18n-cK5pPbj8.js} +2 -2
  53. package/client/dist/assets/index-BT6xDQjB.css +2 -0
  54. package/client/dist/assets/index-CCq_kIrF.js +76 -0
  55. package/client/dist/assets/{spending-C4p06nen.js → spending-BLb5pj6G.js} +1 -1
  56. package/client/dist/assets/{useBrowserCaptureSession-2OqX0vap.js → useBrowserCaptureSession-DR1_jL56.js} +1 -1
  57. package/client/dist/assets/{useDesktop-X8YB9xzv.js → useDesktop-D4DxBGPU.js} +1 -1
  58. package/client/dist/index.html +14 -14
  59. package/docs/internals/safe-pr-review-flow.md +250 -38
  60. package/package.json +1 -1
  61. package/server/dist/active-pr-continuation.js +352 -53
  62. package/server/dist/agent-chat-manager.js +29 -12
  63. package/server/dist/agent-operator-prompt.js +9 -0
  64. package/server/dist/agent-store.js +19 -4
  65. package/server/dist/db.js +21 -0
  66. package/server/dist/mcp/tools/rails.js +8 -0
  67. package/server/dist/pr-lifecycle.js +100 -8
  68. package/server/dist/project-git.js +68 -3
  69. package/server/dist/rail-isolated-launch.js +719 -124
  70. package/server/dist/rail-pr-decision.js +1073 -181
  71. package/server/dist/rail-pr-recovery-git.js +235 -0
  72. package/server/dist/rail-pr-store.js +120 -9
  73. package/server/dist/rail-worktree-release.js +376 -4
  74. package/server/dist/rails-router.js +196 -10
  75. package/server/dist/worktree-manager.js +149 -18
  76. package/server/dist/worktree-overlay.js +166 -4
  77. package/client/dist/assets/agent-4rmXoo_p.js +0 -1
  78. package/client/dist/assets/agent-BxZuNGih.js +0 -1
  79. package/client/dist/assets/agent-C3uXOYv_.js +0 -1
  80. package/client/dist/assets/agent-CA8Y4wbw.js +0 -1
  81. package/client/dist/assets/agent-CASKrhG12.js +0 -1
  82. package/client/dist/assets/agent-Cv-jud5n2.js +0 -1
  83. package/client/dist/assets/agent-DitUxYh2.js +0 -1
  84. package/client/dist/assets/agent-Doow4X7o.js +0 -1
  85. package/client/dist/assets/common-CD1QHriA2.js +0 -1
  86. package/client/dist/assets/common-CwwPaRxx.js +0 -1
  87. package/client/dist/assets/common-Dg2jaX7d.js +0 -1
  88. package/client/dist/assets/common-DuBwj6tK.js +0 -1
  89. package/client/dist/assets/common-MRj9MHcc.js +0 -1
  90. package/client/dist/assets/common-O4ZpnMLp.js +0 -1
  91. package/client/dist/assets/common-fyeF0v7C2.js +0 -1
  92. package/client/dist/assets/dashboard-B_NkSHYm.js +0 -1
  93. package/client/dist/assets/dashboard-ClBb_3pq.js +0 -1
  94. package/client/dist/assets/dashboard-CpCmJvMB.js +0 -1
  95. package/client/dist/assets/dashboard-Czohu35O2.js +0 -1
  96. package/client/dist/assets/dashboard-DcjbD0UK.js +0 -1
  97. package/client/dist/assets/dashboard-Dvx5F3we.js +0 -1
  98. package/client/dist/assets/dashboard-R1bOPjBh2.js +0 -1
  99. package/client/dist/assets/dashboard-owcDucDi.js +0 -1
  100. package/client/dist/assets/index-BflYQpWm.css +0 -2
  101. package/client/dist/assets/index-C1_-BTXy.js +0 -72
@@ -1,8 +1,329 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.durableBranchHeads = durableBranchHeads;
37
+ exports.durableOverlayCleanupEvidence = durableOverlayCleanupEvidence;
38
+ exports.durableSettlementIgnoredPaths = durableSettlementIgnoredPaths;
3
39
  exports.releaseRailWorktrees = releaseRailWorktrees;
4
40
  const rail_worktrees_store_1 = require("./rail-worktrees-store");
5
41
  const worktree_manager_1 = require("./worktree-manager");
42
+ const path = __importStar(require("path"));
43
+ const fs = __importStar(require("fs"));
44
+ const worktree_overlay_1 = require("./worktree-overlay");
45
+ const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
46
+ /** Collapse per-unit settlement evidence into one unambiguous immutable HEAD per
47
+ * branch. Conflicting or malformed records intentionally yield no authorization. */
48
+ function durableBranchHeads(records) {
49
+ const heads = new Map();
50
+ const conflicted = new Set();
51
+ for (const record of records) {
52
+ const sha = record.finalSha?.trim().toLowerCase() ?? '';
53
+ if (!record.branch || !COMMIT_SHA_RE.test(sha) || conflicted.has(record.branch))
54
+ continue;
55
+ const existing = heads.get(record.branch);
56
+ if (existing && existing !== sha) {
57
+ heads.delete(record.branch);
58
+ conflicted.add(record.branch);
59
+ continue;
60
+ }
61
+ heads.set(record.branch, sha);
62
+ }
63
+ return heads;
64
+ }
65
+ function safeRelativeOverlayPath(value) {
66
+ if (process.platform === 'win32' && value.includes('\\'))
67
+ return null;
68
+ if (value.split('/').includes('..'))
69
+ return null;
70
+ const normalized = path.posix.normalize(value);
71
+ if (!normalized || normalized === '.' || normalized.startsWith('../') ||
72
+ path.posix.isAbsolute(normalized) || normalized.includes('\0'))
73
+ return null;
74
+ return normalized;
75
+ }
76
+ /** Collapse persisted overlay fingerprints. Conflicting evidence for the same
77
+ * branch/path grants no exclusion permission. */
78
+ function durableOverlayCleanupEvidence(records) {
79
+ const byBranch = new Map();
80
+ const conflicted = new Map();
81
+ for (const record of records) {
82
+ if (!record.branch || !Array.isArray(record.overlayCleanupEvidence))
83
+ continue;
84
+ const branchEvidence = byBranch.get(record.branch) ?? new Map();
85
+ const branchConflicts = conflicted.get(record.branch) ?? new Set();
86
+ for (const value of record.overlayCleanupEvidence) {
87
+ if (!value || typeof value.path !== 'string' || typeof value.digest !== 'string')
88
+ continue;
89
+ if (value.kind !== 'symlink' && value.kind !== 'file' && value.kind !== 'directory')
90
+ continue;
91
+ const safe = safeRelativeOverlayPath(value.path);
92
+ if (!safe || !/^[0-9a-f]{64}$/.test(value.digest) || branchConflicts.has(safe))
93
+ continue;
94
+ const normalized = { path: safe, kind: value.kind, digest: value.digest };
95
+ const existing = branchEvidence.get(safe);
96
+ if (existing && (existing.kind !== normalized.kind || existing.digest !== normalized.digest)) {
97
+ branchEvidence.delete(safe);
98
+ branchConflicts.add(safe);
99
+ continue;
100
+ }
101
+ branchEvidence.set(safe, normalized);
102
+ }
103
+ byBranch.set(record.branch, branchEvidence);
104
+ conflicted.set(record.branch, branchConflicts);
105
+ }
106
+ return new Map([...byBranch].map(([branch, values]) => [branch, [...values.values()]]));
107
+ }
108
+ /** Collapse persisted settlement ignored-path snapshots per branch. The same
109
+ * conservative rules as overlay evidence: malformed paths, a failed capture
110
+ * (null), or conflicting per-branch snapshots grant NO authorization. */
111
+ function durableSettlementIgnoredPaths(records) {
112
+ const byBranch = new Map();
113
+ for (const record of records) {
114
+ if (!record.branch || record.settlementIgnoredPaths === undefined)
115
+ continue;
116
+ const prior = byBranch.get(record.branch);
117
+ if (prior === null)
118
+ continue; // already poisoned
119
+ if (record.settlementIgnoredPaths === null) {
120
+ byBranch.set(record.branch, null);
121
+ continue;
122
+ }
123
+ if (!Array.isArray(record.settlementIgnoredPaths)) {
124
+ byBranch.set(record.branch, null);
125
+ continue;
126
+ }
127
+ const safe = [];
128
+ let valid = true;
129
+ for (const value of record.settlementIgnoredPaths) {
130
+ const normalized = typeof value === 'string' ? safeRelativeOverlayPath(value.replace(/\/$/, '')) : null;
131
+ if (!normalized) {
132
+ valid = false;
133
+ break;
134
+ }
135
+ safe.push(normalized);
136
+ }
137
+ if (!valid) {
138
+ byBranch.set(record.branch, null);
139
+ continue;
140
+ }
141
+ const sorted = [...new Set(safe)].sort();
142
+ if (prior === undefined) {
143
+ byBranch.set(record.branch, sorted);
144
+ continue;
145
+ }
146
+ if (prior.join('\0') !== sorted.join('\0'))
147
+ byBranch.set(record.branch, null);
148
+ }
149
+ return new Map([...byBranch].flatMap(([branch, paths]) => (paths === null ? [] : [[branch, paths]])));
150
+ }
151
+ function coveredBySnapshot(livePath, snapshot) {
152
+ const live = livePath.replace(/\/$/, '');
153
+ return snapshot.some((entry) => live === entry || live.startsWith(`${entry}/`));
154
+ }
155
+ async function verifyReleaseEvidence(input, worktree) {
156
+ const expected = input.expectedHeadByBranch.get(worktree.branch)?.toLowerCase();
157
+ if (!expected || !COMMIT_SHA_RE.test(expected)) {
158
+ return { failure: 'no durable settled HEAD is recorded for its branch', overlayEntries: [] };
159
+ }
160
+ const overlayEntries = (input.overlayEvidenceByBranch.get(worktree.branch) ?? [])
161
+ .filter((entry) => (0, worktree_overlay_1.matchesOverlayCleanupEvidence)(worktree.worktree_path, entry));
162
+ const overlayExcludes = overlayEntries.map((entry) => entry.path);
163
+ try {
164
+ const status = await input.git.run([
165
+ 'status', '--porcelain', '--untracked-files=all', '--ignored=matching', '--', '.',
166
+ ...overlayExcludes.map((entry) => `:(top,exclude,literal)${entry}`),
167
+ ], worktree.worktree_path);
168
+ if (status.code !== 0) {
169
+ if (!pathStillExists(worktree.worktree_path))
170
+ return { failure: null, overlayEntries: [], gone: true };
171
+ return { failure: 'Git could not verify worktree cleanliness', overlayEntries: [] };
172
+ }
173
+ // Partition: tracked/untracked entries are unconditional dirt (unchanged
174
+ // strictness); gitignored entries are release-safe ONLY when the immutable
175
+ // settlement snapshot already contained them — run-created build caches
176
+ // release cleanly, anything ignored that APPEARED after settlement (a
177
+ // user-parked file) preserves the worktree exactly as before.
178
+ const statusLines = status.stdout.split('\n').map((line) => line.trimEnd()).filter(Boolean);
179
+ const dirtyLines = statusLines.filter((line) => !line.startsWith('!! '));
180
+ if (dirtyLines.length > 0)
181
+ return { failure: 'the worktree contains changes made after settlement', overlayEntries: [] };
182
+ const ignoredPaths = statusLines.filter((line) => line.startsWith('!! ')).map((line) => line.slice(3));
183
+ if (ignoredPaths.length > 0) {
184
+ const snapshot = input.settlementIgnoredByBranch?.get(worktree.branch);
185
+ if (!snapshot) {
186
+ return { failure: 'the worktree contains ignored paths and no settlement snapshot authorizes them', overlayEntries: [] };
187
+ }
188
+ const uncovered = ignoredPaths.filter((livePath) => !coveredBySnapshot(livePath, snapshot));
189
+ if (uncovered.length > 0) {
190
+ return { failure: `the worktree contains ignored paths that appeared after settlement (${uncovered.slice(0, 3).join(', ')})`, overlayEntries: [] };
191
+ }
192
+ }
193
+ const [head, branchRef] = await Promise.all([
194
+ input.git.run(['rev-parse', '--verify', 'HEAD'], worktree.worktree_path),
195
+ input.git.run(['rev-parse', '--verify', `refs/heads/${worktree.branch}`], input.repoDir),
196
+ ]);
197
+ const headSha = head.code === 0 ? head.stdout.trim().toLowerCase() : '';
198
+ const branchSha = branchRef.code === 0 ? branchRef.stdout.trim().toLowerCase() : '';
199
+ if (!COMMIT_SHA_RE.test(headSha) || !COMMIT_SHA_RE.test(branchSha)) {
200
+ return { failure: 'Git could not verify the worktree HEAD and recorded branch ref', overlayEntries: [] };
201
+ }
202
+ if (headSha !== expected || branchSha !== expected) {
203
+ return { failure: `HEAD/ref moved after settlement (expected ${expected.slice(0, 12)})`, overlayEntries: [] };
204
+ }
205
+ return { failure: null, overlayEntries };
206
+ }
207
+ catch (err) {
208
+ if (!pathStillExists(worktree.worktree_path))
209
+ return { failure: null, overlayEntries: [], gone: true };
210
+ return { failure: `verification failed: ${err instanceof Error ? err.message : String(err)}`, overlayEntries: [] };
211
+ }
212
+ }
213
+ function pathStillExists(target) {
214
+ try {
215
+ fs.lstatSync(target);
216
+ return true;
217
+ }
218
+ catch {
219
+ return false;
220
+ }
221
+ }
222
+ function quarantineLocationNotes(entries) {
223
+ return entries
224
+ .filter((entry) => pathStillExists(entry.quarantinePath))
225
+ .map((entry) => `overlay data is preserved at ${entry.quarantinePath}`);
226
+ }
227
+ /** Atomically move fingerprint-matching, untracked overlay roots out of the
228
+ * worktree. Quarantine is a unique sibling on the same filesystem. It is never
229
+ * automatically deleted or restored: writes through an already-open fd after
230
+ * rename remain recoverable, and a concurrently recreated source can never be
231
+ * overwritten by rollback. */
232
+ async function quarantineVerifiedOverlayEntries(input, worktree, evidence) {
233
+ const removable = [];
234
+ for (const entry of evidence) {
235
+ const target = path.join(worktree.worktree_path, ...entry.path.split('/'));
236
+ if (!pathStillExists(target))
237
+ continue;
238
+ const tracked = await input.git.run(['ls-files', '--error-unmatch', '--', entry.path], worktree.worktree_path);
239
+ if (tracked.code === 0)
240
+ continue;
241
+ if (tracked.code !== 1) {
242
+ return { failure: `Git could not verify ownership of overlay path ${entry.path}`, entries: [] };
243
+ }
244
+ if (!(0, worktree_overlay_1.matchesOverlayCleanupEvidence)(worktree.worktree_path, entry)) {
245
+ return { failure: `overlay path ${entry.path} changed after cleanup verification`, entries: [] };
246
+ }
247
+ removable.push(entry);
248
+ }
249
+ const cleanupRoots = [];
250
+ for (const entry of [...removable].sort((a, b) => a.path.split('/').length - b.path.split('/').length)) {
251
+ if (cleanupRoots.some((root) => root.kind === 'directory' && entry.path.startsWith(`${root.path}/`)))
252
+ continue;
253
+ cleanupRoots.push(entry);
254
+ }
255
+ if (cleanupRoots.length === 0)
256
+ return { failure: null, entries: [] };
257
+ let quarantineRoot;
258
+ try {
259
+ // mkdtemp creates the batch directory atomically beside the worktree. The
260
+ // ensuing rename therefore remains within one filesystem.
261
+ quarantineRoot = fs.mkdtempSync(`${worktree.worktree_path}.specrails-overlay-quarantine-`);
262
+ }
263
+ catch (err) {
264
+ return {
265
+ failure: `could not create overlay quarantine beside ${worktree.worktree_path}: ${err instanceof Error ? err.message : String(err)}`,
266
+ entries: [],
267
+ };
268
+ }
269
+ try {
270
+ await input.onSafetyArchive?.(quarantineRoot);
271
+ }
272
+ catch (err) {
273
+ return {
274
+ failure: `could not record safety archive ${quarantineRoot}: ${err instanceof Error ? err.message : String(err)}`,
275
+ entries: [],
276
+ };
277
+ }
278
+ const quarantined = [];
279
+ for (const entry of cleanupRoots) {
280
+ const sourcePath = path.join(worktree.worktree_path, ...entry.path.split('/'));
281
+ if (!pathStillExists(sourcePath))
282
+ continue;
283
+ if (!(0, worktree_overlay_1.matchesOverlayCleanupEvidenceAtPath)(sourcePath, entry)) {
284
+ return { failure: `overlay path ${entry.path} changed before quarantine`, entries: quarantined };
285
+ }
286
+ const quarantinePath = path.join(quarantineRoot, ...entry.path.split('/'));
287
+ try {
288
+ fs.mkdirSync(path.dirname(quarantinePath), { recursive: true });
289
+ await input.beforeOverlayQuarantine?.(sourcePath);
290
+ fs.renameSync(sourcePath, quarantinePath);
291
+ }
292
+ catch (err) {
293
+ return {
294
+ failure: `could not quarantine overlay path ${entry.path}: ${err instanceof Error ? err.message : String(err)}`,
295
+ entries: quarantined,
296
+ };
297
+ }
298
+ const moved = { sourcePath, quarantinePath, evidence: entry };
299
+ quarantined.push(moved);
300
+ try {
301
+ await input.afterOverlayRename?.(sourcePath, quarantinePath);
302
+ }
303
+ catch (err) {
304
+ return {
305
+ failure: `overlay post-rename hook failed at ${quarantinePath}: ${err instanceof Error ? err.message : String(err)}`,
306
+ entries: quarantined,
307
+ };
308
+ }
309
+ if (!(0, worktree_overlay_1.matchesOverlayCleanupEvidenceAtPath)(quarantinePath, entry)) {
310
+ return {
311
+ failure: `overlay path ${entry.path} changed during atomic quarantine at ${quarantinePath}`,
312
+ entries: quarantined,
313
+ };
314
+ }
315
+ try {
316
+ await input.afterOverlayQuarantine?.(quarantinePath);
317
+ }
318
+ catch (err) {
319
+ return {
320
+ failure: `overlay quarantine hook failed at ${quarantinePath}: ${err instanceof Error ? err.message : String(err)}`,
321
+ entries: quarantined,
322
+ };
323
+ }
324
+ }
325
+ return { failure: null, entries: quarantined };
326
+ }
6
327
  /** Remove Specrails-managed linked worktrees while keeping their local branches.
7
328
  * PR deliveries use this once the work has a durable remote branch/PR: the
8
329
  * branch remains available for local checkout, but the internal worktree stops
@@ -15,19 +336,65 @@ async function releaseRailWorktrees(input) {
15
336
  const wt = (0, rail_worktrees_store_1.getRailWorktree)(input.db, wtId);
16
337
  if (!wt)
17
338
  continue;
18
- if (wt.merge_state === 'needs-review' && input.allowNeedsReview !== true)
19
- continue;
20
339
  // Successful removal already terminalized these rows. Re-running `git
21
340
  // worktree remove` against their now-missing paths turns ordinary Publish /
22
341
  // Poll / Discard follow-ups into false cleanup_incomplete warnings.
23
342
  if (wt.merge_state !== 'needs-review' && (0, rail_worktrees_store_1.isTerminalMergeState)(wt.merge_state))
24
343
  continue;
344
+ const evidence = await verifyReleaseEvidence(input, wt);
345
+ // The mount is already GONE (the user ran `git worktree remove` manually,
346
+ // or an external prune cleaned it): Git could not even inspect the path
347
+ // AND the filesystem confirms it is absent. There are no bytes left to
348
+ // preserve — failing verification forever would permanently block Checkout
349
+ // on a branch that is actually free. Prune the stale registration
350
+ // (best-effort; prune only drops entries whose dir is missing) and
351
+ // terminalize the ledger row honestly.
352
+ if (evidence.gone) {
353
+ try {
354
+ await input.git.run(['worktree', 'prune'], input.repoDir);
355
+ }
356
+ catch { /* a leftover registration surfaces at checkout with git's own error */ }
357
+ (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, state);
358
+ continue;
359
+ }
360
+ if (evidence.failure) {
361
+ (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, 'needs-review');
362
+ const warning = `worktree ${wt.worktree_path}: preserved because ${evidence.failure}`;
363
+ warnings.push(warning);
364
+ console.warn(`[rail-worktree-release] ${warning}`);
365
+ continue;
366
+ }
367
+ const quarantine = await quarantineVerifiedOverlayEntries(input, wt, evidence.overlayEntries);
368
+ if (quarantine.failure) {
369
+ (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, 'needs-review');
370
+ const warning = `worktree ${wt.worktree_path}: preserved because ${[
371
+ quarantine.failure,
372
+ ...quarantineLocationNotes(quarantine.entries),
373
+ ].join('; ')}`;
374
+ warnings.push(warning);
375
+ console.warn(`[rail-worktree-release] ${warning}`);
376
+ continue;
377
+ }
378
+ // Quarantining changes the worktree. Re-prove both cleanliness and HEAD/ref
379
+ // identity immediately before asking Git for its final non-force guard.
380
+ const finalEvidence = await verifyReleaseEvidence(input, wt);
381
+ if (finalEvidence.failure) {
382
+ (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, 'needs-review');
383
+ const warning = `worktree ${wt.worktree_path}: preserved because ${[
384
+ finalEvidence.failure,
385
+ ...quarantineLocationNotes(quarantine.entries),
386
+ ].join('; ')}`;
387
+ warnings.push(warning);
388
+ console.warn(`[rail-worktree-release] ${warning}`);
389
+ continue;
390
+ }
25
391
  try {
26
392
  await remove(input.git, {
27
393
  repoDir: input.repoDir,
28
394
  worktreePath: wt.worktree_path,
29
395
  branch: wt.branch,
30
396
  deleteBranch: false,
397
+ force: false,
31
398
  });
32
399
  }
33
400
  catch (err) {
@@ -35,13 +402,18 @@ async function releaseRailWorktrees(input) {
35
402
  if (current && current.merge_state !== 'needs-review' && (0, rail_worktrees_store_1.isTerminalMergeState)(current.merge_state)) {
36
403
  continue;
37
404
  }
38
- const warning = `worktree ${wt.worktree_path}: ${err instanceof Error ? err.message : String(err)}`;
405
+ (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, 'needs-review');
406
+ const warning = `worktree ${wt.worktree_path}: ${[
407
+ err instanceof Error ? err.message : String(err),
408
+ ...quarantineLocationNotes(quarantine.entries),
409
+ ].join('; ')}`;
39
410
  warnings.push(warning);
40
411
  console.warn(`[rail-worktree-release] failed to remove ${warning}`);
41
412
  continue;
42
413
  }
43
- if (!(0, rail_worktrees_store_1.isTerminalMergeState)(wt.merge_state))
414
+ if (wt.merge_state === 'needs-review' || !(0, rail_worktrees_store_1.isTerminalMergeState)(wt.merge_state)) {
44
415
  (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, state);
416
+ }
45
417
  }
46
418
  return warnings;
47
419
  }