switchman-dev 0.1.6 → 0.1.8

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.
@@ -1,18 +1,23 @@
1
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
2
- import { dirname, join, resolve, relative } from 'path';
1
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
2
+ import { dirname, join, posix, relative, resolve } from 'path';
3
3
  import { execFileSync, spawnSync } from 'child_process';
4
4
 
5
5
  import {
6
6
  getActiveFileClaims,
7
+ touchBoundaryValidationState,
7
8
  getCompletedFileClaims,
8
9
  getLease,
9
- getTaskSpec,
10
10
  getWorktree,
11
+ listWorktrees,
11
12
  getWorktreeSnapshotState,
12
13
  replaceWorktreeSnapshotState,
13
14
  getStaleLeases,
15
+ listScopeReservations,
14
16
  listAuditEvents,
15
17
  listLeases,
18
+ listTasks,
19
+ getTask,
20
+ getTaskSpec,
16
21
  logAuditEvent,
17
22
  updateWorktreeCompliance,
18
23
  } from './db.js';
@@ -30,6 +35,14 @@ const DEFAULT_ENFORCEMENT_POLICY = {
30
35
  allowed_generated_paths: [],
31
36
  };
32
37
 
38
+ function normalizeFsPath(filePath) {
39
+ try {
40
+ return realpathSync(filePath);
41
+ } catch {
42
+ return resolve(filePath);
43
+ }
44
+ }
45
+
33
46
  function getEnforcementPolicyPath(repoRoot) {
34
47
  return join(repoRoot, '.switchman', 'enforcement.json');
35
48
  }
@@ -125,7 +138,19 @@ function diffSnapshots(previousSnapshot, currentSnapshot) {
125
138
  }
126
139
 
127
140
  function getLeaseScopePatterns(db, lease) {
128
- return getTaskSpec(db, lease.task_id)?.allowed_paths || [];
141
+ const reservations = listScopeReservations(db, { leaseId: lease.id });
142
+ const scopePatterns = reservations
143
+ .filter((reservation) => reservation.ownership_level === 'path_scope' && reservation.scope_pattern)
144
+ .map((reservation) => reservation.scope_pattern);
145
+ return [...new Set(scopePatterns)];
146
+ }
147
+
148
+ function getLeaseSubsystemTags(db, lease) {
149
+ const reservations = listScopeReservations(db, { leaseId: lease.id });
150
+ const subsystemTags = reservations
151
+ .filter((reservation) => reservation.ownership_level === 'subsystem' && reservation.subsystem_tag)
152
+ .map((reservation) => reservation.subsystem_tag);
153
+ return [...new Set(subsystemTags)];
129
154
  }
130
155
 
