claude-code-session-manager 0.97.0 → 0.100.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/README.md +3 -3
- package/bin/cli.cjs +16 -4
- package/bin/node-floor.cjs +41 -0
- package/dist/assets/{DataModel-D93FoaOm.js → DataModel-HdR0AxFq.js} +1 -1
- package/dist/assets/{History-CXxxqNP1.js → History-Dr4Jr_5z.js} +2 -2
- package/dist/assets/{Hooks-CzeYFQjq.js → Hooks-NhrvH3JP.js} +3 -3
- package/dist/assets/{Library-D2zPkwOC.js → Library-Dv_EtIMr.js} +1 -1
- package/dist/assets/{MarkdownEditor-DbinP78A.js → MarkdownEditor-DTKDWV_n.js} +1 -1
- package/dist/assets/{McpServers-Bu_sVGLr.js → McpServers-M034G9B3.js} +2 -2
- package/dist/assets/{Memory-BgiI6LZ_.js → Memory-DzF1B4gx.js} +6 -6
- package/dist/assets/{Permissions-C1MMzZRm.js → Permissions-DrgbSGWl.js} +3 -3
- package/dist/assets/{Plugins-Dhu6wAZi.js → Plugins-Bd3ef35w.js} +2 -2
- package/dist/assets/{ProvenanceBadge-C3AcWKMG.js → ProvenanceBadge-DcBHT1Iy.js} +1 -1
- package/dist/assets/{SaveBar-plwYm0iO.js → SaveBar-rimJem-Y.js} +1 -1
- package/dist/assets/Scheduler-DjgFJl2w.js +16 -0
- package/dist/assets/{ScopeSwitcher-DFy0vv_o.js → ScopeSwitcher-CnAzTBrl.js} +1 -1
- package/dist/assets/Settings-Dc1F_sSg.js +3 -0
- package/dist/assets/{SkillReferenceGraph-DsI7hRPB.js → SkillReferenceGraph-B9aZDecM.js} +1 -1
- package/dist/assets/{Skills-3E748UfB.js → Skills-DRXd18gu.js} +2 -2
- package/dist/assets/{SystemPrompt-BqmfUJOd.js → SystemPrompt-ahy5-Fwe.js} +1 -1
- package/dist/assets/{TagLibrary-CPz6fO8Q.js → TagLibrary-C_tToDeL.js} +1 -1
- package/dist/assets/{TiptapBody-D9EoBQ1P.js → TiptapBody-DvdefS6K.js} +1 -1
- package/dist/assets/{Toggle-BHAmZNDn.js → Toggle-6Jl5Njc6.js} +1 -1
- package/dist/assets/{index-CKH5Uxik.css → index-Bta-hwud.css} +1 -1
- package/dist/assets/{index-tn5JIVPj.js → index-vx8O73l8.js} +497 -502
- package/dist/assets/{settingsSchema-Crqy_aNz.js → settingsSchema-D6iRguNV.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +26 -29
- package/plugins/CLAUDE.md +6 -6
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +23 -19
- package/plugins/session-manager-dev/skills/builder/SKILL.md +3 -3
- package/plugins/session-manager-dev/skills/develop/SKILL.md +254 -510
- package/plugins/session-manager-dev/skills/develop/standards.md +17 -22
- package/scripts/README.md +3 -11
- package/scripts/audit-ops-hygiene.cjs +3 -3
- package/scripts/hooks/lib/guard-prd-writes-policy.cjs +1 -1
- package/scripts/hooks/lib/guard-self-schedule-policy.cjs +1 -1
- package/scripts/mint-epic.cjs +2 -3
- package/scripts/scheduler-mcp-server.cjs +36 -7
- package/src/main/agentLibrary.cjs +22 -2
- package/src/main/build-info.json +4 -4
- package/src/main/config.cjs +41 -38
- package/src/main/docEdit.cjs +4 -1
- package/src/main/files.cjs +2 -5
- package/src/main/health.cjs +1 -1
- package/src/main/index.cjs +23 -10
- package/src/main/ipcSchemas.cjs +28 -35
- package/src/main/lib/agentPersonaSchema.cjs +13 -2
- package/src/main/lib/atomicFs.cjs +116 -0
- package/src/main/lib/branchSweep.cjs +13 -12
- package/src/main/lib/buildTarget.cjs +3 -3
- package/src/main/lib/claudeCliCaps.cjs +129 -0
- package/src/main/lib/credentials.cjs +2 -4
- package/src/main/lib/crossProjectFeedback.cjs +3 -3
- package/src/main/lib/cwdClassify.cjs +15 -3
- package/src/main/lib/definitionOfDone.cjs +207 -125
- package/src/main/lib/dodDrainHook.cjs +11 -5
- package/src/main/lib/epicMint.cjs +2 -4
- package/src/main/lib/epicStatusMirror.cjs +8 -10
- package/src/main/lib/epicWorktreeProjectConfig.cjs +2 -4
- package/src/main/lib/gateAuthority.cjs +96 -0
- package/src/main/lib/gitExec.cjs +69 -0
- package/src/main/lib/gitWorktree.cjs +656 -92
- package/src/main/lib/instanceLock.cjs +5 -8
- package/src/main/lib/launchFailure.cjs +2 -3
- package/src/main/lib/macroLibrary.cjs +386 -0
- package/src/main/lib/mcpToolCatalog.cjs +26 -17
- package/src/main/lib/needsReviewLedger.cjs +1 -1
- package/src/main/lib/opsErrorLog.cjs +11 -1
- package/src/main/lib/opsOwnership.cjs +0 -5
- package/src/main/lib/pidAlive.cjs +32 -0
- package/src/main/lib/prdCreate.cjs +153 -19
- package/src/main/lib/prdDisposition.cjs +2 -2
- package/src/main/lib/prdGateFiles.cjs +284 -0
- package/src/main/lib/prdLocations.cjs +61 -0
- package/src/main/lib/prdMigration.cjs +2 -3
- package/src/main/lib/prdSizing.cjs +2 -1
- package/src/main/lib/promptSessionSchema.cjs +10 -2
- package/src/main/lib/rcaReport.cjs +4 -6
- package/src/main/lib/reaperHelpers.cjs +8 -1
- package/src/main/lib/reservationExpiry.cjs +6 -1
- package/src/main/lib/reviewNotice.cjs +263 -0
- package/src/main/lib/runClaudeP.cjs +4 -1
- package/src/main/lib/schedulerPaths.cjs +22 -2
- package/src/main/lib/sessionSlots.cjs +2 -4
- package/src/main/lib/shippedPersonaSeeds.cjs +179 -0
- package/src/main/lib/timeoutShim.cjs +123 -0
- package/src/main/lib/timeoutShimScript.cjs +275 -0
- package/src/main/lib/upgradeDrain.cjs +4 -10
- package/src/main/lib/watchdogHelpers.cjs +33 -30
- package/src/main/lib/workTypeLibrary.cjs +7 -1
- package/src/main/promptSessionEvents.cjs +37 -13
- package/src/main/queueOps.cjs +17 -6
- package/src/main/scheduler.cjs +975 -203
- package/src/main/seedAgentPersonas.cjs +278 -23
- package/src/main/supervisor.cjs +4 -1
- package/src/main/templates/PRD_AUTHORING.md +126 -406
- package/src/preload/__tests__/preload-surface.test.cjs +68 -0
- package/src/preload/api.d.ts +41 -94
- package/src/preload/index.cjs +14 -9
- package/src/seed/agents/architect.md +1 -0
- package/src/seed/agents/dev-lead.md +17 -18
- package/src/seed/agents/project-home-builder.md +1 -0
- package/src/seed/agents/validator.md +10 -4
- package/dist/assets/HostBilko-nESrTRIg.js +0 -1
- package/dist/assets/Scheduler-BVYTeh39.js +0 -16
- package/dist/assets/Settings-DIhGgdfs.js +0 -3
- package/src/main/__tests__/activeIndexMerge.test.cjs +0 -235
- package/src/main/__tests__/agentEffortResolve.test.cjs +0 -117
- package/src/main/__tests__/agentLibrary.test.cjs +0 -276
- package/src/main/__tests__/agentModelResolve.test.cjs +0 -311
- package/src/main/__tests__/agentOverlayWrite.test.cjs +0 -95
- package/src/main/__tests__/bilkoHost-deriveSlug.test.cjs +0 -26
- package/src/main/__tests__/bilkoHost-integration.test.cjs +0 -118
- package/src/main/__tests__/bilkoHostCore.test.cjs +0 -72
- package/src/main/__tests__/broadcastCoalescer.test.cjs +0 -122
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +0 -120
- package/src/main/__tests__/chat-dead-channels.test.cjs +0 -63
- package/src/main/__tests__/chat-exit-close-race.test.cjs +0 -146
- package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +0 -139
- package/src/main/__tests__/chat-preamble-anchors.test.cjs +0 -100
- package/src/main/__tests__/chat-queue.test.cjs +0 -97
- package/src/main/__tests__/chat-stop-signal.test.cjs +0 -89
- package/src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs +0 -125
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +0 -252
- package/src/main/__tests__/classifyPromptTicket.test.cjs +0 -101
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +0 -201
- package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +0 -66
- package/src/main/__tests__/config-readText-bounded.test.cjs +0 -84
- package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +0 -59
- package/src/main/__tests__/crossProjectFeedback.test.cjs +0 -334
- package/src/main/__tests__/crossProjectFeedbackRoutes.test.cjs +0 -161
- package/src/main/__tests__/dep-orphan-archive-health.test.cjs +0 -77
- package/src/main/__tests__/develop-skill-failure-modes.test.cjs +0 -70
- package/src/main/__tests__/docEdit.test.cjs +0 -244
- package/src/main/__tests__/dod-batchkey.test.cjs +0 -183
- package/src/main/__tests__/dod-drain-hook.test.cjs +0 -302
- package/src/main/__tests__/dod-report.test.cjs +0 -304
- package/src/main/__tests__/dod-reverify.test.cjs +0 -285
- package/src/main/__tests__/epicContextDigest.test.cjs +0 -174
- package/src/main/__tests__/epicMint.test.cjs +0 -332
- package/src/main/__tests__/epicMintTelemetryTap.test.cjs +0 -64
- package/src/main/__tests__/epicStatusMirror.test.cjs +0 -110
- package/src/main/__tests__/epicValidationHook.test.cjs +0 -291
- package/src/main/__tests__/exchanges.test.cjs +0 -122
- package/src/main/__tests__/exchangesPromptId.test.cjs +0 -61
- package/src/main/__tests__/extractJson.test.cjs +0 -51
- package/src/main/__tests__/files-reject-credentials.test.cjs +0 -40
- package/src/main/__tests__/fixtures/1218-fo-01-move-scripts-lib-into-src-main-lib.log +0 -556
- package/src/main/__tests__/flatPrdTickSweep.test.cjs +0 -110
- package/src/main/__tests__/health-build-freshness.test.cjs +0 -39
- package/src/main/__tests__/health-claude-md-budget.test.cjs +0 -57
- package/src/main/__tests__/health-credentials.test.cjs +0 -81
- package/src/main/__tests__/health-delegation-chain.test.cjs +0 -124
- package/src/main/__tests__/health-per-project-stall.test.cjs +0 -84
- package/src/main/__tests__/health-prd-migration.test.cjs +0 -37
- package/src/main/__tests__/health-queue-dispatch.test.cjs +0 -135
- package/src/main/__tests__/health-starve-escalation.test.cjs +0 -94
- package/src/main/__tests__/health-tick-liveness.test.cjs +0 -179
- package/src/main/__tests__/health-usage-poller.test.cjs +0 -144
- package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +0 -65
- package/src/main/__tests__/heapSnapshot.test.cjs +0 -121
- package/src/main/__tests__/historyAggregatorIntraday.test.cjs +0 -313
- package/src/main/__tests__/historyDashboard.test.cjs +0 -163
- package/src/main/__tests__/historyRollup.test.cjs +0 -333
- package/src/main/__tests__/intradayRefresh.test.cjs +0 -39
- package/src/main/__tests__/ipcSchemas-dependsOn.test.cjs +0 -39
- package/src/main/__tests__/kg-augment.test.cjs +0 -195
- package/src/main/__tests__/loadGateDetailTick.test.cjs +0 -31
- package/src/main/__tests__/machineProfile.test.cjs +0 -152
- package/src/main/__tests__/mcpStatus.test.cjs +0 -61
- package/src/main/__tests__/memoryAggregate.test.cjs +0 -109
- package/src/main/__tests__/memoryStale.test.cjs +0 -88
- package/src/main/__tests__/needsReviewLedger.test.cjs +0 -162
- package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +0 -25
- package/src/main/__tests__/opsErrorLog.test.cjs +0 -109
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +0 -173
- package/src/main/__tests__/personaMerge.test.cjs +0 -169
- package/src/main/__tests__/planValidator.test.cjs +0 -125
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +0 -176
- package/src/main/__tests__/prd-group-allocator.test.cjs +0 -119
- package/src/main/__tests__/prdAdminRouteParity.test.cjs +0 -70
- package/src/main/__tests__/prdAdminRoutes.test.cjs +0 -718
- package/src/main/__tests__/prdAgentType.test.cjs +0 -103
- package/src/main/__tests__/prdAuthoringSeed.test.cjs +0 -39
- package/src/main/__tests__/prdCreate.test.cjs +0 -979
- package/src/main/__tests__/prdCreateDisposition.test.cjs +0 -201
- package/src/main/__tests__/prdCreatePlanId.test.cjs +0 -132
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +0 -117
- package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +0 -136
- package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +0 -125
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +0 -108
- package/src/main/__tests__/prdLocations.test.cjs +0 -195
- package/src/main/__tests__/prdLocationsArchived.test.cjs +0 -201
- package/src/main/__tests__/prdMigration.test.cjs +0 -349
- package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +0 -91
- package/src/main/__tests__/prdParserHighWater.test.cjs +0 -74
- package/src/main/__tests__/prdParserSourcePromptId.test.cjs +0 -65
- package/src/main/__tests__/prdSetDisposition.test.cjs +0 -222
- package/src/main/__tests__/prdSizing.test.cjs +0 -106
- package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +0 -118
- package/src/main/__tests__/prdUpdateDependsOn.test.cjs +0 -160
- package/src/main/__tests__/proc-role-env.test.cjs +0 -125
- package/src/main/__tests__/procname-claude-spawn-sites.test.cjs +0 -304
- package/src/main/__tests__/procname-sm-processes.test.cjs +0 -127
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +0 -177
- package/src/main/__tests__/projectPages.test.cjs +0 -137
- package/src/main/__tests__/promptSessionEvents.test.cjs +0 -234
- package/src/main/__tests__/promptSessionSchema.test.cjs +0 -101
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +0 -159
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +0 -282
- package/src/main/__tests__/pty-session-open-telemetry.test.cjs +0 -96
- package/src/main/__tests__/pty-write-result.test.cjs +0 -46
- package/src/main/__tests__/queue-health-verdict.test.cjs +0 -170
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +0 -286
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +0 -147
- package/src/main/__tests__/queueHistory.test.cjs +0 -355
- package/src/main/__tests__/queueOps-interactive-ac-lint.test.cjs +0 -65
- package/src/main/__tests__/queueOpsArchiveDestination.test.cjs +0 -65
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +0 -154
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +0 -128
- package/src/main/__tests__/rcaReport.test.cjs +0 -266
- package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +0 -119
- package/src/main/__tests__/reconcileTiming.test.cjs +0 -135
- package/src/main/__tests__/runLogRetention.test.cjs +0 -489
- package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +0 -26
- package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +0 -58
- package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +0 -191
- package/src/main/__tests__/runVerify-policy-denial.test.cjs +0 -89
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +0 -225
- package/src/main/__tests__/runVerify.test.cjs +0 -1784
- package/src/main/__tests__/scheduleJobSchema.test.cjs +0 -127
- package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +0 -65
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +0 -277
- package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +0 -59
- package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +0 -72
- package/src/main/__tests__/scheduler-admin-routes.test.cjs +0 -199
- package/src/main/__tests__/scheduler-adopted-run-supervision.test.cjs +0 -143
- package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +0 -105
- package/src/main/__tests__/scheduler-archive-completed-prd.test.cjs +0 -102
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +0 -155
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +0 -188
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +0 -439
- package/src/main/__tests__/scheduler-autopromote.test.cjs +0 -51
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +0 -101
- package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +0 -107
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +0 -153
- package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +0 -121
- package/src/main/__tests__/scheduler-clear-queue-history.test.cjs +0 -134
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +0 -244
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +0 -182
- package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +0 -43
- package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +0 -168
- package/src/main/__tests__/scheduler-dispatch-loop.test.cjs +0 -58
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +0 -81
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +0 -227
- package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +0 -121
- package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +0 -229
- package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +0 -75
- package/src/main/__tests__/scheduler-fix-plan-path.test.cjs +0 -119
- package/src/main/__tests__/scheduler-force-tick-outcome.test.cjs +0 -46
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +0 -78
- package/src/main/__tests__/scheduler-gate-shadow.test.cjs +0 -119
- package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +0 -390
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +0 -61
- package/src/main/__tests__/scheduler-heartbeat-payload.test.cjs +0 -80
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +0 -323
- package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +0 -41
- package/src/main/__tests__/scheduler-investigation-clean-skip.test.cjs +0 -63
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +0 -123
- package/src/main/__tests__/scheduler-job-budget.test.cjs +0 -172
- package/src/main/__tests__/scheduler-job-overrun.test.cjs +0 -175
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +0 -199
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +0 -52
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +0 -199
- package/src/main/__tests__/scheduler-looks-done.test.cjs +0 -537
- package/src/main/__tests__/scheduler-manual-pause.test.cjs +0 -118
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +0 -245
- package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +0 -46
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +0 -197
- package/src/main/__tests__/scheduler-never-stop.test.cjs +0 -157
- package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +0 -152
- package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +0 -81
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +0 -87
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +0 -343
- package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +0 -217
- package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +0 -164
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +0 -161
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +0 -178
- package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +0 -165
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +0 -257
- package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +0 -123
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +0 -152
- package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +0 -156
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +0 -754
- package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +0 -87
- package/src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs +0 -100
- package/src/main/__tests__/scheduler-reconcile-history-backfill.test.cjs +0 -105
- package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +0 -203
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +0 -247
- package/src/main/__tests__/scheduler-reset-job-fields-guard.test.cjs +0 -77
- package/src/main/__tests__/scheduler-resume-recovery.test.cjs +0 -254
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +0 -115
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +0 -380
- package/src/main/__tests__/scheduler-sigterm-commit.test.cjs +0 -43
- package/src/main/__tests__/scheduler-stall-per-project.test.cjs +0 -126
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +0 -154
- package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +0 -252
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +0 -185
- package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +0 -136
- package/src/main/__tests__/scheduler-supervisor-record.test.cjs +0 -81
- package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +0 -54
- package/src/main/__tests__/scheduler-tick-wedge.test.cjs +0 -172
- package/src/main/__tests__/scheduler-transient-failure.test.cjs +0 -141
- package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +0 -62
- package/src/main/__tests__/scheduler-utilization-hold.test.cjs +0 -89
- package/src/main/__tests__/scheduler-verify-prd-path.test.cjs +0 -109
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +0 -234
- package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +0 -120
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +0 -105
- package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +0 -117
- package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +0 -110
- package/src/main/__tests__/seedAgentPersonas.test.cjs +0 -184
- package/src/main/__tests__/seedSchedulerMcp.test.cjs +0 -211
- package/src/main/__tests__/seedStatus.test.cjs +0 -100
- package/src/main/__tests__/seedValidatorPersona.test.cjs +0 -116
- package/src/main/__tests__/stop-signal-anchor.test.cjs +0 -75
- package/src/main/__tests__/telemetryClient.test.cjs +0 -1055
- package/src/main/__tests__/telemetryContract.test.cjs +0 -930
- package/src/main/__tests__/telemetrySettings.test.cjs +0 -210
- package/src/main/__tests__/transcripts-batch-flush.test.cjs +0 -249
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +0 -124
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +0 -241
- package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +0 -154
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +0 -206
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +0 -153
- package/src/main/__tests__/usageSingleFlight.test.cjs +0 -169
- package/src/main/__tests__/validationSentinels.test.cjs +0 -84
- package/src/main/__tests__/workTypeLibrary.test.cjs +0 -89
- package/src/main/bilkoHost.cjs +0 -314
- package/src/main/bilkoHostCore.cjs +0 -89
- package/src/main/lib/__tests__/active-sessions.test.cjs +0 -251
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +0 -179
- package/src/main/lib/__tests__/agentPersonaSchema.test.cjs +0 -67
- package/src/main/lib/__tests__/auditLog.test.cjs +0 -38
- package/src/main/lib/__tests__/bootSelfHeal.test.cjs +0 -107
- package/src/main/lib/__tests__/branchSweep.test.cjs +0 -164
- package/src/main/lib/__tests__/buildIdentity.test.cjs +0 -121
- package/src/main/lib/__tests__/buildTarget.test.cjs +0 -52
- package/src/main/lib/__tests__/childWithLog.test.cjs +0 -321
- package/src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs +0 -87
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +0 -103
- package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +0 -115
- package/src/main/lib/__tests__/cwdClassify.test.cjs +0 -111
- package/src/main/lib/__tests__/definitionOfDoneSequence.test.cjs +0 -95
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +0 -1175
- package/src/main/lib/__tests__/dispatchLoop.test.cjs +0 -63
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +0 -244
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +0 -91
- package/src/main/lib/__tests__/epicDelegationStats.test.cjs +0 -137
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +0 -283
- package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +0 -196
- package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +0 -163
- package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +0 -136
- package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +0 -130
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +0 -117
- package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +0 -137
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +0 -40
- package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +0 -13
- package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +0 -34
- package/src/main/lib/__tests__/gateFixtures.json +0 -20
- package/src/main/lib/__tests__/gitCacheBound.test.cjs +0 -69
- package/src/main/lib/__tests__/gitWorktree.test.cjs +0 -1478
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +0 -107
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +0 -153
- package/src/main/lib/__tests__/guardShims.test.cjs +0 -158
- package/src/main/lib/__tests__/importReferences.spec.cjs +0 -56
- package/src/main/lib/__tests__/instanceLock.test.cjs +0 -173
- package/src/main/lib/__tests__/jobSupervisorRecord.test.cjs +0 -78
- package/src/main/lib/__tests__/jobWorktree.test.cjs +0 -199
- package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +0 -82
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +0 -133
- package/src/main/lib/__tests__/launchFailure.test.cjs +0 -220
- package/src/main/lib/__tests__/loadGate.test.cjs +0 -303
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +0 -214
- package/src/main/lib/__tests__/loopDelay.test.cjs +0 -68
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +0 -107
- package/src/main/lib/__tests__/modelCatalog.test.cjs +0 -202
- package/src/main/lib/__tests__/opsOwnership.test.cjs +0 -113
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +0 -328
- package/src/main/lib/__tests__/opsRootNestedWrite.test.cjs +0 -51
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +0 -149
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +0 -82
- package/src/main/lib/__tests__/prdDisposition.test.cjs +0 -224
- package/src/main/lib/__tests__/procIdentity.test.cjs +0 -119
- package/src/main/lib/__tests__/procName.test.cjs +0 -92
- package/src/main/lib/__tests__/projectBriefCore.test.cjs +0 -216
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +0 -148
- package/src/main/lib/__tests__/queueHealth.test.cjs +0 -58
- package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +0 -88
- package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +0 -190
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +0 -39
- package/src/main/lib/__tests__/rateLimitWindow.test.cjs +0 -88
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +0 -577
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +0 -312
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +0 -213
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +0 -125
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +0 -127
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +0 -109
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +0 -71
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +0 -216
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +0 -183
- package/src/main/lib/__tests__/schedulerPaths.test.cjs +0 -226
- package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +0 -133
- package/src/main/lib/__tests__/schedulerRuntimeState.test.cjs +0 -56
- package/src/main/lib/__tests__/sessionSlots.test.cjs +0 -144
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +0 -626
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +0 -134
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +0 -136
- package/src/main/lib/__tests__/telemetryCounters.test.cjs +0 -57
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +0 -98
- package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +0 -200
- package/src/main/lib/__tests__/toolUseClassify.test.cjs +0 -53
- package/src/main/lib/__tests__/updateCheck.test.cjs +0 -63
- package/src/main/lib/__tests__/upgradeDrain.test.cjs +0 -130
- package/src/main/lib/__tests__/usageCircuit.test.cjs +0 -354
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +0 -375
- package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +0 -266
- package/src/main/lib/kgExchangePairing.cjs +0 -75
|
@@ -1,1478 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* gitWorktree.test.cjs — PRD 1032: generalized `git worktree` isolation
|
|
3
|
-
* primitives, backing both the job kind (unchanged behavior, mirrored from
|
|
4
|
-
* jobWorktree.test.cjs) and the new epic kind.
|
|
5
|
-
*
|
|
6
|
-
* Exercises the real `git worktree` plumbing against throwaway repos under
|
|
7
|
-
* os.tmpdir() (fast — no network, no Electron).
|
|
8
|
-
*
|
|
9
|
-
* Run: timeout 120 npx vitest run src/main/lib/__tests__/gitWorktree.test.cjs
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
'use strict';
|
|
13
|
-
|
|
14
|
-
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
15
|
-
const fs = require('node:fs');
|
|
16
|
-
const os = require('node:os');
|
|
17
|
-
const path = require('node:path');
|
|
18
|
-
const { execFileSync, spawn } = require('node:child_process');
|
|
19
|
-
const gitWorktree = require('../gitWorktree.cjs');
|
|
20
|
-
|
|
21
|
-
let tmpRoot;
|
|
22
|
-
let repoCwd;
|
|
23
|
-
let originalJobDisable;
|
|
24
|
-
let originalJobMax;
|
|
25
|
-
let originalEpicDisable;
|
|
26
|
-
let originalEpicMax;
|
|
27
|
-
let originalWorktreeRoot;
|
|
28
|
-
|
|
29
|
-
function git(args, cwd) {
|
|
30
|
-
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function initRepo(dir) {
|
|
34
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
-
git(['init', '-q'], dir);
|
|
36
|
-
git(['config', 'user.email', 'test@example.com'], dir);
|
|
37
|
-
git(['config', 'user.name', 'Test'], dir);
|
|
38
|
-
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
39
|
-
git(['add', '-A'], dir);
|
|
40
|
-
git(['commit', '-q', '-m', 'initial'], dir);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
beforeEach(() => {
|
|
44
|
-
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gitworktree-'));
|
|
45
|
-
repoCwd = path.join(tmpRoot, 'repo');
|
|
46
|
-
initRepo(repoCwd);
|
|
47
|
-
// Every sweep below runs against THIS throwaway root, never the live job-worktree root.
|
|
48
|
-
originalWorktreeRoot = process.env.SM_WORKTREE_ROOT;
|
|
49
|
-
process.env.SM_WORKTREE_ROOT = path.join(tmpRoot, 'worktrees');
|
|
50
|
-
originalJobDisable = process.env.SM_JOB_WORKTREE_DISABLE;
|
|
51
|
-
originalJobMax = process.env.SM_JOB_WORKTREE_MAX;
|
|
52
|
-
originalEpicDisable = process.env.SM_EPIC_WORKTREE_DISABLE;
|
|
53
|
-
originalEpicMax = process.env.SM_EPIC_WORKTREE_MAX;
|
|
54
|
-
delete process.env.SM_JOB_WORKTREE_DISABLE;
|
|
55
|
-
delete process.env.SM_JOB_WORKTREE_MAX;
|
|
56
|
-
delete process.env.SM_EPIC_WORKTREE_DISABLE;
|
|
57
|
-
delete process.env.SM_EPIC_WORKTREE_MAX;
|
|
58
|
-
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
59
|
-
gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
|
|
60
|
-
gitWorktree._resetObservedWorktreeCountCacheForTests();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
afterEach(async () => {
|
|
64
|
-
const restore = (name, val) => { if (val === undefined) delete process.env[name]; else process.env[name] = val; };
|
|
65
|
-
restore('SM_JOB_WORKTREE_DISABLE', originalJobDisable);
|
|
66
|
-
restore('SM_JOB_WORKTREE_MAX', originalJobMax);
|
|
67
|
-
restore('SM_EPIC_WORKTREE_DISABLE', originalEpicDisable);
|
|
68
|
-
restore('SM_EPIC_WORKTREE_MAX', originalEpicMax);
|
|
69
|
-
// Best-effort: prune any worktree either kind's tests created before removing the repo.
|
|
70
|
-
try { await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' }); } catch { /* ignore */ }
|
|
71
|
-
try { await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic' }); } catch { /* ignore */ }
|
|
72
|
-
restore('SM_WORKTREE_ROOT', originalWorktreeRoot);
|
|
73
|
-
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// ──────────────────────────────────────────── job kind (mirrors jobWorktree.test.cjs)
|
|
77
|
-
|
|
78
|
-
test('[job] createJobWorktree creates a linked worktree on a fresh branch from HEAD', async () => {
|
|
79
|
-
const slug = 'test-slug-create';
|
|
80
|
-
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
81
|
-
expect(result.ok).toBe(true);
|
|
82
|
-
expect(fs.existsSync(result.dir)).toBe(true);
|
|
83
|
-
expect(fs.existsSync(path.join(result.dir, 'README.md'))).toBe(true);
|
|
84
|
-
expect(result.branch).toBe(`sm-job/${slug}`);
|
|
85
|
-
|
|
86
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
87
|
-
expect(fs.existsSync(result.dir)).toBe(false);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test('[job] a commit made inside the worktree becomes visible on the main tree HEAD after integrateJobBranch', async () => {
|
|
91
|
-
const slug = 'test-slug-integrate';
|
|
92
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
93
|
-
expect(worktree.ok).toBe(true);
|
|
94
|
-
|
|
95
|
-
fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'job output\n', 'utf8');
|
|
96
|
-
git(['add', '-A'], worktree.dir);
|
|
97
|
-
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
98
|
-
const jobCommitSha = git(['rev-parse', 'HEAD'], worktree.dir).trim();
|
|
99
|
-
|
|
100
|
-
const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
101
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
102
|
-
expect(outcome.ok).toBe(true);
|
|
103
|
-
expect(outcome.integrated).toBe(true);
|
|
104
|
-
|
|
105
|
-
const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
106
|
-
expect(headAfter).not.toBe(headBefore);
|
|
107
|
-
expect(headAfter).toBe(jobCommitSha);
|
|
108
|
-
expect(fs.existsSync(path.join(repoCwd, 'new-file.txt'))).toBe(true);
|
|
109
|
-
|
|
110
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
111
|
-
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test('[job] integrateJobBranch is a no-op (integrated:false) when the branch has no new commits', async () => {
|
|
115
|
-
const slug = 'test-slug-noop';
|
|
116
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
117
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
118
|
-
expect(outcome.ok).toBe(true);
|
|
119
|
-
expect(outcome.integrated).toBe(false);
|
|
120
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test('[job] a real merge conflict surfaces as an explicit integration failure and preserves the branch', async () => {
|
|
124
|
-
const slug = 'test-slug-conflict';
|
|
125
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
126
|
-
|
|
127
|
-
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
128
|
-
git(['add', '-A'], worktree.dir);
|
|
129
|
-
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
130
|
-
|
|
131
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
|
|
132
|
-
git(['add', '-A'], repoCwd);
|
|
133
|
-
git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
|
|
134
|
-
|
|
135
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
136
|
-
expect(outcome.ok).toBe(false);
|
|
137
|
-
expect(outcome.reason).toMatch(/conflict|merge failed/i);
|
|
138
|
-
|
|
139
|
-
const status = git(['status', '--porcelain'], repoCwd);
|
|
140
|
-
expect(status.trim()).toBe('');
|
|
141
|
-
|
|
142
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
143
|
-
const branches = git(['branch', '--list', worktree.branch], repoCwd);
|
|
144
|
-
expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test('[job] merge-commit message text is byte-for-byte unchanged from the pre-generalization jobWorktree.cjs', async () => {
|
|
148
|
-
const slug = 'test-slug-merge-message';
|
|
149
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
150
|
-
|
|
151
|
-
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
152
|
-
git(['add', '-A'], worktree.dir);
|
|
153
|
-
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
154
|
-
|
|
155
|
-
fs.writeFileSync(path.join(repoCwd, 'other.txt'), 'main tree edit\n', 'utf8');
|
|
156
|
-
git(['add', '-A'], repoCwd);
|
|
157
|
-
git(['commit', '-q', '-m', 'main tree adds other.txt'], repoCwd);
|
|
158
|
-
|
|
159
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
160
|
-
expect(outcome.ok).toBe(true);
|
|
161
|
-
expect(outcome.mergeCommit).toBe(true);
|
|
162
|
-
|
|
163
|
-
const lastMessage = git(['log', '-1', '--format=%s'], repoCwd).trim();
|
|
164
|
-
expect(lastMessage).toBe(`merge scheduler job ${slug}`);
|
|
165
|
-
|
|
166
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('[job] integration is skipped (carried-wip-only) when the branch only commits the carried base WIP, and the base tree stays untouched', async () => {
|
|
170
|
-
const slug = 'test-slug-carried-wip-only';
|
|
171
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
172
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
173
|
-
expect(worktree.ok).toBe(true);
|
|
174
|
-
expect(worktree.carriedPaths).toEqual(['README.md']);
|
|
175
|
-
|
|
176
|
-
// The job commits exactly the carried content, doing no other work.
|
|
177
|
-
git(['add', '-A'], worktree.dir);
|
|
178
|
-
git(['commit', '-q', '-m', 'commit carried README'], worktree.dir);
|
|
179
|
-
|
|
180
|
-
const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
181
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
|
|
182
|
-
expect(outcome.ok).toBe(true);
|
|
183
|
-
expect(outcome.integrated).toBe(false);
|
|
184
|
-
expect(outcome.reason).toBe('carried-wip-only');
|
|
185
|
-
|
|
186
|
-
const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
187
|
-
expect(headAfter).toBe(headBefore);
|
|
188
|
-
// The base tree's own uncommitted README edit is untouched — never
|
|
189
|
-
// discarded, never re-applied.
|
|
190
|
-
expect(git(['status', '--porcelain'], repoCwd)).toContain('README.md');
|
|
191
|
-
|
|
192
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test('[job] a branch that commits carried paths ALONGSIDE its own work auto-resolves the merge (PRD 1125: carried path is byte-identical duplicate, real work still lands)', async () => {
|
|
196
|
-
const slug = 'test-slug-carried-plus-own-work';
|
|
197
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
198
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
199
|
-
expect(worktree.ok).toBe(true);
|
|
200
|
-
expect(worktree.carriedPaths).toEqual(['README.md']);
|
|
201
|
-
|
|
202
|
-
fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'job output\n', 'utf8');
|
|
203
|
-
git(['add', '-A'], worktree.dir);
|
|
204
|
-
git(['commit', '-q', '-m', 'job commit touching carried path + own work'], worktree.dir);
|
|
205
|
-
|
|
206
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
|
|
207
|
-
// The base tree still holds README.md dirty, but its content is BYTE
|
|
208
|
-
// IDENTICAL to the branch's committed copy (the branch never edited it,
|
|
209
|
-
// only carried it through) — this is exactly the shape PRD 1125 teaches
|
|
210
|
-
// integrateBranch to auto-resolve: discard the duplicate, retry once, land
|
|
211
|
-
// the job's real new-file.txt commit too. Not the carried-wip-only
|
|
212
|
-
// shortcut (that only fires when the branch's ONLY changes are carried
|
|
213
|
-
// paths) — here the merge itself is what resolves.
|
|
214
|
-
expect(outcome.ok).toBe(true);
|
|
215
|
-
expect(outcome.integrated).toBe(true);
|
|
216
|
-
expect(outcome.autoResolved).toBe('identical_working_tree_duplicates');
|
|
217
|
-
expect(outcome.resolvedPaths).toEqual(['README.md']);
|
|
218
|
-
expect(fs.existsSync(path.join(repoCwd, 'new-file.txt'))).toBe(true);
|
|
219
|
-
|
|
220
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test('[job] a branch that ADDS its own edit on top of the carried content on the SAME path is never classified carried-wip-only (content-verify, not path-only)', async () => {
|
|
224
|
-
const slug = 'test-slug-carried-same-path-plus-edit';
|
|
225
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
226
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
227
|
-
expect(worktree.ok).toBe(true);
|
|
228
|
-
expect(worktree.carriedPaths).toEqual(['README.md']);
|
|
229
|
-
|
|
230
|
-
// The job's real work lands on the SAME path the base tree had WIP on —
|
|
231
|
-
// committing MORE than just the carried content. A path-only check would
|
|
232
|
-
// wrongly see "only README.md changed, and README.md was carried" and
|
|
233
|
-
// classify this as carried-wip-only, causing the caller to delete this
|
|
234
|
-
// branch (and the job's real commit with it) instead of attempting a
|
|
235
|
-
// normal merge.
|
|
236
|
-
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'uncommitted tracked edit\njob added this line\n', 'utf8');
|
|
237
|
-
git(['add', '-A'], worktree.dir);
|
|
238
|
-
git(['commit', '-q', '-m', 'job extends the carried README further'], worktree.dir);
|
|
239
|
-
|
|
240
|
-
const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
241
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
|
|
242
|
-
// The base tree still holds README.md dirty, so a real merge attempt here
|
|
243
|
-
// is expected to fail exactly like the existing worktreeIntegrationFailure
|
|
244
|
-
// path — branch preserved, not merged, not silently discarded.
|
|
245
|
-
expect(outcome.ok).toBe(false);
|
|
246
|
-
expect(outcome.reason).toBeTruthy();
|
|
247
|
-
expect(outcome.reason).not.toBe('carried-wip-only');
|
|
248
|
-
|
|
249
|
-
const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
250
|
-
expect(headAfter).toBe(headBefore);
|
|
251
|
-
const branches = git(['branch', '--list', worktree.branch], repoCwd);
|
|
252
|
-
expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
|
|
253
|
-
|
|
254
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test('[job] falls back with a reason for a non-git cwd', async () => {
|
|
258
|
-
const nonGitDir = path.join(tmpRoot, 'not-a-repo');
|
|
259
|
-
fs.mkdirSync(nonGitDir, { recursive: true });
|
|
260
|
-
const result = await gitWorktree.createJobWorktree({ cwd: nonGitDir, slug: 'x' });
|
|
261
|
-
expect(result.ok).toBe(false);
|
|
262
|
-
expect(result.reason).toMatch(/not a git repository/);
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
test('[job] a dirty tracked base file is carried into the worktree with identical content, and the base tree is never written to', async () => {
|
|
266
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
267
|
-
const statusBefore = git(['status', '--porcelain'], repoCwd);
|
|
268
|
-
const stashBefore = git(['stash', 'list'], repoCwd);
|
|
269
|
-
|
|
270
|
-
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
271
|
-
expect(result.ok).toBe(true);
|
|
272
|
-
expect(result.carriedPaths).toEqual(['README.md']);
|
|
273
|
-
expect(fs.readFileSync(path.join(result.dir, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
|
|
274
|
-
|
|
275
|
-
const statusAfter = git(['status', '--porcelain'], repoCwd);
|
|
276
|
-
const stashAfter = git(['stash', 'list'], repoCwd);
|
|
277
|
-
expect(statusAfter).toBe(statusBefore);
|
|
278
|
-
expect(stashAfter).toBe(stashBefore);
|
|
279
|
-
|
|
280
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
test('[job] an untracked-only dirty base file is NOT carried and stays on the existing clean-create path', async () => {
|
|
284
|
-
fs.writeFileSync(path.join(repoCwd, 'scratch.txt'), 'stray scratch file\n', 'utf8');
|
|
285
|
-
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
286
|
-
expect(result.ok).toBe(true);
|
|
287
|
-
expect(result.carriedPaths).toEqual([]);
|
|
288
|
-
expect(fs.existsSync(path.join(result.dir, 'scratch.txt'))).toBe(false);
|
|
289
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
test('[job] carry-over failure (git apply cannot apply) degrades cleanly: worktree torn down, ok:false, active count released', async () => {
|
|
293
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
294
|
-
const spy = vi.spyOn(gitWorktree, 'captureAndCarryBaseDiff').mockResolvedValue({ ok: false, reason: 'simulated git apply failure' });
|
|
295
|
-
try {
|
|
296
|
-
const before = gitWorktree._getActiveWorktreeCountForTests('job');
|
|
297
|
-
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
298
|
-
expect(result.ok).toBe(false);
|
|
299
|
-
expect(result.reason).toMatch(/carry-over of base WIP failed/);
|
|
300
|
-
expect(gitWorktree._getActiveWorktreeCountForTests('job')).toBe(before);
|
|
301
|
-
const list = git(['worktree', 'list', '--porcelain'], repoCwd);
|
|
302
|
-
expect(list).not.toContain('sm-job/x');
|
|
303
|
-
} finally {
|
|
304
|
-
spy.mockRestore();
|
|
305
|
-
}
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
// ──────────────────────────────────────────── PRD: stream the base-WIP diff capture (job 1192)
|
|
309
|
-
|
|
310
|
-
test('[job] a wide dirty tree (300+ changed paths incl. a multi-MB file) captures and carries over without a maxBuffer overflow', async () => {
|
|
311
|
-
const files = [];
|
|
312
|
-
for (let i = 0; i < 300; i++) {
|
|
313
|
-
const rel = `wide-${i}.txt`;
|
|
314
|
-
fs.writeFileSync(path.join(repoCwd, rel), `initial ${i}\n`, 'utf8');
|
|
315
|
-
files.push(rel);
|
|
316
|
-
}
|
|
317
|
-
const bigRel = 'big-file.bin';
|
|
318
|
-
fs.writeFileSync(path.join(repoCwd, bigRel), Buffer.alloc(1024, 1));
|
|
319
|
-
git(['add', '-A'], repoCwd);
|
|
320
|
-
git(['commit', '-q', '-m', 'wide baseline'], repoCwd);
|
|
321
|
-
|
|
322
|
-
// Dirty every tracked file (uncommitted tracked edits) and grow the binary
|
|
323
|
-
// file well past Node's execFile default maxBuffer (1MB) — the exact shape
|
|
324
|
-
// that overflowed the old buffered capture (job 1192: ~240 dirty paths).
|
|
325
|
-
for (const rel of files) fs.appendFileSync(path.join(repoCwd, rel), 'dirtied\n', 'utf8');
|
|
326
|
-
const bigBuf = Buffer.alloc(3 * 1024 * 1024); // 3MB of NUL bytes — git detects this as binary
|
|
327
|
-
fs.writeFileSync(path.join(repoCwd, bigRel), bigBuf);
|
|
328
|
-
|
|
329
|
-
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'wide-tree' });
|
|
330
|
-
expect(result.ok).toBe(true);
|
|
331
|
-
expect(result.carriedPaths.length).toBe(files.length + 1);
|
|
332
|
-
expect(fs.readFileSync(path.join(result.dir, files[0]), 'utf8')).toContain('dirtied');
|
|
333
|
-
expect(Buffer.compare(fs.readFileSync(path.join(result.dir, bigRel)), bigBuf)).toBe(0);
|
|
334
|
-
|
|
335
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
336
|
-
}, 30_000);
|
|
337
|
-
|
|
338
|
-
test('[job] a renamed file and a mutated binary file survive the capture/apply round trip using the same flags as production', async () => {
|
|
339
|
-
const binRel = 'asset.bin';
|
|
340
|
-
const binInitial = Buffer.from([0, 1, 2, 3, 4, 0, 255, 254]);
|
|
341
|
-
fs.writeFileSync(path.join(repoCwd, binRel), binInitial);
|
|
342
|
-
fs.writeFileSync(path.join(repoCwd, 'to-rename.txt'), 'original name content\n', 'utf8');
|
|
343
|
-
git(['add', '-A'], repoCwd);
|
|
344
|
-
git(['commit', '-q', '-m', 'binary + rename baseline'], repoCwd);
|
|
345
|
-
|
|
346
|
-
// Dirty tree: a tracked rename plus a mutated binary file, both uncommitted.
|
|
347
|
-
git(['mv', 'to-rename.txt', 'renamed.txt'], repoCwd);
|
|
348
|
-
const binChanged = Buffer.from([9, 8, 7, 0, 6, 5, 0, 4]);
|
|
349
|
-
fs.writeFileSync(path.join(repoCwd, binRel), binChanged);
|
|
350
|
-
|
|
351
|
-
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'rename-binary' });
|
|
352
|
-
expect(result.ok).toBe(true);
|
|
353
|
-
expect(fs.existsSync(path.join(result.dir, 'to-rename.txt'))).toBe(false);
|
|
354
|
-
expect(fs.readFileSync(path.join(result.dir, 'renamed.txt'), 'utf8')).toBe('original name content\n');
|
|
355
|
-
expect(Buffer.compare(fs.readFileSync(path.join(result.dir, binRel)), binChanged)).toBe(0);
|
|
356
|
-
|
|
357
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
test('[job] a capture that cannot write its diff file (target dir missing) falls back with a specific reason, never the old generic maxBuffer message', async () => {
|
|
361
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
362
|
-
const bogusDir = path.join(tmpRoot, 'does-not-exist');
|
|
363
|
-
const result = await gitWorktree.captureAndCarryBaseDiff({ cwd: repoCwd, dir: bogusDir });
|
|
364
|
-
expect(result.ok).toBe(false);
|
|
365
|
-
expect(result.reason).toMatch(/capturing base diff failed/);
|
|
366
|
-
expect(result.reason).not.toMatch(/maxBuffer/i);
|
|
367
|
-
expect(fs.existsSync(bogusDir)).toBe(false); // nothing leaked into a directory that never existed
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
test('[job] the temp diff file is removed after carry-over on both the success and the git-apply-failure paths', async () => {
|
|
371
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
372
|
-
const success = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cleanup-ok' });
|
|
373
|
-
expect(success.ok).toBe(true);
|
|
374
|
-
expect(fs.readdirSync(success.dir).filter((f) => f.startsWith('.sm-worktree-carry-'))).toEqual([]);
|
|
375
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: success.dir, branch: success.branch });
|
|
376
|
-
|
|
377
|
-
// Force a git-apply failure on a genuine clean-base worktree by making the
|
|
378
|
-
// target file read-only before the capture/apply call, so the patch writes
|
|
379
|
-
// fine but applying it fails — the finally block must still remove the temp
|
|
380
|
-
// patch file even though apply itself errored.
|
|
381
|
-
const clean = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cleanup-fail' });
|
|
382
|
-
expect(clean.ok).toBe(true);
|
|
383
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'another uncommitted tracked edit\n', 'utf8');
|
|
384
|
-
const targetFile = path.join(clean.dir, 'README.md');
|
|
385
|
-
fs.chmodSync(targetFile, 0o444);
|
|
386
|
-
try {
|
|
387
|
-
const fail = await gitWorktree.captureAndCarryBaseDiff({ cwd: repoCwd, dir: clean.dir });
|
|
388
|
-
expect(fail.ok).toBe(false);
|
|
389
|
-
expect(fail.reason).toMatch(/git apply failed/);
|
|
390
|
-
} finally {
|
|
391
|
-
fs.chmodSync(targetFile, 0o644);
|
|
392
|
-
}
|
|
393
|
-
expect(fs.readdirSync(clean.dir).filter((f) => f.startsWith('.sm-worktree-carry-'))).toEqual([]);
|
|
394
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: clean.dir, branch: clean.branch });
|
|
395
|
-
}, 30_000);
|
|
396
|
-
|
|
397
|
-
test('[job] falls back once the concurrency cap is reached, and recovers after cleanup', async () => {
|
|
398
|
-
process.env.SM_JOB_WORKTREE_MAX = '1';
|
|
399
|
-
const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-1' });
|
|
400
|
-
expect(first.ok).toBe(true);
|
|
401
|
-
|
|
402
|
-
const second = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-2' });
|
|
403
|
-
expect(second.ok).toBe(false);
|
|
404
|
-
expect(second.reason).toMatch(/cap reached/);
|
|
405
|
-
|
|
406
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
|
|
407
|
-
|
|
408
|
-
const third = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-3' });
|
|
409
|
-
expect(third.ok).toBe(true);
|
|
410
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: third.dir, branch: third.branch });
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
test('[job] a leaked cap count (checkout removed from disk without cleanupWorktree) self-heals on the next createWorktree', async () => {
|
|
414
|
-
process.env.SM_JOB_WORKTREE_MAX = '1';
|
|
415
|
-
const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'leak-1' });
|
|
416
|
-
expect(first.ok).toBe(true);
|
|
417
|
-
|
|
418
|
-
// Simulate the crash/reap path: the checkout is torn off disk directly
|
|
419
|
-
// (not via cleanupJobWorktree), exactly like a job that got SIGTERM'd or
|
|
420
|
-
// reaped before its own teardown ran — activeWorktreeCount never gets
|
|
421
|
-
// decremented, so the in-memory counter is now permanently stuck at the
|
|
422
|
-
// cap even though nothing real is holding it.
|
|
423
|
-
await execFileSync('git', ['worktree', 'remove', '--force', first.dir], { cwd: repoCwd, encoding: 'utf8' });
|
|
424
|
-
fs.rmSync(first.dir, { recursive: true, force: true });
|
|
425
|
-
expect(gitWorktree._getActiveWorktreeCountForTests('job')).toBe(1);
|
|
426
|
-
|
|
427
|
-
// Against unpatched main this next call stays wedged forever: the counter
|
|
428
|
-
// never reflects that nothing is actually checked out anymore.
|
|
429
|
-
const second = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'leak-2' });
|
|
430
|
-
expect(second.ok).toBe(true);
|
|
431
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: second.dir, branch: second.branch });
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
test('[job] reclaimTerminalJobOrphans removes a completed row\'s clean-of-real-work orphan checkout', async () => {
|
|
435
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-clean' });
|
|
436
|
-
expect(worktree.ok).toBe(true);
|
|
437
|
-
// Zero commits ahead of main and nothing dirty at all in the checkout —
|
|
438
|
-
// the simplest "safe to reclaim" shape.
|
|
439
|
-
const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
|
|
440
|
-
cwd: repoCwd,
|
|
441
|
-
terminalSlugs: new Set(['orphan-clean']),
|
|
442
|
-
});
|
|
443
|
-
expect(reclaimed).toEqual(['orphan-clean']);
|
|
444
|
-
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
test('[job] reclaimTerminalJobOrphans reclaims a checkout carrying only ops-folder dirty noise', async () => {
|
|
448
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-ops-noise' });
|
|
449
|
-
expect(worktree.ok).toBe(true);
|
|
450
|
-
fs.mkdirSync(path.join(worktree.dir, 'session-manager-operations'), { recursive: true });
|
|
451
|
-
fs.writeFileSync(path.join(worktree.dir, 'session-manager-operations', 'queue.json'), '{}\n', 'utf8');
|
|
452
|
-
|
|
453
|
-
const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
|
|
454
|
-
cwd: repoCwd,
|
|
455
|
-
terminalSlugs: new Set(['orphan-ops-noise']),
|
|
456
|
-
});
|
|
457
|
-
expect(reclaimed).toEqual(['orphan-ops-noise']);
|
|
458
|
-
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
test('[job] reclaimTerminalJobOrphans leaves a checkout with real (non-ops) dirty content alone', async () => {
|
|
462
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-real-work' });
|
|
463
|
-
expect(worktree.ok).toBe(true);
|
|
464
|
-
fs.writeFileSync(path.join(worktree.dir, 'real-output.txt'), 'not ops noise\n', 'utf8');
|
|
465
|
-
|
|
466
|
-
const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
|
|
467
|
-
cwd: repoCwd,
|
|
468
|
-
terminalSlugs: new Set(['orphan-real-work']),
|
|
469
|
-
});
|
|
470
|
-
expect(reclaimed).toEqual([]);
|
|
471
|
-
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
472
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
test('[job] reclaimTerminalJobOrphans leaves a checkout with unmerged commits alone', async () => {
|
|
476
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-unmerged' });
|
|
477
|
-
expect(worktree.ok).toBe(true);
|
|
478
|
-
fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'real committed work\n', 'utf8');
|
|
479
|
-
git(['add', '-A'], worktree.dir);
|
|
480
|
-
git(['commit', '-q', '-m', 'unmerged job commit'], worktree.dir);
|
|
481
|
-
|
|
482
|
-
const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
|
|
483
|
-
cwd: repoCwd,
|
|
484
|
-
terminalSlugs: new Set(['orphan-unmerged']),
|
|
485
|
-
});
|
|
486
|
-
expect(reclaimed).toEqual([]);
|
|
487
|
-
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
488
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
test('[job] reclaimTerminalJobOrphans ignores a worktree whose slug is not in terminalSlugs', async () => {
|
|
492
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'not-terminal' });
|
|
493
|
-
expect(worktree.ok).toBe(true);
|
|
494
|
-
|
|
495
|
-
const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
|
|
496
|
-
cwd: repoCwd,
|
|
497
|
-
terminalSlugs: new Set(['some-other-slug']),
|
|
498
|
-
});
|
|
499
|
-
expect(reclaimed).toEqual([]);
|
|
500
|
-
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
501
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
// ──────────────────────────────────────────── PRD 1163: liveness gate in front of reclaimTerminalJobOrphans
|
|
505
|
-
|
|
506
|
-
test('[job] reclaimTerminalJobOrphans does NOT remove a checkout when an injected isLive predicate reports it live', async () => {
|
|
507
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'terminal-but-live' });
|
|
508
|
-
expect(worktree.ok).toBe(true);
|
|
509
|
-
// Clean-of-real-work shape (zero commits ahead, nothing dirty) — would be
|
|
510
|
-
// reclaimed by the existing two proofs alone. The liveness gate must be
|
|
511
|
-
// checked BEFORE those proofs and override them.
|
|
512
|
-
const isLive = vi.fn(async () => true);
|
|
513
|
-
|
|
514
|
-
const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
|
|
515
|
-
cwd: repoCwd,
|
|
516
|
-
terminalSlugs: new Set(['terminal-but-live']),
|
|
517
|
-
isLive,
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
expect(reclaimed).toEqual([]);
|
|
521
|
-
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
522
|
-
expect(isLive).toHaveBeenCalledWith('terminal-but-live', expect.objectContaining({ worktree: worktree.dir }));
|
|
523
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
test('[job] reclaimTerminalJobOrphans still removes a terminal, dead, merged, ops-only-dirty checkout when isLive reports it not live', async () => {
|
|
527
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'terminal-and-dead' });
|
|
528
|
-
expect(worktree.ok).toBe(true);
|
|
529
|
-
const isLive = vi.fn(async () => false);
|
|
530
|
-
|
|
531
|
-
const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
|
|
532
|
-
cwd: repoCwd,
|
|
533
|
-
terminalSlugs: new Set(['terminal-and-dead']),
|
|
534
|
-
isLive,
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
expect(reclaimed).toEqual(['terminal-and-dead']);
|
|
538
|
-
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
539
|
-
expect(isLive).toHaveBeenCalled();
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
test('[job] reclaimTerminalJobOrphans treats an isLive that throws as fail-safe (assume live, do not delete)', async () => {
|
|
543
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'isLive-throws' });
|
|
544
|
-
expect(worktree.ok).toBe(true);
|
|
545
|
-
const isLive = vi.fn(async () => { throw new Error('boom'); });
|
|
546
|
-
|
|
547
|
-
const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
|
|
548
|
-
cwd: repoCwd,
|
|
549
|
-
terminalSlugs: new Set(['isLive-throws']),
|
|
550
|
-
isLive,
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
expect(reclaimed).toEqual([]);
|
|
554
|
-
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
555
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
test('[job] jobWorktreeTerminalOrphanLive.buildTerminalOrphanIsLive skips on a live cwd holder BEFORE checking pid, and reports the reason via onLive', async () => {
|
|
559
|
-
const { buildTerminalOrphanIsLive } = require('../jobWorktreeTerminalOrphanLive.cjs');
|
|
560
|
-
const claudePidAlive = vi.fn(() => false); // pid check must never even matter here
|
|
561
|
-
const hasLiveHolder = vi.fn(() => true);
|
|
562
|
-
const onLive = vi.fn();
|
|
563
|
-
const isLive = buildTerminalOrphanIsLive({
|
|
564
|
-
terminalJobs: [{ slug: 'held-slug', runtime: { pid: 4242 } }],
|
|
565
|
-
claudePidAlive,
|
|
566
|
-
hasLiveHolder,
|
|
567
|
-
cwdHolders: new Set(['/tmp/fake']),
|
|
568
|
-
onLive,
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
const result = await isLive('held-slug', { worktree: '/tmp/fake/checkout' });
|
|
572
|
-
|
|
573
|
-
expect(result).toBe(true);
|
|
574
|
-
expect(hasLiveHolder).toHaveBeenCalledWith('/tmp/fake/checkout', expect.any(Set));
|
|
575
|
-
expect(onLive).toHaveBeenCalledWith('held-slug', expect.stringContaining('live cwd holder'));
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
test('[job] jobWorktreeTerminalOrphanLive.buildTerminalOrphanIsLive falls back to the recorded pid when there is no live holder', async () => {
|
|
579
|
-
const { buildTerminalOrphanIsLive } = require('../jobWorktreeTerminalOrphanLive.cjs');
|
|
580
|
-
const claudePidAlive = vi.fn((pid) => pid === 4242);
|
|
581
|
-
const hasLiveHolder = vi.fn(() => false);
|
|
582
|
-
const onLive = vi.fn();
|
|
583
|
-
const isLive = buildTerminalOrphanIsLive({
|
|
584
|
-
terminalJobs: [{ slug: 'pid-slug', runtime: { pid: 4242 } }],
|
|
585
|
-
claudePidAlive,
|
|
586
|
-
hasLiveHolder,
|
|
587
|
-
onLive,
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
expect(await isLive('pid-slug', { worktree: '/tmp/whatever' })).toBe(true);
|
|
591
|
-
expect(onLive).toHaveBeenCalledWith('pid-slug', expect.stringContaining('pid=4242'));
|
|
592
|
-
|
|
593
|
-
expect(await isLive('unknown-slug', { worktree: '/tmp/whatever' })).toBe(false);
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
test('[job] getMaxConcurrentWorktrees floors the job cap at the sessionSlots pool size (PRD 1112)', () => {
|
|
597
|
-
const originalSlots = process.env.SM_SESSION_SLOTS;
|
|
598
|
-
try {
|
|
599
|
-
process.env.SM_SESSION_SLOTS = '10';
|
|
600
|
-
expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(10);
|
|
601
|
-
|
|
602
|
-
// The floor never drops BELOW the existing default (4) even when the
|
|
603
|
-
// slot pool is smaller — this cap only ever widens for isolation, never
|
|
604
|
-
// narrows it below what already shipped.
|
|
605
|
-
process.env.SM_SESSION_SLOTS = '3';
|
|
606
|
-
expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(4);
|
|
607
|
-
} finally {
|
|
608
|
-
if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
|
|
609
|
-
else process.env.SM_SESSION_SLOTS = originalSlots;
|
|
610
|
-
}
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
test('[job] an explicit SM_JOB_WORKTREE_MAX wins verbatim over the sessionSlots floor', () => {
|
|
614
|
-
const originalSlots = process.env.SM_SESSION_SLOTS;
|
|
615
|
-
try {
|
|
616
|
-
process.env.SM_SESSION_SLOTS = '10';
|
|
617
|
-
process.env.SM_JOB_WORKTREE_MAX = '2';
|
|
618
|
-
expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(2);
|
|
619
|
-
} finally {
|
|
620
|
-
if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
|
|
621
|
-
else process.env.SM_SESSION_SLOTS = originalSlots;
|
|
622
|
-
}
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
test('[epic] getMaxConcurrentWorktrees is unaffected by the sessionSlots pool size', () => {
|
|
626
|
-
const originalSlots = process.env.SM_SESSION_SLOTS;
|
|
627
|
-
try {
|
|
628
|
-
process.env.SM_SESSION_SLOTS = '10';
|
|
629
|
-
expect(gitWorktree.getMaxConcurrentWorktrees('epic')).toBe(50);
|
|
630
|
-
} finally {
|
|
631
|
-
if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
|
|
632
|
-
else process.env.SM_SESSION_SLOTS = originalSlots;
|
|
633
|
-
}
|
|
634
|
-
});
|
|
635
|
-
|
|
636
|
-
test('[job] SM_JOB_WORKTREE_DISABLE=1 restores in-place behaviour', async () => {
|
|
637
|
-
process.env.SM_JOB_WORKTREE_DISABLE = '1';
|
|
638
|
-
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
639
|
-
expect(result.ok).toBe(false);
|
|
640
|
-
expect(result.reason).toMatch(/SM_JOB_WORKTREE_DISABLE/);
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
test('[job] reconcileWorktreesOnBoot removes a leaked worktree (simulated crash) and its branch', async () => {
|
|
644
|
-
const slug = 'test-slug-leak';
|
|
645
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
646
|
-
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
647
|
-
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
648
|
-
|
|
649
|
-
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' });
|
|
650
|
-
|
|
651
|
-
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
652
|
-
const branches = git(['branch', '--list', worktree.branch], repoCwd);
|
|
653
|
-
expect(branches.trim()).toBe('');
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
test('[job] reconcileWorktreesOnBoot defaults to kind:job when no opts are given', async () => {
|
|
657
|
-
const slug = 'test-slug-default-kind';
|
|
658
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
659
|
-
await gitWorktree.reconcileWorktreesOnBoot([repoCwd]);
|
|
660
|
-
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
test('[job] reconcileWorktreesOnBoot never touches a worktree outside WORKTREE_ROOT', async () => {
|
|
664
|
-
const humanWorktreeDir = path.join(tmpRoot, 'human-made-worktree');
|
|
665
|
-
git(['worktree', 'add', '-b', 'human-branch', humanWorktreeDir, 'HEAD'], repoCwd);
|
|
666
|
-
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' });
|
|
667
|
-
expect(fs.existsSync(humanWorktreeDir)).toBe(true);
|
|
668
|
-
git(['worktree', 'remove', '--force', humanWorktreeDir], repoCwd);
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
// ──────────────────────────────────────────── PRD 1162: job-kind isLive gate
|
|
672
|
-
// (a job survives an app restart via detached:true, so a worktree found at
|
|
673
|
-
// boot is not by itself proof its run already died — see scheduler.cjs's
|
|
674
|
-
// boot sweep + jobWorktreeBootLive.cjs, exercised here at the
|
|
675
|
-
// reconcileWorktreesOnBoot layer with a fully injected isLive predicate).
|
|
676
|
-
|
|
677
|
-
test('[job] reconcileWorktreesOnBoot removes a job worktree with no live holder and no running row', async () => {
|
|
678
|
-
const slug = 'test-slug-dead';
|
|
679
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
680
|
-
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
681
|
-
|
|
682
|
-
const isLive = vi.fn(() => false);
|
|
683
|
-
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job', isLive });
|
|
684
|
-
|
|
685
|
-
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
686
|
-
expect(isLive).toHaveBeenCalledWith(slug, expect.objectContaining({ worktree: worktree.dir }));
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
test('[job] reconcileWorktreesOnBoot SKIPS a worktree whose row is running with a live pid', async () => {
|
|
690
|
-
const slug = 'test-slug-running-live';
|
|
691
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
692
|
-
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
693
|
-
|
|
694
|
-
const isLive = (key) => key === slug;
|
|
695
|
-
const result = await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job', isLive });
|
|
696
|
-
|
|
697
|
-
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
698
|
-
expect(result.checkoutsRemoved).toBe(0);
|
|
699
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
test('[job] reconcileWorktreesOnBoot SKIPS a worktree whose row is absent/terminal but a live cwd holder is reported', async () => {
|
|
703
|
-
const slug = 'test-slug-terminal-holder';
|
|
704
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
705
|
-
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
706
|
-
|
|
707
|
-
// Row is absent from the caller's bookkeeping entirely (as if the queue row
|
|
708
|
-
// already went terminal) — only the injected cwd-holder signal says live.
|
|
709
|
-
const isLive = (key, entry) => entry.worktree === worktree.dir;
|
|
710
|
-
const result = await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job', isLive });
|
|
711
|
-
|
|
712
|
-
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
713
|
-
expect(result.checkoutsRemoved).toBe(0);
|
|
714
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
715
|
-
});
|
|
716
|
-
|
|
717
|
-
// ──────────────────────────────────────────── PRD 1162: hasLiveHolder / listCwdHolders
|
|
718
|
-
|
|
719
|
-
test('hasLiveHolder returns true for the current process own cwd', () => {
|
|
720
|
-
if (process.platform !== 'linux') return; // darwin has no /proc — covered by the pid-based gate instead
|
|
721
|
-
expect(gitWorktree.hasLiveHolder(process.cwd())).toBe(true);
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
test('hasLiveHolder returns false for a freshly-created empty temp dir nothing holds as cwd', () => {
|
|
725
|
-
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-hasLiveHolder-empty-'));
|
|
726
|
-
try {
|
|
727
|
-
expect(gitWorktree.hasLiveHolder(emptyDir)).toBe(false);
|
|
728
|
-
} finally {
|
|
729
|
-
fs.rmSync(emptyDir, { recursive: true, force: true });
|
|
730
|
-
}
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
test('hasLiveHolder never throws for a non-existent path', () => {
|
|
734
|
-
expect(() => gitWorktree.hasLiveHolder('/definitely/does/not/exist/anywhere')).not.toThrow();
|
|
735
|
-
expect(gitWorktree.hasLiveHolder('/definitely/does/not/exist/anywhere')).toBe(false);
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
test('hasLiveHolder still matches a checkout dir whose /proc readlink shows the "(deleted)" suffix (unlinked while a process still held it as cwd)', async () => {
|
|
739
|
-
if (process.platform !== 'linux') return;
|
|
740
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-hasLiveHolder-deleted-'));
|
|
741
|
-
const child = spawn('sleep', ['5'], { cwd: dir, stdio: 'ignore' });
|
|
742
|
-
try {
|
|
743
|
-
// Give the child a moment to actually chdir before the dir is unlinked.
|
|
744
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
745
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
746
|
-
expect(gitWorktree.hasLiveHolder(dir)).toBe(true);
|
|
747
|
-
} finally {
|
|
748
|
-
child.kill();
|
|
749
|
-
}
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
test('hasLiveHolder accepts a precomputed holders Set from listCwdHolders() and matches a path nested under a holder', () => {
|
|
753
|
-
if (process.platform !== 'linux') return; // darwin: hasLiveHolder always false, per its own contract
|
|
754
|
-
const holders = new Set(['/tmp/some-worktree-checkout']);
|
|
755
|
-
expect(gitWorktree.hasLiveHolder('/tmp/some-worktree-checkout/nested/deep', holders)).toBe(false);
|
|
756
|
-
expect(gitWorktree.hasLiveHolder('/tmp/some-worktree-checkout', holders)).toBe(true);
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
test('listCwdHolders returns a Set and includes the current process own cwd on linux', () => {
|
|
760
|
-
const holders = gitWorktree.listCwdHolders();
|
|
761
|
-
expect(holders).toBeInstanceOf(Set);
|
|
762
|
-
if (process.platform === 'linux') {
|
|
763
|
-
expect(holders.has(path.resolve(process.cwd()))).toBe(true);
|
|
764
|
-
} else {
|
|
765
|
-
expect(holders.size).toBe(0);
|
|
766
|
-
}
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
// ──────────────────────────────────────────── epic kind (new)
|
|
770
|
-
|
|
771
|
-
test('[epic] createEpicWorktree names the branch sm-epic/<id>, distinct from the job root', async () => {
|
|
772
|
-
const epicId = 'my-epic-abc123';
|
|
773
|
-
const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
|
|
774
|
-
expect(result.ok).toBe(true);
|
|
775
|
-
expect(result.branch).toBe(`sm-epic/${epicId}`);
|
|
776
|
-
expect(result.dir.startsWith(gitWorktree.worktreeRootFor('epic'))).toBe(true);
|
|
777
|
-
expect(result.dir.startsWith(gitWorktree.worktreeRootFor('job'))).toBe(false);
|
|
778
|
-
expect(fs.existsSync(path.join(result.dir, 'README.md'))).toBe(true);
|
|
779
|
-
|
|
780
|
-
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
781
|
-
expect(fs.existsSync(result.dir)).toBe(false);
|
|
782
|
-
});
|
|
783
|
-
|
|
784
|
-
test('[epic] a commit made inside an epic worktree integrates onto the main tree HEAD', async () => {
|
|
785
|
-
const epicId = 'epic-integrate';
|
|
786
|
-
const worktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
|
|
787
|
-
expect(worktree.ok).toBe(true);
|
|
788
|
-
|
|
789
|
-
fs.writeFileSync(path.join(worktree.dir, 'epic-file.txt'), 'epic output\n', 'utf8');
|
|
790
|
-
git(['add', '-A'], worktree.dir);
|
|
791
|
-
git(['commit', '-q', '-m', 'epic commit'], worktree.dir);
|
|
792
|
-
|
|
793
|
-
const outcome = await gitWorktree.integrateEpicBranch({ cwd: repoCwd, branch: worktree.branch, epicId });
|
|
794
|
-
expect(outcome.ok).toBe(true);
|
|
795
|
-
expect(outcome.integrated).toBe(true);
|
|
796
|
-
expect(fs.existsSync(path.join(repoCwd, 'epic-file.txt'))).toBe(true);
|
|
797
|
-
|
|
798
|
-
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
test('[epic] no premature cap eviction — many concurrent epic worktrees stay under the default epic cap', async () => {
|
|
802
|
-
const created = [];
|
|
803
|
-
// The job kind's default cap is 4; prove the epic kind doesn't inherit that
|
|
804
|
-
// ceiling by creating more than 4 concurrent epic worktrees successfully.
|
|
805
|
-
for (let i = 0; i < 6; i++) {
|
|
806
|
-
const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: `epic-cap-${i}` });
|
|
807
|
-
expect(result.ok).toBe(true);
|
|
808
|
-
created.push(result);
|
|
809
|
-
}
|
|
810
|
-
expect(gitWorktree._getActiveWorktreeCountForTests('epic')).toBe(6);
|
|
811
|
-
// The job counter must be completely unaffected by epic-kind activity.
|
|
812
|
-
expect(gitWorktree._getActiveWorktreeCountForTests('job')).toBe(0);
|
|
813
|
-
|
|
814
|
-
for (const worktree of created) {
|
|
815
|
-
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
816
|
-
}
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
test('[epic] SM_EPIC_WORKTREE_MAX raises/lowers the epic cap independently of the job cap', async () => {
|
|
820
|
-
process.env.SM_EPIC_WORKTREE_MAX = '1';
|
|
821
|
-
const first = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'epic-max-1' });
|
|
822
|
-
expect(first.ok).toBe(true);
|
|
823
|
-
|
|
824
|
-
const second = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'epic-max-2' });
|
|
825
|
-
expect(second.ok).toBe(false);
|
|
826
|
-
expect(second.reason).toMatch(/cap reached/);
|
|
827
|
-
|
|
828
|
-
// The job cap (default 4) is untouched by SM_EPIC_WORKTREE_MAX=1.
|
|
829
|
-
const job1 = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'job-max-1' });
|
|
830
|
-
expect(job1.ok).toBe(true);
|
|
831
|
-
|
|
832
|
-
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
|
|
833
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: job1.dir, branch: job1.branch });
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
test('[epic] SM_EPIC_WORKTREE_DISABLE=1 restores in-place behaviour independently of the job kill-switch', async () => {
|
|
837
|
-
process.env.SM_EPIC_WORKTREE_DISABLE = '1';
|
|
838
|
-
const epicResult = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'x' });
|
|
839
|
-
expect(epicResult.ok).toBe(false);
|
|
840
|
-
expect(epicResult.reason).toMatch(/SM_EPIC_WORKTREE_DISABLE/);
|
|
841
|
-
|
|
842
|
-
// The job kind must still work — the two kill-switches are independent.
|
|
843
|
-
const jobResult = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
844
|
-
expect(jobResult.ok).toBe(true);
|
|
845
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: jobResult.dir, branch: jobResult.branch });
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
test('[epic] the per-project UI toggle (epicWorktreeProjectConfig.cjs) disables isolation for just that cwd', async () => {
|
|
849
|
-
const projectConfig = require('../epicWorktreeProjectConfig.cjs');
|
|
850
|
-
projectConfig.setEpicWorktreeDisabledForProject(repoCwd, true);
|
|
851
|
-
expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(true);
|
|
852
|
-
|
|
853
|
-
const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-x' });
|
|
854
|
-
expect(result.ok).toBe(false);
|
|
855
|
-
expect(result.reason).toMatch(/isolation toggle/);
|
|
856
|
-
|
|
857
|
-
// A different, untouched project cwd is unaffected — the toggle is
|
|
858
|
-
// per-project (its own <cwd>/session-manager-operations/prompt-sessions/
|
|
859
|
-
// epic-worktree-config.json), not machine-wide like the env var.
|
|
860
|
-
const otherCwd = path.join(tmpRoot, 'other-repo');
|
|
861
|
-
initRepo(otherCwd);
|
|
862
|
-
const otherResult = await gitWorktree.createEpicWorktree({ cwd: otherCwd, epicId: 'toggle-y' });
|
|
863
|
-
expect(otherResult.ok).toBe(true);
|
|
864
|
-
await gitWorktree.cleanupEpicWorktree({ cwd: otherCwd, dir: otherResult.dir, branch: otherResult.branch });
|
|
865
|
-
|
|
866
|
-
// Turning it back off restores isolation for the original project.
|
|
867
|
-
projectConfig.setEpicWorktreeDisabledForProject(repoCwd, false);
|
|
868
|
-
expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(false);
|
|
869
|
-
const restored = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-z' });
|
|
870
|
-
expect(restored.ok).toBe(true);
|
|
871
|
-
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: restored.dir, branch: restored.branch });
|
|
872
|
-
});
|
|
873
|
-
|
|
874
|
-
test('[epic] reconcileWorktreesOnBoot removes a leaked epic worktree with no liveness predicate given', async () => {
|
|
875
|
-
const epicId = 'epic-leak';
|
|
876
|
-
const worktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
|
|
877
|
-
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
878
|
-
gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
|
|
879
|
-
|
|
880
|
-
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic' });
|
|
881
|
-
|
|
882
|
-
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
883
|
-
const branches = git(['branch', '--list', worktree.branch], repoCwd);
|
|
884
|
-
expect(branches.trim()).toBe('');
|
|
885
|
-
});
|
|
886
|
-
|
|
887
|
-
test('[epic] reconcileWorktreesOnBoot does NOT remove a worktree whose owning Epic is still active, per the isLive predicate', async () => {
|
|
888
|
-
const liveEpicId = 'epic-still-active';
|
|
889
|
-
const deadEpicId = 'epic-orphaned';
|
|
890
|
-
const liveWorktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: liveEpicId });
|
|
891
|
-
const deadWorktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: deadEpicId });
|
|
892
|
-
gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
|
|
893
|
-
|
|
894
|
-
// Stub for a real active-index.json read (wired in the next PRD in this
|
|
895
|
-
// chain) — this only needs to prove reconcileWorktreesOnBoot accepts and
|
|
896
|
-
// respects a liveness predicate.
|
|
897
|
-
const activeIndexStub = new Set([liveEpicId]);
|
|
898
|
-
const isLive = async (epicId) => activeIndexStub.has(epicId);
|
|
899
|
-
|
|
900
|
-
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic', isLive });
|
|
901
|
-
|
|
902
|
-
expect(fs.existsSync(liveWorktree.dir)).toBe(true);
|
|
903
|
-
expect(fs.existsSync(deadWorktree.dir)).toBe(false);
|
|
904
|
-
const liveBranches = git(['branch', '--list', liveWorktree.branch], repoCwd);
|
|
905
|
-
expect(liveBranches).toContain(liveEpicId);
|
|
906
|
-
const deadBranches = git(['branch', '--list', deadWorktree.branch], repoCwd);
|
|
907
|
-
expect(deadBranches.trim()).toBe('');
|
|
908
|
-
|
|
909
|
-
// Cleanup: the live one is left registered by design; tear it down manually
|
|
910
|
-
// so afterEach's rm doesn't leave a dangling worktree registration.
|
|
911
|
-
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: liveWorktree.dir, branch: liveWorktree.branch });
|
|
912
|
-
});
|
|
913
|
-
|
|
914
|
-
test('[epic] reconcileWorktreesOnBoot for kind:epic never touches a job-kind worktree, and vice versa', async () => {
|
|
915
|
-
const jobWorktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cross-kind-job' });
|
|
916
|
-
const epicWorktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'cross-kind-epic' });
|
|
917
|
-
|
|
918
|
-
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic' });
|
|
919
|
-
expect(fs.existsSync(jobWorktree.dir)).toBe(true);
|
|
920
|
-
expect(fs.existsSync(epicWorktree.dir)).toBe(false);
|
|
921
|
-
|
|
922
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: jobWorktree.dir, branch: jobWorktree.branch });
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
test('keyFromBranch strips the kind-specific prefix and returns null for a foreign branch', () => {
|
|
926
|
-
expect(gitWorktree.keyFromBranch('epic', 'sm-epic/abc-123')).toBe('abc-123');
|
|
927
|
-
expect(gitWorktree.keyFromBranch('job', 'sm-job/my-slug')).toBe('my-slug');
|
|
928
|
-
expect(gitWorktree.keyFromBranch('epic', 'sm-job/my-slug')).toBe(null);
|
|
929
|
-
expect(gitWorktree.keyFromBranch('job', 'main')).toBe(null);
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
test('createWorktree throws on an unknown kind', async () => {
|
|
933
|
-
await expect(gitWorktree.createWorktree({ kind: 'bogus', cwd: repoCwd, key: 'x' })).rejects.toThrow(/unknown kind/);
|
|
934
|
-
});
|
|
935
|
-
|
|
936
|
-
// ──────────────────────────────────────────── isBaseTreeClean (PRD 1064)
|
|
937
|
-
|
|
938
|
-
test('isBaseTreeClean ignores untracked files — a single stray untracked file must not disable isolation', async () => {
|
|
939
|
-
fs.writeFileSync(path.join(repoCwd, 'scratch_untracked.txt'), 'not tracked\n', 'utf8');
|
|
940
|
-
expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(true);
|
|
941
|
-
});
|
|
942
|
-
|
|
943
|
-
test('isBaseTreeClean still reports dirty for a modified TRACKED file', async () => {
|
|
944
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'modified tracked content\n', 'utf8');
|
|
945
|
-
expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(false);
|
|
946
|
-
});
|
|
947
|
-
|
|
948
|
-
// ──────────────────────────────────────────── orphaned on-disk checkouts (PRD 1108)
|
|
949
|
-
|
|
950
|
-
test('[job] cleanupWorktree rmdirs the now-empty parent hash dir, but a sibling checkout in the same hash dir survives', async () => {
|
|
951
|
-
const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'sibling-a' });
|
|
952
|
-
const second = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'sibling-b' });
|
|
953
|
-
expect(first.ok).toBe(true);
|
|
954
|
-
expect(second.ok).toBe(true);
|
|
955
|
-
const hashDir = path.dirname(first.dir);
|
|
956
|
-
expect(path.dirname(second.dir)).toBe(hashDir);
|
|
957
|
-
|
|
958
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
|
|
959
|
-
expect(fs.existsSync(first.dir)).toBe(false);
|
|
960
|
-
// The hash dir must survive — the sibling checkout still lives under it.
|
|
961
|
-
expect(fs.existsSync(hashDir)).toBe(true);
|
|
962
|
-
expect(fs.existsSync(second.dir)).toBe(true);
|
|
963
|
-
|
|
964
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: second.dir, branch: second.branch });
|
|
965
|
-
expect(fs.existsSync(second.dir)).toBe(false);
|
|
966
|
-
// Now that the last sibling is gone, the empty hash dir is reclaimed too.
|
|
967
|
-
expect(fs.existsSync(hashDir)).toBe(false);
|
|
968
|
-
});
|
|
969
|
-
|
|
970
|
-
test('[job] reconcileWorktreesOnBoot reclaims an orphaned on-disk checkout for a project cwd it was never handed', async () => {
|
|
971
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-old' });
|
|
972
|
-
expect(worktree.ok).toBe(true);
|
|
973
|
-
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
974
|
-
// Simulate age: back-date the checkout dir's mtime well past the 1s
|
|
975
|
-
// threshold used below, without touching anything about its content.
|
|
976
|
-
const old = new Date(Date.now() - 60_000);
|
|
977
|
-
fs.utimesSync(worktree.dir, old, old);
|
|
978
|
-
|
|
979
|
-
// No cwd handed in at all — this is the "project no longer tracked" case
|
|
980
|
-
// the per-cwd loop can never reach on its own.
|
|
981
|
-
await gitWorktree.reconcileWorktreesOnBoot([], { kind: 'job', staleAgeMs: 1_000 });
|
|
982
|
-
|
|
983
|
-
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
984
|
-
// The hash dir it lived under is empty now and must be reclaimed too.
|
|
985
|
-
expect(fs.existsSync(path.dirname(worktree.dir))).toBe(false);
|
|
986
|
-
// The main tree's own worktree registration must not be left dangling.
|
|
987
|
-
const list = git(['worktree', 'list', '--porcelain'], repoCwd);
|
|
988
|
-
expect(list).not.toContain(worktree.dir);
|
|
989
|
-
});
|
|
990
|
-
|
|
991
|
-
test('[job] reconcileWorktreesOnBoot never reclaims a fresh, in-use checkout younger than the age threshold', async () => {
|
|
992
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'fresh-in-use' });
|
|
993
|
-
expect(worktree.ok).toBe(true);
|
|
994
|
-
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
995
|
-
|
|
996
|
-
await gitWorktree.reconcileWorktreesOnBoot([], { kind: 'job', staleAgeMs: 24 * 60 * 60 * 1000 });
|
|
997
|
-
|
|
998
|
-
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
999
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
test('[epic] the orphan sweep age threshold defaults much higher (7 days) than the job kind (24h)', () => {
|
|
1003
|
-
expect(gitWorktree.getStaleSweepAgeMs('job')).toBe(24 * 60 * 60 * 1000);
|
|
1004
|
-
expect(gitWorktree.getStaleSweepAgeMs('epic')).toBe(7 * 24 * 60 * 60 * 1000);
|
|
1005
|
-
});
|
|
1006
|
-
|
|
1007
|
-
test('[epic] SM_EPIC_WORKTREE_STALE_MS overrides the epic orphan-sweep age threshold independently of the job env var', () => {
|
|
1008
|
-
const original = process.env.SM_EPIC_WORKTREE_STALE_MS;
|
|
1009
|
-
try {
|
|
1010
|
-
process.env.SM_EPIC_WORKTREE_STALE_MS = '12345';
|
|
1011
|
-
expect(gitWorktree.getStaleSweepAgeMs('epic')).toBe(12345);
|
|
1012
|
-
expect(gitWorktree.getStaleSweepAgeMs('job')).toBe(24 * 60 * 60 * 1000);
|
|
1013
|
-
} finally {
|
|
1014
|
-
if (original === undefined) delete process.env.SM_EPIC_WORKTREE_STALE_MS;
|
|
1015
|
-
else process.env.SM_EPIC_WORKTREE_STALE_MS = original;
|
|
1016
|
-
}
|
|
1017
|
-
});
|
|
1018
|
-
|
|
1019
|
-
test('[job] sweepStaleWorktreeCheckouts treats staleAgeMs: 0 as "reclaim immediately", not as "unset — use the default"', async () => {
|
|
1020
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'zero-age' });
|
|
1021
|
-
expect(worktree.ok).toBe(true);
|
|
1022
|
-
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
1023
|
-
|
|
1024
|
-
const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0 });
|
|
1025
|
-
|
|
1026
|
-
expect(result.checkoutsRemoved).toBe(1);
|
|
1027
|
-
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
1028
|
-
});
|
|
1029
|
-
|
|
1030
|
-
test('[job] sweepStaleWorktreeCheckouts does NOT remove an old-mtime checkout with a live holder, regardless of age', async () => {
|
|
1031
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'stale-but-live' });
|
|
1032
|
-
expect(worktree.ok).toBe(true);
|
|
1033
|
-
const holders = new Set([path.resolve(worktree.dir)]);
|
|
1034
|
-
|
|
1035
|
-
const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0, holders });
|
|
1036
|
-
|
|
1037
|
-
expect(result.checkoutsRemoved).toBe(0);
|
|
1038
|
-
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
1039
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
1040
|
-
});
|
|
1041
|
-
|
|
1042
|
-
test('[job] sweepStaleWorktreeCheckouts removes an old-mtime checkout when there is no live holder', async () => {
|
|
1043
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'stale-and-dead' });
|
|
1044
|
-
expect(worktree.ok).toBe(true);
|
|
1045
|
-
// A populated holders set (some unrelated live cwd) that does not cover this checkout —
|
|
1046
|
-
// exercises the liveness gate for real instead of defeating it with an empty set.
|
|
1047
|
-
const holders = new Set([path.resolve(tmpRoot, 'unrelated-live-holder')]);
|
|
1048
|
-
|
|
1049
|
-
const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0, holders });
|
|
1050
|
-
|
|
1051
|
-
expect(result.checkoutsRemoved).toBe(1);
|
|
1052
|
-
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
1053
|
-
});
|
|
1054
|
-
|
|
1055
|
-
test('[job] sweepStaleWorktreeCheckouts never deletes anything outside its own kind root, even via a symlink shaped like a traversal', async () => {
|
|
1056
|
-
const outsideDir = path.join(tmpRoot, 'outside-root');
|
|
1057
|
-
fs.mkdirSync(outsideDir, { recursive: true });
|
|
1058
|
-
const markerFile = path.join(outsideDir, 'do-not-delete.txt');
|
|
1059
|
-
fs.writeFileSync(markerFile, 'still here\n', 'utf8');
|
|
1060
|
-
|
|
1061
|
-
const root = gitWorktree.worktreeRootFor('job');
|
|
1062
|
-
const hashDir = path.join(root, 'traversal-hash-dir');
|
|
1063
|
-
fs.mkdirSync(hashDir, { recursive: true });
|
|
1064
|
-
const escapePath = path.join(hashDir, 'escape');
|
|
1065
|
-
fs.symlinkSync(outsideDir, escapePath, 'dir');
|
|
1066
|
-
|
|
1067
|
-
const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0 });
|
|
1068
|
-
|
|
1069
|
-
expect(fs.existsSync(markerFile)).toBe(true);
|
|
1070
|
-
expect(fs.existsSync(outsideDir)).toBe(true);
|
|
1071
|
-
expect(result.checkoutsRemoved).toBe(0);
|
|
1072
|
-
|
|
1073
|
-
fs.rmSync(hashDir, { recursive: true, force: true });
|
|
1074
|
-
});
|
|
1075
|
-
|
|
1076
|
-
test('[job] reconcileWorktreesOnBoot logs a one-shot report line with reclaimed counts', async () => {
|
|
1077
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
1078
|
-
try {
|
|
1079
|
-
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' });
|
|
1080
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/boot sweep \(kind:job\): reclaimed \d+ checkout\(s\), \d+ empty root dir\(s\)/));
|
|
1081
|
-
} finally {
|
|
1082
|
-
logSpy.mockRestore();
|
|
1083
|
-
}
|
|
1084
|
-
});
|
|
1085
|
-
|
|
1086
|
-
// ──────────────────────────────────────────── PRD 1125: auto-resolve identical-duplicate merge blocks
|
|
1087
|
-
|
|
1088
|
-
test('[job] integrateBranch auto-resolves when every blocking path (tracked + untracked) is byte-identical to the branch', async () => {
|
|
1089
|
-
const slug = 'test-slug-identical-duplicates';
|
|
1090
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1091
|
-
expect(worktree.ok).toBe(true);
|
|
1092
|
-
|
|
1093
|
-
// The job's own real work, committed on its branch.
|
|
1094
|
-
fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'shared-value\n', 'utf8');
|
|
1095
|
-
fs.mkdirSync(path.join(worktree.dir, 'newdir'), { recursive: true });
|
|
1096
|
-
fs.writeFileSync(path.join(worktree.dir, 'newdir', 'fileU'), 'untracked-shared-value\n', 'utf8');
|
|
1097
|
-
git(['add', '-A'], worktree.dir);
|
|
1098
|
-
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
1099
|
-
|
|
1100
|
-
// The shared main tree independently ends up with the SAME bytes on both
|
|
1101
|
-
// a tracked path (dirty, uncommitted) and an untracked path — exactly the
|
|
1102
|
-
// 2026-09-06 starry-night-ships incident shape: a prior in-place run left
|
|
1103
|
-
// these duplicates behind uncommitted.
|
|
1104
|
-
fs.writeFileSync(path.join(repoCwd, 'fileA'), 'shared-value\n', 'utf8');
|
|
1105
|
-
fs.mkdirSync(path.join(repoCwd, 'newdir'), { recursive: true });
|
|
1106
|
-
fs.writeFileSync(path.join(repoCwd, 'newdir', 'fileU'), 'untracked-shared-value\n', 'utf8');
|
|
1107
|
-
|
|
1108
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1109
|
-
expect(outcome.ok).toBe(true);
|
|
1110
|
-
expect(outcome.integrated).toBe(true);
|
|
1111
|
-
expect(outcome.mergeCommit ?? outcome.fastForward).toBeTruthy();
|
|
1112
|
-
expect(outcome.autoResolved).toBe('identical_working_tree_duplicates');
|
|
1113
|
-
expect(outcome.resolvedPaths.sort()).toEqual(['fileA', 'newdir/fileU'].sort());
|
|
1114
|
-
|
|
1115
|
-
expect(fs.readFileSync(path.join(repoCwd, 'fileA'), 'utf8')).toBe('shared-value\n');
|
|
1116
|
-
expect(fs.readFileSync(path.join(repoCwd, 'newdir', 'fileU'), 'utf8')).toBe('untracked-shared-value\n');
|
|
1117
|
-
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1118
|
-
|
|
1119
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
|
|
1120
|
-
});
|
|
1121
|
-
|
|
1122
|
-
test('[job] integrateBranch does NOT auto-resolve when one blocking path differs from the branch — same reason as today', async () => {
|
|
1123
|
-
const slug = 'test-slug-one-differing-path';
|
|
1124
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1125
|
-
expect(worktree.ok).toBe(true);
|
|
1126
|
-
|
|
1127
|
-
fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'branch-value\n', 'utf8');
|
|
1128
|
-
git(['add', '-A'], worktree.dir);
|
|
1129
|
-
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
1130
|
-
|
|
1131
|
-
// Main tree's dirty copy diverges from the branch's committed content —
|
|
1132
|
-
// real information is at risk, so this must NOT be discarded.
|
|
1133
|
-
fs.writeFileSync(path.join(repoCwd, 'fileA'), 'different-local-value\n', 'utf8');
|
|
1134
|
-
|
|
1135
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1136
|
-
expect(outcome.ok).toBe(false);
|
|
1137
|
-
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
1138
|
-
expect(fs.readFileSync(path.join(repoCwd, 'fileA'), 'utf8')).toBe('different-local-value\n');
|
|
1139
|
-
expect(git(['status', '--porcelain'], repoCwd).trim()).not.toBe('');
|
|
1140
|
-
|
|
1141
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1142
|
-
});
|
|
1143
|
-
|
|
1144
|
-
test('[job] integrateBranch falls through to today\'s failure when stderr has no parseable blocking-path block (a genuine content conflict)', async () => {
|
|
1145
|
-
const slug = 'test-slug-unparseable-stderr';
|
|
1146
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1147
|
-
expect(worktree.ok).toBe(true);
|
|
1148
|
-
|
|
1149
|
-
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
1150
|
-
git(['add', '-A'], worktree.dir);
|
|
1151
|
-
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
1152
|
-
|
|
1153
|
-
// A committed (not dirty) diverging edit on the main tree produces git's
|
|
1154
|
-
// ordinary "CONFLICT (content)" stderr shape, not the "would be
|
|
1155
|
-
// overwritten" block parseBlockingMergePaths looks for.
|
|
1156
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
|
|
1157
|
-
git(['add', '-A'], repoCwd);
|
|
1158
|
-
git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
|
|
1159
|
-
|
|
1160
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1161
|
-
expect(outcome.ok).toBe(false);
|
|
1162
|
-
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
1163
|
-
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1164
|
-
|
|
1165
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1166
|
-
});
|
|
1167
|
-
|
|
1168
|
-
test('[job] integrateBranch aborts cleanly when the single retry also fails on a genuine conflict elsewhere', async () => {
|
|
1169
|
-
const slug = 'test-slug-retry-fails';
|
|
1170
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1171
|
-
expect(worktree.ok).toBe(true);
|
|
1172
|
-
|
|
1173
|
-
// The branch's own real work: extends fileA (existing, tracked since
|
|
1174
|
-
// initRepo's README.md commit doesn't cover it — add it fresh here) and
|
|
1175
|
-
// ADDS a brand-new fileB.txt.
|
|
1176
|
-
fs.writeFileSync(path.join(worktree.dir, 'fileA'), 'shared-value\n', 'utf8');
|
|
1177
|
-
fs.writeFileSync(path.join(worktree.dir, 'fileB.txt'), 'branch B change\n', 'utf8');
|
|
1178
|
-
git(['add', '-A'], worktree.dir);
|
|
1179
|
-
git(['commit', '-q', '-m', 'job commit: fileA + fileB'], worktree.dir);
|
|
1180
|
-
|
|
1181
|
-
// Main tree independently ADDS the SAME path with DIFFERENT content — a
|
|
1182
|
-
// genuine "both added, diverging content" conflict on fileB.txt.
|
|
1183
|
-
fs.writeFileSync(path.join(repoCwd, 'fileB.txt'), 'base B change\n', 'utf8');
|
|
1184
|
-
git(['add', '-A'], repoCwd);
|
|
1185
|
-
git(['commit', '-q', '-m', 'main tree diverges fileB'], repoCwd);
|
|
1186
|
-
// ...and ALSO leaves fileA dirty with content byte-identical to the branch
|
|
1187
|
-
// — the one blocking path the initial merge attempt refuses on, and the
|
|
1188
|
-
// one the auto-resolve retry successfully discards.
|
|
1189
|
-
fs.writeFileSync(path.join(repoCwd, 'fileA'), 'shared-value\n', 'utf8');
|
|
1190
|
-
|
|
1191
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1192
|
-
expect(outcome.ok).toBe(false);
|
|
1193
|
-
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
1194
|
-
// The retry's half-applied merge must be aborted, not left mid-merge.
|
|
1195
|
-
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1196
|
-
expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
1197
|
-
|
|
1198
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1199
|
-
});
|
|
1200
|
-
|
|
1201
|
-
// ──────────────────────────────────────────── default-branch integration guard (PRD: starry-night-ships wrong-base merges)
|
|
1202
|
-
|
|
1203
|
-
test('[guard] integrateBranch proceeds normally when HEAD is on the repo default branch', async () => {
|
|
1204
|
-
const slug = 'test-slug-guard-default-ok';
|
|
1205
|
-
const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
|
|
1206
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1207
|
-
expect(worktree.ok).toBe(true);
|
|
1208
|
-
|
|
1209
|
-
fs.writeFileSync(path.join(worktree.dir, 'guard-ok.txt'), 'job output\n', 'utf8');
|
|
1210
|
-
git(['add', '-A'], worktree.dir);
|
|
1211
|
-
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
1212
|
-
|
|
1213
|
-
expect(await gitWorktree.getCurrentBranch(repoCwd)).toBe(defaultBranch);
|
|
1214
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1215
|
-
expect(outcome.ok).toBe(true);
|
|
1216
|
-
expect(outcome.integrated).toBe(true);
|
|
1217
|
-
|
|
1218
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
1219
|
-
});
|
|
1220
|
-
|
|
1221
|
-
test('[guard] integrateBranch refuses when HEAD is on a stray sm-job/* branch, naming both branches in the reason, and mutates nothing', async () => {
|
|
1222
|
-
const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
|
|
1223
|
-
const slugB = 'test-slug-guard-stray-b';
|
|
1224
|
-
const strayBranch = 'sm-job/leftover-from-a-prior-run';
|
|
1225
|
-
const worktreeB = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: slugB });
|
|
1226
|
-
fs.writeFileSync(path.join(worktreeB.dir, 'guard-stray.txt'), 'branch b output\n', 'utf8');
|
|
1227
|
-
git(['add', '-A'], worktreeB.dir);
|
|
1228
|
-
git(['commit', '-q', '-m', 'job commit b'], worktreeB.dir);
|
|
1229
|
-
|
|
1230
|
-
// Simulate the live incident: the SHARED tree's own HEAD got checked out
|
|
1231
|
-
// onto a leftover job branch instead of staying on the default branch.
|
|
1232
|
-
git(['checkout', '-b', strayBranch], repoCwd);
|
|
1233
|
-
const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
1234
|
-
const statusBefore = git(['status', '--porcelain'], repoCwd);
|
|
1235
|
-
|
|
1236
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktreeB.branch, slug: slugB });
|
|
1237
|
-
expect(outcome.ok).toBe(false);
|
|
1238
|
-
expect(outcome.reason).toContain(defaultBranch);
|
|
1239
|
-
expect(outcome.reason).toContain(strayBranch);
|
|
1240
|
-
|
|
1241
|
-
// Refusing must not touch git state: no checkout/reset/switch performed.
|
|
1242
|
-
expect(git(['rev-parse', 'HEAD'], repoCwd).trim()).toBe(headBefore);
|
|
1243
|
-
expect(git(['status', '--porcelain'], repoCwd)).toBe(statusBefore);
|
|
1244
|
-
|
|
1245
|
-
git(['checkout', defaultBranch], repoCwd);
|
|
1246
|
-
git(['branch', '-D', strayBranch], repoCwd);
|
|
1247
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktreeB.dir, branch: worktreeB.branch, keepBranch: true });
|
|
1248
|
-
try { git(['branch', '-D', worktreeB.branch], repoCwd); } catch { /* best-effort */ }
|
|
1249
|
-
});
|
|
1250
|
-
|
|
1251
|
-
test('[guard] integrateBranch refuses on a detached HEAD, reporting "detached HEAD" rather than treating it as a branch', async () => {
|
|
1252
|
-
const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
|
|
1253
|
-
const slug = 'test-slug-guard-detached';
|
|
1254
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1255
|
-
fs.writeFileSync(path.join(worktree.dir, 'guard-detached.txt'), 'output\n', 'utf8');
|
|
1256
|
-
git(['add', '-A'], worktree.dir);
|
|
1257
|
-
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
1258
|
-
|
|
1259
|
-
const headSha = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
1260
|
-
git(['checkout', '--detach', headSha], repoCwd);
|
|
1261
|
-
expect(await gitWorktree.getCurrentBranch(repoCwd)).toBeNull();
|
|
1262
|
-
|
|
1263
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1264
|
-
expect(outcome.ok).toBe(false);
|
|
1265
|
-
expect(outcome.reason).toContain('detached HEAD');
|
|
1266
|
-
expect(outcome.reason).toContain(defaultBranch);
|
|
1267
|
-
|
|
1268
|
-
git(['checkout', defaultBranch], repoCwd);
|
|
1269
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1270
|
-
try { git(['branch', '-D', worktree.branch], repoCwd); } catch { /* best-effort */ }
|
|
1271
|
-
});
|
|
1272
|
-
|
|
1273
|
-
test('[guard] resolveDefaultBranch resolves a sensible default (the sole local branch) for a repo with no remote, and integration still proceeds', async () => {
|
|
1274
|
-
const remoteOut = execFileSync('git', ['remote'], { cwd: repoCwd, encoding: 'utf8' }).trim();
|
|
1275
|
-
expect(remoteOut).toBe('');
|
|
1276
|
-
const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
|
|
1277
|
-
expect(await gitWorktree.resolveDefaultBranch(repoCwd)).toBe(defaultBranch);
|
|
1278
|
-
|
|
1279
|
-
const slug = 'test-slug-guard-no-remote';
|
|
1280
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1281
|
-
fs.writeFileSync(path.join(worktree.dir, 'guard-no-remote.txt'), 'output\n', 'utf8');
|
|
1282
|
-
git(['add', '-A'], worktree.dir);
|
|
1283
|
-
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
1284
|
-
|
|
1285
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1286
|
-
expect(outcome.ok).toBe(true);
|
|
1287
|
-
expect(outcome.integrated).toBe(true);
|
|
1288
|
-
|
|
1289
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
1290
|
-
});
|
|
1291
|
-
|
|
1292
|
-
test('[guard][epic] integrateEpicBranch inherits the same guard and still merges cleanly when HEAD is on the default branch', async () => {
|
|
1293
|
-
const epicId = 'test-epic-guard-ok';
|
|
1294
|
-
const worktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
|
|
1295
|
-
expect(worktree.ok).toBe(true);
|
|
1296
|
-
|
|
1297
|
-
fs.writeFileSync(path.join(worktree.dir, 'epic-guard.txt'), 'epic output\n', 'utf8');
|
|
1298
|
-
git(['add', '-A'], worktree.dir);
|
|
1299
|
-
git(['commit', '-q', '-m', 'epic commit'], worktree.dir);
|
|
1300
|
-
|
|
1301
|
-
const outcome = await gitWorktree.integrateEpicBranch({ cwd: repoCwd, branch: worktree.branch, epicId });
|
|
1302
|
-
expect(outcome.ok).toBe(true);
|
|
1303
|
-
expect(outcome.integrated).toBe(true);
|
|
1304
|
-
|
|
1305
|
-
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
1306
|
-
});
|
|
1307
|
-
|
|
1308
|
-
// ── integrateBranch failure subtype classification (mc-01) ──
|
|
1309
|
-
|
|
1310
|
-
test('[job] integrateBranch classifies a dirty different tracked file as failureKind blocking_paths', async () => {
|
|
1311
|
-
const slug = 'test-slug-kind-blocking';
|
|
1312
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1313
|
-
expect(worktree.ok).toBe(true);
|
|
1314
|
-
|
|
1315
|
-
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
1316
|
-
git(['add', '-A'], worktree.dir);
|
|
1317
|
-
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
1318
|
-
// Dirty (uncommitted), byte-DIFFERENT edit in the main tree blocks the merge.
|
|
1319
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'different local edit\n', 'utf8');
|
|
1320
|
-
|
|
1321
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1322
|
-
expect(outcome.ok).toBe(false);
|
|
1323
|
-
expect(outcome.reason).toMatch(/merge failed \(likely a real content conflict\)/);
|
|
1324
|
-
expect(outcome.failureKind).toBe('blocking_paths');
|
|
1325
|
-
expect(outcome.blockingPaths.tracked).toEqual(['README.md']);
|
|
1326
|
-
expect(outcome.blockingPaths.untracked).toEqual([]);
|
|
1327
|
-
|
|
1328
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1329
|
-
});
|
|
1330
|
-
|
|
1331
|
-
test('[job] integrateBranch classifies diverging same-line commits as content_conflict with the conflicted path', async () => {
|
|
1332
|
-
const slug = 'test-slug-kind-content';
|
|
1333
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1334
|
-
expect(worktree.ok).toBe(true);
|
|
1335
|
-
|
|
1336
|
-
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
1337
|
-
git(['add', '-A'], worktree.dir);
|
|
1338
|
-
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
1339
|
-
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
|
|
1340
|
-
git(['add', '-A'], repoCwd);
|
|
1341
|
-
git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
|
|
1342
|
-
|
|
1343
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1344
|
-
expect(outcome.ok).toBe(false);
|
|
1345
|
-
expect(outcome.failureKind).toBe('content_conflict');
|
|
1346
|
-
expect(outcome.conflictedPaths).toEqual(['README.md']);
|
|
1347
|
-
// The abort still ran.
|
|
1348
|
-
expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
1349
|
-
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1350
|
-
|
|
1351
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1352
|
-
});
|
|
1353
|
-
|
|
1354
|
-
test('[job] integrateBranch classifies disjoint appends to the END of one file as content_conflict when the path is not allowlisted (.js)', async () => {
|
|
1355
|
-
const slug = 'test-slug-kind-append';
|
|
1356
|
-
fs.writeFileSync(path.join(repoCwd, 'src.js'), '// intro\n', 'utf8');
|
|
1357
|
-
git(['add', '-A'], repoCwd);
|
|
1358
|
-
git(['commit', '-q', '-m', 'add src.js'], repoCwd);
|
|
1359
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1360
|
-
expect(worktree.ok).toBe(true);
|
|
1361
|
-
|
|
1362
|
-
fs.appendFileSync(path.join(worktree.dir, 'src.js'), '\n// Section Six\nsix body\n', 'utf8');
|
|
1363
|
-
git(['add', '-A'], worktree.dir);
|
|
1364
|
-
git(['commit', '-q', '-m', 'job appends section six'], worktree.dir);
|
|
1365
|
-
fs.appendFileSync(path.join(repoCwd, 'src.js'), '\n## Section Seven\nseven body\n\n## Section Ten\nten body\n', 'utf8');
|
|
1366
|
-
git(['add', '-A'], repoCwd);
|
|
1367
|
-
git(['commit', '-q', '-m', 'main appends sections seven and ten'], repoCwd);
|
|
1368
|
-
|
|
1369
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1370
|
-
expect(outcome.ok).toBe(false);
|
|
1371
|
-
expect(outcome.failureKind).toBe('content_conflict');
|
|
1372
|
-
expect(outcome.conflictedPaths).toEqual(['src.js']);
|
|
1373
|
-
|
|
1374
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1375
|
-
});
|
|
1376
|
-
|
|
1377
|
-
// ── pure-addition merge-conflict auto-resolve (mc-02) ──
|
|
1378
|
-
|
|
1379
|
-
async function conflictScenario(slug, { base, side, main }) {
|
|
1380
|
-
for (const [f, c] of Object.entries(base)) {
|
|
1381
|
-
fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
|
|
1382
|
-
fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
|
|
1383
|
-
}
|
|
1384
|
-
git(['add', '-A'], repoCwd);
|
|
1385
|
-
git(['commit', '-q', '-m', 'base files'], repoCwd);
|
|
1386
|
-
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
1387
|
-
expect(worktree.ok).toBe(true);
|
|
1388
|
-
for (const [f, c] of Object.entries(side)) {
|
|
1389
|
-
fs.mkdirSync(path.dirname(path.join(worktree.dir, f)), { recursive: true });
|
|
1390
|
-
fs.writeFileSync(path.join(worktree.dir, f), c, 'utf8');
|
|
1391
|
-
}
|
|
1392
|
-
git(['add', '-A'], worktree.dir);
|
|
1393
|
-
git(['commit', '-q', '-m', 'job side'], worktree.dir);
|
|
1394
|
-
for (const [f, c] of Object.entries(main)) {
|
|
1395
|
-
fs.mkdirSync(path.dirname(path.join(repoCwd, f)), { recursive: true });
|
|
1396
|
-
fs.writeFileSync(path.join(repoCwd, f), c, 'utf8');
|
|
1397
|
-
}
|
|
1398
|
-
git(['add', '-A'], repoCwd);
|
|
1399
|
-
git(['commit', '-q', '-m', 'main side'], repoCwd);
|
|
1400
|
-
return worktree;
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
const MARKER_RE = /^(<<<<<<<|\|\|\|\|\|\|\||=======|>>>>>>>)/m;
|
|
1404
|
-
|
|
1405
|
-
test('[job] integrateBranch auto-resolves a pure-addition .md conflict by concatenating ours then theirs', async () => {
|
|
1406
|
-
const slug = 'test-slug-pure-add';
|
|
1407
|
-
const worktree = await conflictScenario(slug, {
|
|
1408
|
-
base: { 'd.md': '# Doc\n\nintro\n' },
|
|
1409
|
-
side: { 'd.md': '# Doc\n\nintro\n\n## Section A\na body\n' },
|
|
1410
|
-
main: { 'd.md': '# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n' },
|
|
1411
|
-
});
|
|
1412
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1413
|
-
expect(outcome.ok).toBe(true);
|
|
1414
|
-
expect(outcome.autoResolved).toBe('pure_addition_concat');
|
|
1415
|
-
expect(outcome.resolvedPaths).toEqual(['d.md']);
|
|
1416
|
-
const merged = fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8');
|
|
1417
|
-
expect(merged).toBe('# Doc\n\nintro\n\n## Section B\nb body\n\n## Section C\nc body\n\n## Section A\na body\n');
|
|
1418
|
-
expect(merged).not.toMatch(MARKER_RE);
|
|
1419
|
-
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1420
|
-
expect(git(['log', '-1', '--format=%s'], repoCwd).trim()).toBe(`merge scheduler job ${slug}`);
|
|
1421
|
-
expect(git(['rev-list', '--parents', '-n', '1', 'HEAD'], repoCwd).trim().split(' ').length).toBe(3);
|
|
1422
|
-
|
|
1423
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
1424
|
-
});
|
|
1425
|
-
|
|
1426
|
-
test('[job] integrateBranch does NOT auto-resolve a same-line .md edit and leaves the tree clean', async () => {
|
|
1427
|
-
const slug = 'test-slug-pure-add-neg-sameline';
|
|
1428
|
-
const worktree = await conflictScenario(slug, {
|
|
1429
|
-
base: { 'd.md': 'intro\nORIGINAL LINE\n' },
|
|
1430
|
-
side: { 'd.md': 'intro\nSIDE EDIT\n' },
|
|
1431
|
-
main: { 'd.md': 'intro\nMAIN EDIT\n' },
|
|
1432
|
-
});
|
|
1433
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1434
|
-
expect(outcome.ok).toBe(false);
|
|
1435
|
-
expect(outcome.autoResolved).toBeUndefined();
|
|
1436
|
-
expect(outcome.failureKind).toBe('content_conflict');
|
|
1437
|
-
expect(outcome.conflictedPaths).toEqual(['d.md']);
|
|
1438
|
-
expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\nMAIN EDIT\n');
|
|
1439
|
-
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1440
|
-
|
|
1441
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1442
|
-
});
|
|
1443
|
-
|
|
1444
|
-
test('[job] integrateBranch does NOT auto-resolve a pure-addition conflict on a non-allowlisted path', async () => {
|
|
1445
|
-
const slug = 'test-slug-pure-add-neg-js';
|
|
1446
|
-
const worktree = await conflictScenario(slug, {
|
|
1447
|
-
base: { 'src/thing.js': '// intro\n' },
|
|
1448
|
-
side: { 'src/thing.js': '// intro\n// A\n' },
|
|
1449
|
-
main: { 'src/thing.js': '// intro\n// B\n// C\n' },
|
|
1450
|
-
});
|
|
1451
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1452
|
-
expect(outcome.ok).toBe(false);
|
|
1453
|
-
expect(outcome.autoResolved).toBeUndefined();
|
|
1454
|
-
expect(outcome.failureKind).toBe('content_conflict');
|
|
1455
|
-
expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('// intro\n// B\n// C\n');
|
|
1456
|
-
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1457
|
-
|
|
1458
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1459
|
-
});
|
|
1460
|
-
|
|
1461
|
-
test('[job] integrateBranch bails the whole merge when only some conflicted paths are provable (mixed .md + .js)', async () => {
|
|
1462
|
-
const slug = 'test-slug-pure-add-neg-mixed';
|
|
1463
|
-
const worktree = await conflictScenario(slug, {
|
|
1464
|
-
base: { 'd.md': 'intro\n', 'src/thing.js': 'ORIGINAL\n' },
|
|
1465
|
-
side: { 'd.md': 'intro\n## A\n', 'src/thing.js': 'SIDE\n' },
|
|
1466
|
-
main: { 'd.md': 'intro\n## B\n', 'src/thing.js': 'MAIN\n' },
|
|
1467
|
-
});
|
|
1468
|
-
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
1469
|
-
expect(outcome.ok).toBe(false);
|
|
1470
|
-
expect(outcome.autoResolved).toBeUndefined();
|
|
1471
|
-
expect(outcome.conflictedPaths).toEqual(['d.md', 'src/thing.js']);
|
|
1472
|
-
expect(fs.readFileSync(path.join(repoCwd, 'd.md'), 'utf8')).toBe('intro\n## B\n');
|
|
1473
|
-
expect(fs.readFileSync(path.join(repoCwd, 'src/thing.js'), 'utf8')).toBe('MAIN\n');
|
|
1474
|
-
expect(git(['status', '--porcelain'], repoCwd).trim()).toBe('');
|
|
1475
|
-
expect(fs.existsSync(path.join(repoCwd, '.git', 'MERGE_HEAD'))).toBe(false);
|
|
1476
|
-
|
|
1477
|
-
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
1478
|
-
});
|