@repo-os/repoos 0.5.45
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/LICENSE.md +110 -0
- package/README.md +272 -0
- package/dist/.build-info.json +4 -0
- package/dist/.build-stamp.json +3 -0
- package/dist/cli/colors.d.ts +13 -0
- package/dist/cli/colors.js +37 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +221 -0
- package/dist/commands/check.d.ts +170 -0
- package/dist/commands/check.js +1227 -0
- package/dist/commands/docs.d.ts +2 -0
- package/dist/commands/docs.js +70 -0
- package/dist/commands/gc.d.ts +1 -0
- package/dist/commands/gc.js +57 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +699 -0
- package/dist/commands/screenshots.d.ts +7 -0
- package/dist/commands/screenshots.js +77 -0
- package/dist/commands/serve.d.ts +55 -0
- package/dist/commands/serve.js +204 -0
- package/dist/commands/status.d.ts +140 -0
- package/dist/commands/status.js +569 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +59 -0
- package/dist/commands/tasks.d.ts +47 -0
- package/dist/commands/tasks.js +334 -0
- package/dist/commands/tunnel.d.ts +1 -0
- package/dist/commands/tunnel.js +1271 -0
- package/dist/commands/ui-harness.d.ts +53 -0
- package/dist/commands/ui-harness.js +62 -0
- package/dist/commands/ui-smoke.d.ts +6 -0
- package/dist/commands/ui-smoke.js +220 -0
- package/dist/commands/upgrade.d.ts +15 -0
- package/dist/commands/upgrade.js +188 -0
- package/dist/core/auth-store.d.ts +81 -0
- package/dist/core/auth-store.js +530 -0
- package/dist/core/auth.d.ts +75 -0
- package/dist/core/auth.js +124 -0
- package/dist/core/bootstrap.d.ts +41 -0
- package/dist/core/bootstrap.js +236 -0
- package/dist/core/build.d.ts +92 -0
- package/dist/core/build.js +249 -0
- package/dist/core/canary.d.ts +10 -0
- package/dist/core/canary.js +14 -0
- package/dist/core/check-failure-summary.d.ts +10 -0
- package/dist/core/check-failure-summary.js +34 -0
- package/dist/core/config.d.ts +160 -0
- package/dist/core/config.js +1288 -0
- package/dist/core/context-pack.d.ts +74 -0
- package/dist/core/context-pack.js +778 -0
- package/dist/core/db.d.ts +236 -0
- package/dist/core/db.js +747 -0
- package/dist/core/detect.d.ts +69 -0
- package/dist/core/detect.js +251 -0
- package/dist/core/docs.d.ts +76 -0
- package/dist/core/docs.js +225 -0
- package/dist/core/frontmatter.d.ts +29 -0
- package/dist/core/frontmatter.js +215 -0
- package/dist/core/git.d.ts +505 -0
- package/dist/core/git.js +1453 -0
- package/dist/core/indexer.d.ts +33 -0
- package/dist/core/indexer.js +282 -0
- package/dist/core/input.d.ts +66 -0
- package/dist/core/input.js +222 -0
- package/dist/core/logger.d.ts +70 -0
- package/dist/core/logger.js +138 -0
- package/dist/core/models.d.ts +45 -0
- package/dist/core/models.js +291 -0
- package/dist/core/net-probe.d.ts +4 -0
- package/dist/core/net-probe.js +20 -0
- package/dist/core/providers/deepinfra.d.ts +23 -0
- package/dist/core/providers/deepinfra.js +44 -0
- package/dist/core/providers/index.d.ts +24 -0
- package/dist/core/providers/index.js +59 -0
- package/dist/core/providers/openrouter.d.ts +28 -0
- package/dist/core/providers/openrouter.js +82 -0
- package/dist/core/providers/spend.d.ts +86 -0
- package/dist/core/providers/spend.js +286 -0
- package/dist/core/providers/types.d.ts +56 -0
- package/dist/core/providers/types.js +39 -0
- package/dist/core/repoos.d.ts +59 -0
- package/dist/core/repoos.js +221 -0
- package/dist/core/runtime.d.ts +35 -0
- package/dist/core/runtime.js +167 -0
- package/dist/core/skills-registry.d.ts +39 -0
- package/dist/core/skills-registry.js +148 -0
- package/dist/core/skills-registry.test.d.ts +1 -0
- package/dist/core/skills-registry.test.js +52 -0
- package/dist/core/sysmem.d.ts +7 -0
- package/dist/core/sysmem.js +55 -0
- package/dist/core/tailscale.d.ts +2 -0
- package/dist/core/tailscale.js +19 -0
- package/dist/core/task.d.ts +53 -0
- package/dist/core/task.js +310 -0
- package/dist/core/toml-line.d.ts +20 -0
- package/dist/core/toml-line.js +52 -0
- package/dist/core/toml-validate.d.ts +44 -0
- package/dist/core/toml-validate.js +335 -0
- package/dist/core/tunnel-assistant.d.ts +30 -0
- package/dist/core/tunnel-assistant.js +65 -0
- package/dist/core/tunnel-registry.d.ts +96 -0
- package/dist/core/tunnel-registry.js +267 -0
- package/dist/core/tunnel-registry.test.d.ts +1 -0
- package/dist/core/tunnel-registry.test.js +309 -0
- package/dist/core/tunnel.d.ts +103 -0
- package/dist/core/tunnel.js +320 -0
- package/dist/core/types.d.ts +811 -0
- package/dist/core/types.js +27 -0
- package/dist/core/worktree-gc.d.ts +57 -0
- package/dist/core/worktree-gc.js +194 -0
- package/dist/server/__tests__/integration-job.test.d.ts +1 -0
- package/dist/server/__tests__/integration-job.test.js +129 -0
- package/dist/server/agents.d.ts +1020 -0
- package/dist/server/agents.js +4070 -0
- package/dist/server/attachments.d.ts +51 -0
- package/dist/server/attachments.js +130 -0
- package/dist/server/auto-engineering.d.ts +45 -0
- package/dist/server/auto-engineering.js +275 -0
- package/dist/server/branchless-release.d.ts +22 -0
- package/dist/server/branchless-release.js +67 -0
- package/dist/server/built-in-agents.d.ts +305 -0
- package/dist/server/built-in-agents.js +1838 -0
- package/dist/server/cto-monitor.d.ts +35 -0
- package/dist/server/cto-monitor.js +281 -0
- package/dist/server/cto.d.ts +125 -0
- package/dist/server/cto.js +590 -0
- package/dist/server/deployments.d.ts +119 -0
- package/dist/server/deployments.js +325 -0
- package/dist/server/diff-snapshot.d.ts +20 -0
- package/dist/server/diff-snapshot.js +73 -0
- package/dist/server/done.d.ts +160 -0
- package/dist/server/done.js +488 -0
- package/dist/server/freeform.d.ts +55 -0
- package/dist/server/freeform.js +179 -0
- package/dist/server/handoff.d.ts +99 -0
- package/dist/server/handoff.js +575 -0
- package/dist/server/hetzner.d.ts +45 -0
- package/dist/server/hetzner.js +117 -0
- package/dist/server/icons.d.ts +8 -0
- package/dist/server/icons.js +195 -0
- package/dist/server/integration-job.d.ts +75 -0
- package/dist/server/integration-job.js +144 -0
- package/dist/server/integration-orchestrator.d.ts +211 -0
- package/dist/server/integration-orchestrator.js +1299 -0
- package/dist/server/integration-status.d.ts +48 -0
- package/dist/server/integration-status.js +71 -0
- package/dist/server/live-index.d.ts +295 -0
- package/dist/server/live-index.js +452 -0
- package/dist/server/model-test.d.ts +20 -0
- package/dist/server/model-test.js +100 -0
- package/dist/server/ntfy.d.ts +57 -0
- package/dist/server/ntfy.js +112 -0
- package/dist/server/pm-attachments.d.ts +35 -0
- package/dist/server/pm-attachments.js +181 -0
- package/dist/server/pm-runs.d.ts +58 -0
- package/dist/server/pm-runs.js +80 -0
- package/dist/server/preview.d.ts +174 -0
- package/dist/server/preview.js +641 -0
- package/dist/server/release.d.ts +88 -0
- package/dist/server/release.js +449 -0
- package/dist/server/reload.d.ts +194 -0
- package/dist/server/reload.js +586 -0
- package/dist/server/remote-validation.d.ts +132 -0
- package/dist/server/remote-validation.js +478 -0
- package/dist/server/repo-lock.d.ts +36 -0
- package/dist/server/repo-lock.js +141 -0
- package/dist/server/review-guard.d.ts +14 -0
- package/dist/server/review-guard.js +159 -0
- package/dist/server/review.d.ts +243 -0
- package/dist/server/review.js +1211 -0
- package/dist/server/routes/agents.d.ts +6 -0
- package/dist/server/routes/agents.js +28 -0
- package/dist/server/routes/auth.d.ts +60 -0
- package/dist/server/routes/auth.js +755 -0
- package/dist/server/routes/config.d.ts +20 -0
- package/dist/server/routes/config.js +347 -0
- package/dist/server/routes/debugger.d.ts +33 -0
- package/dist/server/routes/debugger.js +360 -0
- package/dist/server/routes/deployments.d.ts +3 -0
- package/dist/server/routes/deployments.js +16 -0
- package/dist/server/routes/docs.d.ts +8 -0
- package/dist/server/routes/docs.js +135 -0
- package/dist/server/routes/helpers.d.ts +21 -0
- package/dist/server/routes/helpers.js +171 -0
- package/dist/server/routes/index.d.ts +19 -0
- package/dist/server/routes/index.js +18 -0
- package/dist/server/routes/info.d.ts +17 -0
- package/dist/server/routes/info.js +177 -0
- package/dist/server/routes/inputs.d.ts +7 -0
- package/dist/server/routes/inputs.js +125 -0
- package/dist/server/routes/model-providers.d.ts +27 -0
- package/dist/server/routes/model-providers.js +94 -0
- package/dist/server/routes/models.d.ts +3 -0
- package/dist/server/routes/models.js +36 -0
- package/dist/server/routes/notify.d.ts +2 -0
- package/dist/server/routes/notify.js +17 -0
- package/dist/server/routes/playground.d.ts +29 -0
- package/dist/server/routes/playground.js +81 -0
- package/dist/server/routes/release.d.ts +20 -0
- package/dist/server/routes/release.js +86 -0
- package/dist/server/routes/router.d.ts +12 -0
- package/dist/server/routes/router.js +32 -0
- package/dist/server/routes/skill-registry.d.ts +5 -0
- package/dist/server/routes/skill-registry.js +51 -0
- package/dist/server/routes/tasks.d.ts +58 -0
- package/dist/server/routes/tasks.js +1442 -0
- package/dist/server/routes/transcribe.d.ts +2 -0
- package/dist/server/routes/transcribe.js +124 -0
- package/dist/server/routes/types.d.ts +68 -0
- package/dist/server/routes/types.js +1 -0
- package/dist/server/routes/ui.d.ts +5 -0
- package/dist/server/routes/ui.js +81 -0
- package/dist/server/routes/utils.d.ts +3 -0
- package/dist/server/routes/utils.js +23 -0
- package/dist/server/serve-reaper.d.ts +135 -0
- package/dist/server/serve-reaper.js +491 -0
- package/dist/server/server.d.ts +49 -0
- package/dist/server/server.js +2171 -0
- package/dist/server/supervisor.d.ts +89 -0
- package/dist/server/supervisor.js +231 -0
- package/dist/server/system.d.ts +198 -0
- package/dist/server/system.js +480 -0
- package/dist/server/task-check.d.ts +45 -0
- package/dist/server/task-check.js +71 -0
- package/dist/server/task-transitions.d.ts +51 -0
- package/dist/server/task-transitions.js +78 -0
- package/dist/server/task-watchdog.d.ts +158 -0
- package/dist/server/task-watchdog.js +681 -0
- package/dist/server/test-run.d.ts +28 -0
- package/dist/server/test-run.js +81 -0
- package/dist/server/watcher.d.ts +21 -0
- package/dist/server/watcher.js +194 -0
- package/dist/server/write.d.ts +94 -0
- package/dist/server/write.js +345 -0
- package/dist/ui/assets/AgentsView-CF4wRvHh.css +1 -0
- package/dist/ui/assets/AgentsView-D-myL-kA.js +1 -0
- package/dist/ui/assets/ContextView-D3TC-RSp.css +1 -0
- package/dist/ui/assets/ContextView-DsOqAdyE.js +4 -0
- package/dist/ui/assets/DashboardView-BOhsIJqO.js +1 -0
- package/dist/ui/assets/DashboardView-Ck9o0RP2.css +1 -0
- package/dist/ui/assets/DeploymentsView--iIUVfEH.css +1 -0
- package/dist/ui/assets/DeploymentsView-DXu_FYR2.js +1 -0
- package/dist/ui/assets/InputsView-24HVbVPk.js +1 -0
- package/dist/ui/assets/InputsView-BLb7GHtx.css +1 -0
- package/dist/ui/assets/LoginView-6QlGg6cx.css +1 -0
- package/dist/ui/assets/LoginView-BqupI0Lt.js +1 -0
- package/dist/ui/assets/ReleasesView-BEG1A0YH.css +1 -0
- package/dist/ui/assets/ReleasesView-CAgMuxpe.js +3 -0
- package/dist/ui/assets/SettingsView-CA-NYSMK.css +1 -0
- package/dist/ui/assets/SettingsView-O3iZSnjg.js +3 -0
- package/dist/ui/assets/WorkView-D8Tl6qwy.js +1 -0
- package/dist/ui/assets/WorkView-DMYL2s3W.css +1 -0
- package/dist/ui/assets/abnfDiagram-VCTEODGH-J5lkSnEE.js +1 -0
- package/dist/ui/assets/arc-BHZnmhhM.js +1 -0
- package/dist/ui/assets/architecture-7GRP2DOG-pLdFt-xS.js +1 -0
- package/dist/ui/assets/architectureDiagram-5GKGNRK7-C3k89uKX.js +36 -0
- package/dist/ui/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/assets/blockDiagram-I7D4REHJ-AWNoBzop.js +129 -0
- package/dist/ui/assets/button-lbikqSph.js +1 -0
- package/dist/ui/assets/c4Diagram-7LVT6UL2-DKG1D5gd.js +38 -0
- package/dist/ui/assets/channel-gA4qBv3A.js +1 -0
- package/dist/ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/assets/chunk-4HAMMTFA-D1YR8lF_.js +62 -0
- package/dist/ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/assets/chunk-75Z2AOVW-5GuBxfsL.js +2 -0
- package/dist/ui/assets/chunk-DU6HZSFF-DfdNNqwa.js +127 -0
- package/dist/ui/assets/chunk-F27PBJKO-BPZzinQ4.js +1 -0
- package/dist/ui/assets/chunk-FOHPRMQF-l2z4K2jD.js +161 -0
- package/dist/ui/assets/chunk-GMAD6QVW-DGWvSKj5.js +72 -0
- package/dist/ui/assets/chunk-GVQU2GXP-D6Aw75wJ.js +1 -0
- package/dist/ui/assets/chunk-IMKFNOWR-B8mMBKCW.js +231 -0
- package/dist/ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/assets/chunk-L3NEJ4N5-D5OJuXYE.js +1 -0
- package/dist/ui/assets/chunk-OSK3NFVY-B7r9w2UW.js +10 -0
- package/dist/ui/assets/chunk-P2QGCYS3-C74X4AQX.js +1 -0
- package/dist/ui/assets/chunk-POPQ4Y6H-C5_u1gUS.js +1 -0
- package/dist/ui/assets/chunk-PWAF6VOD-DfoTLtxn.js +1 -0
- package/dist/ui/assets/chunk-SHT3W25Y-D40m2eqz.js +168 -0
- package/dist/ui/assets/chunk-SVP7TREG-euxi0Kap.js +88 -0
- package/dist/ui/assets/chunk-TICWLB2K-fkdxngzN.js +206 -0
- package/dist/ui/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- package/dist/ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/assets/classDiagram-ZZMXUADV-BzW8bq2P.js +1 -0
- package/dist/ui/assets/classDiagram-v2-VYDZK3BY-BzW8bq2P.js +1 -0
- package/dist/ui/assets/cose-bilkent-JH36ORCC-BTcgMon1.js +1 -0
- package/dist/ui/assets/cynefin-OW5HDTMX-B5m_COsS.js +1 -0
- package/dist/ui/assets/cynefinDiagram-5FMLGOSQ-C-AG6CGe.js +62 -0
- package/dist/ui/assets/cytoscape.esm-FBntH-9t.js +321 -0
- package/dist/ui/assets/dagre-GXQ25YYZ-DDfDnp8L.js +4 -0
- package/dist/ui/assets/dagre-PrKaheQc.js +1 -0
- package/dist/ui/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/assets/diagram-S7CK7UJ4-DjQZAlEY.js +30 -0
- package/dist/ui/assets/diagram-UQ7AKVKN-DAZ9yp1B.js +41 -0
- package/dist/ui/assets/diagram-VSXAHHWV-BWepUKJD.js +3 -0
- package/dist/ui/assets/diagram-VX7I27RA-EcV1J81b.js +24 -0
- package/dist/ui/assets/diagram-Z3DM3KII-Dbz7XU_K.js +24 -0
- package/dist/ui/assets/dist-DTg6UBE_.js +1 -0
- package/dist/ui/assets/ebnfDiagram-PWID7BFC-BUyQw8Mm.js +1 -0
- package/dist/ui/assets/erDiagram-RLTQ6QDP-WVf8ukKt.js +99 -0
- package/dist/ui/assets/eventmodeling-NTZA5JFV-DHqyLOAn.js +1 -0
- package/dist/ui/assets/flowDiagram-HODETNUW-CYk_ze5K.js +1 -0
- package/dist/ui/assets/ganttDiagram-EL5Y4UJY-rB4MZuB-.js +292 -0
- package/dist/ui/assets/gitGraph-4MIJSDKK-BOTWM8dY.js +1 -0
- package/dist/ui/assets/gitGraphDiagram-WWUBYQGX-eYscuhtP.js +106 -0
- package/dist/ui/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/assets/index-BACBfnga.css +1 -0
- package/dist/ui/assets/index-BOQ-ust6.js +26 -0
- package/dist/ui/assets/info-A6RAGUB7-_67wOORe.js +1 -0
- package/dist/ui/assets/infoDiagram-27XIBGKW-XVXbddTA.js +2 -0
- package/dist/ui/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/assets/ishikawaDiagram-5VMMS53U-ClLNiEru.js +70 -0
- package/dist/ui/assets/journeyDiagram-3NMN7TZE-NRawjR6i.js +139 -0
- package/dist/ui/assets/kanban-definition-UXKFOSKX-BgSzBfIx.js +89 -0
- package/dist/ui/assets/katex-DolUETbr.js +257 -0
- package/dist/ui/assets/line-KAyfuul4.js +1 -0
- package/dist/ui/assets/linear-cCCPOpu7.js +1 -0
- package/dist/ui/assets/mermaid-parser.core-CgXZk1iN.js +7 -0
- package/dist/ui/assets/mermaid.core-BUUkChHW.js +44 -0
- package/dist/ui/assets/mindmap-definition-YA3MSWOX-Cpn7vW4r.js +96 -0
- package/dist/ui/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/assets/packet-AYTQ26CC-0lZwM-d6.js +1 -0
- package/dist/ui/assets/path-COt_16Va.js +1 -0
- package/dist/ui/assets/pegDiagram-XKGWAZYB-impy0inn.js +1 -0
- package/dist/ui/assets/pie-WAS4IAKB-CqfdGHYg.js +1 -0
- package/dist/ui/assets/pieDiagram-E7YTZNPT-CxjL4I1h.js +39 -0
- package/dist/ui/assets/quadrantDiagram-AXDQQJYC-Cjd1QMIe.js +7 -0
- package/dist/ui/assets/radar-RG4KPBEZ-CRkQIwiv.js +1 -0
- package/dist/ui/assets/railroad-74A4TZTK-i67XSWTB.js +1 -0
- package/dist/ui/assets/railroad-abnf-HS5TGJTU-Cv9m5aS3.js +1 -0
- package/dist/ui/assets/railroad-ebnf-LZEXJU2U-C5ZIn-Jh.js +1 -0
- package/dist/ui/assets/railroad-peg-WCYAUIDC-CGra9Oq-.js +1 -0
- package/dist/ui/assets/railroadDiagram-O6MQD6OU-_pK0qyaY.js +1 -0
- package/dist/ui/assets/releases-B0WNV-SY.js +1 -0
- package/dist/ui/assets/repoos-cto-square.webp +0 -0
- package/dist/ui/assets/repoos-helpful-pm-square.webp +0 -0
- package/dist/ui/assets/repoos-orchestrator-square.webp +0 -0
- package/dist/ui/assets/repoos-performance-agent-square.webp +0 -0
- package/dist/ui/assets/repoos-ross-from-friends-square.webp +0 -0
- package/dist/ui/assets/repoos-tech-debt-agent-square.webp +0 -0
- package/dist/ui/assets/requirementDiagram-BXWQKSXE-CRrFX-yw.js +84 -0
- package/dist/ui/assets/rough.esm-By172zw-.js +1 -0
- package/dist/ui/assets/sankeyDiagram-P5KCCOFB-X_xFRj9E.js +40 -0
- package/dist/ui/assets/sequenceDiagram-WJ2MYXX4-Byb49lKf.js +162 -0
- package/dist/ui/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- package/dist/ui/assets/src-BH-TyZbA.js +1 -0
- package/dist/ui/assets/stateDiagram-D77RDMKH-CzaPrqYF.js +1 -0
- package/dist/ui/assets/stateDiagram-v2-MP3YSRHH-BCXckz7K.js +1 -0
- package/dist/ui/assets/swimlanes-42K2YHIH-Cx_1lnqr.js +1 -0
- package/dist/ui/assets/swimlanesDiagram-VR7AAH4N-54qsKi6A.js +8 -0
- package/dist/ui/assets/timeline-definition-24CTP7MA-B1IbZPQ-.js +120 -0
- package/dist/ui/assets/treeView-Q6P3EWNA-DFWhVE3d.js +1 -0
- package/dist/ui/assets/treemap-WGGIJYW6-BmbYPThn.js +1 -0
- package/dist/ui/assets/vendor-ui-DPm7zLxi.js +3 -0
- package/dist/ui/assets/vendor-vue-CobgyEJp.js +1 -0
- package/dist/ui/assets/vennDiagram-4TSXK5OY-Ae2AtRpt.js +34 -0
- package/dist/ui/assets/wardley-WFR3VGLG-BtD4X4tL.js +1 -0
- package/dist/ui/assets/wardleyDiagram-VM6X3IG4-B3YN2fTd.js +78 -0
- package/dist/ui/assets/xychartDiagram-S5SC5T6Z-CIzJfMKA.js +7 -0
- package/dist/ui/favicon.svg +11 -0
- package/dist/ui/index.html +49 -0
- package/dist/ui/sw.js +48 -0
- package/package.json +91 -0
package/dist/core/git.js
ADDED
|
@@ -0,0 +1,1453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort git facts. Everything here degrades gracefully: if git is not
|
|
3
|
+
* installed, or the repo isn't a git repo, callers get safe empty values.
|
|
4
|
+
* We shell out rather than depend on a git library (zero deps).
|
|
5
|
+
*/
|
|
6
|
+
import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
|
|
8
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
9
|
+
import { worktreesDir } from "./config.js";
|
|
10
|
+
function git(root, args) {
|
|
11
|
+
try {
|
|
12
|
+
return execFileSync("git", args, {
|
|
13
|
+
cwd: root,
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
16
|
+
timeout: 4000,
|
|
17
|
+
}).trim();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Blocking `git` that also hands back the exit status and stderr — `git()`
|
|
25
|
+
* collapses every failure to `null`, which is fine for reads but loses the
|
|
26
|
+
* information `removeWorktree` needs to tell "already gone" from "refused
|
|
27
|
+
* because the branch is checked out elsewhere".
|
|
28
|
+
*/
|
|
29
|
+
function gitCapture(root, args) {
|
|
30
|
+
const run = spawnSync("git", args, {
|
|
31
|
+
cwd: root,
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
timeout: 4000,
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
status: run.status,
|
|
37
|
+
stdout: run.stdout ?? "",
|
|
38
|
+
stderr: run.stderr ?? (run.error ? `${run.error.message}\n` : ""),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Non-blocking counterpart of `git()`. Lets a caller enriching many tasks
|
|
43
|
+
* (e.g. `buildIndexAsync`) run their git spawns concurrently instead of
|
|
44
|
+
* one at a time on the main thread — each `execFileSync` call above blocks
|
|
45
|
+
* the event loop for the OS round-trip, so N tasks in a `for` loop costs N
|
|
46
|
+
* full serial round-trips no matter how idle the machine is.
|
|
47
|
+
*/
|
|
48
|
+
function gitAsync(root, args) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
execFile("git", args, { cwd: root, encoding: "utf8", timeout: 4000 }, (error, stdout) => {
|
|
51
|
+
resolve(error ? null : stdout.trim());
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Non-blocking `spawn` that resolves with the exit status and captured output.
|
|
57
|
+
* Never throws; a timeout kills the child. Unlike `spawnSync` this does not
|
|
58
|
+
* freeze the server's event loop, so SSE progress events can flush in real
|
|
59
|
+
* time while a long command (merge, build, check) is in flight.
|
|
60
|
+
*/
|
|
61
|
+
export function runGit(root, args, timeout) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const child = spawn("git", args, { cwd: root });
|
|
64
|
+
let stdout = "";
|
|
65
|
+
let stderr = "";
|
|
66
|
+
let timer;
|
|
67
|
+
let settled = false;
|
|
68
|
+
let timedOut = false;
|
|
69
|
+
const finish = (status) => {
|
|
70
|
+
if (settled)
|
|
71
|
+
return;
|
|
72
|
+
settled = true;
|
|
73
|
+
if (timer)
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
resolve({ status, stdout, stderr, timedOut });
|
|
76
|
+
};
|
|
77
|
+
child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
|
|
78
|
+
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
|
79
|
+
child.on("error", (err) => {
|
|
80
|
+
stderr += `${err.message}\n`;
|
|
81
|
+
finish(null);
|
|
82
|
+
});
|
|
83
|
+
child.on("close", (code) => finish(code));
|
|
84
|
+
timer = setTimeout(() => {
|
|
85
|
+
timedOut = true;
|
|
86
|
+
child.kill("SIGKILL");
|
|
87
|
+
}, timeout);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export function isGitRepo(root) {
|
|
91
|
+
return git(root, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
92
|
+
}
|
|
93
|
+
export function currentBranch(root) {
|
|
94
|
+
return git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
95
|
+
}
|
|
96
|
+
/** ISO-8601 commit date of HEAD at `root` (a worktree or the main checkout), or null. */
|
|
97
|
+
export function headCommitISO(root) {
|
|
98
|
+
return git(root, ["log", "-1", "--format=%cI"]) || null;
|
|
99
|
+
}
|
|
100
|
+
/** Set of local branch names, fetched once and reused across tasks. */
|
|
101
|
+
export function localBranches(root) {
|
|
102
|
+
const out = git(root, ["branch", "--format=%(refname:short)"]);
|
|
103
|
+
if (!out)
|
|
104
|
+
return new Set();
|
|
105
|
+
return new Set(out
|
|
106
|
+
.split("\n")
|
|
107
|
+
.map((s) => s.trim())
|
|
108
|
+
.filter(Boolean));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Commits-ahead count for EVERY local branch relative to `base`, in a single
|
|
112
|
+
* `git for-each-ref` call. The indexer needs "does this task's branch have
|
|
113
|
+
* unmerged commits" for every task; the per-branch alternative
|
|
114
|
+
* (`git rev-list --count base..branch` once per task) was ~2.5s of the index
|
|
115
|
+
* build on a repo with ~40 task branches.
|
|
116
|
+
*
|
|
117
|
+
* `%(ahead-behind:<base>)` needs git ≥ 2.41 (2023-06); on older git the field
|
|
118
|
+
* renders empty and this returns an empty map, so callers fall back to
|
|
119
|
+
* `rev-list` per branch. Fail-soft: empty map when git is missing.
|
|
120
|
+
*/
|
|
121
|
+
export function branchAheadCounts(root, base) {
|
|
122
|
+
const map = new Map();
|
|
123
|
+
if (!base)
|
|
124
|
+
return map;
|
|
125
|
+
// refs never contain spaces, so a space separator parses unambiguously:
|
|
126
|
+
// "<branch> <ahead> <behind>". The ahead/behind pair is empty on git < 2.41.
|
|
127
|
+
const out = git(root, [
|
|
128
|
+
"for-each-ref",
|
|
129
|
+
`--format=%(refname:short) %(ahead-behind:${base})`,
|
|
130
|
+
"refs/heads/",
|
|
131
|
+
]);
|
|
132
|
+
if (!out)
|
|
133
|
+
return map;
|
|
134
|
+
for (const line of out.split("\n")) {
|
|
135
|
+
const parts = line.trim().split(/\s+/);
|
|
136
|
+
if (parts.length < 2)
|
|
137
|
+
continue; // no ahead/behind -> old git, or the base ref
|
|
138
|
+
const ahead = Number(parts[1]);
|
|
139
|
+
if (parts[0] && Number.isFinite(ahead))
|
|
140
|
+
map.set(parts[0], ahead);
|
|
141
|
+
}
|
|
142
|
+
return map;
|
|
143
|
+
}
|
|
144
|
+
/** Last commit subject + ISO date touching a specific file. */
|
|
145
|
+
export function lastCommitForFile(root, relPath) {
|
|
146
|
+
const out = git(root, ["log", "-1", "--format=%s%x00%cI", "--", relPath]);
|
|
147
|
+
if (!out)
|
|
148
|
+
return { subject: null, date: null };
|
|
149
|
+
const [subject, date] = out.split("\u0000");
|
|
150
|
+
return { subject: subject || null, date: date || null };
|
|
151
|
+
}
|
|
152
|
+
/** Async counterpart of lastCommitForFile - see gitAsync. */
|
|
153
|
+
export async function lastCommitForFileAsync(root, relPath) {
|
|
154
|
+
const out = await gitAsync(root, ["log", "-1", "--format=%s%x00%cI", "--", relPath]);
|
|
155
|
+
if (!out)
|
|
156
|
+
return { subject: null, date: null };
|
|
157
|
+
const [subject, date] = out.split("\u0000");
|
|
158
|
+
return { subject: subject || null, date: date || null };
|
|
159
|
+
}
|
|
160
|
+
const DIR_LOG_REC = "\x1e";
|
|
161
|
+
const DIR_LOG_FLD = "\x1f";
|
|
162
|
+
/** Args for a single `git log` pass that reports the last commit per file. */
|
|
163
|
+
function dirLogArgs(relDir) {
|
|
164
|
+
return [
|
|
165
|
+
// Unicode-safe paths; never abbreviate task filenames.
|
|
166
|
+
"-c",
|
|
167
|
+
"core.quotePath=false",
|
|
168
|
+
"log",
|
|
169
|
+
// A rename would list only the new path anyway; skip the extra diffing.
|
|
170
|
+
"--no-renames",
|
|
171
|
+
// Print `--name-only` paths relative to cwd (== root) so they match
|
|
172
|
+
// `Task.path`, not relative to the repo toplevel when root is a subdir.
|
|
173
|
+
"--relative",
|
|
174
|
+
`--format=${DIR_LOG_REC}%s${DIR_LOG_FLD}%cI`,
|
|
175
|
+
"--name-only",
|
|
176
|
+
"--",
|
|
177
|
+
relDir,
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
function parseDirLog(out) {
|
|
181
|
+
const map = new Map();
|
|
182
|
+
if (!out)
|
|
183
|
+
return map;
|
|
184
|
+
// Newest commit first: the first chunk that touches a path wins.
|
|
185
|
+
for (const chunk of out.split(DIR_LOG_REC)) {
|
|
186
|
+
if (!chunk)
|
|
187
|
+
continue;
|
|
188
|
+
const nl = chunk.indexOf("\n");
|
|
189
|
+
const header = nl === -1 ? chunk : chunk.slice(0, nl);
|
|
190
|
+
const sep = header.indexOf(DIR_LOG_FLD);
|
|
191
|
+
const subject = (sep === -1 ? header : header.slice(0, sep)) || null;
|
|
192
|
+
const date = (sep === -1 ? "" : header.slice(sep + 1)) || null;
|
|
193
|
+
if (nl === -1)
|
|
194
|
+
continue;
|
|
195
|
+
for (const line of chunk.slice(nl + 1).split("\n")) {
|
|
196
|
+
const path = line.trim();
|
|
197
|
+
if (!path || map.has(path))
|
|
198
|
+
continue;
|
|
199
|
+
map.set(path, { subject, date });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return map;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Last commit subject + ISO date for EVERY tracked file under `relDir`, in a
|
|
206
|
+
* SINGLE `git log` pass. The indexer needs this for every task file; calling
|
|
207
|
+
* `lastCommitForFile` per task instead runs one full history walk per file,
|
|
208
|
+
* which was a large share of RepoOS's slow index build at 200+ tasks (#0271
|
|
209
|
+
* follow-up). Keys are paths relative to `root`, matching `Task.path`.
|
|
210
|
+
* Fail-soft: an empty map when git is missing or the walk fails.
|
|
211
|
+
*/
|
|
212
|
+
export function lastCommitsForDir(root, relDir) {
|
|
213
|
+
return parseDirLog(git(root, dirLogArgs(relDir)));
|
|
214
|
+
}
|
|
215
|
+
/** Async counterpart of `lastCommitsForDir` - see `gitAsync`. */
|
|
216
|
+
export async function lastCommitsForDirAsync(root, relDir) {
|
|
217
|
+
return parseDirLog(await gitAsync(root, dirLogArgs(relDir)));
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Turn on git's own fast-status machinery for `root` (and, since worktrees
|
|
221
|
+
* share `.git/config`, every task worktree cut from it). RepoOS runs
|
|
222
|
+
* `git status --porcelain` once per task worktree on every index build; on a
|
|
223
|
+
* large monorepo each of those is a full tree stat-walk. `core.fsmonitor`
|
|
224
|
+
* lets git's builtin file-system-monitor daemon (git ≥ 2.37, macOS/Windows/
|
|
225
|
+
* Linux) skip unchanged directories, and `core.untrackedCache` caches the
|
|
226
|
+
* per-directory untracked-file scan. Both are opt-in per repo and safe:
|
|
227
|
+
* git falls back to a full scan if the daemon is unavailable.
|
|
228
|
+
*
|
|
229
|
+
* Idempotent, best-effort, and cheap — call it once at startup. Skips any key
|
|
230
|
+
* already set to the desired value, so a repo that has been tuned once pays
|
|
231
|
+
* only two `git config --get` reads on subsequent boots.
|
|
232
|
+
*/
|
|
233
|
+
export function tuneRepoForScale(root) {
|
|
234
|
+
if (!isGitRepo(root))
|
|
235
|
+
return;
|
|
236
|
+
for (const [key, value] of [
|
|
237
|
+
["core.fsmonitor", "true"],
|
|
238
|
+
["core.untrackedCache", "true"],
|
|
239
|
+
]) {
|
|
240
|
+
try {
|
|
241
|
+
if (git(root, ["config", "--get", key]) === value)
|
|
242
|
+
continue;
|
|
243
|
+
git(root, ["config", key, value]);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* best-effort: an old git or a read-only .git/config just means no speedup */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* True when git tracks `relPath` in the checkout at `root` and its working
|
|
252
|
+
* copy matches HEAD — i.e. whatever the file currently shows is backed by a
|
|
253
|
+
* commit, not a stray mid-edit. Untracked or dirty files return false.
|
|
254
|
+
* Used to prove a worktree's committed task-file state before trusting it.
|
|
255
|
+
*/
|
|
256
|
+
export function fileCommittedClean(root, relPath) {
|
|
257
|
+
const status = git(root, ["status", "--porcelain", "--", relPath]);
|
|
258
|
+
if (status === null || status !== "")
|
|
259
|
+
return false;
|
|
260
|
+
return lastCommitForFile(root, relPath).subject !== null;
|
|
261
|
+
}
|
|
262
|
+
export function emptyGitInfo() {
|
|
263
|
+
return {
|
|
264
|
+
branchExists: false,
|
|
265
|
+
worktreeExists: false,
|
|
266
|
+
lastCommit: null,
|
|
267
|
+
lastCommitAt: null,
|
|
268
|
+
worktreePath: null,
|
|
269
|
+
dirty: false,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Branch -> { path, head } for every registered linked worktree, derived from
|
|
274
|
+
* a single `git worktree list --porcelain`. Empty map when git is missing.
|
|
275
|
+
* Callers resolving several branches at once (indexer, live index) MUST call
|
|
276
|
+
* this once and reuse the map — every call shells out to git.
|
|
277
|
+
*
|
|
278
|
+
* `head` lets a caller skip an expensive per-worktree `git status` when the
|
|
279
|
+
* worktree has not moved since a cached check (see the indexer's worktree
|
|
280
|
+
* dirty-status cache).
|
|
281
|
+
*/
|
|
282
|
+
export function worktreeList(root) {
|
|
283
|
+
const out = git(root, ["worktree", "list", "--porcelain"]);
|
|
284
|
+
const map = new Map();
|
|
285
|
+
if (!out)
|
|
286
|
+
return map;
|
|
287
|
+
let path = null;
|
|
288
|
+
let head = "";
|
|
289
|
+
for (const line of out.split("\n")) {
|
|
290
|
+
if (line.startsWith("worktree ")) {
|
|
291
|
+
path = line.slice("worktree ".length).trim();
|
|
292
|
+
head = "";
|
|
293
|
+
}
|
|
294
|
+
else if (line.startsWith("HEAD ")) {
|
|
295
|
+
head = line.slice("HEAD ".length).trim();
|
|
296
|
+
}
|
|
297
|
+
else if (path && line.startsWith("branch refs/heads/")) {
|
|
298
|
+
map.set(line.slice("branch refs/heads/".length).trim(), { path, head });
|
|
299
|
+
path = null;
|
|
300
|
+
head = "";
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return map;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Every entry from `git worktree list --porcelain`, including detached-HEAD and
|
|
307
|
+
* bare entries that `worktreeList` (branch-keyed) necessarily drops. The garbage
|
|
308
|
+
* collector needs the full picture so it can also prune worktrees that lost
|
|
309
|
+
* their branch ref. Empty when git is missing.
|
|
310
|
+
*/
|
|
311
|
+
export function listWorktrees(root) {
|
|
312
|
+
const out = git(root, ["worktree", "list", "--porcelain"]);
|
|
313
|
+
const infos = [];
|
|
314
|
+
if (!out)
|
|
315
|
+
return infos;
|
|
316
|
+
let cur = null;
|
|
317
|
+
const flush = () => {
|
|
318
|
+
if (cur?.path) {
|
|
319
|
+
infos.push({
|
|
320
|
+
path: cur.path,
|
|
321
|
+
branch: cur.branch ?? null,
|
|
322
|
+
head: cur.head ?? "",
|
|
323
|
+
isMain: infos.length === 0,
|
|
324
|
+
isBare: cur.isBare ?? false,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
cur = null;
|
|
328
|
+
};
|
|
329
|
+
for (const line of out.split("\n")) {
|
|
330
|
+
if (line.startsWith("worktree ")) {
|
|
331
|
+
flush();
|
|
332
|
+
cur = { path: line.slice("worktree ".length).trim() };
|
|
333
|
+
}
|
|
334
|
+
else if (!cur) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
else if (line.startsWith("HEAD ")) {
|
|
338
|
+
cur.head = line.slice("HEAD ".length).trim();
|
|
339
|
+
}
|
|
340
|
+
else if (line.startsWith("branch refs/heads/")) {
|
|
341
|
+
cur.branch = line.slice("branch refs/heads/".length).trim();
|
|
342
|
+
}
|
|
343
|
+
else if (line.trim() === "bare") {
|
|
344
|
+
cur.isBare = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
flush();
|
|
348
|
+
return infos;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Branch -> worktree path for every registered linked worktree. Thin
|
|
352
|
+
* projection of `worktreeList` kept for the many callers that only need the
|
|
353
|
+
* path; prefer `worktreeList` when you also want the checked-out SHA.
|
|
354
|
+
*/
|
|
355
|
+
export function worktreePaths(root) {
|
|
356
|
+
const map = new Map();
|
|
357
|
+
for (const [branch, entry] of worktreeList(root))
|
|
358
|
+
map.set(branch, entry.path);
|
|
359
|
+
return map;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Absolute path of the worktree that has `branch` checked out, or null when the
|
|
363
|
+
* branch has no linked worktree (or git is missing). The main checkout's own
|
|
364
|
+
* branch resolves to the repo root. Uses `git worktree list --porcelain` —
|
|
365
|
+
* never string concatenation — so branch names with `/` and exotic characters
|
|
366
|
+
* resolve correctly.
|
|
367
|
+
*/
|
|
368
|
+
export function worktreePathForBranch(root, branch) {
|
|
369
|
+
return worktreePaths(root).get(branch) ?? null;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Copy `taskRelPath` from `root` into an already-resolved `worktreePath` and
|
|
373
|
+
* commit it there, ONLY when the worktree is missing it. This heals a
|
|
374
|
+
* specific, narrow race: `ensureWorktree` cuts (or reuses) a worktree from
|
|
375
|
+
* whatever main's HEAD was at that moment, but the task's own file can be
|
|
376
|
+
* absent from that HEAD if its creation commit hadn't landed yet (e.g.
|
|
377
|
+
* `commitTaskFile` silently failed at creation time) or the worktree/branch
|
|
378
|
+
* is a leftover from an earlier start attempt that raced task creation. Once
|
|
379
|
+
* a worktree exists, plain reuse (see `ensureWorktree`) never re-checks
|
|
380
|
+
* freshness, so without this the task is stuck forever: every resume reuses
|
|
381
|
+
* the same file-less worktree and finalization keeps failing validation.
|
|
382
|
+
*
|
|
383
|
+
* Best-effort and narrowly scoped to the ONE file — never touches unrelated
|
|
384
|
+
* in-progress work in the worktree, and never runs when the worktree already
|
|
385
|
+
* has its own copy (including a stale/older one; that is a different,
|
|
386
|
+
* legitimate divergence left to the normal review/merge flow).
|
|
387
|
+
*/
|
|
388
|
+
function healMissingTaskFile(root, worktreePath, taskRelPath) {
|
|
389
|
+
if (root === worktreePath)
|
|
390
|
+
return; // the main checkout is never a task worktree
|
|
391
|
+
const worktreeFile = join(worktreePath, taskRelPath);
|
|
392
|
+
if (existsSync(worktreeFile))
|
|
393
|
+
return;
|
|
394
|
+
const mainFile = join(root, taskRelPath);
|
|
395
|
+
if (!existsSync(mainFile))
|
|
396
|
+
return; // nothing to heal from
|
|
397
|
+
try {
|
|
398
|
+
mkdirSync(dirname(worktreeFile), { recursive: true });
|
|
399
|
+
copyFileSync(mainFile, worktreeFile);
|
|
400
|
+
if (git(worktreePath, ["add", "--", taskRelPath]) === null)
|
|
401
|
+
return;
|
|
402
|
+
git(worktreePath, [
|
|
403
|
+
"commit",
|
|
404
|
+
"-m",
|
|
405
|
+
`chore: restore task file missing from worktree (self-heal)`,
|
|
406
|
+
]);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
/* best-effort; handoff validation still catches an unhealed worktree */
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Make sure the agent for `branch` has a dedicated working directory WITHOUT
|
|
414
|
+
* touching the main worktree's checkout. Three outcomes:
|
|
415
|
+
*
|
|
416
|
+
* - branch is what the main worktree already has checked out -> use root
|
|
417
|
+
* - branch already has a linked worktree -> reuse it (idempotent)
|
|
418
|
+
* - otherwise -> `git worktree add` a sibling directory for it
|
|
419
|
+
*
|
|
420
|
+
* Fail-soft like the branch-switching predecessor: returns `{ ok: false,
|
|
421
|
+
* reason }` when git is missing or the worktree can't be created, so agent
|
|
422
|
+
* launch degrades gracefully instead of crashing the server.
|
|
423
|
+
*
|
|
424
|
+
* `taskRelPath`, when given, is the task's own file path relative to `root`
|
|
425
|
+
* (e.g. `work/0151-....md`). Whichever worktree is resolved — new or reused —
|
|
426
|
+
* is healed if that file is missing from it; see `healMissingTaskFile`.
|
|
427
|
+
*/
|
|
428
|
+
export function ensureWorktree(root, branch, taskRelPath) {
|
|
429
|
+
if (!isGitRepo(root)) {
|
|
430
|
+
return { ok: false, path: "", created: false, reason: "not a git repository" };
|
|
431
|
+
}
|
|
432
|
+
if (currentBranch(root) === branch) {
|
|
433
|
+
return { ok: true, path: root, created: false };
|
|
434
|
+
}
|
|
435
|
+
const existing = worktreePaths(root).get(branch);
|
|
436
|
+
if (existing) {
|
|
437
|
+
if (taskRelPath)
|
|
438
|
+
healMissingTaskFile(root, existing, taskRelPath);
|
|
439
|
+
return { ok: true, path: existing, created: false };
|
|
440
|
+
}
|
|
441
|
+
const target = join(worktreesDir(root), branch);
|
|
442
|
+
const branchExists = localBranches(root).has(branch);
|
|
443
|
+
const args = branchExists
|
|
444
|
+
? ["worktree", "add", target, branch]
|
|
445
|
+
: ["worktree", "add", "-b", branch, target];
|
|
446
|
+
if (git(root, args) === null) {
|
|
447
|
+
// An orphaned directory (leftover from a prior interrupted close-out) blocks
|
|
448
|
+
// `git worktree add`. Remove it once and retry — the branch is still valid,
|
|
449
|
+
// only the worktree registration (gitdir) is missing.
|
|
450
|
+
if (existsSync(target)) {
|
|
451
|
+
try {
|
|
452
|
+
rmSync(target, { recursive: true, force: true });
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
/* best-effort */
|
|
456
|
+
}
|
|
457
|
+
if (git(root, args) !== null) {
|
|
458
|
+
let path = target;
|
|
459
|
+
try {
|
|
460
|
+
path = realpathSync(target);
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
/* keep */
|
|
464
|
+
}
|
|
465
|
+
if (taskRelPath)
|
|
466
|
+
healMissingTaskFile(root, path, taskRelPath);
|
|
467
|
+
return { ok: true, path, created: true };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
path: target,
|
|
473
|
+
created: false,
|
|
474
|
+
reason: "could not create worktree",
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
// git reports real paths (macOS /var -> /private/var); normalize the fresh
|
|
478
|
+
// path so a later reuse lookup returns the identical string.
|
|
479
|
+
let path = target;
|
|
480
|
+
try {
|
|
481
|
+
path = realpathSync(target);
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
/* keep the composed path */
|
|
485
|
+
}
|
|
486
|
+
if (taskRelPath)
|
|
487
|
+
healMissingTaskFile(root, path, taskRelPath);
|
|
488
|
+
return { ok: true, path, created: true };
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Best-effort facts about a task's worktree, derived from `git worktree list`
|
|
492
|
+
* plus a status check inside the worktree. Fail-soft: any git failure yields
|
|
493
|
+
* a safe `{ path: null, dirty: false }`.
|
|
494
|
+
*
|
|
495
|
+
* `opts.worktrees`/`opts.baseBranch` let a caller looping over many tasks
|
|
496
|
+
* against the same repo (e.g. `buildIndex`) pass in a `worktreePaths`/
|
|
497
|
+
* `currentBranch` result it already fetched once, instead of this function
|
|
498
|
+
* re-running those two `git` spawns on every single task (#0271 follow-up:
|
|
499
|
+
* this redundancy was a large share of RepoOS's slow boot with 260+ tasks).
|
|
500
|
+
* Omit them and behavior is unchanged — each call fetches fresh.
|
|
501
|
+
*/
|
|
502
|
+
export function worktreeStatus(root, branch, opts = {}) {
|
|
503
|
+
const worktrees = opts.worktrees ?? worktreePaths(root);
|
|
504
|
+
const path = worktrees.get(branch) ?? null;
|
|
505
|
+
if (!path)
|
|
506
|
+
return { path: null, dirty: false };
|
|
507
|
+
// git reports the main checkout as a worktree too; only a LINKED worktree
|
|
508
|
+
// counts, otherwise the whole repo's dirt would mark tasks dirty.
|
|
509
|
+
let realRoot = root;
|
|
510
|
+
let realPath = path;
|
|
511
|
+
try {
|
|
512
|
+
realRoot = realpathSync(root);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
/* keep the composed path */
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
realPath = realpathSync(path);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
/* keep the reported path */
|
|
522
|
+
}
|
|
523
|
+
if (realPath === realRoot)
|
|
524
|
+
return { path: null, dirty: false };
|
|
525
|
+
const status = git(path, ["status", "--porcelain"]);
|
|
526
|
+
const uncommitted = status !== null && status !== "";
|
|
527
|
+
const base = opts.baseBranch !== undefined ? opts.baseBranch : currentBranch(root);
|
|
528
|
+
const count = base && base !== branch ? git(root, ["rev-list", "--count", `${base}..${branch}`]) : null;
|
|
529
|
+
const ahead = count !== null && Number(count) > 0;
|
|
530
|
+
return { path, dirty: uncommitted || ahead };
|
|
531
|
+
}
|
|
532
|
+
/** Async counterpart of `worktreeStatus` — see `gitAsync`. */
|
|
533
|
+
export async function worktreeStatusAsync(root, branch, opts = {}) {
|
|
534
|
+
const worktrees = opts.worktrees ?? worktreePaths(root);
|
|
535
|
+
const path = worktrees.get(branch) ?? null;
|
|
536
|
+
if (!path)
|
|
537
|
+
return { path: null, dirty: false };
|
|
538
|
+
let realRoot = root;
|
|
539
|
+
let realPath = path;
|
|
540
|
+
try {
|
|
541
|
+
realRoot = realpathSync(root);
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
/* keep the composed path */
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
realPath = realpathSync(path);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
/* keep the reported path */
|
|
551
|
+
}
|
|
552
|
+
if (realPath === realRoot)
|
|
553
|
+
return { path: null, dirty: false };
|
|
554
|
+
const base = opts.baseBranch !== undefined ? opts.baseBranch : currentBranch(root);
|
|
555
|
+
const [status, count] = await Promise.all([
|
|
556
|
+
gitAsync(path, ["status", "--porcelain"]),
|
|
557
|
+
base && base !== branch
|
|
558
|
+
? gitAsync(root, ["rev-list", "--count", `${base}..${branch}`])
|
|
559
|
+
: Promise.resolve(null),
|
|
560
|
+
]);
|
|
561
|
+
const uncommitted = status !== null && status !== "";
|
|
562
|
+
const ahead = count !== null && Number(count) > 0;
|
|
563
|
+
return { path, dirty: uncommitted || ahead };
|
|
564
|
+
}
|
|
565
|
+
const WORKTREE_STATUS_CONCURRENCY = 12;
|
|
566
|
+
/**
|
|
567
|
+
* Resolve `WorktreeStatus` for MANY branches with a bounded number of git
|
|
568
|
+
* spawns instead of three per branch:
|
|
569
|
+
*
|
|
570
|
+
* - one `git worktree list --porcelain` (paths + HEAD SHAs)
|
|
571
|
+
* - one `git for-each-ref` for every branch's ahead count
|
|
572
|
+
* - one `git status --porcelain` per worktree WHOSE HEAD MOVED since `cache`
|
|
573
|
+
* (all of them on a cold cache; usually none on a server reload)
|
|
574
|
+
*
|
|
575
|
+
* `opts.cache` is consulted and updated in place; `changed` says whether it
|
|
576
|
+
* needs persisting. `opts.skipStatus` returns the cached dirtiness verbatim
|
|
577
|
+
* and never runs `git status` — the fast boot path, paired with a later
|
|
578
|
+
* reconcile sweep. Fail-soft throughout: a git failure yields
|
|
579
|
+
* `{ path: null, dirty: false }` for that branch, matching `worktreeStatus`.
|
|
580
|
+
*/
|
|
581
|
+
export async function resolveWorktreeStatuses(root, branches, opts) {
|
|
582
|
+
const cache = opts.cache ?? new Map();
|
|
583
|
+
const statuses = new Map();
|
|
584
|
+
const wanted = new Set(branches);
|
|
585
|
+
if (wanted.size === 0)
|
|
586
|
+
return { statuses, cache, changed: false };
|
|
587
|
+
const worktrees = worktreeList(root);
|
|
588
|
+
const aheadCounts = branchAheadCounts(root, opts.baseBranch);
|
|
589
|
+
let realRoot = root;
|
|
590
|
+
try {
|
|
591
|
+
realRoot = realpathSync(root);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
/* keep root */
|
|
595
|
+
}
|
|
596
|
+
// Branches that need a fresh `git status`: HEAD moved (or not cached).
|
|
597
|
+
const toCheck = [];
|
|
598
|
+
const seenBranches = new Set();
|
|
599
|
+
for (const branch of wanted) {
|
|
600
|
+
const entry = worktrees.get(branch);
|
|
601
|
+
if (!entry) {
|
|
602
|
+
statuses.set(branch, { path: null, dirty: false });
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
seenBranches.add(branch);
|
|
606
|
+
let realPath = entry.path;
|
|
607
|
+
try {
|
|
608
|
+
realPath = realpathSync(entry.path);
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
/* keep the reported path */
|
|
612
|
+
}
|
|
613
|
+
// git lists the main checkout as a worktree too; it is never a task worktree.
|
|
614
|
+
if (realPath === realRoot) {
|
|
615
|
+
statuses.set(branch, { path: null, dirty: false });
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const ahead = (aheadCounts.get(branch) ?? 0) > 0;
|
|
619
|
+
const cached = cache.get(branch);
|
|
620
|
+
if (opts.skipStatus || (cached && cached.head === entry.head)) {
|
|
621
|
+
const uncommitted = cached && cached.head === entry.head ? cached.uncommitted : false;
|
|
622
|
+
statuses.set(branch, { path: entry.path, dirty: uncommitted || ahead });
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
toCheck.push({ branch, path: entry.path, head: entry.head });
|
|
626
|
+
// provisional: reflect `ahead` now, refine once status returns
|
|
627
|
+
statuses.set(branch, { path: entry.path, dirty: ahead });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
let changed = false;
|
|
631
|
+
// Prune cache entries for branches we looked for that no longer have a
|
|
632
|
+
// worktree (collect first — don't delete while iterating the Map).
|
|
633
|
+
const stale = [];
|
|
634
|
+
for (const branch of cache.keys()) {
|
|
635
|
+
if (!seenBranches.has(branch) && wanted.has(branch))
|
|
636
|
+
stale.push(branch);
|
|
637
|
+
}
|
|
638
|
+
for (const branch of stale) {
|
|
639
|
+
cache.delete(branch);
|
|
640
|
+
changed = true;
|
|
641
|
+
}
|
|
642
|
+
// Fall back to per-branch rev-list when for-each-ref gave nothing (old git).
|
|
643
|
+
const needAheadFallback = aheadCounts.size === 0 && toCheck.length > 0 && !!opts.baseBranch;
|
|
644
|
+
const queue = [...toCheck];
|
|
645
|
+
const workers = Array.from({ length: Math.min(WORKTREE_STATUS_CONCURRENCY, queue.length) }, async () => {
|
|
646
|
+
for (let job = queue.shift(); job; job = queue.shift()) {
|
|
647
|
+
const [status, count] = await Promise.all([
|
|
648
|
+
gitAsync(job.path, ["status", "--porcelain"]),
|
|
649
|
+
needAheadFallback && opts.baseBranch !== job.branch
|
|
650
|
+
? gitAsync(root, ["rev-list", "--count", `${opts.baseBranch}..${job.branch}`])
|
|
651
|
+
: Promise.resolve(null),
|
|
652
|
+
]);
|
|
653
|
+
const uncommitted = status !== null && status !== "";
|
|
654
|
+
const ahead = needAheadFallback
|
|
655
|
+
? count !== null && Number(count) > 0
|
|
656
|
+
: (aheadCounts.get(job.branch) ?? 0) > 0;
|
|
657
|
+
statuses.set(job.branch, { path: job.path, dirty: uncommitted || ahead });
|
|
658
|
+
cache.set(job.branch, { head: job.head, uncommitted });
|
|
659
|
+
changed = true;
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
await Promise.all(workers);
|
|
663
|
+
return { statuses, cache, changed };
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Discard a task's worktree AND its branch so the next `ensureWorktree`
|
|
667
|
+
* re-creates both from the main checkout's HEAD — a clean restart. The main
|
|
668
|
+
* checkout itself is never touched. Fail-soft: false when git is missing,
|
|
669
|
+
* the branch is the main checkout, or a removal step failed.
|
|
670
|
+
*/
|
|
671
|
+
export function resetWorktree(root, branch) {
|
|
672
|
+
const path = worktreePaths(root).get(branch);
|
|
673
|
+
if (path) {
|
|
674
|
+
let realRoot = root;
|
|
675
|
+
let realPath = path;
|
|
676
|
+
try {
|
|
677
|
+
realRoot = realpathSync(root);
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
/* keep the composed path */
|
|
681
|
+
}
|
|
682
|
+
try {
|
|
683
|
+
realPath = realpathSync(path);
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
/* keep the reported path */
|
|
687
|
+
}
|
|
688
|
+
if (realPath === realRoot)
|
|
689
|
+
return false;
|
|
690
|
+
if (!removeWorktree(root, branch))
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
if (localBranches(root).has(branch)) {
|
|
694
|
+
// Force: the branch may carry commits a clean restart is meant to discard.
|
|
695
|
+
if (git(root, ["branch", "-D", branch]) === null)
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* What the branch checked out in `worktree` has changed relative to its branch
|
|
702
|
+
* point — the merge-base with `baseBranch` — combining already-committed
|
|
703
|
+
* changes with the current uncommitted working-tree diff. This is the correct
|
|
704
|
+
* base for "did the agent implement anything": diffing against `baseBranch`
|
|
705
|
+
* itself would count unrelated main drift as the branch's own work. When the
|
|
706
|
+
* base cannot be resolved (no git, or the two branches share no ancestor)
|
|
707
|
+
* returns `{ base: null, paths: [] }` so callers can fail soft rather than
|
|
708
|
+
* block on a false positive.
|
|
709
|
+
*/
|
|
710
|
+
export function branchChangesSinceBase(worktree, baseBranch) {
|
|
711
|
+
const baseFull = git(worktree, ["merge-base", baseBranch, "HEAD"]);
|
|
712
|
+
if (!baseFull)
|
|
713
|
+
return { base: null, paths: [] };
|
|
714
|
+
const base = git(worktree, ["rev-parse", "--short", baseFull]) ?? baseFull;
|
|
715
|
+
const committed = git(worktree, ["diff", "--name-only", baseFull, "HEAD"])
|
|
716
|
+
?.split("\n")
|
|
717
|
+
.map((s) => s.trim())
|
|
718
|
+
.filter(Boolean) ?? [];
|
|
719
|
+
const uncommitted = git(worktree, ["diff", "--cached", "--name-only"])
|
|
720
|
+
?.split("\n")
|
|
721
|
+
.map((s) => s.trim())
|
|
722
|
+
.filter(Boolean) ?? [];
|
|
723
|
+
return { base, paths: [...new Set([...committed, ...uncommitted])] };
|
|
724
|
+
}
|
|
725
|
+
const MAX_DIFF_BYTES = 256_000;
|
|
726
|
+
const DIFF_SOURCE_PATHS = ["--", ".", ":(exclude)dist", ":(exclude)screenshots"];
|
|
727
|
+
/** Parse `git diff --numstat` output into file/line counts (shared by the sync and async variants below). */
|
|
728
|
+
function parseNumstat(statOutput) {
|
|
729
|
+
let filesChanged = 0;
|
|
730
|
+
let additions = 0;
|
|
731
|
+
let deletions = 0;
|
|
732
|
+
// Parse output like: "278\t7\tsrc/file.ts" ("-\t-\tpath" for binary files)
|
|
733
|
+
for (const line of statOutput.split("\n")) {
|
|
734
|
+
const match = line.match(/^(\d+|-)\t(\d+|-)\t/);
|
|
735
|
+
if (match) {
|
|
736
|
+
filesChanged++;
|
|
737
|
+
if (match[1] !== "-")
|
|
738
|
+
additions += parseInt(match[1], 10);
|
|
739
|
+
if (match[2] !== "-")
|
|
740
|
+
deletions += parseInt(match[2], 10);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return { filesChanged, additions, deletions };
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Get diff statistics comparing a branch point to the current worktree.
|
|
747
|
+
* This deliberately includes committed, staged, and unstaged source edits so
|
|
748
|
+
* the task drawer can show an agent's work before its handoff commit.
|
|
749
|
+
* Generated build output is excluded because it is not task source.
|
|
750
|
+
* Returns file count and line additions/deletions.
|
|
751
|
+
*/
|
|
752
|
+
export function getDiffStats(worktree, baseBranch) {
|
|
753
|
+
const baseFull = git(worktree, ["merge-base", baseBranch, "HEAD"]);
|
|
754
|
+
if (!baseFull)
|
|
755
|
+
return { filesChanged: 0, additions: 0, deletions: 0 };
|
|
756
|
+
// --numstat gives exact per-file added/removed line counts, unlike --stat's
|
|
757
|
+
// ASCII bar (which git scales/truncates to fit terminal width and which
|
|
758
|
+
// this code previously had to reverse-engineer by proportional guessing —
|
|
759
|
+
// lossy and prone to drifting from the real totals, see 0248).
|
|
760
|
+
const statOutput = git(worktree, ["diff", "--numstat", baseFull, ...DIFF_SOURCE_PATHS]);
|
|
761
|
+
if (!statOutput)
|
|
762
|
+
return { filesChanged: 0, additions: 0, deletions: 0 };
|
|
763
|
+
return parseNumstat(statOutput);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Non-blocking counterpart to `getDiffStats`, built on `runGit` (`spawn`)
|
|
767
|
+
* instead of `git`'s `execFileSync`. Every task card on the Work board fetches
|
|
768
|
+
* its diff stats on mount (`TaskCard.vue`), so a board with N cards fires N
|
|
769
|
+
* of these on every reload/tab-switch; the synchronous version froze the
|
|
770
|
+
* entire server's event loop for the duration of all of them serially —
|
|
771
|
+
* including whatever `GET /api/tasks/:id` a drawer-opening click was waiting
|
|
772
|
+
* on — which is what made the drawer take several seconds to appear.
|
|
773
|
+
*/
|
|
774
|
+
export async function getDiffStatsAsync(worktree, baseBranch) {
|
|
775
|
+
const base = await runGit(worktree, ["merge-base", baseBranch, "HEAD"], 4000);
|
|
776
|
+
const baseFull = base.status === 0 && !base.timedOut ? base.stdout.trim() : null;
|
|
777
|
+
if (!baseFull)
|
|
778
|
+
return { filesChanged: 0, additions: 0, deletions: 0 };
|
|
779
|
+
const diff = await runGit(worktree, ["diff", "--numstat", baseFull, ...DIFF_SOURCE_PATHS], 4000);
|
|
780
|
+
const statOutput = diff.status === 0 && !diff.timedOut ? diff.stdout.trim() : null;
|
|
781
|
+
if (!statOutput)
|
|
782
|
+
return { filesChanged: 0, additions: 0, deletions: 0 };
|
|
783
|
+
return parseNumstat(statOutput);
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Get the full patch diff from a branch point to the current worktree.
|
|
787
|
+
* This includes committed, staged, and unstaged source edits, while leaving
|
|
788
|
+
* generated build output out of the task-facing code review view.
|
|
789
|
+
* Bounded at MAX_DIFF_BYTES to avoid sending giant payloads.
|
|
790
|
+
*/
|
|
791
|
+
export async function getDiff(worktree, baseBranch) {
|
|
792
|
+
const baseFull = git(worktree, ["merge-base", baseBranch, "HEAD"]);
|
|
793
|
+
if (!baseFull)
|
|
794
|
+
return { patch: "", truncated: false };
|
|
795
|
+
const run = await runGit(worktree, ["diff", "--patch", baseFull, ...DIFF_SOURCE_PATHS], 15000);
|
|
796
|
+
if (run.status !== 0 && run.stdout === "")
|
|
797
|
+
return { patch: "", truncated: false };
|
|
798
|
+
const buf = Buffer.from(run.stdout, "utf8");
|
|
799
|
+
if (buf.byteLength <= MAX_DIFF_BYTES)
|
|
800
|
+
return { patch: run.stdout, truncated: false };
|
|
801
|
+
let truncated = run.stdout;
|
|
802
|
+
while (Buffer.from(truncated, "utf8").byteLength > MAX_DIFF_BYTES) {
|
|
803
|
+
truncated = truncated.slice(0, -1024);
|
|
804
|
+
}
|
|
805
|
+
truncated += `\n\n--- diff truncated (${(buf.byteLength / 1024).toFixed(0)} kB total) ---`;
|
|
806
|
+
return { patch: truncated, truncated: true };
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* The paths changed between `baseBranch` and the current worktree HEAD
|
|
810
|
+
* (`git diff --name-only <baseBranch> HEAD`), used by the close-out gate's
|
|
811
|
+
* docs-only fast path (#0355) to decide whether the full build/check gate can
|
|
812
|
+
* be skipped.
|
|
813
|
+
*
|
|
814
|
+
* Unlike `getDiff`/`getDiffStats`, this deliberately does NOT go through
|
|
815
|
+
* `merge-base`: the caller wants the cumulative difference between the base
|
|
816
|
+
* branch tip and the candidate commit (which already contains the merge), not
|
|
817
|
+
* the branch's own commits. Returns `null` — never `[]` — when git errors or
|
|
818
|
+
* times out, so a caller can distinguish "no changes" from "could not tell"
|
|
819
|
+
* and fail safe by running the full gate.
|
|
820
|
+
*/
|
|
821
|
+
export async function getChangedFilePaths(worktree, baseBranch, timeout = 10_000) {
|
|
822
|
+
const run = await runGit(worktree, ["diff", "--name-only", baseBranch, "HEAD"], timeout);
|
|
823
|
+
if (run.status !== 0 || run.timedOut)
|
|
824
|
+
return null;
|
|
825
|
+
return run.stdout
|
|
826
|
+
.split("\n")
|
|
827
|
+
.map((s) => s.trim())
|
|
828
|
+
.filter(Boolean);
|
|
829
|
+
}
|
|
830
|
+
/** Whether git is installed at all (independent of being inside a repo). */
|
|
831
|
+
export function gitAvailable(root) {
|
|
832
|
+
return git(root, ["--version"]) !== null;
|
|
833
|
+
}
|
|
834
|
+
/** `git config --get <key>` value, or null when unset or git is missing. */
|
|
835
|
+
export function gitConfig(root, key) {
|
|
836
|
+
const out = git(root, ["config", "--get", key]);
|
|
837
|
+
return out || null;
|
|
838
|
+
}
|
|
839
|
+
/** Run `git init` in root. True on success. */
|
|
840
|
+
export function gitInit(root) {
|
|
841
|
+
return git(root, ["init"]) !== null;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Commit the ENTIRE working tree. Only for a freshly scaffolded repo where
|
|
845
|
+
* nothing else is pre-staged (the guided `repoos init` flow) — do NOT use this
|
|
846
|
+
* where 0023's surgical `commitNewFile` applies. Fail-soft: null on failure.
|
|
847
|
+
*/
|
|
848
|
+
export function gitCommitAll(root, message) {
|
|
849
|
+
if (git(root, ["add", "-A"]) === null)
|
|
850
|
+
return null;
|
|
851
|
+
if (git(root, ["commit", "-m", message]) === null)
|
|
852
|
+
return null;
|
|
853
|
+
return git(root, ["rev-parse", "--short", "HEAD"]);
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Surgically commit a single new file with the user's own git identity.
|
|
857
|
+
*
|
|
858
|
+
* `git commit --only` refuses paths git doesn't know, so we first run
|
|
859
|
+
* `git add -N` (intent-to-add): it registers the path WITHOUT staging any
|
|
860
|
+
* content, then `git commit -o -m <msg> -- <file>` commits only that file's
|
|
861
|
+
* working-tree content and IGNORES whatever else is staged or dirty. Never
|
|
862
|
+
* `git add -A`, never a plain `git commit`, never amend/force, hooks run.
|
|
863
|
+
*
|
|
864
|
+
* Fail-soft: never throws, never leaves content partially staged. On any
|
|
865
|
+
* problem it returns `{ ok: false, reason }` and the caller keeps the file.
|
|
866
|
+
*/
|
|
867
|
+
export function commitNewFile(root, absPath, message) {
|
|
868
|
+
const relPath = relative(root, absPath).split("\\").join("/");
|
|
869
|
+
if (isAbsolute(relPath) || relPath.startsWith("..")) {
|
|
870
|
+
return { ok: false, reason: `path is outside the repository: ${absPath}` };
|
|
871
|
+
}
|
|
872
|
+
if (!isGitRepo(root)) {
|
|
873
|
+
return { ok: false, reason: "not a git repository (is git installed?)" };
|
|
874
|
+
}
|
|
875
|
+
if (git(root, ["add", "-N", "--", relPath]) === null) {
|
|
876
|
+
return { ok: false, reason: "could not stage the file (mid-merge conflict?)" };
|
|
877
|
+
}
|
|
878
|
+
if (git(root, ["commit", "-o", "-m", message, "--", relPath]) === null) {
|
|
879
|
+
return { ok: false, reason: "commit failed (unconfigured identity, conflicts, or a hook)" };
|
|
880
|
+
}
|
|
881
|
+
const hash = git(root, ["rev-parse", "--short", "HEAD"]);
|
|
882
|
+
return hash ? { ok: true, hash } : { ok: true };
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Whether `ancestor` is an ancestor of `descendant` (i.e. a fast-forward merge
|
|
886
|
+
* of `descendant` into a checkout on `ancestor` is possible). Uses
|
|
887
|
+
* `git merge-base --is-ancestor` exit codes; null when git is missing or the
|
|
888
|
+
* refs are invalid.
|
|
889
|
+
*/
|
|
890
|
+
export function isAncestor(root, ancestor, descendant) {
|
|
891
|
+
const run = spawnSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
|
|
892
|
+
cwd: root,
|
|
893
|
+
timeout: 4000,
|
|
894
|
+
});
|
|
895
|
+
if (run.status === 0)
|
|
896
|
+
return true;
|
|
897
|
+
if (run.status === 1)
|
|
898
|
+
return false;
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Cheap, non-destructive pre-flight for merging `branch` into the CURRENT
|
|
903
|
+
* checkout. Uses `git merge --no-commit` and always aborts, so the working
|
|
904
|
+
* tree is left untouched (blocking files may be committed first, as the real
|
|
905
|
+
* `mergeBranch` does, to keep the pre-flight honest).
|
|
906
|
+
*
|
|
907
|
+
* Returns `ok: true` for a clean merge (fast-forward or merge commit), and
|
|
908
|
+
* `ok: false` with the conflict file list when source files would conflict.
|
|
909
|
+
* `drifted` is true whenever main is not an ancestor of the branch.
|
|
910
|
+
*/
|
|
911
|
+
export async function preflightMerge(root, branch, opts = {}) {
|
|
912
|
+
const head = currentBranch(root);
|
|
913
|
+
const drifted = head !== null && branch !== head && isAncestor(root, head, branch) !== true;
|
|
914
|
+
// A missing branch used to "pass" pre-flight (a `git merge` of a nonexistent
|
|
915
|
+
// ref fails without producing conflicts), so a branch that was already
|
|
916
|
+
// merged and cleaned up was mislabelled as a fresh failed merge. Fail fast
|
|
917
|
+
// with an explicit reason instead — the close-out's `alreadyMerged` handling
|
|
918
|
+
// resumes those retries before this is ever reached (#0130).
|
|
919
|
+
if (!localBranches(root).has(branch)) {
|
|
920
|
+
return { ok: false, drifted, conflicts: [], reason: `branch \`${branch}\` does not exist` };
|
|
921
|
+
}
|
|
922
|
+
let run = await runGit(root, ["merge", "--no-commit", "--no-ff", branch], 60_000);
|
|
923
|
+
if (run.status !== 0 && /would be overwritten by merge/.test(run.stderr)) {
|
|
924
|
+
const blocking = blockingFiles(run.stderr);
|
|
925
|
+
if (blocking.length > 0) {
|
|
926
|
+
for (const p of blocking)
|
|
927
|
+
git(root, ["add", "--", p]);
|
|
928
|
+
if (git(root, ["commit", "-m", "chore: sync working tree before merge"]) !== null) {
|
|
929
|
+
run = await runGit(root, ["merge", "--no-commit", "--no-ff", branch], 60_000);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (run.status === 0) {
|
|
934
|
+
await runGit(root, ["merge", "--abort"], 4000);
|
|
935
|
+
return { ok: true, drifted, conflicts: [] };
|
|
936
|
+
}
|
|
937
|
+
const conflicts = git(root, ["diff", "--name-only", "--diff-filter=U"])?.split("\n").filter(Boolean) ?? [];
|
|
938
|
+
// Respect autoResolve semantics so generated files never surface as
|
|
939
|
+
// "conflicts" to the user.
|
|
940
|
+
const autoResolvable = (p) => (opts.autoResolve ?? []).some((r) => p === r || p.startsWith(r.endsWith("/") ? r : r + "/"));
|
|
941
|
+
const unresolved = conflicts.filter((p) => !autoResolvable(p));
|
|
942
|
+
await runGit(root, ["merge", "--abort"], 4000);
|
|
943
|
+
if (unresolved.length > 0) {
|
|
944
|
+
return {
|
|
945
|
+
ok: false,
|
|
946
|
+
drifted,
|
|
947
|
+
conflicts: unresolved,
|
|
948
|
+
reason: `merge conflict: ${unresolved.join(", ")}`,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
return { ok: true, drifted, conflicts: [] };
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Commit a single task file in the main checkout, tracking it first when it is
|
|
955
|
+
* untracked. Task files are the one path that is routinely edited on both sides
|
|
956
|
+
* of a close-out merge: an untracked file aborts `git merge` outright, and a
|
|
957
|
+
* dirty one aborts it with "local changes would be overwritten". Committing the
|
|
958
|
+
* main copy up front (fail-soft) removes both failure modes — the content is
|
|
959
|
+
* preserved in history either way. Returns false when git failed and the file
|
|
960
|
+
* is not clean; callers should treat that as a merge blocker.
|
|
961
|
+
*/
|
|
962
|
+
export function commitTaskFile(root, absPath, message) {
|
|
963
|
+
const rel = relative(root, absPath);
|
|
964
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
965
|
+
return false;
|
|
966
|
+
const status = git(root, ["status", "--porcelain", "--", rel]);
|
|
967
|
+
if (status === null)
|
|
968
|
+
return false;
|
|
969
|
+
if (status.trim() === "")
|
|
970
|
+
return true;
|
|
971
|
+
if (git(root, ["add", "--", rel]) === null)
|
|
972
|
+
return false;
|
|
973
|
+
// `--only` with a pathspec commits just this file and leaves everything else
|
|
974
|
+
// already in the index staged (#0353): a plain `git commit` would sweep in
|
|
975
|
+
// whatever a human or agent had staged in the checkout under our message.
|
|
976
|
+
return git(root, ["commit", "-o", "-m", message, "--", rel]) !== null;
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* The dirty/uncommitted file paths in a checkout (`git status --porcelain`),
|
|
980
|
+
* including tracked modifications and untracked files, in repo-relative form.
|
|
981
|
+
* Used by the close-out flow to check `main` before merging: a dirty tree
|
|
982
|
+
* aborts `git merge`, so the UI surfaces these so it can offer to commit them.
|
|
983
|
+
*
|
|
984
|
+
* FAILS CLOSED: returns `[]` only for a genuinely clean tree (git exit 0 with
|
|
985
|
+
* empty output). Any git failure — a non-zero exit, a timeout, or git being
|
|
986
|
+
* unavailable — throws `GitDirtyCheckError` instead, so a caller cannot
|
|
987
|
+
* mistake an unknown state for a clean tree and silently proceed into a merge
|
|
988
|
+
* that git will abort. This is the fix for #0211, where the old fail-open
|
|
989
|
+
* behaved that way under load.
|
|
990
|
+
*/
|
|
991
|
+
export class GitDirtyCheckError extends Error {
|
|
992
|
+
causeKind;
|
|
993
|
+
constructor(causeKind, detail) {
|
|
994
|
+
super(`could not determine the dirty state of the checkout${detail ? `: ${detail}` : ""}`);
|
|
995
|
+
this.name = "GitDirtyCheckError";
|
|
996
|
+
this.causeKind = causeKind;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
export async function dirtyFiles(root) {
|
|
1000
|
+
const out = await runGit(root, ["status", "--porcelain"], 4000);
|
|
1001
|
+
// A genuinely clean tree is git exit 0 with empty output.
|
|
1002
|
+
if (out.status === 0 && (!out.stdout || out.stdout.trim() === ""))
|
|
1003
|
+
return [];
|
|
1004
|
+
// Fail closed: a timeout, a spawn failure (status null), or a non-zero exit
|
|
1005
|
+
// all mean "unknown", not "clean". Surface which so the caller can explain.
|
|
1006
|
+
if (out.timedOut) {
|
|
1007
|
+
throw new GitDirtyCheckError("timeout", "git status exceeded its time budget");
|
|
1008
|
+
}
|
|
1009
|
+
if (out.status === 0) {
|
|
1010
|
+
// Exit 0 with non-empty output: genuinely dirty files to list.
|
|
1011
|
+
return out.stdout
|
|
1012
|
+
.split("\n")
|
|
1013
|
+
.map((l) => l.replace(/\r$/, ""))
|
|
1014
|
+
.map((l) => {
|
|
1015
|
+
// Porcelain v1 short format is two status columns, a separator space,
|
|
1016
|
+
// then the repo-relative path (`XY path`). We read the raw stdout here
|
|
1017
|
+
// (not the shared `git()` helper, whose `.trim()` eats the first line's
|
|
1018
|
+
// leading status column) so the fixed-width parse stays column-accurate.
|
|
1019
|
+
const stripped = l.slice(3).trim();
|
|
1020
|
+
// Renames/renames-with-changes encode the old path before the new one.
|
|
1021
|
+
const arrow = stripped.indexOf(" -> ");
|
|
1022
|
+
return arrow === -1 ? stripped : stripped.slice(arrow + 4);
|
|
1023
|
+
})
|
|
1024
|
+
.filter(Boolean);
|
|
1025
|
+
}
|
|
1026
|
+
const detail = out.stderr.split("\n").filter(Boolean).slice(0, 2).join(" ").trim() ||
|
|
1027
|
+
(out.status === null ? "git did not run" : `git exited ${out.status}`);
|
|
1028
|
+
throw new GitDirtyCheckError(out.status === null ? "no-repo" : "git-error", detail);
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Stage and commit every dirty/uncommitted file in a checkout with a single
|
|
1032
|
+
* checkpoint commit so the tree is left clean and mergeable. Used before a
|
|
1033
|
+
* close-out merge when the user opts in ("Commit & continue"). Returns the
|
|
1034
|
+
* list of committed dirty files on success (matching `dirtyFiles`), or an
|
|
1035
|
+
* empty array when the tree was clean or the commit failed.
|
|
1036
|
+
*/
|
|
1037
|
+
export async function commitDirtyFiles(root, message) {
|
|
1038
|
+
const files = await dirtyFiles(root);
|
|
1039
|
+
if (files.length === 0)
|
|
1040
|
+
return [];
|
|
1041
|
+
const add = await runGit(root, ["add", "-A"], 15_000);
|
|
1042
|
+
if (add.status !== 0)
|
|
1043
|
+
return [];
|
|
1044
|
+
const commit = await runGit(root, ["commit", "-m", message], 15_000);
|
|
1045
|
+
return commit.status === 0 ? files : [];
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* The tab-indented paths git lists when a merge aborts because a dirty or
|
|
1049
|
+
* untracked working-tree file would be overwritten, e.g.:
|
|
1050
|
+
*
|
|
1051
|
+
* error: Your local changes to the following files would be overwritten by
|
|
1052
|
+
* merge:
|
|
1053
|
+
* dist/.build-info.json
|
|
1054
|
+
* Please commit your changes or stash them before you merge. Aborting
|
|
1055
|
+
*/
|
|
1056
|
+
function blockingFiles(stderr) {
|
|
1057
|
+
return stderr
|
|
1058
|
+
.split("\n")
|
|
1059
|
+
.map((l) => l.replace(/\r$/, ""))
|
|
1060
|
+
.filter((l) => l.startsWith("\t") && l.trim() !== "")
|
|
1061
|
+
.map((l) => l.trim());
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Whether a path that conflicted between two sides is one the merge is allowed
|
|
1065
|
+
* to resolve on its own: it matches an `autoResolve` entry (generated output,
|
|
1066
|
+
* the closing task's own file) or an `autoResolveOurs` entry (unrelated task
|
|
1067
|
+
* files, where main's copy wins). A conflicted path matching neither is a REAL
|
|
1068
|
+
* conflict. This is the single definition of "a real conflict" shared by the
|
|
1069
|
+
* real merge and `mergeBranch`'s dry-run pre-flight, so the two can never drift.
|
|
1070
|
+
*/
|
|
1071
|
+
function isResolvableConflict(path, opts) {
|
|
1072
|
+
const matches = (rule, p) => p === rule || p.startsWith(rule.endsWith("/") ? rule : rule + "/");
|
|
1073
|
+
return [...(opts.autoResolve ?? []), ...(opts.autoResolveOurs ?? [])].some((r) => matches(r, path));
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Whether `root`'s checkout is mid-operation (merge, rebase, cherry-pick,
|
|
1077
|
+
* revert). Used by `dryRunMergeBranch` to refuse a merge it could not cleanly
|
|
1078
|
+
* back out of. `git rev-parse --git-path` resolves the per-worktree location.
|
|
1079
|
+
*/
|
|
1080
|
+
function hasInProgressGitOperation(root) {
|
|
1081
|
+
for (const name of [
|
|
1082
|
+
"MERGE_HEAD",
|
|
1083
|
+
"CHERRY_PICK_HEAD",
|
|
1084
|
+
"REVERT_HEAD",
|
|
1085
|
+
"rebase-merge",
|
|
1086
|
+
"rebase-apply",
|
|
1087
|
+
]) {
|
|
1088
|
+
const path = git(root, ["rev-parse", "--git-path", name]);
|
|
1089
|
+
if (!path)
|
|
1090
|
+
continue;
|
|
1091
|
+
if (existsSync(isAbsolute(path) ? path : join(root, path)))
|
|
1092
|
+
return true;
|
|
1093
|
+
}
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* The dry-run half of `mergeBranch` (#0358): answer "would this merge hit a
|
|
1098
|
+
* real, non-auto-resolvable conflict?" without leaving any trace. Runs
|
|
1099
|
+
* `git merge --no-commit --no-ff` in the given checkout (the task's own
|
|
1100
|
+
* worktree for the close-out pre-flight) and ALWAYS aborts, so the working
|
|
1101
|
+
* tree, index and branch ref are exactly as found. `--no-ff` is required:
|
|
1102
|
+
* without it a fast-forward would move the branch ref and `--no-commit` would
|
|
1103
|
+
* not stop it.
|
|
1104
|
+
*
|
|
1105
|
+
* Classification reuses `isResolvableConflict`, the same predicate the real
|
|
1106
|
+
* merge uses. A merge that cannot run cleanly at all (a dirty checkout git
|
|
1107
|
+
* refuses to touch, unrelated histories, git missing) is reported as a
|
|
1108
|
+
* non-conflict failure so the caller can fail open into the normal flow.
|
|
1109
|
+
*/
|
|
1110
|
+
async function dryRunMergeBranch(root, branch, opts) {
|
|
1111
|
+
// A checkout already mid-merge/rebase is not a state this check may touch:
|
|
1112
|
+
// its unmerged paths are not a property of the branch-vs-main merge, and
|
|
1113
|
+
// `merge --abort` would destroy the user's in-progress operation. Fail open.
|
|
1114
|
+
if (hasInProgressGitOperation(root)) {
|
|
1115
|
+
return {
|
|
1116
|
+
merged: false,
|
|
1117
|
+
ff: false,
|
|
1118
|
+
conflicts: [],
|
|
1119
|
+
reason: "pre-flight skipped: a merge or rebase is already in progress",
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
const run = await runGit(root, ["merge", "--no-commit", "--no-ff", branch], 60_000);
|
|
1123
|
+
if (run.status === 0) {
|
|
1124
|
+
// A merge that makes no changes (branch already an ancestor of the
|
|
1125
|
+
// checkout) is reported as a clean, no-op success WITHOUT ever setting
|
|
1126
|
+
// MERGE_HEAD — there is nothing to abort, and calling `merge --abort`
|
|
1127
|
+
// anyway would itself fail ("MERGE_HEAD not found"). Only abort when
|
|
1128
|
+
// there is actually a merge in progress to undo.
|
|
1129
|
+
if (hasInProgressGitOperation(root)) {
|
|
1130
|
+
const abort = await runGit(root, ["merge", "--abort"], 4000);
|
|
1131
|
+
if (abort.status !== 0) {
|
|
1132
|
+
// The merge itself succeeded, but the checkout is now mid-merge and
|
|
1133
|
+
// we failed to undo it: the "clean merge" verdict is no longer
|
|
1134
|
+
// trustworthy and the caller must not act on it. Fail open.
|
|
1135
|
+
return {
|
|
1136
|
+
merged: false,
|
|
1137
|
+
ff: false,
|
|
1138
|
+
conflicts: [],
|
|
1139
|
+
reason: "pre-flight skipped: merge --abort failed, checkout left mid-merge",
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return { merged: true, ff: false, conflicts: [] };
|
|
1144
|
+
}
|
|
1145
|
+
if (/would be overwritten by merge/.test(run.stderr)) {
|
|
1146
|
+
// Do NOT commit the blocking files here the way the real merge does: this
|
|
1147
|
+
// checkout belongs to the task's own worktree, so writing to its history
|
|
1148
|
+
// would be a side effect the pre-flight must never have. Fail open.
|
|
1149
|
+
return {
|
|
1150
|
+
merged: false,
|
|
1151
|
+
ff: false,
|
|
1152
|
+
conflicts: [],
|
|
1153
|
+
reason: "pre-flight skipped: working tree not clean",
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
const conflicts = git(root, ["diff", "--name-only", "--diff-filter=U"])?.split("\n").filter(Boolean) ?? [];
|
|
1157
|
+
const blocking = conflicts.filter((p) => !isResolvableConflict(p, opts));
|
|
1158
|
+
const abort = await runGit(root, ["merge", "--abort"], 4000);
|
|
1159
|
+
if (abort.status !== 0) {
|
|
1160
|
+
// Same reasoning as above: the checkout's merge state is no longer known,
|
|
1161
|
+
// so a "conflict" verdict here would risk sending a repair engineer into
|
|
1162
|
+
// a worktree that is not actually mid-merge on the branch it expects, or
|
|
1163
|
+
// still mid-merge with conflicts unresolved.
|
|
1164
|
+
return {
|
|
1165
|
+
merged: false,
|
|
1166
|
+
ff: false,
|
|
1167
|
+
conflicts: [],
|
|
1168
|
+
reason: "pre-flight skipped: merge --abort failed, checkout left mid-merge",
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
if (blocking.length > 0) {
|
|
1172
|
+
return { merged: false, ff: false, conflicts: blocking, reason: "merge conflict" };
|
|
1173
|
+
}
|
|
1174
|
+
if (conflicts.length > 0) {
|
|
1175
|
+
// Every conflicted path auto-resolves, so the real merge completes cleanly.
|
|
1176
|
+
return { merged: true, ff: false, conflicts: [] };
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
merged: false,
|
|
1180
|
+
ff: false,
|
|
1181
|
+
conflicts: [],
|
|
1182
|
+
reason: `${run.stderr}\n${run.stdout}`.trim().split("\n").filter(Boolean).slice(0, 4).join(" ") ||
|
|
1183
|
+
"pre-flight merge failed",
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Merge `branch` into the CURRENT checkout — the main worktree, never the
|
|
1188
|
+
* task's worktree. Default merge semantics: fast-forward when main is an
|
|
1189
|
+
* ancestor of the branch, otherwise a merge commit (`--no-edit` so we never
|
|
1190
|
+
* block on an editor). Longer timeout than `git()`: real merges are slow.
|
|
1191
|
+
*
|
|
1192
|
+
* A dirty working tree aborts `git merge` outright (e.g. the build marker that
|
|
1193
|
+
* `repoos check` regenerates on every run). Rather than fail, the exact files
|
|
1194
|
+
* git refuses to overwrite are committed and the merge is retried once.
|
|
1195
|
+
*
|
|
1196
|
+
* On a conflict the merge is aborted (`git merge --abort`) so nothing is left
|
|
1197
|
+
* half-applied; the conflicted file list is returned and the branch stays
|
|
1198
|
+
* untouched. The exception: when every conflicted path is listed in
|
|
1199
|
+
* `opts.autoResolve`, the branch's version is taken (`--theirs`) and the merge
|
|
1200
|
+
* is completed — used for the task file, which always changes on both sides of
|
|
1201
|
+
* a close-out merge and whose branch version is authoritative for the task's
|
|
1202
|
+
* final state.
|
|
1203
|
+
*
|
|
1204
|
+
* `opts.dryRun` answers the same conflict question non-destructively (see
|
|
1205
|
+
* `dryRunMergeBranch`) — no commit, no branch move, the checkout restored.
|
|
1206
|
+
*/
|
|
1207
|
+
export async function mergeBranch(root, branch, opts = {}) {
|
|
1208
|
+
if (opts.dryRun)
|
|
1209
|
+
return dryRunMergeBranch(root, branch, opts);
|
|
1210
|
+
const head = currentBranch(root);
|
|
1211
|
+
let ff = head !== null && branch !== head && isAncestor(root, head, branch) === true;
|
|
1212
|
+
let run = await runGit(root, ["merge", "--no-edit", branch], 60_000);
|
|
1213
|
+
if (run.status !== 0 && /would be overwritten by merge/.test(run.stderr)) {
|
|
1214
|
+
const blocking = blockingFiles(run.stderr);
|
|
1215
|
+
if (blocking.length > 0) {
|
|
1216
|
+
for (const p of blocking)
|
|
1217
|
+
git(root, ["add", "--", p]);
|
|
1218
|
+
if (git(root, ["commit", "-m", "chore: sync working tree before merge"]) !== null) {
|
|
1219
|
+
ff = false;
|
|
1220
|
+
run = await runGit(root, ["merge", "--no-edit", branch], 60_000);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
const stderr = `${run.stderr}\n${run.stdout}`;
|
|
1225
|
+
if (run.status === 0) {
|
|
1226
|
+
return { merged: true, ff, conflicts: [] };
|
|
1227
|
+
}
|
|
1228
|
+
// Authoritative conflicted paths come from the merge-in-progress index, not
|
|
1229
|
+
// from parsing git's prose (modify/delete and rename messages mangle easily).
|
|
1230
|
+
const conflicts = git(root, ["diff", "--name-only", "--diff-filter=U"])?.split("\n").filter(Boolean) ?? [];
|
|
1231
|
+
if (conflicts.length > 0) {
|
|
1232
|
+
// A conflicted path is auto-resolvable when it matches an `autoResolve`
|
|
1233
|
+
// entry exactly or sits under a directory entry (e.g. "dist/").
|
|
1234
|
+
const autoResolvable = (p) => (opts.autoResolve ?? []).some((r) => p === r || p.startsWith(r.endsWith("/") ? r : r + "/"));
|
|
1235
|
+
const keepOurs = (p) => (opts.autoResolveOurs ?? []).some((r) => p === r || p.startsWith(r.endsWith("/") ? r : r + "/"));
|
|
1236
|
+
const blocking = conflicts.filter((p) => !isResolvableConflict(p, opts));
|
|
1237
|
+
if (conflicts.every((p) => autoResolvable(p) || keepOurs(p))) {
|
|
1238
|
+
// Task metadata may be updated concurrently. The closing task's branch
|
|
1239
|
+
// copy is authoritative, while unrelated task files must retain main's
|
|
1240
|
+
// newer activity/status. Resolve each direction explicitly rather than
|
|
1241
|
+
// applying `-X theirs` to every task file wholesale.
|
|
1242
|
+
for (const p of conflicts) {
|
|
1243
|
+
const side = autoResolvable(p) ? "--theirs" : "--ours";
|
|
1244
|
+
const resolved = await runGit(root, ["checkout", side, "--", p], 10_000);
|
|
1245
|
+
if (resolved.status !== 0)
|
|
1246
|
+
break;
|
|
1247
|
+
}
|
|
1248
|
+
const staged = await runGit(root, ["add", "-A", "--", ...conflicts], 10_000);
|
|
1249
|
+
if (staged.status === 0 && git(root, ["commit", "--no-edit"]) !== null) {
|
|
1250
|
+
return { merged: true, ff: false, conflicts: [] };
|
|
1251
|
+
}
|
|
1252
|
+
await runGit(root, ["merge", "--abort"], 4000);
|
|
1253
|
+
// Every conflicted path was auto-resolvable, but the resolution itself
|
|
1254
|
+
// failed to commit. Report that explicitly rather than a generic failure —
|
|
1255
|
+
// the auto-resolvable paths are not the real culprit, so they are excluded
|
|
1256
|
+
// from `conflicts` (nothing there genuinely blocks a hand-led resolution).
|
|
1257
|
+
return {
|
|
1258
|
+
merged: false,
|
|
1259
|
+
ff: false,
|
|
1260
|
+
conflicts: [],
|
|
1261
|
+
reason: `auto-resolve failed for ${conflicts.join(", ")}`,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
// A genuine, non-auto-resolvable conflict (or a mix of one with auto-
|
|
1265
|
+
// resolvable bookkeeping, e.g. the task's own file). Return ONLY the paths
|
|
1266
|
+
// that genuinely block the merge so a hand-led resolution — and the debugger
|
|
1267
|
+
// the user is routed to — sees the real culprit, never the task-file
|
|
1268
|
+
// bookkeeping that the close-out is supposed to auto-resolve anyway (#0271).
|
|
1269
|
+
// This is consistent with `preflightMerge`, which already filters to the
|
|
1270
|
+
// unresolved (non-auto-resolvable) paths.
|
|
1271
|
+
// Nothing may be left half-applied: back out of the merge entirely.
|
|
1272
|
+
await runGit(root, ["merge", "--abort"], 4000);
|
|
1273
|
+
return { merged: false, ff: false, conflicts: blocking, reason: "merge conflict" };
|
|
1274
|
+
}
|
|
1275
|
+
return {
|
|
1276
|
+
merged: false,
|
|
1277
|
+
ff: false,
|
|
1278
|
+
conflicts: [],
|
|
1279
|
+
reason: stderr.trim().split("\n").filter(Boolean).slice(0, 4).join(" ") || "merge failed",
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Sync `branch` with main: merge the main checkout's current branch into
|
|
1284
|
+
* `branch`'s linked worktree. This is the repository's existing "sync with
|
|
1285
|
+
* main" operation — the same `mergeBranch` semantics the review-transition
|
|
1286
|
+
* auto-sync uses, so the automatic done-flow fallback and the manual route
|
|
1287
|
+
* never diverge. On conflict the merge is aborted (nothing half-applied) and
|
|
1288
|
+
* the conflict list is returned. Fail-soft: false when the branch has no
|
|
1289
|
+
* linked worktree or git is missing.
|
|
1290
|
+
*/
|
|
1291
|
+
export async function syncBranchWithMain(root, branch, opts = {}) {
|
|
1292
|
+
const wtPath = worktreePathForBranch(root, branch);
|
|
1293
|
+
if (!wtPath) {
|
|
1294
|
+
return { ok: false, conflicts: [], reason: "no worktree for branch" };
|
|
1295
|
+
}
|
|
1296
|
+
const baseBranch = currentBranch(root) ?? "main";
|
|
1297
|
+
const result = await mergeBranch(wtPath, baseBranch, opts);
|
|
1298
|
+
if (result.merged)
|
|
1299
|
+
return { ok: true, conflicts: [] };
|
|
1300
|
+
return {
|
|
1301
|
+
ok: false,
|
|
1302
|
+
conflicts: result.conflicts,
|
|
1303
|
+
reason: result.conflicts.length
|
|
1304
|
+
? `merge conflict: ${result.conflicts.join(", ")}`
|
|
1305
|
+
: (result.reason ?? "sync failed"),
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Delete a local branch. The close-out flow only ever deletes a branch that
|
|
1310
|
+
* was just merged into main, so `-d` (refuses unless fully merged) is the
|
|
1311
|
+
* default. Pass `force: true` for a throwaway branch (a `repoos/integrate/*`
|
|
1312
|
+
* candidate) that git will not see as merged.
|
|
1313
|
+
* Fail-soft: false when the branch is gone, not merged, or git is missing —
|
|
1314
|
+
* the caller treats it as best-effort cleanup.
|
|
1315
|
+
*/
|
|
1316
|
+
export function deleteBranch(root, branch, opts = {}) {
|
|
1317
|
+
return git(root, ["branch", opts.force ? "-D" : "-d", branch]) !== null;
|
|
1318
|
+
}
|
|
1319
|
+
/** `git worktree prune` — clears metadata for worktrees whose directory is gone. */
|
|
1320
|
+
export function pruneWorktrees(root) {
|
|
1321
|
+
git(root, ["worktree", "prune"]);
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Remove the linked worktree for `branch` and make sure it is really gone.
|
|
1325
|
+
* Forced when dirty — close-out content is preserved in the merged main, so
|
|
1326
|
+
* nothing is lost.
|
|
1327
|
+
*
|
|
1328
|
+
* Handles the two states a leak actually shows up in:
|
|
1329
|
+
* - directory + metadata present -> `git worktree remove --force`
|
|
1330
|
+
* - directory gone, metadata stale -> `remove` fails ("is not a working
|
|
1331
|
+
* tree"), so fall back to `git worktree prune`, then `rm -rf` any leftover
|
|
1332
|
+
* directory and prune once more.
|
|
1333
|
+
*
|
|
1334
|
+
* Returns false (without forcing anything) when `branch` is checked out in a
|
|
1335
|
+
* DIFFERENT worktree than expected, or is the main checkout — the caller asked
|
|
1336
|
+
* to remove the wrong thing.
|
|
1337
|
+
*/
|
|
1338
|
+
export function removeWorktree(root, branch) {
|
|
1339
|
+
const path = worktreePaths(root).get(branch);
|
|
1340
|
+
if (!path)
|
|
1341
|
+
return true;
|
|
1342
|
+
let realRoot = root;
|
|
1343
|
+
try {
|
|
1344
|
+
realRoot = realpathSync(root);
|
|
1345
|
+
}
|
|
1346
|
+
catch {
|
|
1347
|
+
/* keep */
|
|
1348
|
+
}
|
|
1349
|
+
try {
|
|
1350
|
+
if (realpathSync(path) === realRoot)
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1353
|
+
catch {
|
|
1354
|
+
/* dir gone — safe to continue */
|
|
1355
|
+
}
|
|
1356
|
+
const first = gitCapture(root, ["worktree", "remove", "--force", path]);
|
|
1357
|
+
if (first.status === 0)
|
|
1358
|
+
return true;
|
|
1359
|
+
if (/is a main working tree|checked out at/i.test(first.stderr))
|
|
1360
|
+
return false;
|
|
1361
|
+
pruneWorktrees(root);
|
|
1362
|
+
const second = gitCapture(root, ["worktree", "remove", "--force", path]);
|
|
1363
|
+
if (second.status === 0)
|
|
1364
|
+
return true;
|
|
1365
|
+
// Last resort: the registration is stale AND a directory is in the way.
|
|
1366
|
+
if (existsSync(path)) {
|
|
1367
|
+
try {
|
|
1368
|
+
rmSync(path, { recursive: true, force: true });
|
|
1369
|
+
}
|
|
1370
|
+
catch {
|
|
1371
|
+
/* best-effort */
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
pruneWorktrees(root);
|
|
1375
|
+
// Success == the branch no longer resolves to any registered worktree.
|
|
1376
|
+
return !worktreePaths(root).has(branch);
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Prepare the main checkout for a hotfix task.
|
|
1380
|
+
*
|
|
1381
|
+
* 1. Create a `hotfix/<id>-<slug>` branch from main (when hotfixTarget is
|
|
1382
|
+
* "branch") or stay on main (when target is "main").
|
|
1383
|
+
* 2. Refuse if the main checkout is dirty.
|
|
1384
|
+
* 3. Return config.root as the working directory — the agent runs here.
|
|
1385
|
+
*/
|
|
1386
|
+
export function ensureHotfix(root, branch, hotfixTarget) {
|
|
1387
|
+
if (!isGitRepo(root)) {
|
|
1388
|
+
return { ok: false, path: root, branch, reason: "not a git repository" };
|
|
1389
|
+
}
|
|
1390
|
+
const head = currentBranch(root);
|
|
1391
|
+
if (!head) {
|
|
1392
|
+
return { ok: false, path: root, branch, reason: "could not determine current branch" };
|
|
1393
|
+
}
|
|
1394
|
+
// For branch-mode hotfixes: create the hotfix branch if it doesn't exist,
|
|
1395
|
+
// then check it out. For main-mode: verify we're already on main.
|
|
1396
|
+
if (hotfixTarget === "branch") {
|
|
1397
|
+
if (head !== branch) {
|
|
1398
|
+
if (!localBranches(root).has(branch)) {
|
|
1399
|
+
if (git(root, ["checkout", "-b", branch]) === null) {
|
|
1400
|
+
return { ok: false, path: root, branch, reason: `could not create branch ${branch}` };
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
else {
|
|
1404
|
+
if (git(root, ["checkout", branch]) === null) {
|
|
1405
|
+
return { ok: false, path: root, branch, reason: `could not checkout branch ${branch}` };
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
else {
|
|
1411
|
+
if (head !== "main") {
|
|
1412
|
+
return {
|
|
1413
|
+
ok: false,
|
|
1414
|
+
path: root,
|
|
1415
|
+
branch,
|
|
1416
|
+
reason: "main-mode hotfix requires the main checkout to be on main",
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
return { ok: true, path: root, branch };
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Reset the main checkout back to `main` after a hotfix, discarding any
|
|
1424
|
+
* in-flight work. Only touches root when it's on a hotfix branch.
|
|
1425
|
+
*/
|
|
1426
|
+
export function resetHotfix(root, branch) {
|
|
1427
|
+
// Only safe to reset if we're on the hotfix branch and it's not main.
|
|
1428
|
+
const head = currentBranch(root);
|
|
1429
|
+
if (!head || head === "main")
|
|
1430
|
+
return false;
|
|
1431
|
+
if (head !== branch)
|
|
1432
|
+
return false;
|
|
1433
|
+
return git(root, ["checkout", "main"]) !== null;
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Get agent-touched files in the working directory: the union of tracked
|
|
1437
|
+
* worktree-changed files and newly-added files since the last ref, excluding
|
|
1438
|
+
* dist/ and screenshots/. Returns an empty list on git failure.
|
|
1439
|
+
*/
|
|
1440
|
+
export function agentTouchedFiles(root, sinceRef) {
|
|
1441
|
+
// Staged + unstaged changes (tracked files)
|
|
1442
|
+
const diff = git(root, ["diff", "--name-only", sinceRef, "--", "."])
|
|
1443
|
+
?.split("\n")
|
|
1444
|
+
.map((s) => s.trim())
|
|
1445
|
+
.filter(Boolean) ?? [];
|
|
1446
|
+
// Untracked files that aren't in dist/ or screenshots/
|
|
1447
|
+
const untracked = git(root, ["ls-files", "--others", "--exclude-standard"])
|
|
1448
|
+
?.split("\n")
|
|
1449
|
+
.map((s) => s.trim())
|
|
1450
|
+
.filter(Boolean)
|
|
1451
|
+
.filter((p) => !p.startsWith("dist/") && !p.startsWith("screenshots/")) ?? [];
|
|
1452
|
+
return [...new Set([...diff, ...untracked])];
|
|
1453
|
+
}
|