131
156
  function findScopedLeaseOwner(db, leases, filePath, excludeLeaseId = null) {
@@ -139,6 +164,18 @@ function findScopedLeaseOwner(db, leases, filePath, excludeLeaseId = null) {
139
164
  return null;
140
165
  }
141
166
 
167
+ function findSubsystemLeaseOwner(db, leases, subsystemTags, excludeLeaseId = null) {
168
+ if (!subsystemTags.length) return null;
169
+ for (const lease of leases) {
170
+ if (excludeLeaseId && lease.id === excludeLeaseId) continue;
171
+ const leaseTags = getLeaseSubsystemTags(db, lease);
172
+ if (leaseTags.some((tag) => subsystemTags.includes(tag))) {
173
+ return lease;
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+
142
179
  function normalizeDirectoryScopeRoot(pattern) {
143
180
  return String(pattern || '').replace(/\\/g, '/').replace(/\/\*\*$/, '').replace(/\/\*$/, '').replace(/\/+$/, '');
144
181
  }
@@ -163,8 +200,13 @@ function resolveLeasePathOwnership(db, lease, filePath, activeClaims, activeLeas
163
200
  }
164
201
 
165
202
  const ownScopePatterns = getLeaseScopePatterns(db, lease);
203
+ const ownSubsystemTags = getLeaseSubsystemTags(db, lease);
166
204
  const ownScopeMatch = ownScopePatterns.length > 0 && matchesPathPatterns(filePath, ownScopePatterns);
167
205
  if (ownScopeMatch) {
206
+ const foreignSubsystemOwner = findSubsystemLeaseOwner(db, activeLeases, ownSubsystemTags, lease.id);
207
+ if (foreignSubsystemOwner) {
208
+ return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
209
+ }
168
210
  const foreignScopeOwner = findScopedLeaseOwner(db, activeLeases, filePath, lease.id);
169
211
  if (foreignScopeOwner) {
170
212
  return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
@@ -177,9 +219,25 @@ function resolveLeasePathOwnership(db, lease, filePath, activeClaims, activeLeas
177
219
  return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
178
220
  }
179
221
 
222
+ const foreignSubsystemOwner = findSubsystemLeaseOwner(db, activeLeases, ownSubsystemTags, lease.id);
223
+ if (foreignSubsystemOwner) {
224
+ return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
225
+ }
226
+
180
227
  return { ok: false, reason_code: 'path_not_claimed', claim: null, ownership_type: null };
181
228
  }
182
229
 
230
+ function completedScopedTaskOwnsPath(db, completedTasks, filePath) {
231
+ for (const task of completedTasks) {
232
+ const taskSpec = getTaskSpec(db, task.id);
233
+ const allowedPaths = Array.isArray(taskSpec?.allowed_paths) ? taskSpec.allowed_paths : [];
234
+ if (allowedPaths.length > 0 && matchesPathPatterns(filePath, allowedPaths)) {
235
+ return true;
236
+ }
237
+ }
238
+ return false;
239
+ }
240
+
183
241
  function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
184
242
  const activeLeases = options.activeLeases || listLeases(db, 'active');
185
243
  const activeClaims = options.activeClaims || getActiveFileClaims(db);
@@ -188,12 +246,28 @@ function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
188
246
 
189
247
  const activeLease = activeLeases.find((lease) => lease.worktree === worktree.name) || null;
190
248
  const claim = activeClaims.find((item) => item.file_path === filePath && item.worktree === worktree.name) || null;
249
+ const foreignClaim = activeClaims.find((item) => item.file_path === filePath && item.worktree !== worktree.name) || null;
250
+ const foreignScopeOwner = findScopedLeaseOwner(db, activeLeases, filePath, activeLease?.id || null);
191
251
 
192
252
  if (!activeLease) {
193
- return { status: 'denied', reason_code: 'no_active_lease', lease: null, claim };
253
+ return {
254
+ status: 'denied',
255
+ reason_code: 'no_active_lease',
256
+ lease: null,
257
+ claim,
258
+ owner_claim: foreignClaim,
259
+ owner_lease: foreignScopeOwner,
260
+ };
194
261
  }
195
262
  if (staleLeaseIds.has(activeLease.id)) {
196
- return { status: 'denied', reason_code: 'lease_expired', lease: activeLease, claim };
263
+ return {
264
+ status: 'denied',
265
+ reason_code: 'lease_expired',
266
+ lease: activeLease,
267
+ claim,
268
+ owner_claim: foreignClaim,
269
+ owner_lease: foreignScopeOwner,
270
+ };
197
271
  }
198
272
  const ownership = resolveLeasePathOwnership(db, activeLease, filePath, activeClaims, activeLeases);
199
273
  if (ownership.ok) {
@@ -203,21 +277,42 @@ function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
203
277
  lease: activeLease,
204
278
  claim: ownership.claim ?? claim,
205
279
  ownership_type: ownership.ownership_type,
280
+ owner_claim: null,
281
+ owner_lease: null,
206
282
  };
207
283
  }
208
284
  if (matchesPathPatterns(filePath, policy.allowed_generated_paths || [])) {
209
- return { status: 'allowed', reason_code: 'policy_exception_allowed', lease: activeLease, claim: null, ownership_type: 'policy' };
285
+ return {
286
+ status: 'allowed',
287
+ reason_code: 'policy_exception_allowed',
288
+ lease: activeLease,
289
+ claim: null,
290
+ ownership_type: 'policy',
291
+ owner_claim: null,
292
+ owner_lease: null,
293
+ };
210
294
  }
211
- return { status: 'denied', reason_code: ownership.reason_code, lease: activeLease, claim: ownership.claim ?? claim, ownership_type: null };
295
+ return {
296
+ status: 'denied',
297
+ reason_code: ownership.reason_code,
298
+ lease: activeLease,
299
+ claim: ownership.claim ?? claim,
300
+ ownership_type: null,
301
+ owner_claim: ownership.claim ?? foreignClaim,
302
+ owner_lease: foreignScopeOwner,
303
+ };
212
304
  }
213
305
 
214
306
  function normalizeRepoPath(repoRoot, targetPath) {
215
- const relativePath = String(targetPath || '').replace(/\\/g, '/').replace(/^\.\/+/, '');
307
+ const rawPath = String(targetPath || '').replace(/\\/g, '/').trim();
308
+ const relativePath = posix.normalize(rawPath.replace(/^\.\/+/, ''));
216
309
  if (
217
310
  relativePath === '' ||
218
- relativePath.startsWith('..') ||
219
311
  relativePath === '.' ||
220
- relativePath.startsWith('/')
312
+ relativePath === '..' ||
313
+ relativePath.startsWith('../') ||
314
+ rawPath.startsWith('/') ||
315
+ /^[A-Za-z]:\//.test(rawPath)
221
316
  ) {
222
317
  throw new Error('Target path must point to a file inside the repository.');
223
318
  }
@@ -358,6 +453,9 @@ export function validateLeaseAccess(db, { leaseId, worktree = null }) {
358
453
  }
359
454
 
360
455
  function logWriteEvent(db, status, reasonCode, validation, eventType, details = null) {
456
+ if (status === 'allowed' && validation.lease?.id) {
457
+ touchBoundaryValidationState(db, validation.lease.id, `write:${details?.operation || 'unknown'}`);
458
+ }
361
459
  logAuditEvent(db, {
362
460
  eventType,
363
461
  status,
@@ -529,7 +627,17 @@ export function gatewayMovePath(db, repoRoot, { leaseId, sourcePath, destination
529
627
  }
530
628
 
531
629
  export function gatewayMakeDirectory(db, repoRoot, { leaseId, path: targetPath, worktree = null }) {
532
- const normalizedPath = String(targetPath || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
630
+ let normalizedPath;
631
+ try {
632
+ normalizedPath = normalizeRepoPath(repoRoot, targetPath).relativePath.replace(/\/+$/, '');
633
+ } catch {
634
+ return {
635
+ ok: false,
636
+ reason_code: 'policy_exception_required',
637
+ file_path: targetPath,
638
+ lease_id: leaseId,
639
+ };
640
+ }
533
641
  const lease = getLease(db, leaseId);
534
642
 
535
643
  if (!lease || lease.status !== 'active') {
@@ -703,6 +811,9 @@ export function evaluateWorktreeCompliance(db, repoRoot, worktree, options = {})
703
811
  const activeLeases = options.activeLeases || listLeases(db, 'active');
704
812
  const activeClaims = options.activeClaims || getActiveFileClaims(db);
705
813
  const completedClaims = options.completedClaims || getCompletedFileClaims(db, worktree.name);
814
+ const completedTasks = options.completedTasks
815
+ || listTasks(db)
816
+ .filter((task) => task.status === 'done' && task.worktree === worktree.name);
706
817
 
707
818
  const changedFiles = getWorktreeChangedFiles(worktree.path, repoRoot);
708
819
  const activeLease = activeLeases.find((lease) => lease.worktree === worktree.name) || null;
@@ -715,7 +826,7 @@ export function evaluateWorktreeCompliance(db, repoRoot, worktree, options = {})
715
826
  for (const file of changedFiles) {
716
827
  const completedClaim = completedClaimsByPath.get(file);
717
828
  if (!activeLease) {
718
- if (completedClaim) {
829
+ if (completedClaim || completedScopedTaskOwnsPath(db, completedTasks, file)) {
719
830
  continue;
720
831
  }
721
832
  violations.push({ file, reason_code: 'no_active_lease' });
@@ -825,6 +936,9 @@ export function monitorWorktreesOnce(db, repoRoot, worktrees, options = {}) {
825
936
  reason_code: classification.reason_code,
826
937
  lease_id: classification.lease?.id ?? null,
827
938
  task_id: classification.lease?.task_id ?? null,
939
+ owner_worktree: classification.owner_claim?.worktree || classification.owner_lease?.worktree || null,
940
+ owner_task_id: classification.owner_claim?.task_id || classification.owner_lease?.task_id || null,
941
+ owner_task_title: classification.owner_claim?.task_title || getTask(db, classification.owner_lease?.task_id || '')?.title || null,
828
942
  enforcement_action: null,
829
943
  quarantine_path: null,
830
944
  };
@@ -840,6 +954,10 @@ export function monitorWorktreesOnce(db, repoRoot, worktrees, options = {}) {
840
954
  details: JSON.stringify({ change_type: change.change_type }),
841
955
  });
842
956
 
957
+ if (classification.status === 'allowed' && classification.lease?.id) {
958
+ touchBoundaryValidationState(db, classification.lease.id, `observed:${change.change_type}`);
959
+ }
960
+
843
961
  if (classification.status === 'denied') {
844
962
  updateWorktreeCompliance(db, worktree.name, COMPLIANCE_STATES.NON_COMPLIANT);
845
963
  if (options.quarantine) {
@@ -881,9 +999,16 @@ export function runCommitGate(db, repoRoot, { cwd = process.cwd(), worktreeName
881
999
  const currentWorktree = worktreeName
882
1000
  ? null
883
1001
  : getCurrentWorktree(repoRoot, cwd);
1002
+ const registeredWorktree = worktreeName
1003
+ ? getWorktree(db, worktreeName)
1004
+ : listWorktrees(db).find((entry) => normalizeFsPath(entry.path) === normalizeFsPath(cwd))
1005
+ || (currentWorktree
1006
+ ? listWorktrees(db).find((entry) => normalizeFsPath(entry.path) === normalizeFsPath(currentWorktree.path))
1007
+ : null)
1008
+ || null;
884
1009
  const resolvedWorktree = worktreeName
885
- ? { name: worktreeName, path: cwd }
886
- : currentWorktree;
1010
+ ? { name: worktreeName, path: registeredWorktree?.path || cwd }
1011
+ : registeredWorktree || currentWorktree;
887
1012
 
888
1013
  if (!resolvedWorktree) {
889
1014
  const result = {
package/src/core/git.js CHANGED
@@ -4,8 +4,9 @@
4
4
  */
5
5
 
6
6
  import { execFileSync, execSync, spawnSync } from 'child_process';
7
- import { existsSync, realpathSync } from 'fs';
7
+ import { existsSync, realpathSync, rmSync, statSync } from 'fs';
8
8
  import { join, relative, resolve, basename } from 'path';
9
+ import { tmpdir } from 'os';
9
10
  import { filterIgnoredPaths } from './ignore.js';
10
11
 
11
12
  function normalizeFsPath(path) {
@@ -339,6 +340,289 @@ export function gitMergeBranchInto(repoRoot, baseBranch, topicBranch) {
339
340
  }
340
341
  }
341
342
 
343
+ export function gitAssessBranchFreshness(repoRoot, baseBranch, topicBranch) {
344
+ const baseCommit = gitRevParse(repoRoot, baseBranch);
345
+ const topicCommit = gitRevParse(repoRoot, topicBranch);
346
+ if (!baseCommit || !topicCommit) {
347
+ return {
348
+ state: 'unknown',
349
+ base_commit: baseCommit || null,
350
+ topic_commit: topicCommit || null,
351
+ merge_base: null,
352
+ };
353
+ }
354
+
355
+ try {
356
+ const mergeBase = execFileSync('git', ['merge-base', baseBranch, topicBranch], {
357
+ cwd: repoRoot,
358
+ encoding: 'utf8',
359
+ stdio: ['ignore', 'pipe', 'pipe'],
360
+ }).trim();
361
+ return {
362
+ state: mergeBase === baseCommit ? 'fresh' : 'behind',
363
+ base_commit: baseCommit,
364
+ topic_commit: topicCommit,
365
+ merge_base: mergeBase,
366
+ };
367
+ } catch {
368
+ return {
369
+ state: 'unknown',
370
+ base_commit: baseCommit,
371
+ topic_commit: topicCommit,
372
+ merge_base: null,
373
+ };
374
+ }
375
+ }
376
+
377
+ export function gitMaterializeIntegrationBranch(repoRoot, {
378
+ branch,
379
+ baseBranch = 'main',
380
+ mergeBranches = [],
381
+ tempWorktreePath = null,
382
+ } = {}) {
383
+ const uniqueBranches = [...new Set(mergeBranches.filter(Boolean))].filter((candidate) => candidate !== branch);
384
+ const resolvedTempWorktreePath = tempWorktreePath || join(tmpdir(), `switchman-landing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
385
+
386
+ try {
387
+ execFileSync('git', ['worktree', 'add', '--detach', resolvedTempWorktreePath, baseBranch], {
388
+ cwd: repoRoot,
389
+ encoding: 'utf8',
390
+ stdio: ['ignore', 'pipe', 'pipe'],
391
+ });
392
+
393
+ execFileSync('git', ['checkout', '-B', branch, baseBranch], {
394
+ cwd: resolvedTempWorktreePath,
395
+ encoding: 'utf8',
396
+ stdio: ['ignore', 'pipe', 'pipe'],
397
+ });
398
+
399
+ for (const mergeBranch of uniqueBranches) {
400
+ execFileSync('git', ['merge', '--no-ff', '--no-edit', mergeBranch], {
401
+ cwd: resolvedTempWorktreePath,
402
+ encoding: 'utf8',
403
+ stdio: ['ignore', 'pipe', 'pipe'],
404
+ });
405
+ }
406
+
407
+ return {
408
+ branch,
409
+ base_branch: baseBranch,
410
+ merged_branches: uniqueBranches,
411
+ head_commit: gitRevParse(resolvedTempWorktreePath, 'HEAD'),
412
+ temp_worktree_path: resolvedTempWorktreePath,
413
+ };
414
+ } catch (err) {
415
+ const stderr = String(err?.stderr || '');
416
+ const stdout = String(err?.stdout || '');
417
+ const combinedOutput = `${stdout}${stderr}`.trim();
418
+ const message = String(err?.message || combinedOutput || 'Failed to materialize integration branch.');
419
+ const currentMergeBranch = uniqueBranches.find((candidate) =>
420
+ message.includes(candidate) || combinedOutput.includes(candidate),
421
+ ) || null;
422
+ let reasonCode = 'landing_branch_materialization_failed';
423
+ if (/CONFLICT|Automatic merge failed|Merge conflict/i.test(message) || /CONFLICT|Automatic merge failed/i.test(combinedOutput)) {
424
+ reasonCode = 'landing_branch_merge_conflict';
425
+ } else if (/not something we can merge|unknown revision|bad revision|not a valid object name/i.test(message) || /not something we can merge|unknown revision|bad revision|not a valid object name/i.test(combinedOutput)) {
426
+ reasonCode = 'landing_branch_missing_component';
427
+ } else if (message.includes(baseBranch) && /not something we can merge|unknown revision|bad revision|not a valid object name/i.test(message)) {
428
+ reasonCode = 'landing_branch_missing_base';
429
+ }
430
+ const conflictingFiles = reasonCode === 'landing_branch_merge_conflict'
431
+ ? execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
432
+ cwd: resolvedTempWorktreePath,
433
+ encoding: 'utf8',
434
+ stdio: ['ignore', 'pipe', 'pipe'],
435
+ }).trim().split('\n').filter(Boolean)
436
+ : [];
437
+ try {
438
+ execFileSync('git', ['merge', '--abort'], {
439
+ cwd: resolvedTempWorktreePath,
440
+ encoding: 'utf8',
441
+ stdio: ['ignore', 'pipe', 'pipe'],
442
+ });
443
+ } catch {
444
+ // No active merge to abort.
445
+ }
446
+ const landingError = new Error(message);
447
+ landingError.code = reasonCode;
448
+ landingError.details = {
449
+ branch,
450
+ base_branch: baseBranch,
451
+ merge_branches: uniqueBranches,
452
+ failed_branch: currentMergeBranch,
453
+ conflicting_files: conflictingFiles,
454
+ output: combinedOutput.slice(0, 1000),
455
+ temp_worktree_path: resolvedTempWorktreePath,
456
+ };
457
+ throw landingError;
458
+ } finally {
459
+ try {
460
+ execFileSync('git', ['worktree', 'remove', resolvedTempWorktreePath, '--force'], {
461
+ cwd: repoRoot,
462
+ encoding: 'utf8',
463
+ stdio: ['ignore', 'pipe', 'pipe'],
464
+ });
465
+ } catch {
466
+ rmSync(resolvedTempWorktreePath, { recursive: true, force: true });
467
+ }
468
+ }
469
+ }
470
+
471
+ export function gitPrepareIntegrationRecoveryWorktree(repoRoot, {
472
+ branch,
473
+ baseBranch = 'main',
474
+ mergeBranches = [],
475
+ recoveryPath,
476
+ } = {}) {
477
+ const uniqueBranches = [...new Set(mergeBranches.filter(Boolean))].filter((candidate) => candidate !== branch);
478
+ if (!recoveryPath) {
479
+ throw new Error('Recovery path is required.');
480
+ }
481
+ if (existsSync(recoveryPath)) {
482
+ throw new Error(`Recovery path already exists: ${recoveryPath}`);
483
+ }
484
+
485
+ execFileSync('git', ['worktree', 'add', '--detach', recoveryPath, baseBranch], {
486
+ cwd: repoRoot,
487
+ encoding: 'utf8',
488
+ stdio: ['ignore', 'pipe', 'pipe'],
489
+ });
490
+
491
+ try {
492
+ execFileSync('git', ['checkout', '-B', branch, baseBranch], {
493
+ cwd: recoveryPath,
494
+ encoding: 'utf8',
495
+ stdio: ['ignore', 'pipe', 'pipe'],
496
+ });
497
+
498
+ for (const mergeBranch of uniqueBranches) {
499
+ try {
500
+ execFileSync('git', ['merge', '--no-ff', '--no-edit', mergeBranch], {
501
+ cwd: recoveryPath,
502
+ encoding: 'utf8',
503
+ stdio: ['ignore', 'pipe', 'pipe'],
504
+ });
505
+ } catch (err) {
506
+ const conflictingFiles = execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
507
+ cwd: recoveryPath,
508
+ encoding: 'utf8',
509
+ stdio: ['ignore', 'pipe', 'pipe'],
510
+ }).trim().split('\n').filter(Boolean);
511
+ return {
512
+ ok: false,
513
+ branch,
514
+ base_branch: baseBranch,
515
+ recovery_path: recoveryPath,
516
+ merged_branches: uniqueBranches,
517
+ failed_branch: mergeBranch,
518
+ conflicting_files: conflictingFiles,
519
+ output: `${String(err.stdout || '')}${String(err.stderr || '')}`.trim().slice(0, 1000),
520
+ };
521
+ }
522
+ }
523
+
524
+ return {
525
+ ok: true,
526
+ branch,
527
+ base_branch: baseBranch,
528
+ recovery_path: recoveryPath,
529
+ merged_branches: uniqueBranches,
530
+ head_commit: gitRevParse(recoveryPath, 'HEAD'),
531
+ conflicting_files: [],
532
+ failed_branch: null,
533
+ output: '',
534
+ };
535
+ } catch (err) {
536
+ try {
537
+ execFileSync('git', ['worktree', 'remove', recoveryPath, '--force'], {
538
+ cwd: repoRoot,
539
+ encoding: 'utf8',
540
+ stdio: ['ignore', 'pipe', 'pipe'],
541
+ });
542
+ } catch {
543
+ rmSync(recoveryPath, { recursive: true, force: true });
544
+ }
545
+ throw err;
546
+ }
547
+ }
548
+
549
+ export function gitRemoveWorktree(repoRoot, worktreePath) {
550
+ execFileSync('git', ['worktree', 'remove', worktreePath, '--force'], {
551
+ cwd: repoRoot,
552
+ encoding: 'utf8',
553
+ stdio: ['ignore', 'pipe', 'pipe'],
554
+ });
555
+ }
556
+
557
+ export function gitPruneWorktrees(repoRoot) {
558
+ execFileSync('git', ['worktree', 'prune'], {
559
+ cwd: repoRoot,
560
+ encoding: 'utf8',
561
+ stdio: ['ignore', 'pipe', 'pipe'],
562
+ });
563
+ }
564
+
565
+ function isSwitchmanTempLandingWorktreePath(worktreePath) {
566
+ const resolvedPath = normalizeFsPath(worktreePath || '');
567
+ const tempRoot = normalizeFsPath(tmpdir());
568
+ return resolvedPath.startsWith(`${tempRoot}/`) && basename(resolvedPath).startsWith('switchman-landing-');
569
+ }
570
+
571
+ export function cleanupCrashedLandingTempWorktrees(
572
+ repoRoot,
573
+ {
574
+ olderThanMs = 15 * 60 * 1000,
575
+ now = Date.now(),
576
+ } = {},
577
+ ) {
578
+ const actions = [];
579
+ const beforePrune = listGitWorktrees(repoRoot)
580
+ .filter((worktree) => isSwitchmanTempLandingWorktreePath(worktree.path));
581
+ const missingBeforePrune = beforePrune.filter((worktree) => !existsSync(worktree.path));
582
+
583
+ gitPruneWorktrees(repoRoot);
584
+
585
+ const afterPrune = listGitWorktrees(repoRoot)
586
+ .filter((worktree) => isSwitchmanTempLandingWorktreePath(worktree.path));
587
+ const remainingPaths = new Set(afterPrune.map((worktree) => worktree.path));
588
+
589
+ for (const worktree of missingBeforePrune) {
590
+ if (!remainingPaths.has(worktree.path)) {
591
+ actions.push({
592
+ kind: 'stale_temp_worktree_pruned',
593
+ path: worktree.path,
594
+ branch: worktree.branch || null,
595
+ });
596
+ }
597
+ }
598
+
599
+ for (const worktree of afterPrune) {
600
+ if (!existsSync(worktree.path)) continue;
601
+
602
+ let ageMs = 0;
603
+ try {
604
+ ageMs = Math.max(0, now - statSync(worktree.path).mtimeMs);
605
+ } catch {
606
+ continue;
607
+ }
608
+
609
+ if (ageMs < olderThanMs) continue;
610
+
611
+ gitRemoveWorktree(repoRoot, worktree.path);
612
+ actions.push({
613
+ kind: 'stale_temp_worktree_removed',
614
+ path: worktree.path,
615
+ branch: worktree.branch || null,
616
+ age_ms: ageMs,
617
+ });
618
+ }
619
+
620
+ return {
621
+ repaired: actions.length > 0,
622
+ actions,
623
+ };
624
+ }
625
+
342
626
  /**
343
627
  * Create a new git worktree
344
628
  */
@@ -348,6 +632,7 @@ export function createGitWorktree(repoRoot, name, branch) {
348
632
  execSync(`git worktree add -b "${branch}" "${wtPath}"`, {
349
633
  cwd: repoRoot,
350
634
  encoding: 'utf8',
635
+ stdio: ['pipe', 'pipe', 'pipe'],
351
636
  });
352
637
  return wtPath;
353
638
  }
@@ -1,4 +1,5 @@
1
1
  export const DEFAULT_SCAN_IGNORE_PATTERNS = [
2
+ '.mcp.json',
2
3
  'node_modules/**',
3
4
  '.git/**',
4
5
  '.mcp.json',