From 615ff85c159ab272f964a261411000fa3347212a Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:06:46 -0600 Subject: [PATCH 1/3] feat: implement worktree routes for merge, kill, and PR creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Fastify server infrastructure and three worktree action endpoints: - POST /api/worktrees/:id/merge — squash or no-ff merge with force flag and cleanup - POST /api/worktrees/:id/kill — kill all running agents in a worktree - POST /api/worktrees/:id/pr — create GitHub PR via gh CLI and store URL in manifest Includes 15 tests covering success paths, error handling (404, 409, 500, 502), merge strategies, cleanup toggles, draft PR flag, and gh/push failures. Closes #71 --- package-lock.json | 645 ++++++++++++++++++++++++++++ package.json | 4 +- src/lib/paths.ts | 8 + src/server/index.ts | 131 ++++++ src/server/routes/worktrees.test.ts | 373 ++++++++++++++++ src/server/routes/worktrees.ts | 280 ++++++++++++ 6 files changed, 1440 insertions(+), 1 deletion(-) create mode 100644 src/server/index.ts create mode 100644 src/server/routes/worktrees.test.ts create mode 100644 src/server/routes/worktrees.ts diff --git a/package-lock.json b/package-lock.json index a036a8f..52a467b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "0.3.3", "license": "MIT", "dependencies": { + "@fastify/cors": "^11.2.0", "commander": "^14.0.0", "cron-parser": "^5.5.0", "execa": "^9.5.2", + "fastify": "^5.7.4", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", "write-file-atomic": "^7.0.0", @@ -474,6 +476,137 @@ "node": ">=18" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -513,6 +646,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", @@ -1048,6 +1187,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1061,6 +1206,39 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1078,6 +1256,35 @@ "node": ">=12" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1173,6 +1380,19 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron-parser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", @@ -1227,6 +1447,15 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1322,6 +1551,125 @@ "node": ">=12.0.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1355,6 +1703,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1435,6 +1797,15 @@ "node": ">=0.8.19" } }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -1494,6 +1865,68 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1638,6 +2071,15 @@ "node": ">=0.10.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -1696,6 +2138,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -1824,6 +2303,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -1841,6 +2336,12 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1855,6 +2356,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -1875,6 +2394,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -1884,6 +2412,22 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", @@ -1929,6 +2473,68 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1969,6 +2575,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1989,6 +2604,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2084,6 +2708,18 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2145,6 +2781,15 @@ "node": ">=14.0.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/package.json b/package.json index b4cd8bf..fba723c 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", "write-file-atomic": "^7.0.0", - "yaml": "^2.7.1" + "yaml": "^2.7.1", + "fastify": "^5.7.4", + "@fastify/cors": "^11.2.0" }, "devDependencies": { "@types/node": "^22.13.4", diff --git a/src/lib/paths.ts b/src/lib/paths.ts index d456f5f..1e902e4 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -86,3 +86,11 @@ export function worktreeBaseDir(projectRoot: string): string { export function worktreePath(projectRoot: string, id: string): string { return path.join(worktreeBaseDir(projectRoot), id); } + +export function serveStatePath(projectRoot: string): string { + return path.join(ppgDir(projectRoot), 'serve.json'); +} + +export function servePidPath(projectRoot: string): string { + return path.join(ppgDir(projectRoot), 'serve.pid'); +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..1a01669 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,131 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import { createRequire } from 'node:module'; +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import { serveStatePath, servePidPath } from '../lib/paths.js'; +import { info, success, warn } from '../lib/output.js'; + +const require = createRequire(import.meta.url); +const pkg = require('../../package.json') as { version: string }; + +export interface ServeOptions { + projectRoot: string; + port: number; + host: string; + token?: string; + json?: boolean; +} + +export interface ServeState { + pid: number; + port: number; + host: string; + lanAddress?: string; + startedAt: string; + version: string; +} + +export function detectLanAddress(): string | undefined { + const interfaces = os.networkInterfaces(); + for (const addrs of Object.values(interfaces)) { + if (!addrs) continue; + for (const addr of addrs) { + if (addr.family === 'IPv4' && !addr.internal) { + return addr.address; + } + } + } + return undefined; +} + +async function writeStateFile(projectRoot: string, state: ServeState): Promise { + const statePath = serveStatePath(projectRoot); + await fs.writeFile(statePath, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }); +} + +async function writePidFile(projectRoot: string, pid: number): Promise { + const pidPath = servePidPath(projectRoot); + await fs.writeFile(pidPath, String(pid) + '\n', { mode: 0o600 }); +} + +async function removeStateFiles(projectRoot: string): Promise { + for (const filePath of [serveStatePath(projectRoot), servePidPath(projectRoot)]) { + try { + await fs.unlink(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } +} + +export async function startServer(options: ServeOptions): Promise { + const { projectRoot, port, host, token, json } = options; + + const app = Fastify({ logger: false }); + + await app.register(cors, { origin: true }); + + if (token) { + app.addHook('onRequest', async (request, reply) => { + if (request.url === '/health') return; + const authHeader = request.headers.authorization; + if (authHeader !== `Bearer ${token}`) { + reply.code(401).send({ error: 'Unauthorized' }); + } + }); + } + + // Decorate with projectRoot so routes can access it + app.decorate('projectRoot', projectRoot); + + app.get('/health', async () => { + return { + status: 'ok', + uptime: process.uptime(), + version: pkg.version, + }; + }); + + // Register route plugins + const { worktreeRoutes } = await import('./routes/worktrees.js'); + await app.register(worktreeRoutes, { prefix: '/api' }); + + const lanAddress = detectLanAddress(); + + const shutdown = async (signal: string) => { + if (!json) info(`Received ${signal}, shutting down...`); + await removeStateFiles(projectRoot); + await app.close(); + process.exit(0); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + await app.listen({ port, host }); + + const state: ServeState = { + pid: process.pid, + port, + host, + lanAddress, + startedAt: new Date().toISOString(), + version: pkg.version, + }; + + await writeStateFile(projectRoot, state); + await writePidFile(projectRoot, process.pid); + + if (json) { + console.log(JSON.stringify(state)); + } else { + success(`Server listening on http://${host}:${port}`); + if (lanAddress) { + info(`LAN address: http://${lanAddress}:${port}`); + } + if (token) { + info('Bearer token authentication enabled'); + } + } +} diff --git a/src/server/routes/worktrees.test.ts b/src/server/routes/worktrees.test.ts new file mode 100644 index 0000000..58d0c75 --- /dev/null +++ b/src/server/routes/worktrees.test.ts @@ -0,0 +1,373 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { makeWorktree, makeAgent } from '../../test-fixtures.js'; +import type { Manifest } from '../../types/manifest.js'; + +// ---- Mocks ---- + +const mockManifest: Manifest = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: {}, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +vi.mock('../../core/manifest.js', () => ({ + requireManifest: vi.fn(), + updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { + return updater(structuredClone(mockManifest)); + }), + resolveWorktree: vi.fn(), +})); + +vi.mock('../../core/agent.js', () => ({ + refreshAllAgentStatuses: vi.fn((m: Manifest) => m), + killAgents: vi.fn(), +})); + +vi.mock('../../core/worktree.js', () => ({ + getCurrentBranch: vi.fn(() => 'main'), +})); + +vi.mock('../../core/cleanup.js', () => ({ + cleanupWorktree: vi.fn(), +})); + +vi.mock('../../commands/pr.js', () => ({ + buildBodyFromResults: vi.fn(() => 'PR body'), +})); + +vi.mock('execa', () => ({ + execa: vi.fn(() => ({ stdout: 'https://github.com/owner/repo/pull/1' })), +})); + +vi.mock('../../lib/env.js', () => ({ + execaEnv: {}, +})); + +// ---- Imports (after mocks) ---- + +import { resolveWorktree } from '../../core/manifest.js'; +import { killAgents } from '../../core/agent.js'; +import { cleanupWorktree } from '../../core/cleanup.js'; +import { getCurrentBranch } from '../../core/worktree.js'; +import { execa } from 'execa'; +import { worktreeRoutes } from './worktrees.js'; + +const PROJECT_ROOT = '/tmp/project'; + +async function buildApp(): Promise { + const app = Fastify(); + app.decorate('projectRoot', PROJECT_ROOT); + await app.register(worktreeRoutes, { prefix: '/api' }); + await app.ready(); + return app; +} + +describe('worktreeRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockManifest.worktrees = {}; + }); + + // ================================================================== + // POST /api/worktrees/:id/merge + // ================================================================== + describe('POST /api/worktrees/:id/merge', () => { + test('given valid worktree, should merge with squash strategy by default', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.worktreeId).toBe('wt-abc123'); + expect(body.strategy).toBe('squash'); + expect(body.cleaned).toBe(true); + expect(vi.mocked(cleanupWorktree)).toHaveBeenCalled(); + }); + + test('given strategy no-ff, should merge with --no-ff', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: { strategy: 'no-ff' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().strategy).toBe('no-ff'); + + // Should have called git merge --no-ff + const execaCalls = vi.mocked(execa).mock.calls; + const mergeCall = execaCalls.find((c) => c[0] === 'git' && (c[1] as string[])?.[0] === 'merge'); + expect(mergeCall).toBeDefined(); + expect((mergeCall![1] as string[])).toContain('--no-ff'); + }); + + test('given cleanup false, should skip cleanup', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: { cleanup: false }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().cleaned).toBe(false); + expect(vi.mocked(cleanupWorktree)).not.toHaveBeenCalled(); + }); + + test('given worktree not found, should return 404', async () => { + vi.mocked(resolveWorktree).mockReturnValue(undefined as unknown as ReturnType); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-nonexist/merge', + payload: {}, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('WORKTREE_NOT_FOUND'); + }); + + test('given running agents without force, should return 409', async () => { + const agent = makeAgent({ id: 'ag-running1', status: 'running' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-running1': agent }, + }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: {}, + }); + + expect(res.statusCode).toBe(409); + expect(res.json().code).toBe('AGENTS_RUNNING'); + }); + + test('given running agents with force, should merge anyway', async () => { + const agent = makeAgent({ id: 'ag-running1', status: 'running' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-running1': agent }, + }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: { force: true }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().success).toBe(true); + }); + + test('given git merge failure, should return 500 with MERGE_FAILED', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + vi.mocked(execa).mockRejectedValueOnce(new Error('conflict')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/merge', + payload: {}, + }); + + // getCurrentBranch returns 'main' which matches baseBranch, so no checkout call. + // First execa call is git merge --squash which fails. + expect(res.statusCode).toBe(500); + expect(res.json().code).toBe('MERGE_FAILED'); + }); + }); + + // ================================================================== + // POST /api/worktrees/:id/kill + // ================================================================== + describe('POST /api/worktrees/:id/kill', () => { + test('given worktree with running agents, should kill all running agents', async () => { + const agent1 = makeAgent({ id: 'ag-run00001', status: 'running' }); + const agent2 = makeAgent({ id: 'ag-idle0001', status: 'idle' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-run00001': agent1, 'ag-idle0001': agent2 }, + }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/kill', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.killed).toEqual(['ag-run00001']); + expect(vi.mocked(killAgents)).toHaveBeenCalledWith([agent1]); + }); + + test('given worktree with no running agents, should return empty killed list', async () => { + const agent = makeAgent({ id: 'ag-done0001', status: 'exited' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-done0001': agent }, + }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/kill', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().killed).toEqual([]); + expect(vi.mocked(killAgents)).toHaveBeenCalledWith([]); + }); + + test('given worktree not found, should return 404', async () => { + vi.mocked(resolveWorktree).mockReturnValue(undefined as unknown as ReturnType); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-nonexist/kill', + payload: {}, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('WORKTREE_NOT_FOUND'); + }); + }); + + // ================================================================== + // POST /api/worktrees/:id/pr + // ================================================================== + describe('POST /api/worktrees/:id/pr', () => { + test('given valid worktree, should create PR and store URL', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: { title: 'My PR', body: 'Description' }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.prUrl).toBe('https://github.com/owner/repo/pull/1'); + expect(body.worktreeId).toBe('wt-abc123'); + }); + + test('given draft flag, should pass --draft to gh', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + const app = await buildApp(); + await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: { draft: true }, + }); + + const ghCalls = vi.mocked(execa).mock.calls.filter((c) => c[0] === 'gh'); + const prCreateCall = ghCalls.find((c) => (c[1] as string[])?.includes('create')); + expect(prCreateCall).toBeDefined(); + expect((prCreateCall![1] as string[])).toContain('--draft'); + }); + + test('given worktree not found, should return 404', async () => { + vi.mocked(resolveWorktree).mockReturnValue(undefined as unknown as ReturnType); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-nonexist/pr', + payload: {}, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('WORKTREE_NOT_FOUND'); + }); + + test('given gh not available, should return 502', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + // First call is gh --version which should fail + vi.mocked(execa).mockRejectedValueOnce(new Error('gh not found')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: {}, + }); + + expect(res.statusCode).toBe(502); + expect(res.json().code).toBe('GH_NOT_FOUND'); + }); + + test('given push failure, should return 400', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + mockManifest.worktrees['wt-abc123'] = wt; + vi.mocked(resolveWorktree).mockReturnValue(wt); + + // gh --version succeeds, git push fails + vi.mocked(execa) + .mockResolvedValueOnce({ stdout: 'gh version 2.0' } as never) + .mockRejectedValueOnce(new Error('push rejected')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/worktrees/wt-abc123/pr', + payload: {}, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('INVALID_ARGS'); + }); + }); +}); diff --git a/src/server/routes/worktrees.ts b/src/server/routes/worktrees.ts new file mode 100644 index 0000000..8d7c140 --- /dev/null +++ b/src/server/routes/worktrees.ts @@ -0,0 +1,280 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { execa } from 'execa'; +import { requireManifest, updateManifest, resolveWorktree } from '../../core/manifest.js'; +import { refreshAllAgentStatuses, killAgents } from '../../core/agent.js'; +import { getCurrentBranch } from '../../core/worktree.js'; +import { cleanupWorktree } from '../../core/cleanup.js'; +import { PpgError, WorktreeNotFoundError, MergeFailedError, GhNotFoundError } from '../../lib/errors.js'; +import { execaEnv } from '../../lib/env.js'; +import { buildBodyFromResults } from '../../commands/pr.js'; + +// ------------------------------------------------------------------ +// Fastify plugin — worktree action routes +// ------------------------------------------------------------------ + +interface WorktreeParams { + id: string; +} + +interface MergeBody { + strategy?: 'squash' | 'no-ff'; + cleanup?: boolean; + force?: boolean; +} + +interface KillBody { + force?: boolean; +} + +interface PrBody { + title?: string; + body?: string; + draft?: boolean; +} + +function errorReply(reply: FastifyReply, err: unknown): void { + if (err instanceof PpgError) { + const statusMap: Record = { + WORKTREE_NOT_FOUND: 404, + AGENT_NOT_FOUND: 404, + NOT_INITIALIZED: 400, + AGENTS_RUNNING: 409, + MERGE_FAILED: 500, + GH_NOT_FOUND: 502, + INVALID_ARGS: 400, + }; + const status = statusMap[err.code] ?? 500; + reply.code(status).send({ error: err.message, code: err.code }); + return; + } + const message = err instanceof Error ? err.message : String(err); + reply.code(500).send({ error: message }); +} + +export async function worktreeRoutes(app: FastifyInstance): Promise { + const projectRoot: string = (app as unknown as Record)['projectRoot'] as string; + + // ---------------------------------------------------------------- + // POST /api/worktrees/:id/merge + // ---------------------------------------------------------------- + app.post<{ Params: WorktreeParams; Body: MergeBody }>( + '/worktrees/:id/merge', + async (request, reply) => { + try { + const { id } = request.params; + const { strategy = 'squash', cleanup = true, force = false } = request.body ?? {}; + + await requireManifest(projectRoot); + const manifest = await updateManifest(projectRoot, async (m) => { + return refreshAllAgentStatuses(m, projectRoot); + }); + + const wt = resolveWorktree(manifest, id); + if (!wt) throw new WorktreeNotFoundError(id); + + // Check all agents finished + const incomplete = Object.values(wt.agents).filter((a) => a.status === 'running'); + if (incomplete.length > 0 && !force) { + const ids = incomplete.map((a) => a.id).join(', '); + throw new PpgError( + `${incomplete.length} agent(s) still running: ${ids}. Use force: true to merge anyway.`, + 'AGENTS_RUNNING', + ); + } + + // Set worktree status to merging + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merging'; + } + return m; + }); + + try { + const currentBranch = await getCurrentBranch(projectRoot); + if (currentBranch !== wt.baseBranch) { + await execa('git', ['checkout', wt.baseBranch], { ...execaEnv, cwd: projectRoot }); + } + + if (strategy === 'squash') { + await execa('git', ['merge', '--squash', wt.branch], { ...execaEnv, cwd: projectRoot }); + await execa('git', ['commit', '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } else { + await execa('git', ['merge', '--no-ff', wt.branch, '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } + } catch (err) { + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'failed'; + } + return m; + }); + throw new MergeFailedError( + `Merge failed: ${err instanceof Error ? err.message : err}`, + ); + } + + // Mark as merged + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merged'; + m.worktrees[wt.id].mergedAt = new Date().toISOString(); + } + return m; + }); + + // Cleanup (no self-protection needed in server context) + let cleaned = false; + if (cleanup) { + await cleanupWorktree(projectRoot, wt); + cleaned = true; + } + + return { + success: true, + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + strategy, + cleaned, + }; + } catch (err) { + errorReply(reply, err); + } + }, + ); + + // ---------------------------------------------------------------- + // POST /api/worktrees/:id/kill + // ---------------------------------------------------------------- + app.post<{ Params: WorktreeParams; Body: KillBody }>( + '/worktrees/:id/kill', + async (request, reply) => { + try { + const { id } = request.params; + + await requireManifest(projectRoot); + const manifest = await updateManifest(projectRoot, async (m) => { + return refreshAllAgentStatuses(m, projectRoot); + }); + + const wt = resolveWorktree(manifest, id); + if (!wt) throw new WorktreeNotFoundError(id); + + const toKill = Object.values(wt.agents).filter((a) => a.status === 'running'); + const killedIds = toKill.map((a) => a.id); + + await killAgents(toKill); + + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (mWt) { + for (const agent of Object.values(mWt.agents)) { + if (killedIds.includes(agent.id)) { + agent.status = 'gone'; + } + } + } + return m; + }); + + return { + success: true, + worktreeId: wt.id, + killed: killedIds, + }; + } catch (err) { + errorReply(reply, err); + } + }, + ); + + // ---------------------------------------------------------------- + // POST /api/worktrees/:id/pr + // ---------------------------------------------------------------- + app.post<{ Params: WorktreeParams; Body: PrBody }>( + '/worktrees/:id/pr', + async (request, reply) => { + try { + const { id } = request.params; + const { title, body, draft = false } = request.body ?? {}; + + await requireManifest(projectRoot); + const manifest = await updateManifest(projectRoot, async (m) => { + return refreshAllAgentStatuses(m, projectRoot); + }); + + const wt = resolveWorktree(manifest, id); + if (!wt) throw new WorktreeNotFoundError(id); + + // Verify gh is available + try { + await execa('gh', ['--version'], execaEnv); + } catch { + throw new GhNotFoundError(); + } + + // Push the worktree branch + try { + await execa('git', ['push', '-u', 'origin', wt.branch], { ...execaEnv, cwd: projectRoot }); + } catch (err) { + throw new PpgError( + `Failed to push branch ${wt.branch}: ${err instanceof Error ? err.message : err}`, + 'INVALID_ARGS', + ); + } + + // Build PR title and body + const prTitle = title ?? wt.name; + const prBody = body ?? await buildBodyFromResults(Object.values(wt.agents)); + + // Build gh pr create args + const ghArgs = [ + 'pr', 'create', + '--head', wt.branch, + '--base', wt.baseBranch, + '--title', prTitle, + '--body', prBody, + ]; + if (draft) { + ghArgs.push('--draft'); + } + + let prUrl: string; + try { + const result = await execa('gh', ghArgs, { ...execaEnv, cwd: projectRoot }); + prUrl = result.stdout.trim(); + } catch (err) { + throw new PpgError( + `Failed to create PR: ${err instanceof Error ? err.message : err}`, + 'INVALID_ARGS', + ); + } + + // Store PR URL in manifest + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].prUrl = prUrl; + } + return m; + }); + + return { + success: true, + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + prUrl, + }; + } catch (err) { + errorReply(reply, err); + } + }, + ); +} From c94c746791f431fba0b8a5ffbc852191391cdda7 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:58:23 -0600 Subject: [PATCH 2/3] fix: address code review findings for worktree routes Security: - Use crypto.timingSafeEqual for bearer token comparison (was string ===) - Use request.routeOptions.url for auth bypass check (was request.url) Architecture: - Extract mergeWorktree() to core/merge.ts (was duplicated in commands + routes) - Extract killWorktreeAgents() to core/kill.ts (was duplicated in commands + routes) - Extract createWorktreePr(), buildBodyFromResults(), truncateBody() to core/pr.ts - Routes now import from core/ only, not commands/ (fixes layer violation) - Commands are thin wrappers: core logic + CLI output Code quality: - errorReply() now returns FastifyReply (prevents undefined return after reply.send) - Remove unused imports (FastifyRequest, warn, KillBody) - Remove redundant requireManifest() calls before updateManifest() - Use Fastify declare module augmentation for projectRoot typing Tests: - Add core/merge.test.ts (7 tests): strategies, branch checkout, force, cleanup, failure - Add core/kill.test.ts (2 tests): running agents set to gone, no-op for idle - Route tests mock at core/ layer instead of duplicating low-level mock setup - 242 total tests passing (was 233) --- src/commands/merge.ts | 117 +++---------- src/commands/pr.ts | 89 ++-------- src/core/kill.test.ts | 74 +++++++++ src/core/kill.ts | 36 ++++ src/core/merge.test.ts | 119 ++++++++++++++ src/core/merge.ts | 105 ++++++++++++ src/core/pr.ts | 98 +++++++++++ src/server/index.ts | 14 +- src/server/routes/worktrees.test.ts | 140 ++++++++-------- src/server/routes/worktrees.ts | 246 +++++----------------------- 10 files changed, 599 insertions(+), 439 deletions(-) create mode 100644 src/core/kill.test.ts create mode 100644 src/core/kill.ts create mode 100644 src/core/merge.test.ts create mode 100644 src/core/merge.ts diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 5dca227..5812732 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -1,13 +1,11 @@ -import { execa } from 'execa'; import { requireManifest, updateManifest, resolveWorktree } from '../core/manifest.js'; import { refreshAllAgentStatuses } from '../core/agent.js'; -import { getRepoRoot, getCurrentBranch } from '../core/worktree.js'; -import { cleanupWorktree } from '../core/cleanup.js'; +import { getRepoRoot } from '../core/worktree.js'; +import { mergeWorktree } from '../core/merge.js'; import { getCurrentPaneId } from '../core/self.js'; -import { listSessionPanes, type PaneInfo } from '../core/tmux.js'; -import { PpgError, WorktreeNotFoundError, MergeFailedError } from '../lib/errors.js'; +import { listSessionPanes } from '../core/tmux.js'; +import { WorktreeNotFoundError } from '../lib/errors.js'; import { output, success, info, warn } from '../lib/output.js'; -import { execaEnv } from '../lib/env.js'; export interface MergeOptions { strategy?: 'squash' | 'no-ff'; @@ -29,18 +27,6 @@ export async function mergeCommand(worktreeId: string, options: MergeOptions): P if (!wt) throw new WorktreeNotFoundError(worktreeId); - // Check all agents finished - const agents = Object.values(wt.agents); - const incomplete = agents.filter((a) => a.status === 'running'); - - if (incomplete.length > 0 && !options.force) { - const ids = incomplete.map((a) => a.id).join(', '); - throw new PpgError( - `${incomplete.length} agent(s) still running: ${ids}. Use --force to merge anyway.`, - 'AGENTS_RUNNING', - ); - } - if (options.dryRun) { info('Dry run — no changes will be made'); info(`Would merge branch ${wt.branch} into ${wt.baseBranch} using ${options.strategy ?? 'squash'} strategy`); @@ -50,89 +36,40 @@ export async function mergeCommand(worktreeId: string, options: MergeOptions): P return; } - // Set worktree status to merging - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'merging'; - } - return m; - }); - - const strategy = options.strategy ?? 'squash'; - - try { - const currentBranch = await getCurrentBranch(projectRoot); - if (currentBranch !== wt.baseBranch) { - info(`Switching to base branch ${wt.baseBranch}`); - await execa('git', ['checkout', wt.baseBranch], { ...execaEnv, cwd: projectRoot }); - } - - info(`Merging ${wt.branch} into ${wt.baseBranch} (${strategy})`); - - if (strategy === 'squash') { - await execa('git', ['merge', '--squash', wt.branch], { ...execaEnv, cwd: projectRoot }); - await execa('git', ['commit', '-m', `ppg: merge ${wt.name} (${wt.branch})`], { - ...execaEnv, - cwd: projectRoot, - }); - } else { - await execa('git', ['merge', '--no-ff', wt.branch, '-m', `ppg: merge ${wt.name} (${wt.branch})`], { - ...execaEnv, - cwd: projectRoot, - }); - } - - success(`Merged ${wt.branch} into ${wt.baseBranch}`); - } catch (err) { - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'failed'; - } - return m; - }); - throw new MergeFailedError( - `Merge failed: ${err instanceof Error ? err.message : err}`, - ); + // Build self-protection context for cleanup + const selfPaneId = getCurrentPaneId(); + let paneMap; + if (selfPaneId) { + paneMap = await listSessionPanes(manifest.sessionName); } - // Mark as merged - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'merged'; - m.worktrees[wt.id].mergedAt = new Date().toISOString(); - } - return m; - }); - - // Cleanup with self-protection - let selfProtected = false; - if (options.cleanup !== false) { - info('Cleaning up...'); + info(`Merging ${wt.branch} into ${wt.baseBranch} (${options.strategy ?? 'squash'})`); - const selfPaneId = getCurrentPaneId(); - let paneMap: Map | undefined; - if (selfPaneId) { - paneMap = await listSessionPanes(manifest.sessionName); - } + const result = await mergeWorktree(projectRoot, wt, { + strategy: options.strategy, + cleanup: options.cleanup !== false, + force: options.force, + cleanupOptions: { selfPaneId, paneMap }, + }); - const cleanupResult = await cleanupWorktree(projectRoot, wt, { selfPaneId, paneMap }); - selfProtected = cleanupResult.selfProtected; + success(`Merged ${wt.branch} into ${wt.baseBranch}`); - if (selfProtected) { - warn(`Some tmux targets skipped during cleanup — contains current ppg process`); - } + if (result.selfProtected) { + warn(`Some tmux targets skipped during cleanup — contains current ppg process`); + } + if (result.cleaned) { success(`Cleaned up worktree ${wt.id}`); } if (options.json) { output({ success: true, - worktreeId: wt.id, - branch: wt.branch, - baseBranch: wt.baseBranch, - strategy, - cleaned: options.cleanup !== false, - selfProtected: selfProtected || undefined, + worktreeId: result.worktreeId, + branch: result.branch, + baseBranch: result.baseBranch, + strategy: result.strategy, + cleaned: result.cleaned, + selfProtected: result.selfProtected || undefined, }, true); } } diff --git a/src/commands/pr.ts b/src/commands/pr.ts index aeb559d..534b471 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -1,13 +1,12 @@ -import { execa } from 'execa'; import { updateManifest, resolveWorktree } from '../core/manifest.js'; import { refreshAllAgentStatuses } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; -import { PpgError, NotInitializedError, WorktreeNotFoundError, GhNotFoundError } from '../lib/errors.js'; +import { createWorktreePr } from '../core/pr.js'; +import { NotInitializedError, WorktreeNotFoundError } from '../lib/errors.js'; import { output, success, info } from '../lib/output.js'; -import { execaEnv } from '../lib/env.js'; -// GitHub PR body limit is 65536 chars; leave room for truncation notice -const MAX_BODY_LENGTH = 60_000; +// Re-export for backwards compatibility with existing tests/consumers +export { buildBodyFromResults, truncateBody } from '../core/pr.js'; export interface PrOptions { title?: string; @@ -31,82 +30,16 @@ export async function prCommand(worktreeRef: string, options: PrOptions): Promis const wt = resolveWorktree(manifest, worktreeRef); if (!wt) throw new WorktreeNotFoundError(worktreeRef); - // Verify gh is available - try { - await execa('gh', ['--version'], execaEnv); - } catch { - throw new GhNotFoundError(); - } - - // Push the worktree branch - info(`Pushing branch ${wt.branch} to origin`); - try { - await execa('git', ['push', '-u', 'origin', wt.branch], { ...execaEnv, cwd: projectRoot }); - } catch (err) { - throw new PpgError( - `Failed to push branch ${wt.branch}: ${err instanceof Error ? err.message : err}`, - 'INVALID_ARGS', - ); - } - - // Build PR title and body - const title = options.title ?? wt.name; - const body = options.body ?? await buildBodyFromResults(Object.values(wt.agents)); - - // Build gh pr create args - const ghArgs = [ - 'pr', 'create', - '--head', wt.branch, - '--base', wt.baseBranch, - '--title', title, - '--body', body, - ]; - if (options.draft) { - ghArgs.push('--draft'); - } - - info(`Creating PR: ${title}`); - let prUrl: string; - try { - const result = await execa('gh', ghArgs, { ...execaEnv, cwd: projectRoot }); - prUrl = result.stdout.trim(); - } catch (err) { - throw new PpgError( - `Failed to create PR: ${err instanceof Error ? err.message : err}`, - 'INVALID_ARGS', - ); - } - - // Store PR URL in manifest - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].prUrl = prUrl; - } - return m; + info(`Creating PR for ${wt.branch}`); + const result = await createWorktreePr(projectRoot, wt, { + title: options.title, + body: options.body, + draft: options.draft, }); if (options.json) { - output({ - success: true, - worktreeId: wt.id, - branch: wt.branch, - baseBranch: wt.baseBranch, - prUrl, - }, true); + output({ success: true, ...result }, true); } else { - success(`PR created: ${prUrl}`); + success(`PR created: ${result.prUrl}`); } } - -/** Build PR body from agent prompts, with truncation. */ -export async function buildBodyFromResults(agents: { id: string; prompt: string }[]): Promise { - if (agents.length === 0) return ''; - const sections = agents.map((a) => `## Agent: ${a.id}\n\n${a.prompt}`); - return truncateBody(sections.join('\n\n---\n\n')); -} - -/** Truncate body to stay within GitHub's PR body size limit. */ -export function truncateBody(body: string): string { - if (body.length <= MAX_BODY_LENGTH) return body; - return body.slice(0, MAX_BODY_LENGTH) + '\n\n---\n\n*[Truncated — full results available in `.ppg/results/`]*'; -} diff --git a/src/core/kill.test.ts b/src/core/kill.test.ts new file mode 100644 index 0000000..a6db7d1 --- /dev/null +++ b/src/core/kill.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { makeWorktree, makeAgent } from '../test-fixtures.js'; +import type { Manifest } from '../types/manifest.js'; + +// ---- Mocks ---- + +let manifestState: Manifest; + +vi.mock('./manifest.js', () => ({ + updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { + manifestState = await updater(structuredClone(manifestState)); + return manifestState; + }), +})); + +vi.mock('./agent.js', () => ({ + killAgents: vi.fn(), +})); + +// ---- Imports (after mocks) ---- + +import { killWorktreeAgents } from './kill.js'; +import { killAgents } from './agent.js'; + +describe('killWorktreeAgents', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('given worktree with running agents, should kill running agents and set status to gone', async () => { + const agent1 = makeAgent({ id: 'ag-run00001', status: 'running' }); + const agent2 = makeAgent({ id: 'ag-idle0001', status: 'idle' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-run00001': agent1, 'ag-idle0001': agent2 }, + }); + manifestState = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: { 'wt-abc123': structuredClone(wt) }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + const result = await killWorktreeAgents('/tmp/project', wt); + + expect(result.killed).toEqual(['ag-run00001']); + expect(vi.mocked(killAgents)).toHaveBeenCalledWith([agent1]); + expect(manifestState.worktrees['wt-abc123'].agents['ag-run00001'].status).toBe('gone'); + expect(manifestState.worktrees['wt-abc123'].agents['ag-idle0001'].status).toBe('idle'); + }); + + test('given worktree with no running agents, should return empty killed list', async () => { + const agent = makeAgent({ id: 'ag-done0001', status: 'exited' }); + const wt = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-done0001': agent }, + }); + manifestState = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: { 'wt-abc123': structuredClone(wt) }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + const result = await killWorktreeAgents('/tmp/project', wt); + + expect(result.killed).toEqual([]); + expect(vi.mocked(killAgents)).toHaveBeenCalledWith([]); + }); +}); diff --git a/src/core/kill.ts b/src/core/kill.ts new file mode 100644 index 0000000..ef26e67 --- /dev/null +++ b/src/core/kill.ts @@ -0,0 +1,36 @@ +import { updateManifest } from './manifest.js'; +import { killAgents } from './agent.js'; +import type { WorktreeEntry } from '../types/manifest.js'; + +export interface KillWorktreeResult { + worktreeId: string; + killed: string[]; +} + +/** Kill all running agents in a worktree and set their status to 'gone'. */ +export async function killWorktreeAgents( + projectRoot: string, + wt: WorktreeEntry, +): Promise { + const toKill = Object.values(wt.agents).filter((a) => a.status === 'running'); + const killedIds = toKill.map((a) => a.id); + + await killAgents(toKill); + + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (mWt) { + for (const agent of Object.values(mWt.agents)) { + if (killedIds.includes(agent.id)) { + agent.status = 'gone'; + } + } + } + return m; + }); + + return { + worktreeId: wt.id, + killed: killedIds, + }; +} diff --git a/src/core/merge.test.ts b/src/core/merge.test.ts new file mode 100644 index 0000000..99eb75a --- /dev/null +++ b/src/core/merge.test.ts @@ -0,0 +1,119 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { makeWorktree, makeAgent } from '../test-fixtures.js'; +import type { Manifest } from '../types/manifest.js'; + +// ---- Mocks ---- + +let manifestState: Manifest; + +vi.mock('./manifest.js', () => ({ + updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { + manifestState = await updater(structuredClone(manifestState)); + return manifestState; + }), +})); + +vi.mock('./worktree.js', () => ({ + getCurrentBranch: vi.fn(() => 'main'), +})); + +vi.mock('./cleanup.js', () => ({ + cleanupWorktree: vi.fn(async () => ({ selfProtected: false, selfProtectedTargets: [] })), +})); + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +vi.mock('../lib/env.js', () => ({ + execaEnv: {}, +})); + +// ---- Imports (after mocks) ---- + +import { mergeWorktree } from './merge.js'; +import { getCurrentBranch } from './worktree.js'; +import { cleanupWorktree } from './cleanup.js'; +import { execa } from 'execa'; + +describe('mergeWorktree', () => { + beforeEach(() => { + vi.clearAllMocks(); + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + manifestState = { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: { 'wt-abc123': wt }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + }); + + test('given valid worktree, should merge with squash and update manifest to merged', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + + const result = await mergeWorktree('/tmp/project', wt); + + expect(result.strategy).toBe('squash'); + expect(result.cleaned).toBe(true); + expect(manifestState.worktrees['wt-abc123'].status).toBe('merged'); + expect(manifestState.worktrees['wt-abc123'].mergedAt).toBeDefined(); + }); + + test('given no-ff strategy, should call git merge --no-ff', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + + await mergeWorktree('/tmp/project', wt, { strategy: 'no-ff' }); + + const calls = vi.mocked(execa).mock.calls; + const mergeCall = calls.find((c) => c[0] === 'git' && (c[1] as string[])?.[0] === 'merge'); + expect(mergeCall).toBeDefined(); + expect((mergeCall![1] as string[])).toContain('--no-ff'); + }); + + test('given different current branch, should checkout base branch first', async () => { + vi.mocked(getCurrentBranch).mockResolvedValueOnce('feature-x'); + const wt = makeWorktree({ id: 'wt-abc123', baseBranch: 'main', agents: {} }); + + await mergeWorktree('/tmp/project', wt); + + const calls = vi.mocked(execa).mock.calls; + const checkoutCall = calls.find((c) => c[0] === 'git' && (c[1] as string[])?.[0] === 'checkout'); + expect(checkoutCall).toBeDefined(); + expect((checkoutCall![1] as string[])).toContain('main'); + }); + + test('given running agents without force, should throw AGENTS_RUNNING', async () => { + const agent = makeAgent({ id: 'ag-running1', status: 'running' }); + const wt = makeWorktree({ id: 'wt-abc123', agents: { 'ag-running1': agent } }); + + await expect(mergeWorktree('/tmp/project', wt)).rejects.toThrow('agent(s) still running'); + }); + + test('given running agents with force, should merge anyway', async () => { + const agent = makeAgent({ id: 'ag-running1', status: 'running' }); + const wt = makeWorktree({ id: 'wt-abc123', agents: { 'ag-running1': agent } }); + + const result = await mergeWorktree('/tmp/project', wt, { force: true }); + + expect(result.worktreeId).toBe('wt-abc123'); + }); + + test('given cleanup false, should skip cleanup', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + + const result = await mergeWorktree('/tmp/project', wt, { cleanup: false }); + + expect(result.cleaned).toBe(false); + expect(vi.mocked(cleanupWorktree)).not.toHaveBeenCalled(); + }); + + test('given git merge failure, should set status to failed and throw', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); + vi.mocked(execa).mockRejectedValueOnce(new Error('conflict')); + + await expect(mergeWorktree('/tmp/project', wt)).rejects.toThrow('Merge failed'); + expect(manifestState.worktrees['wt-abc123'].status).toBe('failed'); + }); +}); diff --git a/src/core/merge.ts b/src/core/merge.ts new file mode 100644 index 0000000..ad98701 --- /dev/null +++ b/src/core/merge.ts @@ -0,0 +1,105 @@ +import { execa } from 'execa'; +import { updateManifest } from './manifest.js'; +import { getCurrentBranch } from './worktree.js'; +import { cleanupWorktree, type CleanupOptions } from './cleanup.js'; +import { PpgError, MergeFailedError } from '../lib/errors.js'; +import { execaEnv } from '../lib/env.js'; +import type { WorktreeEntry } from '../types/manifest.js'; + +export interface MergeWorktreeOptions { + strategy?: 'squash' | 'no-ff'; + cleanup?: boolean; + force?: boolean; + cleanupOptions?: CleanupOptions; +} + +export interface MergeWorktreeResult { + worktreeId: string; + branch: string; + baseBranch: string; + strategy: 'squash' | 'no-ff'; + cleaned: boolean; + selfProtected: boolean; +} + +/** Merge a worktree branch into its base branch. Updates manifest status throughout. */ +export async function mergeWorktree( + projectRoot: string, + wt: WorktreeEntry, + options: MergeWorktreeOptions = {}, +): Promise { + const { strategy = 'squash', cleanup = true, force = false } = options; + + // Check all agents finished + const incomplete = Object.values(wt.agents).filter((a) => a.status === 'running'); + if (incomplete.length > 0 && !force) { + const ids = incomplete.map((a) => a.id).join(', '); + throw new PpgError( + `${incomplete.length} agent(s) still running: ${ids}. Use --force to merge anyway.`, + 'AGENTS_RUNNING', + ); + } + + // Set worktree status to merging + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merging'; + } + return m; + }); + + try { + const currentBranch = await getCurrentBranch(projectRoot); + if (currentBranch !== wt.baseBranch) { + await execa('git', ['checkout', wt.baseBranch], { ...execaEnv, cwd: projectRoot }); + } + + if (strategy === 'squash') { + await execa('git', ['merge', '--squash', wt.branch], { ...execaEnv, cwd: projectRoot }); + await execa('git', ['commit', '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } else { + await execa('git', ['merge', '--no-ff', wt.branch, '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } + } catch (err) { + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'failed'; + } + return m; + }); + throw new MergeFailedError( + `Merge failed: ${err instanceof Error ? err.message : err}`, + ); + } + + // Mark as merged + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merged'; + m.worktrees[wt.id].mergedAt = new Date().toISOString(); + } + return m; + }); + + // Cleanup + let selfProtected = false; + if (cleanup) { + const cleanupResult = await cleanupWorktree(projectRoot, wt, options.cleanupOptions); + selfProtected = cleanupResult.selfProtected; + } + + return { + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + strategy, + cleaned: cleanup, + selfProtected, + }; +} diff --git a/src/core/pr.ts b/src/core/pr.ts index 2849401..1411c43 100644 --- a/src/core/pr.ts +++ b/src/core/pr.ts @@ -1,8 +1,106 @@ import { execa } from 'execa'; import { execaEnv } from '../lib/env.js'; +import { PpgError, GhNotFoundError } from '../lib/errors.js'; +import { updateManifest } from './manifest.js'; +import type { WorktreeEntry } from '../types/manifest.js'; export type PrState = 'MERGED' | 'OPEN' | 'CLOSED' | 'UNKNOWN'; +// GitHub PR body limit is 65536 chars; leave room for truncation notice +const MAX_BODY_LENGTH = 60_000; + +/** Build PR body from agent prompts, with truncation. */ +export async function buildBodyFromResults(agents: { id: string; prompt: string }[]): Promise { + if (agents.length === 0) return ''; + const sections = agents.map((a) => `## Agent: ${a.id}\n\n${a.prompt}`); + return truncateBody(sections.join('\n\n---\n\n')); +} + +/** Truncate body to stay within GitHub's PR body size limit. */ +export function truncateBody(body: string): string { + if (body.length <= MAX_BODY_LENGTH) return body; + return body.slice(0, MAX_BODY_LENGTH) + '\n\n---\n\n*[Truncated — full results available in `.ppg/results/`]*'; +} + +export interface CreatePrOptions { + title?: string; + body?: string; + draft?: boolean; +} + +export interface CreatePrResult { + worktreeId: string; + branch: string; + baseBranch: string; + prUrl: string; +} + +/** Push branch and create a GitHub PR for a worktree. Stores prUrl in manifest. */ +export async function createWorktreePr( + projectRoot: string, + wt: WorktreeEntry, + options: CreatePrOptions = {}, +): Promise { + // Verify gh is available + try { + await execa('gh', ['--version'], execaEnv); + } catch { + throw new GhNotFoundError(); + } + + // Push the worktree branch + try { + await execa('git', ['push', '-u', 'origin', wt.branch], { ...execaEnv, cwd: projectRoot }); + } catch (err) { + throw new PpgError( + `Failed to push branch ${wt.branch}: ${err instanceof Error ? err.message : err}`, + 'INVALID_ARGS', + ); + } + + // Build PR title and body + const prTitle = options.title ?? wt.name; + const prBody = options.body ?? await buildBodyFromResults(Object.values(wt.agents)); + + // Build gh pr create args + const ghArgs = [ + 'pr', 'create', + '--head', wt.branch, + '--base', wt.baseBranch, + '--title', prTitle, + '--body', prBody, + ]; + if (options.draft) { + ghArgs.push('--draft'); + } + + let prUrl: string; + try { + const result = await execa('gh', ghArgs, { ...execaEnv, cwd: projectRoot }); + prUrl = result.stdout.trim(); + } catch (err) { + throw new PpgError( + `Failed to create PR: ${err instanceof Error ? err.message : err}`, + 'INVALID_ARGS', + ); + } + + // Store PR URL in manifest + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].prUrl = prUrl; + } + return m; + }); + + return { + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + prUrl, + }; +} + /** * Check the GitHub PR state for a given branch. * Uses `gh pr view` to query the PR associated with the branch. diff --git a/src/server/index.ts b/src/server/index.ts index 1a01669..27eec1e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,10 +1,11 @@ +import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import os from 'node:os'; import { createRequire } from 'node:module'; import Fastify from 'fastify'; import cors from '@fastify/cors'; import { serveStatePath, servePidPath } from '../lib/paths.js'; -import { info, success, warn } from '../lib/output.js'; +import { info, success } from '../lib/output.js'; const require = createRequire(import.meta.url); const pkg = require('../../package.json') as { version: string }; @@ -67,11 +68,14 @@ export async function startServer(options: ServeOptions): Promise { await app.register(cors, { origin: true }); if (token) { + const expectedHeader = `Bearer ${token}`; app.addHook('onRequest', async (request, reply) => { - if (request.url === '/health') return; - const authHeader = request.headers.authorization; - if (authHeader !== `Bearer ${token}`) { - reply.code(401).send({ error: 'Unauthorized' }); + if (request.routeOptions.url === '/health') return; + const authHeader = request.headers.authorization ?? ''; + const headerBuf = Buffer.from(authHeader); + const expectedBuf = Buffer.from(expectedHeader); + if (headerBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(headerBuf, expectedBuf)) { + return reply.code(401).send({ error: 'Unauthorized' }); } }); } diff --git a/src/server/routes/worktrees.test.ts b/src/server/routes/worktrees.test.ts index 58d0c75..dad0351 100644 --- a/src/server/routes/worktrees.test.ts +++ b/src/server/routes/worktrees.test.ts @@ -3,6 +3,7 @@ import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { makeWorktree, makeAgent } from '../../test-fixtures.js'; import type { Manifest } from '../../types/manifest.js'; +import type { WorktreeEntry } from '../../types/manifest.js'; // ---- Mocks ---- @@ -16,7 +17,6 @@ const mockManifest: Manifest = { }; vi.mock('../../core/manifest.js', () => ({ - requireManifest: vi.fn(), updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { return updater(structuredClone(mockManifest)); }), @@ -25,36 +25,43 @@ vi.mock('../../core/manifest.js', () => ({ vi.mock('../../core/agent.js', () => ({ refreshAllAgentStatuses: vi.fn((m: Manifest) => m), - killAgents: vi.fn(), })); -vi.mock('../../core/worktree.js', () => ({ - getCurrentBranch: vi.fn(() => 'main'), +vi.mock('../../core/merge.js', () => ({ + mergeWorktree: vi.fn(async (_root: string, wt: WorktreeEntry, opts: Record = {}) => ({ + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + strategy: (opts.strategy as string) ?? 'squash', + cleaned: opts.cleanup !== false, + selfProtected: false, + })), })); -vi.mock('../../core/cleanup.js', () => ({ - cleanupWorktree: vi.fn(), -})); - -vi.mock('../../commands/pr.js', () => ({ - buildBodyFromResults: vi.fn(() => 'PR body'), -})); - -vi.mock('execa', () => ({ - execa: vi.fn(() => ({ stdout: 'https://github.com/owner/repo/pull/1' })), +vi.mock('../../core/kill.js', () => ({ + killWorktreeAgents: vi.fn(async (_root: string, wt: WorktreeEntry) => { + const killed = Object.values(wt.agents) + .filter((a) => a.status === 'running') + .map((a) => a.id); + return { worktreeId: wt.id, killed }; + }), })); -vi.mock('../../lib/env.js', () => ({ - execaEnv: {}, +vi.mock('../../core/pr.js', () => ({ + createWorktreePr: vi.fn(async (_root: string, wt: WorktreeEntry) => ({ + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + prUrl: 'https://github.com/owner/repo/pull/1', + })), })); // ---- Imports (after mocks) ---- -import { resolveWorktree } from '../../core/manifest.js'; -import { killAgents } from '../../core/agent.js'; -import { cleanupWorktree } from '../../core/cleanup.js'; -import { getCurrentBranch } from '../../core/worktree.js'; -import { execa } from 'execa'; +import { resolveWorktree, updateManifest } from '../../core/manifest.js'; +import { mergeWorktree } from '../../core/merge.js'; +import { killWorktreeAgents } from '../../core/kill.js'; +import { createWorktreePr } from '../../core/pr.js'; import { worktreeRoutes } from './worktrees.js'; const PROJECT_ROOT = '/tmp/project'; @@ -95,10 +102,12 @@ describe('worktreeRoutes', () => { expect(body.worktreeId).toBe('wt-abc123'); expect(body.strategy).toBe('squash'); expect(body.cleaned).toBe(true); - expect(vi.mocked(cleanupWorktree)).toHaveBeenCalled(); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, { strategy: undefined, cleanup: undefined, force: undefined }, + ); }); - test('given strategy no-ff, should merge with --no-ff', async () => { + test('given strategy no-ff, should pass strategy to mergeWorktree', async () => { const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); mockManifest.worktrees['wt-abc123'] = wt; vi.mocked(resolveWorktree).mockReturnValue(wt); @@ -112,15 +121,12 @@ describe('worktreeRoutes', () => { expect(res.statusCode).toBe(200); expect(res.json().strategy).toBe('no-ff'); - - // Should have called git merge --no-ff - const execaCalls = vi.mocked(execa).mock.calls; - const mergeCall = execaCalls.find((c) => c[0] === 'git' && (c[1] as string[])?.[0] === 'merge'); - expect(mergeCall).toBeDefined(); - expect((mergeCall![1] as string[])).toContain('--no-ff'); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ strategy: 'no-ff' }), + ); }); - test('given cleanup false, should skip cleanup', async () => { + test('given cleanup false, should pass cleanup false', async () => { const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); mockManifest.worktrees['wt-abc123'] = wt; vi.mocked(resolveWorktree).mockReturnValue(wt); @@ -134,7 +140,9 @@ describe('worktreeRoutes', () => { expect(res.statusCode).toBe(200); expect(res.json().cleaned).toBe(false); - expect(vi.mocked(cleanupWorktree)).not.toHaveBeenCalled(); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ cleanup: false }), + ); }); test('given worktree not found, should return 404', async () => { @@ -151,15 +159,16 @@ describe('worktreeRoutes', () => { expect(res.json().code).toBe('WORKTREE_NOT_FOUND'); }); - test('given running agents without force, should return 409', async () => { - const agent = makeAgent({ id: 'ag-running1', status: 'running' }); - const wt = makeWorktree({ - id: 'wt-abc123', - agents: { 'ag-running1': agent }, - }); + test('given AGENTS_RUNNING error from core, should return 409', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); mockManifest.worktrees['wt-abc123'] = wt; vi.mocked(resolveWorktree).mockReturnValue(wt); + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(mergeWorktree).mockRejectedValueOnce( + new PpgError('1 agent(s) still running', 'AGENTS_RUNNING'), + ); + const app = await buildApp(); const res = await app.inject({ method: 'POST', @@ -171,12 +180,8 @@ describe('worktreeRoutes', () => { expect(res.json().code).toBe('AGENTS_RUNNING'); }); - test('given running agents with force, should merge anyway', async () => { - const agent = makeAgent({ id: 'ag-running1', status: 'running' }); - const wt = makeWorktree({ - id: 'wt-abc123', - agents: { 'ag-running1': agent }, - }); + test('given force flag, should pass force to mergeWorktree', async () => { + const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); mockManifest.worktrees['wt-abc123'] = wt; vi.mocked(resolveWorktree).mockReturnValue(wt); @@ -188,14 +193,20 @@ describe('worktreeRoutes', () => { }); expect(res.statusCode).toBe(200); - expect(res.json().success).toBe(true); + expect(vi.mocked(mergeWorktree)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ force: true }), + ); }); - test('given git merge failure, should return 500 with MERGE_FAILED', async () => { + test('given MERGE_FAILED error from core, should return 500', async () => { const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); mockManifest.worktrees['wt-abc123'] = wt; vi.mocked(resolveWorktree).mockReturnValue(wt); - vi.mocked(execa).mockRejectedValueOnce(new Error('conflict')); + + const { MergeFailedError } = await import('../../lib/errors.js'); + vi.mocked(mergeWorktree).mockRejectedValueOnce( + new MergeFailedError('Merge failed: conflict'), + ); const app = await buildApp(); const res = await app.inject({ @@ -204,8 +215,6 @@ describe('worktreeRoutes', () => { payload: {}, }); - // getCurrentBranch returns 'main' which matches baseBranch, so no checkout call. - // First execa call is git merge --squash which fails. expect(res.statusCode).toBe(500); expect(res.json().code).toBe('MERGE_FAILED'); }); @@ -215,7 +224,7 @@ describe('worktreeRoutes', () => { // POST /api/worktrees/:id/kill // ================================================================== describe('POST /api/worktrees/:id/kill', () => { - test('given worktree with running agents, should kill all running agents', async () => { + test('given worktree with running agents, should kill via core and return killed list', async () => { const agent1 = makeAgent({ id: 'ag-run00001', status: 'running' }); const agent2 = makeAgent({ id: 'ag-idle0001', status: 'idle' }); const wt = makeWorktree({ @@ -236,7 +245,7 @@ describe('worktreeRoutes', () => { const body = res.json(); expect(body.success).toBe(true); expect(body.killed).toEqual(['ag-run00001']); - expect(vi.mocked(killAgents)).toHaveBeenCalledWith([agent1]); + expect(vi.mocked(killWorktreeAgents)).toHaveBeenCalledWith(PROJECT_ROOT, wt); }); test('given worktree with no running agents, should return empty killed list', async () => { @@ -257,7 +266,6 @@ describe('worktreeRoutes', () => { expect(res.statusCode).toBe(200); expect(res.json().killed).toEqual([]); - expect(vi.mocked(killAgents)).toHaveBeenCalledWith([]); }); test('given worktree not found, should return 404', async () => { @@ -279,7 +287,7 @@ describe('worktreeRoutes', () => { // POST /api/worktrees/:id/pr // ================================================================== describe('POST /api/worktrees/:id/pr', () => { - test('given valid worktree, should create PR and store URL', async () => { + test('given valid worktree, should create PR and return URL', async () => { const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); mockManifest.worktrees['wt-abc123'] = wt; vi.mocked(resolveWorktree).mockReturnValue(wt); @@ -296,9 +304,12 @@ describe('worktreeRoutes', () => { expect(body.success).toBe(true); expect(body.prUrl).toBe('https://github.com/owner/repo/pull/1'); expect(body.worktreeId).toBe('wt-abc123'); + expect(vi.mocked(createWorktreePr)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, { title: 'My PR', body: 'Description', draft: undefined }, + ); }); - test('given draft flag, should pass --draft to gh', async () => { + test('given draft flag, should pass draft to createWorktreePr', async () => { const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); mockManifest.worktrees['wt-abc123'] = wt; vi.mocked(resolveWorktree).mockReturnValue(wt); @@ -310,10 +321,9 @@ describe('worktreeRoutes', () => { payload: { draft: true }, }); - const ghCalls = vi.mocked(execa).mock.calls.filter((c) => c[0] === 'gh'); - const prCreateCall = ghCalls.find((c) => (c[1] as string[])?.includes('create')); - expect(prCreateCall).toBeDefined(); - expect((prCreateCall![1] as string[])).toContain('--draft'); + expect(vi.mocked(createWorktreePr)).toHaveBeenCalledWith( + PROJECT_ROOT, wt, expect.objectContaining({ draft: true }), + ); }); test('given worktree not found, should return 404', async () => { @@ -330,13 +340,13 @@ describe('worktreeRoutes', () => { expect(res.json().code).toBe('WORKTREE_NOT_FOUND'); }); - test('given gh not available, should return 502', async () => { + test('given GH_NOT_FOUND error from core, should return 502', async () => { const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); mockManifest.worktrees['wt-abc123'] = wt; vi.mocked(resolveWorktree).mockReturnValue(wt); - // First call is gh --version which should fail - vi.mocked(execa).mockRejectedValueOnce(new Error('gh not found')); + const { GhNotFoundError } = await import('../../lib/errors.js'); + vi.mocked(createWorktreePr).mockRejectedValueOnce(new GhNotFoundError()); const app = await buildApp(); const res = await app.inject({ @@ -349,15 +359,15 @@ describe('worktreeRoutes', () => { expect(res.json().code).toBe('GH_NOT_FOUND'); }); - test('given push failure, should return 400', async () => { + test('given INVALID_ARGS error from core, should return 400', async () => { const wt = makeWorktree({ id: 'wt-abc123', agents: {} }); mockManifest.worktrees['wt-abc123'] = wt; vi.mocked(resolveWorktree).mockReturnValue(wt); - // gh --version succeeds, git push fails - vi.mocked(execa) - .mockResolvedValueOnce({ stdout: 'gh version 2.0' } as never) - .mockRejectedValueOnce(new Error('push rejected')); + const { PpgError } = await import('../../lib/errors.js'); + vi.mocked(createWorktreePr).mockRejectedValueOnce( + new PpgError('Failed to push', 'INVALID_ARGS'), + ); const app = await buildApp(); const res = await app.inject({ diff --git a/src/server/routes/worktrees.ts b/src/server/routes/worktrees.ts index 8d7c140..517da5a 100644 --- a/src/server/routes/worktrees.ts +++ b/src/server/routes/worktrees.ts @@ -1,17 +1,21 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { execa } from 'execa'; -import { requireManifest, updateManifest, resolveWorktree } from '../../core/manifest.js'; -import { refreshAllAgentStatuses, killAgents } from '../../core/agent.js'; -import { getCurrentBranch } from '../../core/worktree.js'; -import { cleanupWorktree } from '../../core/cleanup.js'; -import { PpgError, WorktreeNotFoundError, MergeFailedError, GhNotFoundError } from '../../lib/errors.js'; -import { execaEnv } from '../../lib/env.js'; -import { buildBodyFromResults } from '../../commands/pr.js'; +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { updateManifest, resolveWorktree } from '../../core/manifest.js'; +import { refreshAllAgentStatuses } from '../../core/agent.js'; +import { mergeWorktree } from '../../core/merge.js'; +import { killWorktreeAgents } from '../../core/kill.js'; +import { createWorktreePr } from '../../core/pr.js'; +import { PpgError, WorktreeNotFoundError } from '../../lib/errors.js'; // ------------------------------------------------------------------ // Fastify plugin — worktree action routes // ------------------------------------------------------------------ +declare module 'fastify' { + interface FastifyInstance { + projectRoot: string; + } +} + interface WorktreeParams { id: string; } @@ -22,17 +26,13 @@ interface MergeBody { force?: boolean; } -interface KillBody { - force?: boolean; -} - interface PrBody { title?: string; body?: string; draft?: boolean; } -function errorReply(reply: FastifyReply, err: unknown): void { +function errorReply(reply: FastifyReply, err: unknown): FastifyReply { if (err instanceof PpgError) { const statusMap: Record = { WORKTREE_NOT_FOUND: 404, @@ -44,15 +44,27 @@ function errorReply(reply: FastifyReply, err: unknown): void { INVALID_ARGS: 400, }; const status = statusMap[err.code] ?? 500; - reply.code(status).send({ error: err.message, code: err.code }); - return; + return reply.code(status).send({ error: err.message, code: err.code }); } const message = err instanceof Error ? err.message : String(err); - reply.code(500).send({ error: message }); + return reply.code(500).send({ error: message }); +} + +async function resolveWorktreeFromRequest( + projectRoot: string, + id: string, +) { + const manifest = await updateManifest(projectRoot, async (m) => { + return refreshAllAgentStatuses(m, projectRoot); + }); + + const wt = resolveWorktree(manifest, id); + if (!wt) throw new WorktreeNotFoundError(id); + return wt; } export async function worktreeRoutes(app: FastifyInstance): Promise { - const projectRoot: string = (app as unknown as Record)['projectRoot'] as string; + const { projectRoot } = app; // ---------------------------------------------------------------- // POST /api/worktrees/:id/merge @@ -61,91 +73,14 @@ export async function worktreeRoutes(app: FastifyInstance): Promise { '/worktrees/:id/merge', async (request, reply) => { try { - const { id } = request.params; - const { strategy = 'squash', cleanup = true, force = false } = request.body ?? {}; - - await requireManifest(projectRoot); - const manifest = await updateManifest(projectRoot, async (m) => { - return refreshAllAgentStatuses(m, projectRoot); - }); - - const wt = resolveWorktree(manifest, id); - if (!wt) throw new WorktreeNotFoundError(id); - - // Check all agents finished - const incomplete = Object.values(wt.agents).filter((a) => a.status === 'running'); - if (incomplete.length > 0 && !force) { - const ids = incomplete.map((a) => a.id).join(', '); - throw new PpgError( - `${incomplete.length} agent(s) still running: ${ids}. Use force: true to merge anyway.`, - 'AGENTS_RUNNING', - ); - } - - // Set worktree status to merging - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'merging'; - } - return m; - }); - - try { - const currentBranch = await getCurrentBranch(projectRoot); - if (currentBranch !== wt.baseBranch) { - await execa('git', ['checkout', wt.baseBranch], { ...execaEnv, cwd: projectRoot }); - } + const wt = await resolveWorktreeFromRequest(projectRoot, request.params.id); + const { strategy, cleanup, force } = request.body ?? {}; - if (strategy === 'squash') { - await execa('git', ['merge', '--squash', wt.branch], { ...execaEnv, cwd: projectRoot }); - await execa('git', ['commit', '-m', `ppg: merge ${wt.name} (${wt.branch})`], { - ...execaEnv, - cwd: projectRoot, - }); - } else { - await execa('git', ['merge', '--no-ff', wt.branch, '-m', `ppg: merge ${wt.name} (${wt.branch})`], { - ...execaEnv, - cwd: projectRoot, - }); - } - } catch (err) { - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'failed'; - } - return m; - }); - throw new MergeFailedError( - `Merge failed: ${err instanceof Error ? err.message : err}`, - ); - } + const result = await mergeWorktree(projectRoot, wt, { strategy, cleanup, force }); - // Mark as merged - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'merged'; - m.worktrees[wt.id].mergedAt = new Date().toISOString(); - } - return m; - }); - - // Cleanup (no self-protection needed in server context) - let cleaned = false; - if (cleanup) { - await cleanupWorktree(projectRoot, wt); - cleaned = true; - } - - return { - success: true, - worktreeId: wt.id, - branch: wt.branch, - baseBranch: wt.baseBranch, - strategy, - cleaned, - }; + return { success: true, ...result }; } catch (err) { - errorReply(reply, err); + return errorReply(reply, err); } }, ); @@ -153,44 +88,17 @@ export async function worktreeRoutes(app: FastifyInstance): Promise { // ---------------------------------------------------------------- // POST /api/worktrees/:id/kill // ---------------------------------------------------------------- - app.post<{ Params: WorktreeParams; Body: KillBody }>( + app.post<{ Params: WorktreeParams }>( '/worktrees/:id/kill', async (request, reply) => { try { - const { id } = request.params; - - await requireManifest(projectRoot); - const manifest = await updateManifest(projectRoot, async (m) => { - return refreshAllAgentStatuses(m, projectRoot); - }); - - const wt = resolveWorktree(manifest, id); - if (!wt) throw new WorktreeNotFoundError(id); + const wt = await resolveWorktreeFromRequest(projectRoot, request.params.id); - const toKill = Object.values(wt.agents).filter((a) => a.status === 'running'); - const killedIds = toKill.map((a) => a.id); + const result = await killWorktreeAgents(projectRoot, wt); - await killAgents(toKill); - - await updateManifest(projectRoot, (m) => { - const mWt = m.worktrees[wt.id]; - if (mWt) { - for (const agent of Object.values(mWt.agents)) { - if (killedIds.includes(agent.id)) { - agent.status = 'gone'; - } - } - } - return m; - }); - - return { - success: true, - worktreeId: wt.id, - killed: killedIds, - }; + return { success: true, ...result }; } catch (err) { - errorReply(reply, err); + return errorReply(reply, err); } }, ); @@ -202,78 +110,14 @@ export async function worktreeRoutes(app: FastifyInstance): Promise { '/worktrees/:id/pr', async (request, reply) => { try { - const { id } = request.params; - const { title, body, draft = false } = request.body ?? {}; - - await requireManifest(projectRoot); - const manifest = await updateManifest(projectRoot, async (m) => { - return refreshAllAgentStatuses(m, projectRoot); - }); - - const wt = resolveWorktree(manifest, id); - if (!wt) throw new WorktreeNotFoundError(id); - - // Verify gh is available - try { - await execa('gh', ['--version'], execaEnv); - } catch { - throw new GhNotFoundError(); - } - - // Push the worktree branch - try { - await execa('git', ['push', '-u', 'origin', wt.branch], { ...execaEnv, cwd: projectRoot }); - } catch (err) { - throw new PpgError( - `Failed to push branch ${wt.branch}: ${err instanceof Error ? err.message : err}`, - 'INVALID_ARGS', - ); - } - - // Build PR title and body - const prTitle = title ?? wt.name; - const prBody = body ?? await buildBodyFromResults(Object.values(wt.agents)); - - // Build gh pr create args - const ghArgs = [ - 'pr', 'create', - '--head', wt.branch, - '--base', wt.baseBranch, - '--title', prTitle, - '--body', prBody, - ]; - if (draft) { - ghArgs.push('--draft'); - } - - let prUrl: string; - try { - const result = await execa('gh', ghArgs, { ...execaEnv, cwd: projectRoot }); - prUrl = result.stdout.trim(); - } catch (err) { - throw new PpgError( - `Failed to create PR: ${err instanceof Error ? err.message : err}`, - 'INVALID_ARGS', - ); - } + const wt = await resolveWorktreeFromRequest(projectRoot, request.params.id); + const { title, body, draft } = request.body ?? {}; - // Store PR URL in manifest - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].prUrl = prUrl; - } - return m; - }); + const result = await createWorktreePr(projectRoot, wt, { title, body, draft }); - return { - success: true, - worktreeId: wt.id, - branch: wt.branch, - baseBranch: wt.baseBranch, - prUrl, - }; + return { success: true, ...result }; } catch (err) { - errorReply(reply, err); + return errorReply(reply, err); } }, ); From 6600b8442e12b297366bef6025b1e0a8220aca15 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:37:03 -0600 Subject: [PATCH 3/3] Fix typecheck and merge cleanup context handling --- src/commands/merge.ts | 10 ++++++---- src/commands/spawn.test.ts | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 5812732..4d97845 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -36,10 +36,12 @@ export async function mergeCommand(worktreeId: string, options: MergeOptions): P return; } + const cleanupEnabled = options.cleanup !== false; + // Build self-protection context for cleanup - const selfPaneId = getCurrentPaneId(); + const selfPaneId = cleanupEnabled ? getCurrentPaneId() : null; let paneMap; - if (selfPaneId) { + if (cleanupEnabled && selfPaneId) { paneMap = await listSessionPanes(manifest.sessionName); } @@ -47,9 +49,9 @@ export async function mergeCommand(worktreeId: string, options: MergeOptions): P const result = await mergeWorktree(projectRoot, wt, { strategy: options.strategy, - cleanup: options.cleanup !== false, + cleanup: cleanupEnabled, force: options.force, - cleanupOptions: { selfPaneId, paneMap }, + cleanupOptions: cleanupEnabled ? { selfPaneId, paneMap } : undefined, }); success(`Merged ${wt.branch} into ${wt.baseBranch}`); diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..e29d746 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -6,6 +6,7 @@ import { readManifest, resolveWorktree, updateManifest } from '../core/manifest. import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; +import type { Manifest } from '../types/manifest.js'; import * as tmux from '../core/tmux.js'; vi.mock('node:fs/promises', async () => { @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo',