switchman-dev 0.1.6 → 0.1.7

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,5 +1,5 @@
1
1
  import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
2
- import { dirname, join, resolve, relative } from 'path';
2
+ import { dirname, join, posix, relative, resolve } from 'path';
3
3
  import { execFileSync, spawnSync } from 'child_process';
4
4
 
5
5
  import {
@@ -212,12 +212,15 @@ function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
212
212
  }
213
213
 
214
214
  function normalizeRepoPath(repoRoot, targetPath) {
215
- const relativePath = String(targetPath || '').replace(/\\/g, '/').replace(/^\.\/+/, '');
215
+ const rawPath = String(targetPath || '').replace(/\\/g, '/').trim();
216
+ const relativePath = posix.normalize(rawPath.replace(/^\.\/+/, ''));
216
217
  if (
217
218
  relativePath === '' ||
218
- relativePath.startsWith('..') ||
219
219
  relativePath === '.' ||
220
- relativePath.startsWith('/')
220
+ relativePath === '..' ||
221
+ relativePath.startsWith('../') ||
222
+ rawPath.startsWith('/') ||
223
+ /^[A-Za-z]:\//.test(rawPath)
221
224
  ) {
222
225
  throw new Error('Target path must point to a file inside the repository.');
223
226
  }
@@ -529,7 +532,17 @@ export function gatewayMovePath(db, repoRoot, { leaseId, sourcePath, destination
529
532
  }
530
533
 
531
534
  export function gatewayMakeDirectory(db, repoRoot, { leaseId, path: targetPath, worktree = null }) {
532
- const normalizedPath = String(targetPath || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
535
+ let normalizedPath;
536
+ try {
537
+ normalizedPath = normalizeRepoPath(repoRoot, targetPath).relativePath.replace(/\/+$/, '');
538
+ } catch {
539
+ return {
540
+ ok: false,
541
+ reason_code: 'policy_exception_required',
542
+ file_path: targetPath,
543
+ lease_id: leaseId,
544
+ };
545
+ }
533
546
  const lease = getLease(db, leaseId);
534
547
 
535
548
  if (!lease || lease.status !== 'active') {
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
  }
@@ -225,10 +225,24 @@ function evaluateDependencyInvalidations(db) {
225
225
  return listDependencyInvalidations(db, { status: 'stale' })
226
226
  .map((state) => {
227
227
  const affectedTask = getTask(db, state.affected_task_id);
228
- const severity = affectedTask?.status === 'done' ? 'blocked' : 'warn';
228
+ const details = state.details || {};
229
+ const severity = details.severity || (affectedTask?.status === 'done' ? 'blocked' : 'warn');
229
230
  const staleArea = state.reason_type === 'subsystem_overlap'
230
231
  ? `subsystem:${state.subsystem_tag}`
232
+ : state.reason_type === 'semantic_contract_drift'
233
+ ? `contract:${(details.contract_names || []).join('|') || 'unknown'}`
234
+ : state.reason_type === 'semantic_object_overlap'
235
+ ? `object:${(details.object_names || []).join('|') || 'unknown'}`
236
+ : state.reason_type === 'shared_module_drift'
237
+ ? `module:${(details.module_paths || []).join('|') || 'unknown'}`
231
238
  : `${state.source_scope_pattern} ↔ ${state.affected_scope_pattern}`;
239
+ const summary = state.reason_type === 'semantic_contract_drift'
240
+ ? `${details.source_task_title || state.source_task_id} changed shared contract ${(details.contract_names || []).join(', ') || 'unknown'}`
241
+ : state.reason_type === 'semantic_object_overlap'
242
+ ? `${details.source_task_title || state.source_task_id} changed shared exported object ${(details.object_names || []).join(', ') || 'unknown'}`
243
+ : state.reason_type === 'shared_module_drift'
244
+ ? `${details.source_task_title || state.source_task_id} changed shared module ${(details.module_paths || []).join(', ') || 'unknown'} used by ${(details.dependent_files || []).join(', ') || state.affected_task_id}`
245
+ : `${affectedTask?.title || state.affected_task_id} is stale because ${details?.source_task_title || state.source_task_id} changed shared ${staleArea}`;
232
246
  return {
233
247
  source_lease_id: state.source_lease_id,
234
248
  source_task_id: state.source_task_id,
@@ -243,9 +257,10 @@ function evaluateDependencyInvalidations(db) {
243
257
  subsystem_tag: state.subsystem_tag,
244
258
  source_scope_pattern: state.source_scope_pattern,
245
259
  affected_scope_pattern: state.affected_scope_pattern,
246
- summary: `${affectedTask?.title || state.affected_task_id} is stale because ${state.details?.source_task_title || state.source_task_id} changed shared ${staleArea}`,
260
+ summary,
247
261
  stale_area: staleArea,
248
262
  created_at: state.created_at,
263
+ details,
249
264
  };
250
265
  });
251
266
  }
@@ -183,7 +183,7 @@ export function evaluateTaskOutcome(db, repoRoot, { taskId = null, leaseId = nul
183
183
  };
184
184
 
185
185
  if (execution.leaseId) {
186
- touchBoundaryValidationState(db, execution.leaseId, 'task_outcome_accepted');
186
+ touchBoundaryValidationState(db, execution.leaseId, 'task_outcome_accepted', { changed_files: changedFiles });
187
187
  }
188
188
 
189
189
  return result;