@paths.design/caws-cli 10.2.0 → 11.0.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 +125 -374
- package/dist/index.js +43 -785
- package/dist/shell/binding/resolve-binding.d.ts +4 -0
- package/dist/shell/binding/resolve-binding.d.ts.map +1 -0
- package/dist/shell/binding/resolve-binding.js +228 -0
- package/dist/shell/binding/resolve-binding.js.map +1 -0
- package/dist/shell/binding/types.d.ts +42 -0
- package/dist/shell/binding/types.d.ts.map +1 -0
- package/dist/shell/binding/types.js +21 -0
- package/dist/shell/binding/types.js.map +1 -0
- package/dist/shell/commands/claim.d.ts +14 -0
- package/dist/shell/commands/claim.d.ts.map +1 -0
- package/dist/shell/commands/claim.js +197 -0
- package/dist/shell/commands/claim.js.map +1 -0
- package/dist/shell/commands/doctor.d.ts +13 -0
- package/dist/shell/commands/doctor.d.ts.map +1 -0
- package/dist/shell/commands/doctor.js +97 -0
- package/dist/shell/commands/doctor.js.map +1 -0
- package/dist/shell/commands/evidence.d.ts +28 -0
- package/dist/shell/commands/evidence.d.ts.map +1 -0
- package/dist/shell/commands/evidence.js +166 -0
- package/dist/shell/commands/evidence.js.map +1 -0
- package/dist/shell/commands/gates.d.ts +19 -0
- package/dist/shell/commands/gates.d.ts.map +1 -0
- package/dist/shell/commands/gates.js +181 -0
- package/dist/shell/commands/gates.js.map +1 -0
- package/dist/shell/commands/init.d.ts +8 -0
- package/dist/shell/commands/init.d.ts.map +1 -0
- package/dist/shell/commands/init.js +64 -0
- package/dist/shell/commands/init.js.map +1 -0
- package/dist/shell/commands/scope.d.ts +11 -0
- package/dist/shell/commands/scope.d.ts.map +1 -0
- package/dist/shell/commands/scope.js +92 -0
- package/dist/shell/commands/scope.js.map +1 -0
- package/dist/shell/commands/status.d.ts +15 -0
- package/dist/shell/commands/status.d.ts.map +1 -0
- package/dist/shell/commands/status.js +106 -0
- package/dist/shell/commands/status.js.map +1 -0
- package/dist/shell/commands/waiver.d.ts +38 -0
- package/dist/shell/commands/waiver.d.ts.map +1 -0
- package/dist/shell/commands/waiver.js +240 -0
- package/dist/shell/commands/waiver.js.map +1 -0
- package/dist/shell/gates/disposition.d.ts +23 -0
- package/dist/shell/gates/disposition.d.ts.map +1 -0
- package/dist/shell/gates/disposition.js +87 -0
- package/dist/shell/gates/disposition.js.map +1 -0
- package/dist/shell/gates/gate-result-contract.d.ts +39 -0
- package/dist/shell/gates/gate-result-contract.d.ts.map +1 -0
- package/dist/shell/gates/gate-result-contract.js +150 -0
- package/dist/shell/gates/gate-result-contract.js.map +1 -0
- package/dist/shell/gates/quality-gates-adapter.d.ts +55 -0
- package/dist/shell/gates/quality-gates-adapter.d.ts.map +1 -0
- package/dist/shell/gates/quality-gates-adapter.js +161 -0
- package/dist/shell/gates/quality-gates-adapter.js.map +1 -0
- package/dist/shell/gates/waiver-filter.d.ts +58 -0
- package/dist/shell/gates/waiver-filter.d.ts.map +1 -0
- package/dist/shell/gates/waiver-filter.js +119 -0
- package/dist/shell/gates/waiver-filter.js.map +1 -0
- package/dist/shell/index.d.ts +50 -0
- package/dist/shell/index.d.ts.map +1 -0
- package/dist/shell/index.js +73 -0
- package/dist/shell/index.js.map +1 -0
- package/dist/shell/register.d.ts +11 -0
- package/dist/shell/register.d.ts.map +1 -0
- package/dist/shell/register.js +274 -0
- package/dist/shell/register.js.map +1 -0
- package/dist/shell/render/claim.d.ts +22 -0
- package/dist/shell/render/claim.d.ts.map +1 -0
- package/dist/shell/render/claim.js +75 -0
- package/dist/shell/render/claim.js.map +1 -0
- package/dist/shell/render/decision.d.ts +15 -0
- package/dist/shell/render/decision.d.ts.map +1 -0
- package/dist/shell/render/decision.js +66 -0
- package/dist/shell/render/decision.js.map +1 -0
- package/dist/shell/render/diagnostic.d.ts +19 -0
- package/dist/shell/render/diagnostic.d.ts.map +1 -0
- package/dist/shell/render/diagnostic.js +76 -0
- package/dist/shell/render/diagnostic.js.map +1 -0
- package/dist/shell/render/finding.d.ts +15 -0
- package/dist/shell/render/finding.d.ts.map +1 -0
- package/dist/shell/render/finding.js +57 -0
- package/dist/shell/render/finding.js.map +1 -0
- package/dist/shell/render/gates.d.ts +3 -0
- package/dist/shell/render/gates.d.ts.map +1 -0
- package/dist/shell/render/gates.js +56 -0
- package/dist/shell/render/gates.js.map +1 -0
- package/dist/shell/render/init.d.ts +11 -0
- package/dist/shell/render/init.d.ts.map +1 -0
- package/dist/shell/render/init.js +32 -0
- package/dist/shell/render/init.js.map +1 -0
- package/dist/shell/render/status.d.ts +26 -0
- package/dist/shell/render/status.d.ts.map +1 -0
- package/dist/shell/render/status.js +143 -0
- package/dist/shell/render/status.js.map +1 -0
- package/dist/shell/render/waiver.d.ts +21 -0
- package/dist/shell/render/waiver.d.ts.map +1 -0
- package/dist/shell/render/waiver.js +94 -0
- package/dist/shell/render/waiver.js.map +1 -0
- package/dist/shell/rules.d.ts +37 -0
- package/dist/shell/rules.d.ts.map +1 -0
- package/dist/shell/rules.js +51 -0
- package/dist/shell/rules.js.map +1 -0
- package/dist/shell/session/actor.d.ts +14 -0
- package/dist/shell/session/actor.d.ts.map +1 -0
- package/dist/shell/session/actor.js +34 -0
- package/dist/shell/session/actor.js.map +1 -0
- package/dist/shell/session/resolve-session.d.ts +5 -0
- package/dist/shell/session/resolve-session.d.ts.map +1 -0
- package/dist/shell/session/resolve-session.js +239 -0
- package/dist/shell/session/resolve-session.js.map +1 -0
- package/dist/shell/session/types.d.ts +56 -0
- package/dist/shell/session/types.d.ts.map +1 -0
- package/dist/shell/session/types.js +15 -0
- package/dist/shell/session/types.js.map +1 -0
- package/dist/store/agents-store.d.ts +3 -0
- package/dist/store/agents-store.d.ts.map +1 -0
- package/dist/store/agents-store.js +63 -0
- package/dist/store/agents-store.js.map +1 -0
- package/dist/store/apply-patch.d.ts +16 -0
- package/dist/store/apply-patch.d.ts.map +1 -0
- package/dist/store/apply-patch.js +191 -0
- package/dist/store/apply-patch.js.map +1 -0
- package/dist/store/atomic-write.d.ts +16 -0
- package/dist/store/atomic-write.d.ts.map +1 -0
- package/dist/store/atomic-write.js +132 -0
- package/dist/store/atomic-write.js.map +1 -0
- package/dist/store/doctor-snapshot.d.ts +20 -0
- package/dist/store/doctor-snapshot.d.ts.map +1 -0
- package/dist/store/doctor-snapshot.js +176 -0
- package/dist/store/doctor-snapshot.js.map +1 -0
- package/dist/store/events-store.d.ts +33 -0
- package/dist/store/events-store.d.ts.map +1 -0
- package/dist/store/events-store.js +297 -0
- package/dist/store/events-store.js.map +1 -0
- package/dist/store/index.d.ts +21 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +47 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/init-store.d.ts +21 -0
- package/dist/store/init-store.d.ts.map +1 -0
- package/dist/store/init-store.js +295 -0
- package/dist/store/init-store.js.map +1 -0
- package/dist/store/json-store.d.ts +3 -0
- package/dist/store/json-store.d.ts.map +1 -0
- package/dist/store/json-store.js +65 -0
- package/dist/store/json-store.js.map +1 -0
- package/dist/store/policy-store.d.ts +3 -0
- package/dist/store/policy-store.d.ts.map +1 -0
- package/dist/store/policy-store.js +65 -0
- package/dist/store/policy-store.js.map +1 -0
- package/dist/store/repo-root.d.ts +46 -0
- package/dist/store/repo-root.d.ts.map +1 -0
- package/dist/store/repo-root.js +145 -0
- package/dist/store/repo-root.js.map +1 -0
- package/dist/store/rules.d.ts +53 -0
- package/dist/store/rules.d.ts.map +1 -0
- package/dist/store/rules.js +78 -0
- package/dist/store/rules.js.map +1 -0
- package/dist/store/specs-store.d.ts +3 -0
- package/dist/store/specs-store.d.ts.map +1 -0
- package/dist/store/specs-store.js +131 -0
- package/dist/store/specs-store.js.map +1 -0
- package/dist/store/types.d.ts +84 -0
- package/dist/store/types.d.ts.map +1 -0
- package/dist/store/types.js +14 -0
- package/dist/store/types.js.map +1 -0
- package/dist/store/waivers-store.d.ts +25 -0
- package/dist/store/waivers-store.d.ts.map +1 -0
- package/dist/store/waivers-store.js +232 -0
- package/dist/store/waivers-store.js.map +1 -0
- package/dist/store/worktrees-store.d.ts +3 -0
- package/dist/store/worktrees-store.d.ts.map +1 -0
- package/dist/store/worktrees-store.js +62 -0
- package/dist/store/worktrees-store.js.map +1 -0
- package/dist/store/yaml-store.d.ts +9 -0
- package/dist/store/yaml-store.d.ts.map +1 -0
- package/dist/store/yaml-store.js +121 -0
- package/dist/store/yaml-store.js.map +1 -0
- package/package.json +15 -13
- package/dist/budget-derivation.js +0 -751
- package/dist/cicd-optimizer.js +0 -504
- package/dist/commands/agents.js +0 -124
- package/dist/commands/archive.js +0 -500
- package/dist/commands/burnup.js +0 -198
- package/dist/commands/diagnose.js +0 -525
- package/dist/commands/evaluate.js +0 -314
- package/dist/commands/gates.js +0 -149
- package/dist/commands/init.js +0 -857
- package/dist/commands/iterate.js +0 -417
- package/dist/commands/mode.js +0 -269
- package/dist/commands/parallel.js +0 -242
- package/dist/commands/plan.js +0 -438
- package/dist/commands/provenance.js +0 -1143
- package/dist/commands/quality-monitor.js +0 -284
- package/dist/commands/scope.js +0 -264
- package/dist/commands/session.js +0 -312
- package/dist/commands/sidecar.js +0 -74
- package/dist/commands/specs.js +0 -1656
- package/dist/commands/status.js +0 -1172
- package/dist/commands/templates.js +0 -237
- package/dist/commands/tool.js +0 -136
- package/dist/commands/tutorial.js +0 -480
- package/dist/commands/validate.js +0 -357
- package/dist/commands/verify-acs.js +0 -443
- package/dist/commands/waivers.js +0 -599
- package/dist/commands/workflow.js +0 -243
- package/dist/commands/worktree.js +0 -502
- package/dist/config/lite-scope.js +0 -158
- package/dist/config/modes.js +0 -347
- package/dist/constants/spec-types.js +0 -65
- package/dist/gates/budget-limit.js +0 -121
- package/dist/gates/feedback.js +0 -260
- package/dist/gates/format.js +0 -179
- package/dist/gates/god-object.js +0 -117
- package/dist/gates/pipeline.js +0 -167
- package/dist/gates/scope-boundary.js +0 -112
- package/dist/gates/spec-completeness.js +0 -109
- package/dist/gates/todo-detection.js +0 -205
- package/dist/generators/jest-config-generator.js +0 -242
- package/dist/generators/working-spec.js +0 -237
- package/dist/minimal-cli.js +0 -88
- package/dist/parallel/parallel-manager.js +0 -433
- package/dist/policy/PolicyManager.js +0 -470
- package/dist/scaffold/claude-hooks.js +0 -443
- package/dist/scaffold/cursor-hooks.js +0 -177
- package/dist/scaffold/git-hooks.js +0 -928
- package/dist/scaffold/index.js +0 -794
- package/dist/session/session-manager.js +0 -653
- package/dist/sidecars/index.js +0 -33
- package/dist/sidecars/listeners.js +0 -40
- package/dist/sidecars/provenance-summary.js +0 -238
- package/dist/sidecars/quality-gaps.js +0 -258
- package/dist/sidecars/schema.js +0 -149
- package/dist/sidecars/spec-drift.js +0 -151
- package/dist/sidecars/waiver-draft.js +0 -176
- package/dist/spec/SpecFileManager.js +0 -419
- package/dist/templates/.caws/schemas/policy.schema.json +0 -117
- package/dist/templates/.caws/schemas/scope.schema.json +0 -52
- package/dist/templates/.caws/schemas/waivers.schema.json +0 -106
- package/dist/templates/.caws/schemas/working-spec.schema.json +0 -340
- package/dist/templates/.caws/schemas/worktrees.schema.json +0 -38
- package/dist/templates/.caws/templates/working-spec.template.yml +0 -80
- package/dist/templates/.caws/tools/README.md +0 -18
- package/dist/templates/.caws/tools/scope-guard.js +0 -203
- package/dist/templates/.caws/tools-allow.json +0 -331
- package/dist/templates/.caws/waivers.yml +0 -19
- package/dist/templates/.claude/README.md +0 -190
- package/dist/templates/.claude/hooks/audit.sh +0 -121
- package/dist/templates/.claude/hooks/block-dangerous.sh +0 -203
- package/dist/templates/.claude/hooks/classify_command.py +0 -592
- package/dist/templates/.claude/hooks/doc-frontmatter-check.sh +0 -173
- package/dist/templates/.claude/hooks/lite-sprawl-check.sh +0 -145
- package/dist/templates/.claude/hooks/naming-check.sh +0 -100
- package/dist/templates/.claude/hooks/protected-paths.sh +0 -39
- package/dist/templates/.claude/hooks/quality-check.sh +0 -81
- package/dist/templates/.claude/hooks/scan-secrets.sh +0 -85
- package/dist/templates/.claude/hooks/scope-guard.sh +0 -381
- package/dist/templates/.claude/hooks/session-caws-status.sh +0 -117
- package/dist/templates/.claude/hooks/session-log.sh +0 -634
- package/dist/templates/.claude/hooks/simplification-guard.sh +0 -92
- package/dist/templates/.claude/hooks/stop-worktree-check.sh +0 -46
- package/dist/templates/.claude/hooks/test_classify_command.py +0 -370
- package/dist/templates/.claude/hooks/test_wrapper_smoke.sh +0 -96
- package/dist/templates/.claude/hooks/validate-spec.sh +0 -76
- package/dist/templates/.claude/hooks/worktree-guard.sh +0 -220
- package/dist/templates/.claude/hooks/worktree-write-guard.sh +0 -190
- package/dist/templates/.claude/rules/git-safety.md +0 -26
- package/dist/templates/.claude/rules/worktree-isolation.md +0 -101
- package/dist/templates/.claude/settings.json +0 -141
- package/dist/templates/.cursor/README.md +0 -299
- package/dist/templates/.cursor/hooks/audit.sh +0 -55
- package/dist/templates/.cursor/hooks/block-dangerous.sh +0 -84
- package/dist/templates/.cursor/hooks/caws-quality-check.sh +0 -52
- package/dist/templates/.cursor/hooks/caws-scope-guard.sh +0 -130
- package/dist/templates/.cursor/hooks/format.sh +0 -38
- package/dist/templates/.cursor/hooks/naming-check.sh +0 -64
- package/dist/templates/.cursor/hooks/scan-secrets.sh +0 -51
- package/dist/templates/.cursor/hooks/scope-guard.sh +0 -52
- package/dist/templates/.cursor/hooks/session-log.sh +0 -924
- package/dist/templates/.cursor/hooks/validate-spec.sh +0 -83
- package/dist/templates/.cursor/hooks.json +0 -76
- package/dist/templates/.cursor/rules/00-claims-verification.mdc +0 -144
- package/dist/templates/.cursor/rules/01-working-style.mdc +0 -50
- package/dist/templates/.cursor/rules/02-quality-gates.mdc +0 -368
- package/dist/templates/.cursor/rules/03-naming-and-refactor.mdc +0 -33
- package/dist/templates/.cursor/rules/04-logging-language-style.mdc +0 -23
- package/dist/templates/.cursor/rules/05-safe-defaults-guards.mdc +0 -23
- package/dist/templates/.cursor/rules/06-typescript-conventions.mdc +0 -36
- package/dist/templates/.cursor/rules/07-process-ops.mdc +0 -20
- package/dist/templates/.cursor/rules/08-solid-and-architecture.mdc +0 -16
- package/dist/templates/.cursor/rules/09-docstrings.mdc +0 -89
- package/dist/templates/.cursor/rules/10-documentation-quality-standards.mdc +0 -385
- package/dist/templates/.cursor/rules/11-scope-management-waivers.mdc +0 -381
- package/dist/templates/.cursor/rules/12-implementation-completeness.mdc +0 -516
- package/dist/templates/.cursor/rules/13-language-agnostic-standards.mdc +0 -578
- package/dist/templates/.cursor/rules/README.md +0 -148
- package/dist/templates/.github/copilot-instructions.md +0 -82
- package/dist/templates/.idea/runConfigurations/CAWS_Evaluate.xml +0 -5
- package/dist/templates/.idea/runConfigurations/CAWS_Validate.xml +0 -5
- package/dist/templates/.junie/guidelines.md +0 -73
- package/dist/templates/.vscode/launch.json +0 -17
- package/dist/templates/.vscode/settings.json +0 -95
- package/dist/templates/.windsurf/rules/caws-quality-standards.md +0 -54
- package/dist/templates/.windsurf/workflows/caws-guided-development.md +0 -92
- package/dist/templates/CLAUDE.md +0 -196
- package/dist/templates/COMMIT_CONVENTIONS.md +0 -86
- package/dist/templates/OIDC_SETUP.md +0 -300
- package/dist/templates/agents.md +0 -171
- package/dist/templates/codemod/README.md +0 -1
- package/dist/templates/codemod/test.js +0 -93
- package/dist/templates/docs/README.md +0 -151
- package/dist/templates/scripts/new_feature.sh +0 -80
- package/dist/templates/scripts/quality-gates/check-god-objects.js +0 -146
- package/dist/templates/scripts/quality-gates/run-quality-gates.js +0 -50
- package/dist/templates/scripts/v3/analysis/todo_analyzer.py +0 -1997
- package/dist/test-analysis.js +0 -786
- package/dist/tool-interface.js +0 -314
- package/dist/tool-loader.js +0 -303
- package/dist/tool-validator.js +0 -393
- package/dist/utils/agent-display.js +0 -210
- package/dist/utils/agent-session.js +0 -344
- package/dist/utils/async-utils.js +0 -188
- package/dist/utils/command-wrapper.js +0 -200
- package/dist/utils/event-log.js +0 -584
- package/dist/utils/event-renderer.js +0 -521
- package/dist/utils/finalization.js +0 -230
- package/dist/utils/git-lock.js +0 -119
- package/dist/utils/gitignore-updater.js +0 -158
- package/dist/utils/ide-detection.js +0 -133
- package/dist/utils/lifecycle-events.js +0 -94
- package/dist/utils/project-analysis.js +0 -367
- package/dist/utils/promise-utils.js +0 -72
- package/dist/utils/quality-gates-errors.js +0 -520
- package/dist/utils/quality-gates-utils.js +0 -387
- package/dist/utils/schema-validator.js +0 -50
- package/dist/utils/spec-resolver.js +0 -711
- package/dist/utils/typescript-detector.js +0 -369
- package/dist/utils/working-state.js +0 -530
- package/dist/utils/yaml-validation.js +0 -156
- package/dist/validation/spec-validation.js +0 -924
- package/dist/waivers-manager.js +0 -732
- package/dist/worktree/worktree-manager.js +0 -1735
- package/templates/.caws/schemas/policy.schema.json +0 -117
- package/templates/.caws/schemas/scope.schema.json +0 -52
- package/templates/.caws/schemas/waivers.schema.json +0 -106
- package/templates/.caws/schemas/working-spec.schema.json +0 -340
- package/templates/.caws/schemas/worktrees.schema.json +0 -38
- package/templates/.caws/templates/working-spec.template.yml +0 -80
- package/templates/.caws/tools/README.md +0 -18
- package/templates/.caws/tools/scope-guard.js +0 -203
- package/templates/.caws/tools-allow.json +0 -331
- package/templates/.caws/waivers.yml +0 -19
- package/templates/.claude/README.md +0 -190
- package/templates/.claude/hooks/audit.sh +0 -121
- package/templates/.claude/hooks/block-dangerous.sh +0 -203
- package/templates/.claude/hooks/classify_command.py +0 -592
- package/templates/.claude/hooks/doc-frontmatter-check.sh +0 -173
- package/templates/.claude/hooks/lite-sprawl-check.sh +0 -145
- package/templates/.claude/hooks/naming-check.sh +0 -100
- package/templates/.claude/hooks/protected-paths.sh +0 -39
- package/templates/.claude/hooks/quality-check.sh +0 -81
- package/templates/.claude/hooks/scan-secrets.sh +0 -85
- package/templates/.claude/hooks/scope-guard.sh +0 -381
- package/templates/.claude/hooks/session-caws-status.sh +0 -117
- package/templates/.claude/hooks/session-log.sh +0 -634
- package/templates/.claude/hooks/simplification-guard.sh +0 -92
- package/templates/.claude/hooks/stop-worktree-check.sh +0 -46
- package/templates/.claude/hooks/test_classify_command.py +0 -370
- package/templates/.claude/hooks/test_wrapper_smoke.sh +0 -96
- package/templates/.claude/hooks/validate-spec.sh +0 -76
- package/templates/.claude/hooks/worktree-guard.sh +0 -220
- package/templates/.claude/hooks/worktree-write-guard.sh +0 -190
- package/templates/.claude/rules/git-safety.md +0 -26
- package/templates/.claude/rules/worktree-isolation.md +0 -101
- package/templates/.claude/settings.json +0 -141
- package/templates/.cursor/README.md +0 -299
- package/templates/.cursor/hooks/audit.sh +0 -55
- package/templates/.cursor/hooks/block-dangerous.sh +0 -84
- package/templates/.cursor/hooks/caws-quality-check.sh +0 -52
- package/templates/.cursor/hooks/caws-scope-guard.sh +0 -130
- package/templates/.cursor/hooks/format.sh +0 -38
- package/templates/.cursor/hooks/naming-check.sh +0 -64
- package/templates/.cursor/hooks/scan-secrets.sh +0 -51
- package/templates/.cursor/hooks/scope-guard.sh +0 -52
- package/templates/.cursor/hooks/session-log.sh +0 -924
- package/templates/.cursor/hooks/validate-spec.sh +0 -83
- package/templates/.cursor/hooks.json +0 -76
- package/templates/.cursor/rules/00-claims-verification.mdc +0 -144
- package/templates/.cursor/rules/01-working-style.mdc +0 -50
- package/templates/.cursor/rules/02-quality-gates.mdc +0 -368
- package/templates/.cursor/rules/03-naming-and-refactor.mdc +0 -33
- package/templates/.cursor/rules/04-logging-language-style.mdc +0 -23
- package/templates/.cursor/rules/05-safe-defaults-guards.mdc +0 -23
- package/templates/.cursor/rules/06-typescript-conventions.mdc +0 -36
- package/templates/.cursor/rules/07-process-ops.mdc +0 -20
- package/templates/.cursor/rules/08-solid-and-architecture.mdc +0 -16
- package/templates/.cursor/rules/09-docstrings.mdc +0 -89
- package/templates/.cursor/rules/10-documentation-quality-standards.mdc +0 -385
- package/templates/.cursor/rules/11-scope-management-waivers.mdc +0 -381
- package/templates/.cursor/rules/12-implementation-completeness.mdc +0 -516
- package/templates/.cursor/rules/13-language-agnostic-standards.mdc +0 -578
- package/templates/.cursor/rules/README.md +0 -148
- package/templates/.github/copilot-instructions.md +0 -82
- package/templates/.idea/runConfigurations/CAWS_Evaluate.xml +0 -5
- package/templates/.idea/runConfigurations/CAWS_Validate.xml +0 -5
- package/templates/.junie/guidelines.md +0 -73
- package/templates/.vscode/launch.json +0 -17
- package/templates/.vscode/settings.json +0 -95
- package/templates/.windsurf/rules/caws-quality-standards.md +0 -54
- package/templates/.windsurf/workflows/caws-guided-development.md +0 -92
- package/templates/CLAUDE.md +0 -196
- package/templates/COMMIT_CONVENTIONS.md +0 -86
- package/templates/OIDC_SETUP.md +0 -300
- package/templates/agents.md +0 -171
- package/templates/codemod/README.md +0 -1
- package/templates/codemod/test.js +0 -93
- package/templates/docs/README.md +0 -151
- package/templates/scripts/new_feature.sh +0 -80
- package/templates/scripts/quality-gates/check-god-objects.js +0 -146
- package/templates/scripts/quality-gates/run-quality-gates.js +0 -50
- package/templates/scripts/v3/analysis/todo_analyzer.py +0 -1997
|
@@ -1,1735 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview CAWS Git Worktree Manager
|
|
3
|
-
* Provides CRUD operations for git worktrees with scope isolation
|
|
4
|
-
* @author @darianrosebrook
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const { execFileSync } = require('child_process');
|
|
8
|
-
const fs = require('fs-extra');
|
|
9
|
-
const path = require('path');
|
|
10
|
-
const chalk = require('chalk');
|
|
11
|
-
const { createValidator, getSchemaPath } = require('../utils/schema-validator');
|
|
12
|
-
const {
|
|
13
|
-
getAgentSessionId,
|
|
14
|
-
loadAgentRegistry,
|
|
15
|
-
findSessionLogs,
|
|
16
|
-
refreshAgentClaim,
|
|
17
|
-
} = require('../utils/agent-session');
|
|
18
|
-
const { formatClaimNotice, formatOrphanLogHint } = require('../utils/agent-display');
|
|
19
|
-
const { lifecycle, EVENTS } = require('../utils/lifecycle-events');
|
|
20
|
-
|
|
21
|
-
const WORKTREES_DIR = '.caws/worktrees';
|
|
22
|
-
const REGISTRY_FILE = '.caws/worktrees.json';
|
|
23
|
-
const BRANCH_PREFIX = 'caws/';
|
|
24
|
-
|
|
25
|
-
function findFeatureSpecPath(root, specId) {
|
|
26
|
-
if (!specId) return null;
|
|
27
|
-
|
|
28
|
-
const candidates = [
|
|
29
|
-
path.join(root, '.caws', 'specs', `${specId}.yaml`),
|
|
30
|
-
path.join(root, '.caws', 'specs', `${specId}.yml`),
|
|
31
|
-
];
|
|
32
|
-
|
|
33
|
-
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Resolve a feature spec path, preferring a worktree-local copy when cwd
|
|
38
|
-
* is inside a worktree. Falls back to the main repo.
|
|
39
|
-
*
|
|
40
|
-
* Why two-step: `caws worktree bind` may be invoked from inside a worktree
|
|
41
|
-
* that was forked off a non-main base branch (Option C fork-off-sibling
|
|
42
|
-
* pattern). In that workflow the spec is committed on the worktree's own
|
|
43
|
-
* branch and never lands on main. The pre-CAWSFIX-25 behavior of looking
|
|
44
|
-
* only in `root/.caws/specs/` made bind unusable there (D8 ledger entry).
|
|
45
|
-
*
|
|
46
|
-
* @param {string} root - Main repo root (from getRepoRoot())
|
|
47
|
-
* @param {string} specId - Spec identifier
|
|
48
|
-
* @param {string} [cwd=process.cwd()] - Directory to resolve from
|
|
49
|
-
* @returns {string|null} Absolute path to the spec file, or null
|
|
50
|
-
*/
|
|
51
|
-
function findFeatureSpecPathFromCwd(root, specId, cwd) {
|
|
52
|
-
if (!specId) return null;
|
|
53
|
-
const effectiveCwd = cwd || process.cwd();
|
|
54
|
-
|
|
55
|
-
// Normalize both sides against symlinks before comparing. On macOS
|
|
56
|
-
// `/tmp` and `/var/folders` are symlinks under `/private`, so the literal
|
|
57
|
-
// `startsWith` check fails intermittently in the test fixture. Fall back
|
|
58
|
-
// to the pre-resolution path if realpath throws (e.g., cwd removed).
|
|
59
|
-
const resolve = (p) => {
|
|
60
|
-
try { return fs.realpathSync(p); } catch { return p; }
|
|
61
|
-
};
|
|
62
|
-
const resolvedCwd = resolve(effectiveCwd);
|
|
63
|
-
const worktreesBase = resolve(path.join(root, '.caws', 'worktrees'));
|
|
64
|
-
|
|
65
|
-
if (resolvedCwd.startsWith(worktreesBase + path.sep)) {
|
|
66
|
-
const relative = path.relative(worktreesBase, resolvedCwd);
|
|
67
|
-
const worktreeName = relative.split(path.sep)[0];
|
|
68
|
-
if (worktreeName) {
|
|
69
|
-
const worktreeRoot = path.join(worktreesBase, worktreeName);
|
|
70
|
-
const local = findFeatureSpecPath(worktreeRoot, specId);
|
|
71
|
-
if (local) return local;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return findFeatureSpecPath(root, specId);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function writeSpecWithWorktree(filePath, worktreeName) {
|
|
79
|
-
const yaml = require('js-yaml');
|
|
80
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
81
|
-
const parsed = yaml.load(content);
|
|
82
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
83
|
-
return content;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// CAWSFIX-24 / D10: if the on-disk spec already declares the target
|
|
87
|
-
// worktree and reloads to an equivalent object, return the original
|
|
88
|
-
// bytes untouched. js-yaml.dump re-wraps folded scalars at its own
|
|
89
|
-
// line-width preference, which otherwise produces spurious bytes-only
|
|
90
|
-
// diffs on every bind/create. That mechanical churn (a) leaves dirty
|
|
91
|
-
// files on main after worktree create, and (b) causes merge conflicts
|
|
92
|
-
// when two validator invocations wrap the same title at different
|
|
93
|
-
// widths.
|
|
94
|
-
if (parsed.worktree === worktreeName) {
|
|
95
|
-
return content;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
parsed.worktree = worktreeName;
|
|
99
|
-
return yaml.dump(parsed, { lineWidth: 120, noRefs: true });
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function hasPathChanges(root, relativePath) {
|
|
103
|
-
try {
|
|
104
|
-
const output = execFileSync(
|
|
105
|
-
'git',
|
|
106
|
-
['status', '--porcelain', '--', relativePath],
|
|
107
|
-
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
108
|
-
).trim();
|
|
109
|
-
return output.length > 0;
|
|
110
|
-
} catch {
|
|
111
|
-
return false;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function ensureCanonicalSpecCommitted(root, specPath, specId, worktreeName) {
|
|
116
|
-
const relativeSpecPath = path.relative(root, specPath);
|
|
117
|
-
const nextContent = writeSpecWithWorktree(specPath, worktreeName);
|
|
118
|
-
const currentContent = fs.readFileSync(specPath, 'utf8');
|
|
119
|
-
|
|
120
|
-
if (currentContent !== nextContent) {
|
|
121
|
-
fs.writeFileSync(specPath, nextContent);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (!hasPathChanges(root, relativeSpecPath)) {
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
execFileSync('git', ['add', '--', relativeSpecPath], {
|
|
129
|
-
cwd: root,
|
|
130
|
-
stdio: 'pipe',
|
|
131
|
-
});
|
|
132
|
-
execFileSync(
|
|
133
|
-
'git',
|
|
134
|
-
['commit', '-m', `chore(caws): bind spec ${specId} to worktree ${worktreeName}`, '--', relativeSpecPath],
|
|
135
|
-
{
|
|
136
|
-
cwd: root,
|
|
137
|
-
stdio: 'pipe',
|
|
138
|
-
}
|
|
139
|
-
);
|
|
140
|
-
return true;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
|
|
144
|
-
if (!specId) return;
|
|
145
|
-
|
|
146
|
-
const canonicalSpecPath = findFeatureSpecPath(root, specId);
|
|
147
|
-
const destSpecsDir = path.join(cawsDest, 'specs');
|
|
148
|
-
|
|
149
|
-
// CAWSFIX-24 / D5: never write to .caws/working-spec.yaml inside the
|
|
150
|
-
// worktree. That file is the shared project baseline and must remain
|
|
151
|
-
// byte-identical to what was checked out from HEAD. The feature spec
|
|
152
|
-
// is materialized only under .caws/specs/<id>.yaml, which is what
|
|
153
|
-
// spec-resolver and commands actually read via --spec-id / registry.
|
|
154
|
-
|
|
155
|
-
if (canonicalSpecPath) {
|
|
156
|
-
const destSpecPath = path.join(destSpecsDir, path.basename(canonicalSpecPath));
|
|
157
|
-
fs.ensureDirSync(destSpecsDir);
|
|
158
|
-
|
|
159
|
-
// Keep a canonical feature-spec copy inside the worktree.
|
|
160
|
-
const specContent = writeSpecWithWorktree(canonicalSpecPath, worktreeName);
|
|
161
|
-
// writeSpecWithWorktree is idempotent (CAWSFIX-24 / D10): if the spec
|
|
162
|
-
// already has the worktree field and reloads to equivalent YAML, the
|
|
163
|
-
// returned content matches what's on disk. Skip the write in that case
|
|
164
|
-
// so `git status` stays clean.
|
|
165
|
-
const existing = fs.existsSync(destSpecPath) ? fs.readFileSync(destSpecPath, 'utf8') : null;
|
|
166
|
-
if (existing !== specContent) {
|
|
167
|
-
fs.writeFileSync(destSpecPath, specContent);
|
|
168
|
-
}
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// specId given but no canonical spec found — generate a default feature
|
|
173
|
-
// spec at .caws/specs/<specId>.yaml so the worktree has something to
|
|
174
|
-
// resolve against. Do not touch .caws/working-spec.yaml.
|
|
175
|
-
console.warn(
|
|
176
|
-
chalk.yellow(`Warning: spec '${specId}' not found in .caws/specs/ — generating default feature spec for worktree`)
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
const { generateWorkingSpec } = require('../generators/working-spec');
|
|
180
|
-
let specContent = generateWorkingSpec({
|
|
181
|
-
projectId: specId,
|
|
182
|
-
projectTitle: `Worktree: ${worktreeName}`,
|
|
183
|
-
projectDescription: `Isolated worktree for ${worktreeName}`,
|
|
184
|
-
riskTier: 3,
|
|
185
|
-
projectMode: 'feature',
|
|
186
|
-
scopeIn: scope || 'src/',
|
|
187
|
-
scopeOut: 'node_modules/, dist/, build/',
|
|
188
|
-
maxFiles: 25,
|
|
189
|
-
maxLoc: 1000,
|
|
190
|
-
blastModules: scope || 'src',
|
|
191
|
-
dataMigration: false,
|
|
192
|
-
rollbackSlo: '5m',
|
|
193
|
-
projectThreats: '',
|
|
194
|
-
projectInvariants: 'System maintains data consistency',
|
|
195
|
-
acceptanceCriteria: 'Given current state, when action occurs, then expected result',
|
|
196
|
-
a11yRequirements: 'keyboard',
|
|
197
|
-
perfBudget: 250,
|
|
198
|
-
securityRequirements: 'validation',
|
|
199
|
-
contractType: '',
|
|
200
|
-
contractPath: '',
|
|
201
|
-
observabilityLogs: '',
|
|
202
|
-
observabilityMetrics: '',
|
|
203
|
-
observabilityTraces: '',
|
|
204
|
-
migrationPlan: '',
|
|
205
|
-
rollbackPlan: '',
|
|
206
|
-
needsOverride: false,
|
|
207
|
-
isExperimental: false,
|
|
208
|
-
aiConfidence: 0.8,
|
|
209
|
-
uncertaintyAreas: '',
|
|
210
|
-
complexityFactors: '',
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
try {
|
|
214
|
-
const yaml = require('js-yaml');
|
|
215
|
-
const parsed = yaml.load(specContent);
|
|
216
|
-
if (parsed && typeof parsed === 'object') {
|
|
217
|
-
parsed.worktree = worktreeName;
|
|
218
|
-
specContent = yaml.dump(parsed, { lineWidth: 120, noRefs: true });
|
|
219
|
-
}
|
|
220
|
-
} catch {
|
|
221
|
-
// Keep generated spec content if augmentation fails.
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
fs.ensureDirSync(destSpecsDir);
|
|
225
|
-
const generatedSpecPath = path.join(destSpecsDir, `${specId}.yaml`);
|
|
226
|
-
fs.writeFileSync(generatedSpecPath, specContent);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function parseSpecIdFromYamlFile(filePath) {
|
|
230
|
-
try {
|
|
231
|
-
const yaml = require('js-yaml');
|
|
232
|
-
const doc = yaml.load(fs.readFileSync(filePath, 'utf8'));
|
|
233
|
-
if (doc && typeof doc.id === 'string' && doc.id.trim()) {
|
|
234
|
-
return doc.id.trim();
|
|
235
|
-
}
|
|
236
|
-
} catch {
|
|
237
|
-
// Ignore malformed YAML during inference
|
|
238
|
-
}
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Scan .caws/specs/ for a spec that declares `worktree: <name>`.
|
|
244
|
-
* Returns the spec's id if found, null otherwise.
|
|
245
|
-
* This enables auto-binding: when a spec already names the worktree
|
|
246
|
-
* it expects, the registry entry gets the specId automatically.
|
|
247
|
-
* @param {string} root - Repository root
|
|
248
|
-
* @param {string} worktreeName - Worktree name to match
|
|
249
|
-
* @returns {string|null} Spec ID or null
|
|
250
|
-
*/
|
|
251
|
-
function findSpecByWorktreeName(root, worktreeName) {
|
|
252
|
-
const yaml = require('js-yaml');
|
|
253
|
-
const specsDir = path.join(root, '.caws', 'specs');
|
|
254
|
-
if (!fs.existsSync(specsDir)) return null;
|
|
255
|
-
|
|
256
|
-
const specFiles = fs.readdirSync(specsDir)
|
|
257
|
-
.filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'));
|
|
258
|
-
|
|
259
|
-
for (const specFile of specFiles) {
|
|
260
|
-
try {
|
|
261
|
-
const doc = yaml.load(fs.readFileSync(path.join(specsDir, specFile), 'utf8'));
|
|
262
|
-
if (doc && doc.worktree === worktreeName && typeof doc.id === 'string') {
|
|
263
|
-
return doc.id.trim();
|
|
264
|
-
}
|
|
265
|
-
} catch {
|
|
266
|
-
// Skip malformed spec files
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function inferSpecIdForWorktree(worktreePath) {
|
|
273
|
-
if (!worktreePath) return null;
|
|
274
|
-
|
|
275
|
-
const specsDir = path.join(worktreePath, '.caws', 'specs');
|
|
276
|
-
if (fs.existsSync(specsDir)) {
|
|
277
|
-
const specFiles = fs.readdirSync(specsDir)
|
|
278
|
-
.filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'))
|
|
279
|
-
.sort();
|
|
280
|
-
|
|
281
|
-
for (const specFile of specFiles) {
|
|
282
|
-
const inferred = parseSpecIdFromYamlFile(path.join(specsDir, specFile));
|
|
283
|
-
if (inferred) {
|
|
284
|
-
return inferred;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return parseSpecIdFromYamlFile(path.join(worktreePath, '.caws', 'working-spec.yaml'));
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Get the last commit info for a branch
|
|
294
|
-
* @param {string} branch - Branch name
|
|
295
|
-
* @param {string} root - Repository root
|
|
296
|
-
* @returns {{ age: string, timestamp: Date, sha: string } | null}
|
|
297
|
-
*/
|
|
298
|
-
function getLastCommitInfo(branch, root) {
|
|
299
|
-
try {
|
|
300
|
-
const output = execFileSync(
|
|
301
|
-
'git',
|
|
302
|
-
['log', branch, '-1', '--format=%H%n%aI%n%ar'],
|
|
303
|
-
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
304
|
-
).trim();
|
|
305
|
-
const [sha, iso, age] = output.split('\n');
|
|
306
|
-
return { sha, timestamp: new Date(iso), age };
|
|
307
|
-
} catch {
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Check if a branch has been merged into another branch
|
|
314
|
-
* @param {string} branch - Branch to check
|
|
315
|
-
* @param {string} target - Target branch (e.g., "main")
|
|
316
|
-
* @param {string} root - Repository root
|
|
317
|
-
* @returns {boolean}
|
|
318
|
-
*/
|
|
319
|
-
function isBranchMerged(branch, target, root) {
|
|
320
|
-
try {
|
|
321
|
-
const merged = execFileSync(
|
|
322
|
-
'git',
|
|
323
|
-
['branch', '--merged', target, '--list', branch],
|
|
324
|
-
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
325
|
-
).trim();
|
|
326
|
-
return merged.length > 0;
|
|
327
|
-
} catch {
|
|
328
|
-
return false;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Check if a branch has divergent commits from target (commits on branch not on target).
|
|
334
|
-
* @param {string} branch - Branch to check
|
|
335
|
-
* @param {string} target - Target branch (e.g., "main")
|
|
336
|
-
* @param {string} root - Repository root
|
|
337
|
-
* @returns {boolean}
|
|
338
|
-
*/
|
|
339
|
-
function hasDivergentCommits(branch, target, root) {
|
|
340
|
-
try {
|
|
341
|
-
const count = execFileSync(
|
|
342
|
-
'git',
|
|
343
|
-
['rev-list', '--count', `${target}..${branch}`],
|
|
344
|
-
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
345
|
-
).trim();
|
|
346
|
-
return parseInt(count, 10) > 0;
|
|
347
|
-
} catch {
|
|
348
|
-
return false;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Check if a worktree directory has dirty (uncommitted) files.
|
|
354
|
-
* @param {string} worktreePath - Path to the worktree
|
|
355
|
-
* @returns {boolean}
|
|
356
|
-
*/
|
|
357
|
-
function hasDirtyFiles(worktreePath) {
|
|
358
|
-
try {
|
|
359
|
-
const status = execFileSync(
|
|
360
|
-
'git',
|
|
361
|
-
['status', '--porcelain'],
|
|
362
|
-
{ cwd: worktreePath, encoding: 'utf8', stdio: 'pipe' }
|
|
363
|
-
).trim();
|
|
364
|
-
return status.length > 0;
|
|
365
|
-
} catch {
|
|
366
|
-
return false;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Get the canonical git repository root (main worktree, not a linked worktree).
|
|
372
|
-
*
|
|
373
|
-
* `git rev-parse --show-toplevel` returns the root of whichever worktree
|
|
374
|
-
* the CWD is inside. In a linked worktree that is NOT the main repo root,
|
|
375
|
-
* so CAWS would read the wrong (or missing) .caws/worktrees.json.
|
|
376
|
-
*
|
|
377
|
-
* `--git-common-dir` always resolves to the main repo's .git directory,
|
|
378
|
-
* even from inside a linked worktree. Its parent is the canonical repo root.
|
|
379
|
-
*
|
|
380
|
-
* @returns {string} Absolute path to the main repo root
|
|
381
|
-
*/
|
|
382
|
-
function getRepoRoot() {
|
|
383
|
-
const gitCommonDir = execFileSync(
|
|
384
|
-
'git',
|
|
385
|
-
['rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
386
|
-
{ encoding: 'utf8' }
|
|
387
|
-
).trim();
|
|
388
|
-
// gitCommonDir is /path/to/main-repo/.git — parent is the repo root
|
|
389
|
-
return path.dirname(gitCommonDir);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Get current branch name
|
|
394
|
-
* @returns {string}
|
|
395
|
-
*/
|
|
396
|
-
function getCurrentBranch() {
|
|
397
|
-
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
398
|
-
encoding: 'utf8',
|
|
399
|
-
}).trim();
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Track whether we've already warned about schema violations this process.
|
|
403
|
-
// loadRegistry() is called multiple times per command; warning every time
|
|
404
|
-
// floods stderr and contributes to Claude Code context-window exhaustion.
|
|
405
|
-
let _schemaWarned = false;
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* CAWSFIX-31: Assert that the current agent session may operate on
|
|
409
|
-
* worktree `name`. The decision is purely session-id-equality based —
|
|
410
|
-
* never TTL, never log freshness — because rolled-over and resumed
|
|
411
|
-
* sessions should not be auto-blocked just because their registry
|
|
412
|
-
* entry was pruned.
|
|
413
|
-
*
|
|
414
|
-
* Returns `{ allowed, warning?, priorOwner? }`. The caller decides
|
|
415
|
-
* how to react:
|
|
416
|
-
*
|
|
417
|
-
* - allowed=true, no warning → silent proceed (same session id, no claim, etc.)
|
|
418
|
-
* - allowed=true, warning present → soft notice (orphan session log, no block)
|
|
419
|
-
* - allowed=false, warning present → soft-block; surface warning, exit non-zero
|
|
420
|
-
*
|
|
421
|
-
* On takeover (`allowTakeover: true`), the function rewrites the
|
|
422
|
-
* worktree entry's owner to the current session id and appends the
|
|
423
|
-
* prior owner to a `prior_owners` audit array (with lastSeen captured
|
|
424
|
-
* from agents.json at takeover time, or null if pruned).
|
|
425
|
-
*
|
|
426
|
-
* @param {string} root - Project root
|
|
427
|
-
* @param {string} name - Worktree name
|
|
428
|
-
* @param {object} [opts]
|
|
429
|
-
* @param {boolean} [opts.allowTakeover=false] - Apply takeover when true
|
|
430
|
-
* @param {string} [opts.takeoverCommandHint] - Suggested command for the warning
|
|
431
|
-
* @returns {{ allowed: boolean, warning?: string, priorOwner?: object }}
|
|
432
|
-
*/
|
|
433
|
-
function assertWorktreeOwnership(root, name, opts = {}) {
|
|
434
|
-
const { allowTakeover = false, takeoverCommandHint } = opts;
|
|
435
|
-
const registry = loadRegistry(root);
|
|
436
|
-
const entry = registry.worktrees[name];
|
|
437
|
-
if (!entry) {
|
|
438
|
-
return { allowed: true };
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const currentSession = getAgentSessionId(root);
|
|
442
|
-
const owner = entry.owner;
|
|
443
|
-
|
|
444
|
-
// No CAWS-tracked owner — surface session-log hint if present, but
|
|
445
|
-
// allow the operation to proceed.
|
|
446
|
-
if (!owner) {
|
|
447
|
-
const branch = entry.branch || null;
|
|
448
|
-
const logs = branch ? findSessionLogs(root, { branch }) : [];
|
|
449
|
-
if (logs.length > 0) {
|
|
450
|
-
return {
|
|
451
|
-
allowed: true,
|
|
452
|
-
warning: formatOrphanLogHint({ worktree: name, sessionLogs: logs, root }),
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
return { allowed: true };
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Same session id → silent proceed. Roll-over case included: an
|
|
459
|
-
// agent that resumed with the same session id is its own claimant.
|
|
460
|
-
if (currentSession && owner === currentSession) {
|
|
461
|
-
return { allowed: true };
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Foreign claim — gather context.
|
|
465
|
-
const agentRegistry = loadAgentRegistry(root);
|
|
466
|
-
const priorOwnerEntry = agentRegistry.agents[owner] || null;
|
|
467
|
-
const priorOwnerLastSeen = priorOwnerEntry ? priorOwnerEntry.lastSeen : null;
|
|
468
|
-
const priorOwnerPlatform = priorOwnerEntry ? priorOwnerEntry.platform : 'unknown';
|
|
469
|
-
|
|
470
|
-
// Surface session-log pointers (by sid OR by branch).
|
|
471
|
-
const branch = entry.branch || null;
|
|
472
|
-
const seen = new Set();
|
|
473
|
-
const sessionLogs = [];
|
|
474
|
-
for (const log of findSessionLogs(root, { sessionId: owner })) {
|
|
475
|
-
if (seen.has(log.path)) continue;
|
|
476
|
-
seen.add(log.path);
|
|
477
|
-
sessionLogs.push(log);
|
|
478
|
-
}
|
|
479
|
-
if (branch) {
|
|
480
|
-
for (const log of findSessionLogs(root, { branch })) {
|
|
481
|
-
if (seen.has(log.path)) continue;
|
|
482
|
-
seen.add(log.path);
|
|
483
|
-
sessionLogs.push(log);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
const takeoverCommand =
|
|
488
|
-
takeoverCommandHint || `caws worktree claim ${name} --takeover`;
|
|
489
|
-
const warning = formatClaimNotice({
|
|
490
|
-
worktree: name,
|
|
491
|
-
priorOwnerEntry,
|
|
492
|
-
priorOwnerSessionId: owner,
|
|
493
|
-
sessionLogs,
|
|
494
|
-
root,
|
|
495
|
-
takeoverCommand,
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
if (!allowTakeover) {
|
|
499
|
-
return { allowed: false, warning };
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// Takeover: rewrite owner, append prior_owners audit entry.
|
|
503
|
-
const priorOwners = Array.isArray(entry.prior_owners) ? entry.prior_owners : [];
|
|
504
|
-
priorOwners.push({
|
|
505
|
-
sessionId: owner,
|
|
506
|
-
platform: priorOwnerPlatform,
|
|
507
|
-
lastSeen: priorOwnerLastSeen,
|
|
508
|
-
takenOver_at: new Date().toISOString(),
|
|
509
|
-
});
|
|
510
|
-
registry.worktrees[name] = {
|
|
511
|
-
...entry,
|
|
512
|
-
owner: currentSession || null,
|
|
513
|
-
prior_owners: priorOwners,
|
|
514
|
-
};
|
|
515
|
-
saveRegistry(root, registry);
|
|
516
|
-
|
|
517
|
-
// Heartbeat the new owner so agents.json reflects the takeover too.
|
|
518
|
-
// Without this, `caws status` and `caws agents list` would show the
|
|
519
|
-
// takeover'd worktree with an "unknown / pruned" current owner until
|
|
520
|
-
// some other lifecycle verb fires.
|
|
521
|
-
refreshAgentClaim(root, { worktree: name });
|
|
522
|
-
|
|
523
|
-
return {
|
|
524
|
-
allowed: true,
|
|
525
|
-
priorOwner: {
|
|
526
|
-
sessionId: owner,
|
|
527
|
-
platform: priorOwnerPlatform,
|
|
528
|
-
lastSeen: priorOwnerLastSeen,
|
|
529
|
-
},
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Load the worktree registry
|
|
535
|
-
* @param {string} root - Repository root
|
|
536
|
-
* @returns {Object} Registry object
|
|
537
|
-
*/
|
|
538
|
-
function loadRegistry(root) {
|
|
539
|
-
const registryPath = path.join(root, REGISTRY_FILE);
|
|
540
|
-
try {
|
|
541
|
-
if (fs.existsSync(registryPath)) {
|
|
542
|
-
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
543
|
-
try {
|
|
544
|
-
const validate = createValidator(getSchemaPath('worktrees.schema.json', root));
|
|
545
|
-
const result = validate(data);
|
|
546
|
-
if (!result.valid && !_schemaWarned) {
|
|
547
|
-
_schemaWarned = true;
|
|
548
|
-
console.warn('Worktree registry has schema violations:', result.errors);
|
|
549
|
-
}
|
|
550
|
-
} catch (schemaErr) {
|
|
551
|
-
if (!_schemaWarned) {
|
|
552
|
-
_schemaWarned = true;
|
|
553
|
-
console.warn('Could not validate worktree registry schema:', schemaErr.message);
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
return data;
|
|
557
|
-
}
|
|
558
|
-
} catch {
|
|
559
|
-
// Corrupted registry, start fresh
|
|
560
|
-
}
|
|
561
|
-
return { version: 1, worktrees: {} };
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/**
|
|
565
|
-
* Save the worktree registry
|
|
566
|
-
* @param {string} root - Repository root
|
|
567
|
-
* @param {Object} registry - Registry object
|
|
568
|
-
*/
|
|
569
|
-
function saveRegistry(root, registry) {
|
|
570
|
-
// Auto-prune ghost entries: any registry entry whose path directory AND
|
|
571
|
-
// stored branch are BOTH gone. Previously this only fired for entries
|
|
572
|
-
// explicitly marked `status: destroyed`, which missed two common cases:
|
|
573
|
-
// 1. A worktree removed via `git worktree remove` (not `caws worktree
|
|
574
|
-
// destroy`) that later had its branch manually deleted with
|
|
575
|
-
// `git branch -D`.
|
|
576
|
-
// 2. A worktree whose create failed partway, leaving a registry entry
|
|
577
|
-
// at `fresh`/`active` but no artifacts on disk.
|
|
578
|
-
// Both are pure ghost state — no recoverable work remains in either
|
|
579
|
-
// the directory or the branch. Pruning is safe. (CAWSFIX-25 / D7)
|
|
580
|
-
//
|
|
581
|
-
// Entries with ONE artifact intact (dir gone but branch still present,
|
|
582
|
-
// or vice versa) are preserved. The branch may still hold unmerged
|
|
583
|
-
// commits, or the directory may still hold uncommitted work — the user
|
|
584
|
-
// should merge or explicitly destroy.
|
|
585
|
-
for (const [name, entry] of Object.entries(registry.worktrees || {})) {
|
|
586
|
-
const dirGone = !fs.existsSync(entry.path);
|
|
587
|
-
let branchGone = true;
|
|
588
|
-
if (entry.branch) {
|
|
589
|
-
try {
|
|
590
|
-
execFileSync('git', ['rev-parse', '--verify', entry.branch], {
|
|
591
|
-
cwd: root, stdio: 'pipe',
|
|
592
|
-
});
|
|
593
|
-
branchGone = false;
|
|
594
|
-
} catch {
|
|
595
|
-
branchGone = true;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
if (dirGone && branchGone) {
|
|
599
|
-
delete registry.worktrees[name];
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
const registryPath = path.join(root, REGISTRY_FILE);
|
|
604
|
-
fs.ensureDirSync(path.dirname(registryPath));
|
|
605
|
-
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* Discover git worktrees under .caws/worktrees/ that are not in the registry.
|
|
610
|
-
* @param {string} root - Repository root
|
|
611
|
-
* @param {Object} registry - Current registry object
|
|
612
|
-
* @returns {Array<{ name: string, path: string, branch: string }>}
|
|
613
|
-
*/
|
|
614
|
-
function discoverUnregisteredWorktrees(root, registry) {
|
|
615
|
-
const unregistered = [];
|
|
616
|
-
try {
|
|
617
|
-
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
618
|
-
cwd: root,
|
|
619
|
-
encoding: 'utf8',
|
|
620
|
-
stdio: 'pipe',
|
|
621
|
-
});
|
|
622
|
-
let worktreesDir;
|
|
623
|
-
try {
|
|
624
|
-
worktreesDir = fs.realpathSync(path.resolve(root, WORKTREES_DIR));
|
|
625
|
-
} catch {
|
|
626
|
-
// Directory might not exist yet
|
|
627
|
-
worktreesDir = path.resolve(root, WORKTREES_DIR);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
const blocks = output.split('\n\n').filter(Boolean);
|
|
631
|
-
for (const block of blocks) {
|
|
632
|
-
const lines = block.split('\n');
|
|
633
|
-
const wtLine = lines.find((l) => l.startsWith('worktree '));
|
|
634
|
-
const branchLine = lines.find((l) => l.startsWith('branch '));
|
|
635
|
-
if (!wtLine) continue;
|
|
636
|
-
|
|
637
|
-
const wtPath = wtLine.replace('worktree ', '');
|
|
638
|
-
let resolvedPath;
|
|
639
|
-
try {
|
|
640
|
-
resolvedPath = fs.realpathSync(wtPath);
|
|
641
|
-
} catch {
|
|
642
|
-
resolvedPath = path.resolve(wtPath);
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Only consider worktrees under .caws/worktrees/
|
|
646
|
-
if (!resolvedPath.startsWith(worktreesDir + path.sep)) continue;
|
|
647
|
-
|
|
648
|
-
const name = path.basename(resolvedPath);
|
|
649
|
-
if (registry.worktrees[name]) continue;
|
|
650
|
-
|
|
651
|
-
const branch = branchLine
|
|
652
|
-
? branchLine.replace('branch refs/heads/', '')
|
|
653
|
-
: `${BRANCH_PREFIX}${name}`;
|
|
654
|
-
unregistered.push({ name, path: resolvedPath, branch });
|
|
655
|
-
}
|
|
656
|
-
} catch {
|
|
657
|
-
// git worktree list failed
|
|
658
|
-
}
|
|
659
|
-
return unregistered;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
/**
|
|
663
|
-
* Auto-register an unregistered worktree. Infers baseBranch via merge-base.
|
|
664
|
-
* @param {string} root - Repository root
|
|
665
|
-
* @param {Object} registry - Registry object (mutated in place)
|
|
666
|
-
* @param {{ name: string, path: string, branch: string }} discovered
|
|
667
|
-
* @returns {Object} The registered entry
|
|
668
|
-
*/
|
|
669
|
-
function autoRegisterWorktree(root, registry, discovered) {
|
|
670
|
-
let baseBranch = 'main';
|
|
671
|
-
try {
|
|
672
|
-
execFileSync(
|
|
673
|
-
'git',
|
|
674
|
-
['merge-base', discovered.branch, 'main'],
|
|
675
|
-
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
676
|
-
);
|
|
677
|
-
} catch {
|
|
678
|
-
try {
|
|
679
|
-
execFileSync(
|
|
680
|
-
'git',
|
|
681
|
-
['merge-base', discovered.branch, 'master'],
|
|
682
|
-
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
683
|
-
);
|
|
684
|
-
baseBranch = 'master';
|
|
685
|
-
} catch {
|
|
686
|
-
// Keep 'main' as default
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
const entry = {
|
|
691
|
-
name: discovered.name,
|
|
692
|
-
path: discovered.path,
|
|
693
|
-
branch: discovered.branch,
|
|
694
|
-
baseBranch,
|
|
695
|
-
scope: null,
|
|
696
|
-
specId: inferSpecIdForWorktree(discovered.path),
|
|
697
|
-
owner: null,
|
|
698
|
-
createdAt: new Date().toISOString(),
|
|
699
|
-
status: 'active',
|
|
700
|
-
autoRegistered: true,
|
|
701
|
-
};
|
|
702
|
-
|
|
703
|
-
registry.worktrees[discovered.name] = entry;
|
|
704
|
-
saveRegistry(root, registry);
|
|
705
|
-
return entry;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* Create a new git worktree with scope isolation
|
|
710
|
-
* @param {string} name - Worktree name
|
|
711
|
-
* @param {Object} options - Creation options
|
|
712
|
-
* @param {string} [options.scope] - Sparse checkout pattern (e.g., "src/auth/**")
|
|
713
|
-
* @param {string} [options.baseBranch] - Base branch to create from
|
|
714
|
-
* @param {string} [options.specId] - Associated spec ID for standard+ modes
|
|
715
|
-
* @returns {Object} Created worktree info
|
|
716
|
-
*/
|
|
717
|
-
function createWorktree(name, options = {}) {
|
|
718
|
-
const root = getRepoRoot();
|
|
719
|
-
const { scope, baseBranch, specId } = options;
|
|
720
|
-
|
|
721
|
-
// Validate name
|
|
722
|
-
if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
723
|
-
throw new Error('Worktree name must contain only letters, numbers, hyphens, and underscores');
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
const registry = loadRegistry(root);
|
|
727
|
-
|
|
728
|
-
// Check for duplicate in registry
|
|
729
|
-
if (registry.worktrees[name]) {
|
|
730
|
-
const existing = registry.worktrees[name];
|
|
731
|
-
if (existing.status !== 'destroyed') {
|
|
732
|
-
const ownerInfo = existing.owner ? ` (owned by session ${existing.owner})` : '';
|
|
733
|
-
throw new Error(
|
|
734
|
-
`Worktree '${name}' already exists with status '${existing.status}'${ownerInfo}.\n` +
|
|
735
|
-
`Use 'caws worktree destroy ${name}' first, or choose a different name.`
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
// Destroyed entries: check if another session owns the branch
|
|
739
|
-
if (existing.owner && existing.owner !== getAgentSessionId(root)) {
|
|
740
|
-
// Branch may still be in use by the owning session for merge
|
|
741
|
-
try {
|
|
742
|
-
const branchExists = execFileSync('git', ['rev-parse', '--verify', BRANCH_PREFIX + name], {
|
|
743
|
-
cwd: root, stdio: 'pipe',
|
|
744
|
-
}).toString().trim();
|
|
745
|
-
if (branchExists) {
|
|
746
|
-
throw new Error(
|
|
747
|
-
`Worktree '${name}' was destroyed but branch '${BRANCH_PREFIX}${name}' still exists ` +
|
|
748
|
-
`(owned by session ${existing.owner}).\n` +
|
|
749
|
-
`The owning session may still need this branch for merging.\n` +
|
|
750
|
-
`Choose a different name, or delete the branch first: git branch -d ${BRANCH_PREFIX}${name}`
|
|
751
|
-
);
|
|
752
|
-
}
|
|
753
|
-
} catch (e) {
|
|
754
|
-
if (e.message.includes('owned by session')) throw e;
|
|
755
|
-
// Branch doesn't exist — safe to reuse the name
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
const worktreePath = path.join(root, WORKTREES_DIR, name);
|
|
761
|
-
const branchName = BRANCH_PREFIX + name;
|
|
762
|
-
const base = baseBranch || getCurrentBranch();
|
|
763
|
-
|
|
764
|
-
// CAWSFIX-27: resolve the bound specId (explicit --spec-id OR auto-bind
|
|
765
|
-
// via worktree-name match) BEFORE creating the worktree, so the
|
|
766
|
-
// draft→active flip + bind commit land on the base branch before the
|
|
767
|
-
// worktree forks. Pre-CAWSFIX-27 the auto-bind path activated the spec
|
|
768
|
-
// but never committed it, leaving main with a dirty spec after
|
|
769
|
-
// `caws worktree create <name>` (no --spec-id).
|
|
770
|
-
let resolvedSpecId = specId || null;
|
|
771
|
-
if (!resolvedSpecId) {
|
|
772
|
-
resolvedSpecId = findSpecByWorktreeName(root, name);
|
|
773
|
-
if (resolvedSpecId) {
|
|
774
|
-
console.log(chalk.gray(` Auto-bound spec: ${resolvedSpecId}`));
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
const canonicalSpecPath = findFeatureSpecPath(root, resolvedSpecId);
|
|
778
|
-
|
|
779
|
-
// Check if the branch already exists in git (even if not in registry)
|
|
780
|
-
// This catches cases where another agent created the branch outside CAWS
|
|
781
|
-
try {
|
|
782
|
-
execFileSync('git', ['rev-parse', '--verify', branchName], {
|
|
783
|
-
cwd: root, stdio: 'pipe',
|
|
784
|
-
});
|
|
785
|
-
// Branch exists — refuse unless it's fully merged into base
|
|
786
|
-
const currentSession = getAgentSessionId(root);
|
|
787
|
-
const registryOwner = registry.worktrees[name]?.owner;
|
|
788
|
-
if (registryOwner && registryOwner !== currentSession) {
|
|
789
|
-
throw new Error(
|
|
790
|
-
`Branch '${branchName}' already exists and is owned by another session (${registryOwner}).\n` +
|
|
791
|
-
`Another agent may be using this branch. Choose a different worktree name.`
|
|
792
|
-
);
|
|
793
|
-
}
|
|
794
|
-
// Branch exists but no owner conflict — warn and reuse
|
|
795
|
-
console.warn(`Warning: Branch '${branchName}' already exists, reusing it.`);
|
|
796
|
-
} catch (e) {
|
|
797
|
-
if (e.message.includes('already exists and is owned')) throw e;
|
|
798
|
-
// Branch doesn't exist — this is the normal path
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// Create the worktree directory
|
|
802
|
-
fs.ensureDirSync(path.dirname(worktreePath));
|
|
803
|
-
|
|
804
|
-
if (canonicalSpecPath && resolvedSpecId) {
|
|
805
|
-
// CAWSFIX-23: flip draft→active BEFORE the bind commit so the spec
|
|
806
|
-
// lifecycle transition lands in the same commit as the worktree field.
|
|
807
|
-
// CAWSFIX-27: this block now handles BOTH the explicit --spec-id path
|
|
808
|
-
// and the auto-bind (findSpecByWorktreeName) path — previously only
|
|
809
|
-
// the explicit path committed the flip.
|
|
810
|
-
autoActivateBoundSpec(root, resolvedSpecId);
|
|
811
|
-
ensureCanonicalSpecCommitted(root, canonicalSpecPath, resolvedSpecId, name);
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// Create git worktree with new branch
|
|
815
|
-
try {
|
|
816
|
-
execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, base], {
|
|
817
|
-
cwd: root,
|
|
818
|
-
stdio: 'pipe',
|
|
819
|
-
});
|
|
820
|
-
} catch (error) {
|
|
821
|
-
// Branch already exists (caught above and allowed) — attach to it
|
|
822
|
-
if (error.message.includes('already exists')) {
|
|
823
|
-
execFileSync('git', ['worktree', 'add', worktreePath, branchName], {
|
|
824
|
-
cwd: root,
|
|
825
|
-
stdio: 'pipe',
|
|
826
|
-
});
|
|
827
|
-
} else {
|
|
828
|
-
throw new Error(`Failed to create worktree: ${error.message}`);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// Set up sparse checkout if scope is provided
|
|
833
|
-
if (scope) {
|
|
834
|
-
try {
|
|
835
|
-
// Parse scope patterns (comma-separated)
|
|
836
|
-
const patterns = scope.split(',').map((p) => p.trim());
|
|
837
|
-
|
|
838
|
-
// Detect glob characters — cone mode only accepts directory paths,
|
|
839
|
-
// not glob patterns like "core/reasoning/**" or "*.py".
|
|
840
|
-
const hasGlobs = patterns.some((p) => /[*?[\]]/.test(p));
|
|
841
|
-
const coneFlag = hasGlobs ? '--no-cone' : '--cone';
|
|
842
|
-
|
|
843
|
-
execFileSync('git', ['sparse-checkout', 'init', coneFlag], {
|
|
844
|
-
cwd: worktreePath,
|
|
845
|
-
stdio: 'pipe',
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
execFileSync('git', ['sparse-checkout', 'set', ...patterns], {
|
|
849
|
-
cwd: worktreePath,
|
|
850
|
-
stdio: 'pipe',
|
|
851
|
-
});
|
|
852
|
-
} catch (error) {
|
|
853
|
-
console.warn(chalk.yellow(`Sparse checkout setup failed: ${error.message}`));
|
|
854
|
-
console.warn(chalk.blue('Worktree created but without sparse checkout'));
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// Copy .caws/ config into worktree
|
|
859
|
-
const cawsSource = path.join(root, '.caws');
|
|
860
|
-
const cawsDest = path.join(worktreePath, '.caws');
|
|
861
|
-
if (fs.existsSync(cawsSource)) {
|
|
862
|
-
try {
|
|
863
|
-
fs.copySync(cawsSource, cawsDest, {
|
|
864
|
-
filter: (src) => {
|
|
865
|
-
// Don't copy worktrees directory or registry into the worktree
|
|
866
|
-
const rel = path.relative(cawsSource, src);
|
|
867
|
-
return !rel.startsWith('worktrees') && rel !== 'worktrees.json';
|
|
868
|
-
},
|
|
869
|
-
});
|
|
870
|
-
} catch {
|
|
871
|
-
// Non-fatal
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// CAWSFIX-27: resolvedSpecId is now computed before the worktree is
|
|
876
|
-
// added (see block above the `fs.ensureDirSync` call). The activation
|
|
877
|
-
// and bind-commit already ran on the base branch, so the worktree forks
|
|
878
|
-
// from a base that already includes the flip commit.
|
|
879
|
-
|
|
880
|
-
// Materialize a worktree-local working spec. Prefer the canonical feature
|
|
881
|
-
// spec when it exists so isolated worktrees stay aligned with the main
|
|
882
|
-
// registry/resolver model.
|
|
883
|
-
if (resolvedSpecId) {
|
|
884
|
-
try {
|
|
885
|
-
materializeWorktreeSpec(root, cawsDest, resolvedSpecId, name, scope);
|
|
886
|
-
} catch (error) {
|
|
887
|
-
console.warn(
|
|
888
|
-
chalk.yellow(`Could not materialize spec '${resolvedSpecId}' for worktree '${name}': ${error.message}`)
|
|
889
|
-
);
|
|
890
|
-
// Non-fatal: spec generation is optional
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
// Register worktree
|
|
895
|
-
const entry = {
|
|
896
|
-
name,
|
|
897
|
-
path: worktreePath,
|
|
898
|
-
branch: branchName,
|
|
899
|
-
baseBranch: base,
|
|
900
|
-
scope: scope || null,
|
|
901
|
-
specId: resolvedSpecId,
|
|
902
|
-
owner: options.owner || getAgentSessionId(root) || null,
|
|
903
|
-
createdAt: new Date().toISOString(),
|
|
904
|
-
status: 'fresh',
|
|
905
|
-
};
|
|
906
|
-
|
|
907
|
-
registry.worktrees[name] = entry;
|
|
908
|
-
saveRegistry(root, registry);
|
|
909
|
-
|
|
910
|
-
// CAWSFIX-32: heartbeat the current session into agents.json so the
|
|
911
|
-
// worktree+spec context is visible to other agents and to
|
|
912
|
-
// `caws status` / `caws agents list` immediately after create.
|
|
913
|
-
refreshAgentClaim(root, { worktree: name, specId: resolvedSpecId || null });
|
|
914
|
-
|
|
915
|
-
return entry;
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/**
|
|
919
|
-
* Reconcile registry state against git worktree list and filesystem.
|
|
920
|
-
*
|
|
921
|
-
* Non-destructive read that classifies every known worktree entry
|
|
922
|
-
* (from registry + git discovery) into one of:
|
|
923
|
-
* active — directory exists AND in git worktree list
|
|
924
|
-
* orphaned — directory exists but NOT in git worktree list
|
|
925
|
-
* missing — directory gone, branch may or may not exist
|
|
926
|
-
* destroyed — explicitly destroyed via CAWS
|
|
927
|
-
* unregistered — in git worktree list but not in registry
|
|
928
|
-
* stale-merged — missing + branch already merged to base
|
|
929
|
-
*
|
|
930
|
-
* Does NOT mutate the registry. Callers decide what to persist.
|
|
931
|
-
*
|
|
932
|
-
* @param {string} root - Repository root
|
|
933
|
-
* @returns {{ entries: Array, gitWorktrees: string[] }}
|
|
934
|
-
*/
|
|
935
|
-
function reconcileRegistry(root) {
|
|
936
|
-
const registry = loadRegistry(root);
|
|
937
|
-
|
|
938
|
-
let gitWorktrees = [];
|
|
939
|
-
try {
|
|
940
|
-
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
941
|
-
cwd: root,
|
|
942
|
-
encoding: 'utf8',
|
|
943
|
-
stdio: 'pipe',
|
|
944
|
-
});
|
|
945
|
-
gitWorktrees = output
|
|
946
|
-
.split('\n\n')
|
|
947
|
-
.filter(Boolean)
|
|
948
|
-
.map((block) => {
|
|
949
|
-
const lines = block.split('\n');
|
|
950
|
-
const worktreeLine = lines.find((l) => l.startsWith('worktree '));
|
|
951
|
-
return worktreeLine ? worktreeLine.replace('worktree ', '') : null;
|
|
952
|
-
})
|
|
953
|
-
.filter(Boolean);
|
|
954
|
-
} catch {
|
|
955
|
-
// Git worktree list failed
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
const entries = Object.values(registry.worktrees).map((entry) => {
|
|
959
|
-
const exists = fs.existsSync(entry.path);
|
|
960
|
-
const inGit = gitWorktrees.some(
|
|
961
|
-
(wt) => path.resolve(wt) === path.resolve(entry.path)
|
|
962
|
-
);
|
|
963
|
-
|
|
964
|
-
const merged = entry.branch && entry.baseBranch
|
|
965
|
-
? isBranchMerged(entry.branch, entry.baseBranch, root)
|
|
966
|
-
: false;
|
|
967
|
-
const divergent = entry.branch && entry.baseBranch
|
|
968
|
-
? hasDivergentCommits(entry.branch, entry.baseBranch, root)
|
|
969
|
-
: false;
|
|
970
|
-
const dirty = exists ? hasDirtyFiles(entry.path) : false;
|
|
971
|
-
|
|
972
|
-
let status;
|
|
973
|
-
if (entry.status === 'destroyed') {
|
|
974
|
-
status = 'destroyed';
|
|
975
|
-
} else if (exists && inGit) {
|
|
976
|
-
// Worktree directory exists and is tracked by git
|
|
977
|
-
if (divergent || dirty) {
|
|
978
|
-
// Has commits beyond base or uncommitted work → active
|
|
979
|
-
status = 'active';
|
|
980
|
-
} else if (merged) {
|
|
981
|
-
// No divergent commits, branch aligned with base.
|
|
982
|
-
// Use stored status as history to distinguish fresh vs merged:
|
|
983
|
-
// - stored 'fresh' → never had divergent commits → still fresh
|
|
984
|
-
// - stored 'active' → had work that's now merged → merged
|
|
985
|
-
if (entry.status === 'active') {
|
|
986
|
-
status = 'merged';
|
|
987
|
-
} else {
|
|
988
|
-
status = 'fresh';
|
|
989
|
-
}
|
|
990
|
-
} else {
|
|
991
|
-
status = 'fresh';
|
|
992
|
-
}
|
|
993
|
-
} else if (exists) {
|
|
994
|
-
status = 'orphaned';
|
|
995
|
-
} else {
|
|
996
|
-
status = merged ? 'stale-merged' : 'missing';
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
|
|
1000
|
-
|
|
1001
|
-
return { ...entry, status, lastCommit, merged, divergent, dirty };
|
|
1002
|
-
});
|
|
1003
|
-
|
|
1004
|
-
// Append unregistered worktrees discovered from git
|
|
1005
|
-
const unregistered = discoverUnregisteredWorktrees(root, registry);
|
|
1006
|
-
for (const discovered of unregistered) {
|
|
1007
|
-
const lastCommit = getLastCommitInfo(discovered.branch, root);
|
|
1008
|
-
entries.push({
|
|
1009
|
-
name: discovered.name,
|
|
1010
|
-
path: discovered.path,
|
|
1011
|
-
branch: discovered.branch,
|
|
1012
|
-
baseBranch: null,
|
|
1013
|
-
scope: null,
|
|
1014
|
-
specId: null,
|
|
1015
|
-
owner: null,
|
|
1016
|
-
createdAt: null,
|
|
1017
|
-
status: 'unregistered',
|
|
1018
|
-
lastCommit,
|
|
1019
|
-
merged: false,
|
|
1020
|
-
});
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
return { entries, gitWorktrees };
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* Repair registry drift caused by manual git operations outside CAWS.
|
|
1028
|
-
*
|
|
1029
|
-
* Scans registry vs git vs filesystem, classifies each entry, and optionally
|
|
1030
|
-
* prunes stale entries. Reports the delta before persisting.
|
|
1031
|
-
*
|
|
1032
|
-
* @param {Object} options
|
|
1033
|
-
* @param {boolean} [options.prune=false] - Remove destroyed, stale-merged, and missing entries
|
|
1034
|
-
* @param {boolean} [options.dryRun=false] - Report only, do not persist
|
|
1035
|
-
* @param {boolean} [options.force=false] - Allow pruning entries owned by other sessions
|
|
1036
|
-
* @returns {{ repaired: Array, pruned: Array, skipped: Array }}
|
|
1037
|
-
*/
|
|
1038
|
-
function repairWorktrees(options = {}) {
|
|
1039
|
-
const { prune: shouldPrune = false, dryRun = false, force = false } = options;
|
|
1040
|
-
const root = getRepoRoot();
|
|
1041
|
-
const registry = loadRegistry(root);
|
|
1042
|
-
const { entries } = reconcileRegistry(root);
|
|
1043
|
-
const currentSession = getAgentSessionId(root);
|
|
1044
|
-
|
|
1045
|
-
const repaired = [];
|
|
1046
|
-
const pruned = [];
|
|
1047
|
-
const skipped = [];
|
|
1048
|
-
|
|
1049
|
-
for (const entry of entries) {
|
|
1050
|
-
const regEntry = registry.worktrees[entry.name];
|
|
1051
|
-
|
|
1052
|
-
if (entry.status === 'unregistered') {
|
|
1053
|
-
if (!dryRun) {
|
|
1054
|
-
autoRegisterWorktree(root, registry, entry);
|
|
1055
|
-
}
|
|
1056
|
-
repaired.push({ name: entry.name, action: 'registered', status: entry.status });
|
|
1057
|
-
continue;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
if (!regEntry) continue;
|
|
1061
|
-
|
|
1062
|
-
// Update registry status to match filesystem reality
|
|
1063
|
-
const wasAlive = regEntry.status === 'active' || regEntry.status === 'fresh';
|
|
1064
|
-
const nowDead = entry.status === 'missing' || entry.status === 'stale-merged';
|
|
1065
|
-
if (wasAlive && nowDead) {
|
|
1066
|
-
repaired.push({
|
|
1067
|
-
name: entry.name,
|
|
1068
|
-
action: 'status-updated',
|
|
1069
|
-
from: regEntry.status,
|
|
1070
|
-
to: entry.status,
|
|
1071
|
-
owner: entry.owner || null,
|
|
1072
|
-
});
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// Determine if entry is prunable (destroyed, stale-merged, or missing)
|
|
1076
|
-
const isPrunable = entry.status === 'destroyed' ||
|
|
1077
|
-
entry.status === 'stale-merged' ||
|
|
1078
|
-
entry.status === 'missing';
|
|
1079
|
-
|
|
1080
|
-
if (!isPrunable) continue;
|
|
1081
|
-
|
|
1082
|
-
// Ownership check: refuse to prune another session's entries without --force
|
|
1083
|
-
const isOwnedByOther = entry.owner && currentSession && entry.owner !== currentSession;
|
|
1084
|
-
|
|
1085
|
-
if (shouldPrune && isPrunable) {
|
|
1086
|
-
if (isOwnedByOther && !force) {
|
|
1087
|
-
skipped.push({
|
|
1088
|
-
name: entry.name,
|
|
1089
|
-
reason: `owned by another session (${entry.owner}). Use --force to override`,
|
|
1090
|
-
owner: entry.owner,
|
|
1091
|
-
});
|
|
1092
|
-
} else {
|
|
1093
|
-
if (!dryRun) {
|
|
1094
|
-
delete registry.worktrees[entry.name];
|
|
1095
|
-
}
|
|
1096
|
-
pruned.push({ name: entry.name, status: entry.status, owner: entry.owner || null });
|
|
1097
|
-
}
|
|
1098
|
-
} else if (!shouldPrune && isPrunable) {
|
|
1099
|
-
skipped.push({
|
|
1100
|
-
name: entry.name,
|
|
1101
|
-
reason: entry.status + ' (use --prune to remove)',
|
|
1102
|
-
owner: entry.owner || null,
|
|
1103
|
-
});
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
if (!dryRun) {
|
|
1108
|
-
saveRegistry(root, registry);
|
|
1109
|
-
try {
|
|
1110
|
-
execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
|
|
1111
|
-
} catch {
|
|
1112
|
-
// Non-fatal
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
return { repaired, pruned, skipped };
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
/**
|
|
1120
|
-
* List all registered worktrees with filesystem validation.
|
|
1121
|
-
* Delegates to reconcileRegistry() for state classification.
|
|
1122
|
-
* Persists status transitions (fresh → active, active → merged) so
|
|
1123
|
-
* future calls can distinguish "never had work" from "work was merged back".
|
|
1124
|
-
* @returns {Array} Worktree entries with status
|
|
1125
|
-
*/
|
|
1126
|
-
function listWorktrees() {
|
|
1127
|
-
const root = getRepoRoot();
|
|
1128
|
-
const registry = loadRegistry(root);
|
|
1129
|
-
const { entries } = reconcileRegistry(root);
|
|
1130
|
-
|
|
1131
|
-
// Persist status transitions so future reconcile can use stored status as history
|
|
1132
|
-
let dirty = false;
|
|
1133
|
-
for (const entry of entries) {
|
|
1134
|
-
const regEntry = registry.worktrees[entry.name];
|
|
1135
|
-
if (regEntry && regEntry.status !== entry.status &&
|
|
1136
|
-
entry.status !== 'unregistered') {
|
|
1137
|
-
regEntry.status = entry.status;
|
|
1138
|
-
dirty = true;
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
if (dirty) {
|
|
1142
|
-
saveRegistry(root, registry);
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
return entries;
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
/**
|
|
1149
|
-
* Destroy a worktree
|
|
1150
|
-
* @param {string} name - Worktree name
|
|
1151
|
-
* @param {Object} options - Destruction options
|
|
1152
|
-
* @param {boolean} [options.deleteBranch] - Also delete the branch
|
|
1153
|
-
* @param {boolean} [options.force] - Force removal even if dirty
|
|
1154
|
-
*/
|
|
1155
|
-
function destroyWorktree(name, options = {}) {
|
|
1156
|
-
const root = getRepoRoot();
|
|
1157
|
-
// Ensure CWD is not inside the worktree we're about to destroy.
|
|
1158
|
-
// If CWD is the worktree directory, removing it crashes subsequent commands.
|
|
1159
|
-
try { process.chdir(root); } catch { /* non-fatal */ }
|
|
1160
|
-
const registry = loadRegistry(root);
|
|
1161
|
-
const { deleteBranch = false, force = false } = options;
|
|
1162
|
-
|
|
1163
|
-
let entry = registry.worktrees[name];
|
|
1164
|
-
if (!entry) {
|
|
1165
|
-
// Fallback: scan git for unregistered worktree and auto-register
|
|
1166
|
-
const unregistered = discoverUnregisteredWorktrees(root, registry);
|
|
1167
|
-
const discovered = unregistered.find((u) => u.name === name);
|
|
1168
|
-
if (discovered) {
|
|
1169
|
-
console.log(chalk.yellow(`Worktree '${name}' not in registry but found in git. Auto-registering.`));
|
|
1170
|
-
entry = autoRegisterWorktree(root, registry, discovered);
|
|
1171
|
-
} else {
|
|
1172
|
-
throw new Error(`Worktree '${name}' not found in registry or git worktree list`);
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
// Ownership check: refuse to destroy another agent's worktree without --force
|
|
1177
|
-
const currentSession = getAgentSessionId(root);
|
|
1178
|
-
const isLiveStatus = entry.status === 'active' || entry.status === 'fresh' || entry.status === 'merged';
|
|
1179
|
-
if (
|
|
1180
|
-
!force &&
|
|
1181
|
-
isLiveStatus &&
|
|
1182
|
-
entry.owner &&
|
|
1183
|
-
currentSession &&
|
|
1184
|
-
entry.owner !== currentSession
|
|
1185
|
-
) {
|
|
1186
|
-
const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
|
|
1187
|
-
const recency = lastCommit ? ` (last commit: ${lastCommit.age})` : '';
|
|
1188
|
-
throw new Error(
|
|
1189
|
-
`Worktree '${name}' belongs to another session${recency}.\n` +
|
|
1190
|
-
` Owner: ${entry.owner}\n` +
|
|
1191
|
-
` You: ${currentSession}\n` +
|
|
1192
|
-
`Another agent may be actively working here.\n` +
|
|
1193
|
-
`Do NOT destroy worktrees you did not create. Ask the user if cleanup is needed.`
|
|
1194
|
-
);
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
// Even with --force, warn loudly when destroying another session's worktree
|
|
1198
|
-
if (
|
|
1199
|
-
force &&
|
|
1200
|
-
isLiveStatus &&
|
|
1201
|
-
entry.owner &&
|
|
1202
|
-
currentSession &&
|
|
1203
|
-
entry.owner !== currentSession
|
|
1204
|
-
) {
|
|
1205
|
-
const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
|
|
1206
|
-
const recency = lastCommit ? ` (last commit: ${lastCommit.age})` : '';
|
|
1207
|
-
console.log(chalk.red(`\n ⚠ WARNING: Force-destroying worktree '${name}' owned by another session${recency}`));
|
|
1208
|
-
console.log(chalk.red(` Owner: ${entry.owner}`));
|
|
1209
|
-
console.log(chalk.red(` You: ${currentSession}`));
|
|
1210
|
-
console.log(chalk.red(` If the other agent is still running, this WILL break their work.\n`));
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
// Auto-force when the branch is already merged to its base branch.
|
|
1214
|
-
// Dirty files in a merged worktree are definitionally stale.
|
|
1215
|
-
const merged = entry.branch && entry.baseBranch
|
|
1216
|
-
? isBranchMerged(entry.branch, entry.baseBranch, root)
|
|
1217
|
-
: false;
|
|
1218
|
-
const effectiveForce = force || merged;
|
|
1219
|
-
if (merged && !force) {
|
|
1220
|
-
console.log(chalk.gray(` Branch ${entry.branch} already merged to ${entry.baseBranch}, auto-forcing cleanup`));
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
// Remove git worktree — handle already-deleted directories gracefully
|
|
1224
|
-
const dirExists = fs.existsSync(entry.path);
|
|
1225
|
-
if (dirExists) {
|
|
1226
|
-
try {
|
|
1227
|
-
const args = ['worktree', 'remove'];
|
|
1228
|
-
if (effectiveForce) args.push('--force');
|
|
1229
|
-
args.push(entry.path);
|
|
1230
|
-
execFileSync('git', args, { cwd: root, stdio: 'pipe' });
|
|
1231
|
-
} catch (error) {
|
|
1232
|
-
if (effectiveForce) {
|
|
1233
|
-
// Force cleanup: remove directory manually
|
|
1234
|
-
fs.removeSync(entry.path);
|
|
1235
|
-
} else {
|
|
1236
|
-
throw new Error(`Failed to remove worktree: ${error.message}. Use --force to override.`);
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
} else {
|
|
1240
|
-
// Directory already gone — just clean up git's tracking
|
|
1241
|
-
console.log(` Worktree directory already removed, cleaning up registry`);
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
// Always prune git's worktree list to stay in sync
|
|
1245
|
-
try {
|
|
1246
|
-
execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
|
|
1247
|
-
} catch {
|
|
1248
|
-
// Non-fatal
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// Optionally delete branch
|
|
1252
|
-
if (deleteBranch && entry.branch) {
|
|
1253
|
-
try {
|
|
1254
|
-
execFileSync('git', ['branch', '-d', entry.branch], { cwd: root, stdio: 'pipe' });
|
|
1255
|
-
} catch {
|
|
1256
|
-
if (effectiveForce) {
|
|
1257
|
-
try {
|
|
1258
|
-
execFileSync('git', ['branch', '-D', entry.branch], { cwd: root, stdio: 'pipe' });
|
|
1259
|
-
} catch {
|
|
1260
|
-
// Non-fatal
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
// Update registry
|
|
1267
|
-
const wasAlreadyDestroyed = registry.worktrees[name].status === 'destroyed';
|
|
1268
|
-
registry.worktrees[name].status = 'destroyed';
|
|
1269
|
-
registry.worktrees[name].destroyedAt = new Date().toISOString();
|
|
1270
|
-
saveRegistry(root, registry);
|
|
1271
|
-
|
|
1272
|
-
// CAWSFIX-18: auto-commit the registry so the working tree stays clean
|
|
1273
|
-
if (!wasAlreadyDestroyed) {
|
|
1274
|
-
try {
|
|
1275
|
-
const status = execFileSync('git', ['status', '--porcelain', '.caws/worktrees.json'], {
|
|
1276
|
-
cwd: root, stdio: ['pipe', 'pipe', 'pipe'],
|
|
1277
|
-
}).toString().trim();
|
|
1278
|
-
if (status) {
|
|
1279
|
-
const otherActive = Object.values(registry.worktrees || {}).some(
|
|
1280
|
-
(e) => e.status === 'active' || e.status === 'fresh'
|
|
1281
|
-
);
|
|
1282
|
-
const prefix = otherActive ? 'wip(checkpoint)' : 'chore(worktree)';
|
|
1283
|
-
execFileSync('git', ['add', '.caws/worktrees.json'], { cwd: root, stdio: 'pipe' });
|
|
1284
|
-
execFileSync('git', ['commit', '-m', `${prefix}: record destroyed ${name}`], {
|
|
1285
|
-
cwd: root, stdio: 'pipe',
|
|
1286
|
-
});
|
|
1287
|
-
}
|
|
1288
|
-
} catch (err) {
|
|
1289
|
-
console.warn(chalk.yellow(` Warning: could not auto-commit .caws/worktrees.json: ${err.message}`));
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
/**
|
|
1295
|
-
* Merge a worktree branch back to base in one operation.
|
|
1296
|
-
* Sequence: dry-run conflict check → destroy worktree → merge → cleanup.
|
|
1297
|
-
* @param {string} name - Worktree name
|
|
1298
|
-
* @param {Object} options - Merge options
|
|
1299
|
-
* @param {boolean} [options.dryRun] - Preview conflicts without merging
|
|
1300
|
-
* @param {boolean} [options.deleteBranch] - Delete branch after merge
|
|
1301
|
-
* @param {string} [options.message] - Custom merge commit message
|
|
1302
|
-
* @returns {Object} Merge result
|
|
1303
|
-
*/
|
|
1304
|
-
function mergeWorktree(name, options = {}) {
|
|
1305
|
-
const root = getRepoRoot();
|
|
1306
|
-
const registry = loadRegistry(root);
|
|
1307
|
-
const { dryRun = false, deleteBranch = true, message, takeover = false } = options;
|
|
1308
|
-
|
|
1309
|
-
let entry = registry.worktrees[name];
|
|
1310
|
-
if (!entry) {
|
|
1311
|
-
// Fallback: scan git for unregistered worktree and auto-register
|
|
1312
|
-
const unregistered = discoverUnregisteredWorktrees(root, registry);
|
|
1313
|
-
const discovered = unregistered.find((u) => u.name === name);
|
|
1314
|
-
if (discovered) {
|
|
1315
|
-
console.log(chalk.yellow(`Worktree '${name}' not in registry but found in git. Auto-registering.`));
|
|
1316
|
-
entry = autoRegisterWorktree(root, registry, discovered);
|
|
1317
|
-
} else {
|
|
1318
|
-
throw new Error(`Worktree '${name}' not found in registry or git worktree list`);
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
// CAWSFIX-32: assert ownership BEFORE any merge/git work. Foreign
|
|
1323
|
-
// claim soft-blocks unless --takeover is supplied, matching the
|
|
1324
|
-
// bind/claim semantics. Throws on refusal so the CLI command handler
|
|
1325
|
-
// surfaces the structured warning.
|
|
1326
|
-
const ownership = assertWorktreeOwnership(root, name, {
|
|
1327
|
-
allowTakeover: takeover,
|
|
1328
|
-
takeoverCommandHint: `caws worktree merge ${name} --takeover`,
|
|
1329
|
-
});
|
|
1330
|
-
if (!ownership.allowed) {
|
|
1331
|
-
const err = new Error(ownership.warning);
|
|
1332
|
-
err.claimWarning = true;
|
|
1333
|
-
throw err;
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
// CAWSFIX-32: heartbeat the current session into agents.json now that
|
|
1337
|
-
// ownership is confirmed. Same-session merges need this since the
|
|
1338
|
-
// takeover branch only fires inside assertWorktreeOwnership when a
|
|
1339
|
-
// takeover actually occurs.
|
|
1340
|
-
refreshAgentClaim(root, { worktree: name, specId: entry.specId || null });
|
|
1341
|
-
|
|
1342
|
-
const baseBranch = entry.baseBranch || 'main';
|
|
1343
|
-
|
|
1344
|
-
// Check for uncommitted work in the worktree.
|
|
1345
|
-
// Ignore .caws/ changes (provenance chain, registry) — these are
|
|
1346
|
-
// infrastructure artifacts written by git hooks, not user work.
|
|
1347
|
-
// The post-commit hook appends to .caws/provenance/chain.json after
|
|
1348
|
-
// every commit, which immediately dirties the tree and blocks merges.
|
|
1349
|
-
if (fs.existsSync(entry.path)) {
|
|
1350
|
-
try {
|
|
1351
|
-
const rawStatus = execFileSync(
|
|
1352
|
-
'git',
|
|
1353
|
-
['status', '--porcelain'],
|
|
1354
|
-
{ cwd: entry.path, encoding: 'utf8', stdio: 'pipe' }
|
|
1355
|
-
);
|
|
1356
|
-
// Filter out .caws/ infrastructure changes (provenance, registry).
|
|
1357
|
-
// Git porcelain format: "XY PATH" — 2 status chars, space, path.
|
|
1358
|
-
// IMPORTANT: do NOT .trim() the raw output — it strips the leading
|
|
1359
|
-
// space from " M file" (unstaged), corrupting the XY prefix and
|
|
1360
|
-
// breaking substring(3) path extraction.
|
|
1361
|
-
const statusLines = rawStatus.split('\n').filter(l => l.length > 0);
|
|
1362
|
-
const userChanges = statusLines
|
|
1363
|
-
.filter(line => {
|
|
1364
|
-
const filePath = line.substring(3);
|
|
1365
|
-
return !filePath.startsWith('.caws/');
|
|
1366
|
-
}).join('\n');
|
|
1367
|
-
if (userChanges) {
|
|
1368
|
-
throw new Error(
|
|
1369
|
-
`Worktree '${name}' has uncommitted changes:\n${userChanges}\n` +
|
|
1370
|
-
`Commit or discard changes before merging.`
|
|
1371
|
-
);
|
|
1372
|
-
}
|
|
1373
|
-
} catch (error) {
|
|
1374
|
-
if (error.message.includes('uncommitted changes')) throw error;
|
|
1375
|
-
// Non-fatal: status check failed, proceed cautiously
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
// Dry-run: check for conflicts using git merge-tree (new-style, git 2.38+)
|
|
1380
|
-
let conflicts = [];
|
|
1381
|
-
try {
|
|
1382
|
-
// New-style merge-tree: takes two branches, computes merge-base automatically
|
|
1383
|
-
execFileSync(
|
|
1384
|
-
'git',
|
|
1385
|
-
['merge-tree', '--write-tree', baseBranch, entry.branch],
|
|
1386
|
-
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
1387
|
-
);
|
|
1388
|
-
// Exit 0 = clean merge, no conflicts
|
|
1389
|
-
} catch (mergeTreeError) {
|
|
1390
|
-
// Exit 1 = conflicts detected; parse them from output
|
|
1391
|
-
const output = (mergeTreeError.stdout || '') + (mergeTreeError.stderr || '');
|
|
1392
|
-
const conflictLines = output.split('\n').filter(
|
|
1393
|
-
(l) => l.includes('CONFLICT') || l.includes('conflict')
|
|
1394
|
-
);
|
|
1395
|
-
if (mergeTreeError.status === 1 && conflictLines.length > 0) {
|
|
1396
|
-
conflicts = conflictLines;
|
|
1397
|
-
} else if (mergeTreeError.status === 1) {
|
|
1398
|
-
conflicts = ['Merge conflicts detected (run merge manually to inspect)'];
|
|
1399
|
-
}
|
|
1400
|
-
// Other exit codes (e.g., merge-tree not supported) = can't detect, proceed
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
if (dryRun) {
|
|
1404
|
-
return {
|
|
1405
|
-
name,
|
|
1406
|
-
branch: entry.branch,
|
|
1407
|
-
baseBranch,
|
|
1408
|
-
conflicts,
|
|
1409
|
-
wouldMerge: conflicts.length === 0,
|
|
1410
|
-
};
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
// Emit merge:pre event
|
|
1414
|
-
try {
|
|
1415
|
-
lifecycle.emit(EVENTS.MERGE_PRE, {
|
|
1416
|
-
worktreeName: name, branch: entry.branch, baseBranch, conflicts,
|
|
1417
|
-
timestamp: new Date().toISOString(),
|
|
1418
|
-
});
|
|
1419
|
-
} catch { /* non-fatal */ }
|
|
1420
|
-
|
|
1421
|
-
// Ensure CWD is the repo root BEFORE destroying the worktree.
|
|
1422
|
-
// If the caller's CWD is inside the worktree directory, destroying it
|
|
1423
|
-
// removes the CWD out from under the process, causing all subsequent
|
|
1424
|
-
// git commands to fail with "Unable to read current working directory".
|
|
1425
|
-
try { process.chdir(root); } catch { /* non-fatal */ }
|
|
1426
|
-
|
|
1427
|
-
// Destroy the worktree (auto-forces since we're about to merge)
|
|
1428
|
-
destroyWorktree(name, { deleteBranch: false, force: true });
|
|
1429
|
-
|
|
1430
|
-
// Switch to base branch (use cwd: root since getCurrentBranch has no cwd param)
|
|
1431
|
-
const currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
1432
|
-
cwd: root, encoding: 'utf8', stdio: 'pipe',
|
|
1433
|
-
}).trim();
|
|
1434
|
-
if (currentBranch !== baseBranch) {
|
|
1435
|
-
execFileSync('git', ['checkout', baseBranch], { cwd: root, stdio: 'pipe' });
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
// Merge
|
|
1439
|
-
// Use --no-verify to skip pre-commit/commit-msg hooks during merge.
|
|
1440
|
-
// The worktree commits were already validated by those hooks when originally
|
|
1441
|
-
// committed. Re-running them here adds seconds of blocking time (especially
|
|
1442
|
-
// in projects with heavy hooks like quality gates, YAML validation, etc.)
|
|
1443
|
-
// and can trigger OAuth token expiry races in long-running sessions.
|
|
1444
|
-
const mergeMessage = message || `merge(worktree): ${name}`;
|
|
1445
|
-
try {
|
|
1446
|
-
execFileSync(
|
|
1447
|
-
'git',
|
|
1448
|
-
['merge', '--no-ff', '--no-verify', entry.branch, '-m', mergeMessage],
|
|
1449
|
-
{ cwd: root, stdio: 'pipe' }
|
|
1450
|
-
);
|
|
1451
|
-
} catch (error) {
|
|
1452
|
-
const failResult = {
|
|
1453
|
-
name, branch: entry.branch, baseBranch, merged: false,
|
|
1454
|
-
conflicts: [`Merge failed: ${error.message}`],
|
|
1455
|
-
message: 'Merge conflicts detected. Resolve with git and commit.',
|
|
1456
|
-
};
|
|
1457
|
-
try {
|
|
1458
|
-
lifecycle.emit(EVENTS.MERGE_POST, { ...failResult, timestamp: new Date().toISOString() });
|
|
1459
|
-
} catch { /* non-fatal */ }
|
|
1460
|
-
return failResult;
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
// Delete branch after successful merge
|
|
1464
|
-
if (deleteBranch) {
|
|
1465
|
-
try {
|
|
1466
|
-
execFileSync('git', ['branch', '-d', entry.branch], { cwd: root, stdio: 'pipe' });
|
|
1467
|
-
} catch {
|
|
1468
|
-
// Non-fatal
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
// Auto-close the bound spec if one exists. A worktree merge is the
|
|
1473
|
-
// lifecycle signal that the spec's work is done; leaving the spec
|
|
1474
|
-
// `active` (or `draft`, pre-CAWSFIX-23) after merge accumulates stale
|
|
1475
|
-
// entries (D6). Direct YAML status flip bypasses the ownership +
|
|
1476
|
-
// worktree-reference checks in `closeSpec` — the caller has already
|
|
1477
|
-
// proven authority by merging.
|
|
1478
|
-
let autoClose = {
|
|
1479
|
-
specId: null, acsPassing: null, acsFailureCount: 0, acsTotal: 0, acsFailureIds: [],
|
|
1480
|
-
didWrite: false, specPath: null,
|
|
1481
|
-
};
|
|
1482
|
-
if (entry.specId) {
|
|
1483
|
-
autoClose = autoCloseBoundSpec(root, entry.specId);
|
|
1484
|
-
if (autoClose.acsPassing === false && autoClose.acsFailureCount > 0) {
|
|
1485
|
-
console.warn(chalk.yellow(
|
|
1486
|
-
` ⚠ Spec ${entry.specId} closed with ${autoClose.acsFailureCount}/${autoClose.acsTotal} failing AC(s): ${autoClose.acsFailureIds.join(', ')}`
|
|
1487
|
-
));
|
|
1488
|
-
console.warn(chalk.yellow(
|
|
1489
|
-
` Merge succeeded — the spec reflects that — but follow up to address the failing ACs.`
|
|
1490
|
-
));
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
// CAWSFIX-24 / D6: if the auto-close flipped the status, commit the
|
|
1494
|
-
// change on the base branch before returning. Leaving it uncommitted
|
|
1495
|
-
// was the "dirty main" footgun: the next worktree merge would abort
|
|
1496
|
-
// on "local changes would be overwritten," after the prior worktree
|
|
1497
|
-
// was already destroyed. Use --no-verify to match the merge commit's
|
|
1498
|
-
// hook-skip discipline (the content was verified when the merge ran).
|
|
1499
|
-
if (autoClose.didWrite && autoClose.specPath) {
|
|
1500
|
-
try {
|
|
1501
|
-
const relPath = path.relative(root, autoClose.specPath);
|
|
1502
|
-
execFileSync('git', ['add', '--', relPath], { cwd: root, stdio: 'pipe' });
|
|
1503
|
-
execFileSync(
|
|
1504
|
-
'git',
|
|
1505
|
-
['commit', '--no-verify', '-m', `chore(caws): close ${autoClose.specId} spec post-merge`, '--', relPath],
|
|
1506
|
-
{ cwd: root, stdio: 'pipe' }
|
|
1507
|
-
);
|
|
1508
|
-
} catch (commitErr) {
|
|
1509
|
-
// Non-fatal: a failed auto-commit leaves the spec dirty but the
|
|
1510
|
-
// merge itself already succeeded. Warn so the caller can clean up.
|
|
1511
|
-
console.warn(chalk.yellow(
|
|
1512
|
-
` ⚠ Auto-commit of ${autoClose.specId} close flip failed: ${commitErr.message}. Commit manually.`
|
|
1513
|
-
));
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
const mergeResult = {
|
|
1519
|
-
name, branch: entry.branch, baseBranch, merged: true, conflicts: [],
|
|
1520
|
-
specId: entry.specId || null,
|
|
1521
|
-
autoClosedSpecId: autoClose.specId,
|
|
1522
|
-
acsPassing: autoClose.acsPassing,
|
|
1523
|
-
acsFailureCount: autoClose.acsFailureCount,
|
|
1524
|
-
acsTotal: autoClose.acsTotal,
|
|
1525
|
-
acsFailureIds: autoClose.acsFailureIds,
|
|
1526
|
-
};
|
|
1527
|
-
try {
|
|
1528
|
-
lifecycle.emit(EVENTS.MERGE_POST, { ...mergeResult, timestamp: new Date().toISOString() });
|
|
1529
|
-
} catch { /* non-fatal */ }
|
|
1530
|
-
return mergeResult;
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
/**
|
|
1534
|
-
* Flip a spec's status from `draft` to `active` by rewriting just the
|
|
1535
|
-
* `status:` line. Called on worktree-bind so specs whose work is
|
|
1536
|
-
* starting transition out of draft without manual intervention.
|
|
1537
|
-
* Idempotent: no-op when the spec is already active/closed/etc.
|
|
1538
|
-
* @param {string} root - Repo root
|
|
1539
|
-
* @param {string} specId - Spec identifier
|
|
1540
|
-
* @returns {string|null} specId on flip, null if no change
|
|
1541
|
-
*/
|
|
1542
|
-
function autoActivateBoundSpec(root, specId, specPathOverride = null) {
|
|
1543
|
-
try {
|
|
1544
|
-
// CAWSFIX-25 / D8: callers that resolved a worktree-local spec path
|
|
1545
|
-
// (via findFeatureSpecPathFromCwd) pass it in so the flip lands on
|
|
1546
|
-
// the worktree's copy, not main's. Falls through to main resolution
|
|
1547
|
-
// for backward compatibility when override is null.
|
|
1548
|
-
const specPath = specPathOverride || findFeatureSpecPath(root, specId);
|
|
1549
|
-
if (!specPath || !fs.existsSync(specPath)) return null;
|
|
1550
|
-
const original = fs.readFileSync(specPath, 'utf8');
|
|
1551
|
-
// Idempotent: already active/closed/archived → no write.
|
|
1552
|
-
if (/^status:[ \t]*active[ \t]*$/m.test(original)) return specId;
|
|
1553
|
-
const patched = original.replace(/^status:[ \t]*draft[ \t]*$/m, 'status: active');
|
|
1554
|
-
if (patched === original) return null;
|
|
1555
|
-
fs.writeFileSync(specPath, patched, 'utf8');
|
|
1556
|
-
return specId;
|
|
1557
|
-
} catch {
|
|
1558
|
-
return null;
|
|
1559
|
-
}
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
/**
|
|
1563
|
-
* Flip a spec's status to `closed` by rewriting just the `status:` line.
|
|
1564
|
-
* Accepts both `draft` and `active` as source states — merge is the
|
|
1565
|
-
* authoritative "work done" signal regardless of whether the spec ever
|
|
1566
|
-
* transitioned through active. Runs verify-acs in collect-only mode
|
|
1567
|
-
* before the flip and returns AC health so the caller can warn.
|
|
1568
|
-
* @param {string} root - Repo root
|
|
1569
|
-
* @param {string} specId - Spec identifier (e.g. CAWSFIX-14)
|
|
1570
|
-
* @returns {{specId: string|null, acsPassing: boolean|null, acsFailureCount: number, acsTotal: number, acsFailureIds: string[]}}
|
|
1571
|
-
*/
|
|
1572
|
-
function autoCloseBoundSpec(root, specId) {
|
|
1573
|
-
const result = {
|
|
1574
|
-
specId: null,
|
|
1575
|
-
acsPassing: null,
|
|
1576
|
-
acsFailureCount: 0,
|
|
1577
|
-
acsTotal: 0,
|
|
1578
|
-
acsFailureIds: [],
|
|
1579
|
-
didWrite: false,
|
|
1580
|
-
specPath: null,
|
|
1581
|
-
};
|
|
1582
|
-
try {
|
|
1583
|
-
const specPath = findFeatureSpecPath(root, specId);
|
|
1584
|
-
if (!specPath || !fs.existsSync(specPath)) return result;
|
|
1585
|
-
result.specPath = specPath;
|
|
1586
|
-
const original = fs.readFileSync(specPath, 'utf8');
|
|
1587
|
-
// Idempotent: already closed → no-op, no write, no diff.
|
|
1588
|
-
if (/^status:[ \t]*closed[ \t]*$/m.test(original)) {
|
|
1589
|
-
result.specId = specId;
|
|
1590
|
-
return result;
|
|
1591
|
-
}
|
|
1592
|
-
// Run verify-acs in collect-only mode before flipping. Never throws —
|
|
1593
|
-
// any error (missing tests, unavailable runner, malformed spec) leaves
|
|
1594
|
-
// acsPassing: null so the caller knows verification didn't run.
|
|
1595
|
-
try {
|
|
1596
|
-
const yaml = require('js-yaml');
|
|
1597
|
-
const { verifySpec } = require('../commands/verify-acs');
|
|
1598
|
-
const parsed = yaml.load(original);
|
|
1599
|
-
if (parsed && typeof parsed === 'object') {
|
|
1600
|
-
const verdict = verifySpec(parsed, root, { run: false });
|
|
1601
|
-
const fails = (verdict.results || []).filter((r) => r.status === 'FAIL');
|
|
1602
|
-
result.acsTotal = (verdict.results || []).length;
|
|
1603
|
-
result.acsFailureCount = fails.length;
|
|
1604
|
-
result.acsFailureIds = fails.map((r) => r.id);
|
|
1605
|
-
result.acsPassing = fails.length === 0;
|
|
1606
|
-
}
|
|
1607
|
-
} catch {
|
|
1608
|
-
// verify-acs unavailable — don't block close
|
|
1609
|
-
}
|
|
1610
|
-
// Flip status. Accept both draft and active as source so specs that
|
|
1611
|
-
// never transitioned through active (D6 pre-CAWSFIX-23 drift) still close.
|
|
1612
|
-
const patched = original.replace(/^status:[ \t]*(?:draft|active)[ \t]*$/m, 'status: closed');
|
|
1613
|
-
if (patched === original) {
|
|
1614
|
-
return result; // status was archived/unknown — leave alone
|
|
1615
|
-
}
|
|
1616
|
-
fs.writeFileSync(specPath, patched, 'utf8');
|
|
1617
|
-
result.specId = specId;
|
|
1618
|
-
result.didWrite = true;
|
|
1619
|
-
return result;
|
|
1620
|
-
} catch {
|
|
1621
|
-
return result;
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
/**
|
|
1626
|
-
* Prune stale worktree entries
|
|
1627
|
-
* @param {Object} options - Prune options
|
|
1628
|
-
* @param {number} [options.maxAgeDays] - Remove entries older than this many days
|
|
1629
|
-
* @param {number} [options.recentCommitMinutes] - Protect branches with commits newer than this (default: 60)
|
|
1630
|
-
* @param {boolean} [options.force] - Allow pruning entries owned by other sessions
|
|
1631
|
-
* @returns {{ pruned: Array, skipped: Array }} Pruned and skipped entries
|
|
1632
|
-
*/
|
|
1633
|
-
function pruneWorktrees(options = {}) {
|
|
1634
|
-
const root = getRepoRoot();
|
|
1635
|
-
const registry = loadRegistry(root);
|
|
1636
|
-
const { maxAgeDays = 30, recentCommitMinutes = 60, force = false } = options;
|
|
1637
|
-
const currentSession = getAgentSessionId(root);
|
|
1638
|
-
|
|
1639
|
-
const now = new Date();
|
|
1640
|
-
const pruned = [];
|
|
1641
|
-
const skipped = [];
|
|
1642
|
-
|
|
1643
|
-
for (const [name, entry] of Object.entries(registry.worktrees)) {
|
|
1644
|
-
const created = new Date(entry.createdAt);
|
|
1645
|
-
const ageDays = (now - created) / (1000 * 60 * 60 * 24);
|
|
1646
|
-
const dirExists = fs.existsSync(entry.path);
|
|
1647
|
-
|
|
1648
|
-
const shouldPrune =
|
|
1649
|
-
// Always prune destroyed entries
|
|
1650
|
-
entry.status === 'destroyed' ||
|
|
1651
|
-
// Prune active/fresh entries whose directory is gone (filesystem-registry desync)
|
|
1652
|
-
((entry.status === 'active' || entry.status === 'fresh') && !dirExists) ||
|
|
1653
|
-
// Prune old missing entries
|
|
1654
|
-
(!dirExists && ageDays > maxAgeDays);
|
|
1655
|
-
|
|
1656
|
-
if (shouldPrune) {
|
|
1657
|
-
// Ownership check: skip entries owned by other sessions unless --force
|
|
1658
|
-
const isOwnedByOther = entry.owner && currentSession && entry.owner !== currentSession;
|
|
1659
|
-
if (isOwnedByOther && entry.status !== 'destroyed' && !force) {
|
|
1660
|
-
skipped.push({
|
|
1661
|
-
name,
|
|
1662
|
-
reason: `owned by another session (${entry.owner})`,
|
|
1663
|
-
entry,
|
|
1664
|
-
});
|
|
1665
|
-
continue;
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1668
|
-
// Before pruning a non-destroyed entry, check for recent commits (skip if --force)
|
|
1669
|
-
if (!force && entry.status !== 'destroyed' && entry.branch) {
|
|
1670
|
-
const lastCommit = getLastCommitInfo(entry.branch, root);
|
|
1671
|
-
if (lastCommit) {
|
|
1672
|
-
const commitAgeMinutes = (now - lastCommit.timestamp) / (1000 * 60);
|
|
1673
|
-
if (commitAgeMinutes < recentCommitMinutes) {
|
|
1674
|
-
skipped.push({ name, reason: `recent commit (${lastCommit.age})`, entry });
|
|
1675
|
-
continue;
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
// Clean up filesystem if still exists
|
|
1681
|
-
if (dirExists) {
|
|
1682
|
-
try {
|
|
1683
|
-
execFileSync('git', ['worktree', 'remove', '--force', entry.path], {
|
|
1684
|
-
cwd: root,
|
|
1685
|
-
stdio: 'pipe',
|
|
1686
|
-
});
|
|
1687
|
-
} catch {
|
|
1688
|
-
fs.removeSync(entry.path);
|
|
1689
|
-
}
|
|
1690
|
-
}
|
|
1691
|
-
pruned.push(entry);
|
|
1692
|
-
delete registry.worktrees[name];
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
|
|
1696
|
-
// Prune git's worktree list
|
|
1697
|
-
try {
|
|
1698
|
-
execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
|
|
1699
|
-
} catch {
|
|
1700
|
-
// Non-fatal
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
saveRegistry(root, registry);
|
|
1704
|
-
return { pruned, skipped };
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
|
-
module.exports = {
|
|
1708
|
-
createWorktree,
|
|
1709
|
-
listWorktrees,
|
|
1710
|
-
destroyWorktree,
|
|
1711
|
-
mergeWorktree,
|
|
1712
|
-
autoActivateBoundSpec,
|
|
1713
|
-
autoCloseBoundSpec,
|
|
1714
|
-
pruneWorktrees,
|
|
1715
|
-
repairWorktrees,
|
|
1716
|
-
reconcileRegistry,
|
|
1717
|
-
loadRegistry,
|
|
1718
|
-
saveRegistry,
|
|
1719
|
-
assertWorktreeOwnership,
|
|
1720
|
-
getRepoRoot,
|
|
1721
|
-
getLastCommitInfo,
|
|
1722
|
-
isBranchMerged,
|
|
1723
|
-
hasDivergentCommits,
|
|
1724
|
-
hasDirtyFiles,
|
|
1725
|
-
discoverUnregisteredWorktrees,
|
|
1726
|
-
autoRegisterWorktree,
|
|
1727
|
-
WORKTREES_DIR,
|
|
1728
|
-
REGISTRY_FILE,
|
|
1729
|
-
BRANCH_PREFIX,
|
|
1730
|
-
findFeatureSpecPath,
|
|
1731
|
-
findFeatureSpecPathFromCwd,
|
|
1732
|
-
materializeWorktreeSpec,
|
|
1733
|
-
inferSpecIdForWorktree,
|
|
1734
|
-
findSpecByWorktreeName,
|
|
1735
|
-
};
|