aicodeman 1.41.0 → 1.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -21
- package/dist/attachment-registry.d.ts +2 -0
- package/dist/attachment-registry.d.ts.map +1 -1
- package/dist/attachment-registry.js +14 -3
- package/dist/attachment-registry.js.map +1 -1
- package/dist/cli-agent.d.ts +2 -0
- package/dist/cli-agent.d.ts.map +1 -1
- package/dist/cli-agent.js +13 -9
- package/dist/cli-agent.js.map +1 -1
- package/dist/cli.d.ts +25 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +97 -11
- package/dist/cli.js.map +1 -1
- package/dist/config/buffer-limits.d.ts +9 -0
- package/dist/config/buffer-limits.d.ts.map +1 -1
- package/dist/config/buffer-limits.js +9 -0
- package/dist/config/buffer-limits.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +10 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +26 -0
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +89 -6
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +61 -0
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/folder-listing.d.ts +94 -0
- package/dist/folder-listing.d.ts.map +1 -0
- package/dist/folder-listing.js +241 -0
- package/dist/folder-listing.js.map +1 -0
- package/dist/git-worktrees.d.ts +292 -0
- package/dist/git-worktrees.d.ts.map +1 -0
- package/dist/git-worktrees.js +791 -0
- package/dist/git-worktrees.js.map +1 -0
- package/dist/hooks-config.d.ts +36 -16
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +45 -20
- package/dist/hooks-config.js.map +1 -1
- package/dist/html-view-capabilities.d.ts +54 -0
- package/dist/html-view-capabilities.d.ts.map +1 -0
- package/dist/html-view-capabilities.js +103 -0
- package/dist/html-view-capabilities.js.map +1 -0
- package/dist/mcp-server.d.ts +114 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +918 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/mcp-sync.d.ts +5 -0
- package/dist/mcp-sync.d.ts.map +1 -1
- package/dist/mcp-sync.js +51 -5
- package/dist/mcp-sync.js.map +1 -1
- package/dist/mux-interface.d.ts +36 -1
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/remote-files.d.ts +37 -0
- package/dist/remote-files.d.ts.map +1 -1
- package/dist/remote-files.js +103 -0
- package/dist/remote-files.js.map +1 -1
- package/dist/remote-pane-history.d.ts +103 -0
- package/dist/remote-pane-history.d.ts.map +1 -0
- package/dist/remote-pane-history.js +258 -0
- package/dist/remote-pane-history.js.map +1 -0
- package/dist/remote-ssh-limiter.d.ts +16 -3
- package/dist/remote-ssh-limiter.d.ts.map +1 -1
- package/dist/remote-ssh-limiter.js +37 -6
- package/dist/remote-ssh-limiter.js.map +1 -1
- package/dist/services/unified-session-service.d.ts +8 -0
- package/dist/services/unified-session-service.d.ts.map +1 -1
- package/dist/services/unified-session-service.js +21 -0
- package/dist/services/unified-session-service.js.map +1 -1
- package/dist/session-activity.d.ts +51 -0
- package/dist/session-activity.d.ts.map +1 -1
- package/dist/session-activity.js +62 -0
- package/dist/session-activity.js.map +1 -1
- package/dist/session-submit-verifier.js +1 -1
- package/dist/session.d.ts +90 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +247 -9
- package/dist/session.js.map +1 -1
- package/dist/tab-layout-legacy-order.d.ts.map +1 -1
- package/dist/tab-layout-legacy-order.js +72 -13
- package/dist/tab-layout-legacy-order.js.map +1 -1
- package/dist/tab-layout-service.d.ts +33 -2
- package/dist/tab-layout-service.d.ts.map +1 -1
- package/dist/tab-layout-service.js +102 -7
- package/dist/tab-layout-service.js.map +1 -1
- package/dist/tab-layout.d.ts +9 -0
- package/dist/tab-layout.d.ts.map +1 -1
- package/dist/tab-layout.js +15 -2
- package/dist/tab-layout.js.map +1 -1
- package/dist/tmux-manager.d.ts +130 -7
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +263 -36
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/session.d.ts +53 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/usage-telemetry.d.ts +44 -7
- package/dist/usage-telemetry.d.ts.map +1 -1
- package/dist/usage-telemetry.js +72 -7
- package/dist/usage-telemetry.js.map +1 -1
- package/dist/utils/deepseek-cli-resolver.d.ts.map +1 -1
- package/dist/utils/deepseek-cli-resolver.js +1 -1
- package/dist/utils/deepseek-cli-resolver.js.map +1 -1
- package/dist/web/approval-inbox.d.ts +24 -2
- package/dist/web/approval-inbox.d.ts.map +1 -1
- package/dist/web/approval-inbox.js +8 -2
- package/dist/web/approval-inbox.js.map +1 -1
- package/dist/web/middleware/auth.d.ts.map +1 -1
- package/dist/web/middleware/auth.js +33 -0
- package/dist/web/middleware/auth.js.map +1 -1
- package/dist/web/ports/session-port.d.ts +8 -1
- package/dist/web/ports/session-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.f04bb2a5.js +43 -0
- package/dist/web/public/app.f04bb2a5.js.br +0 -0
- package/dist/web/public/app.f04bb2a5.js.gz +0 -0
- package/dist/web/public/approvals-ui.js +4 -0
- package/dist/web/public/approvals-ui.js.br +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.bc9c1f63.js → constants.12b0c247.js} +395 -7
- package/dist/web/public/constants.12b0c247.js.br +0 -0
- package/dist/web/public/constants.12b0c247.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/gesture/gesture-codeman.js +112 -6
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.06659676.js +2 -0
- package/dist/web/public/i18n.06659676.js.br +0 -0
- package/dist/web/public/i18n.06659676.js.gz +0 -0
- package/dist/web/public/image-input.fcdcb46c.js +462 -0
- package/dist/web/public/image-input.fcdcb46c.js.br +0 -0
- package/dist/web/public/image-input.fcdcb46c.js.gz +0 -0
- package/dist/web/public/index.html +169 -24
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/{keyboard-accessory.c1e7c7b7.js → keyboard-accessory.b42f539b.js} +124 -44
- package/dist/web/public/keyboard-accessory.b42f539b.js.br +0 -0
- package/dist/web/public/keyboard-accessory.b42f539b.js.gz +0 -0
- package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
- package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
- package/dist/web/public/mobile-overview.js +11 -2
- package/dist/web/public/mobile-overview.js.br +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.2dff1580.css +1 -0
- package/dist/web/public/mobile.2dff1580.css.br +0 -0
- package/dist/web/public/mobile.2dff1580.css.gz +0 -0
- package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.71846fcb.js → panels-ui.73ec0137.js} +87 -87
- package/dist/web/public/panels-ui.73ec0137.js.br +0 -0
- package/dist/web/public/panels-ui.73ec0137.js.gz +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/{respawn-ui.ff0dae4c.js → respawn-ui.d7adcf6a.js} +1 -1
- package/dist/web/public/respawn-ui.d7adcf6a.js.br +0 -0
- package/dist/web/public/respawn-ui.d7adcf6a.js.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/session-ui.d52ff6aa.js +100 -0
- package/dist/web/public/session-ui.d52ff6aa.js.br +0 -0
- package/dist/web/public/session-ui.d52ff6aa.js.gz +0 -0
- package/dist/web/public/settings-ui.3a22bd2a.js +97 -0
- package/dist/web/public/settings-ui.3a22bd2a.js.br +0 -0
- package/dist/web/public/settings-ui.3a22bd2a.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js +11 -0
- package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/{spreadsheet-preview.af82fa2f.js → spreadsheet-preview.75c751de.js} +1 -1
- package/dist/web/public/spreadsheet-preview.75c751de.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js +110 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/styles.ba6a9b84.css +1 -0
- package/dist/web/public/styles.ba6a9b84.css.br +0 -0
- package/dist/web/public/styles.ba6a9b84.css.gz +0 -0
- package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
- package/dist/web/public/sw.js +6 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js +1 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.br +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
- package/dist/web/public/{tab-rail-resize.a5867793.js → tab-rail-resize.7157e0bd.js} +1 -1
- package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
- package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +45 -24
- package/dist/web/public/terminal-tile.js.br +0 -0
- package/dist/web/public/terminal-tile.js.gz +0 -0
- package/dist/web/public/terminal-ui.0a7ccc29.js +2 -0
- package/dist/web/public/terminal-ui.0a7ccc29.js.br +0 -0
- package/dist/web/public/terminal-ui.0a7ccc29.js.gz +0 -0
- package/dist/web/public/tile-grid.js +439 -18
- package/dist/web/public/tile-grid.js.br +0 -0
- package/dist/web/public/tile-grid.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
- package/dist/web/public/vendor/fflate.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js +12 -0
- package/dist/web/public/webview-tabs.js.br +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/public/whats-new.json +13 -0
- package/dist/web/route-helpers.d.ts +8 -0
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +8 -0
- package/dist/web/route-helpers.js.map +1 -1
- package/dist/web/routes/admin-routes.d.ts.map +1 -1
- package/dist/web/routes/admin-routes.js +4 -1
- package/dist/web/routes/admin-routes.js.map +1 -1
- package/dist/web/routes/approval-routes.d.ts.map +1 -1
- package/dist/web/routes/approval-routes.js +12 -10
- package/dist/web/routes/approval-routes.js.map +1 -1
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +10 -3
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/file-routes.d.ts +46 -1
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +214 -27
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
- package/dist/web/routes/hook-event-routes.js +8 -1
- package/dist/web/routes/hook-event-routes.js.map +1 -1
- package/dist/web/routes/html-view-routes.d.ts +60 -0
- package/dist/web/routes/html-view-routes.d.ts.map +1 -0
- package/dist/web/routes/html-view-routes.js +288 -0
- package/dist/web/routes/html-view-routes.js.map +1 -0
- package/dist/web/routes/index.d.ts +2 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +2 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/ralph-routes.d.ts.map +1 -1
- package/dist/web/routes/ralph-routes.js +19 -14
- package/dist/web/routes/ralph-routes.js.map +1 -1
- package/dist/web/routes/reboot-restore-routes.js +2 -2
- package/dist/web/routes/reboot-restore-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +8 -5
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +462 -109
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
- package/dist/web/routes/status-telemetry-routes.js +24 -6
- package/dist/web/routes/status-telemetry-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts +9 -0
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +91 -12
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/routes/worktree-routes.d.ts +52 -0
- package/dist/web/routes/worktree-routes.d.ts.map +1 -0
- package/dist/web/routes/worktree-routes.js +220 -0
- package/dist/web/routes/worktree-routes.js.map +1 -0
- package/dist/web/schemas.d.ts +45 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +105 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/screen-blocked.d.ts +80 -0
- package/dist/web/screen-blocked.d.ts.map +1 -0
- package/dist/web/screen-blocked.js +106 -0
- package/dist/web/screen-blocked.js.map +1 -0
- package/dist/web/server.d.ts +6 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +73 -14
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-listener-wiring.d.ts +1 -0
- package/dist/web/session-listener-wiring.d.ts.map +1 -1
- package/dist/web/session-listener-wiring.js +20 -0
- package/dist/web/session-listener-wiring.js.map +1 -1
- package/dist/web/session-wait-registry.d.ts +27 -9
- package/dist/web/session-wait-registry.d.ts.map +1 -1
- package/dist/web/session-wait-registry.js +36 -13
- package/dist/web/session-wait-registry.js.map +1 -1
- package/dist/web/sse-events.d.ts +12 -4
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +12 -4
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +3 -2
- package/skills/codeman/SKILL.md +47 -27
- package/skills/codeman/preamble.sh +14 -11
- package/skills/codeman/reference/endpoints.md +1 -1
- package/skills/codeman/reference/recipes.md +12 -9
- package/skills/codeman/reference/verbs.md +3 -1
- package/dist/web/public/app.6f8552e1.js +0 -43
- package/dist/web/public/app.6f8552e1.js.br +0 -0
- package/dist/web/public/app.6f8552e1.js.gz +0 -0
- package/dist/web/public/constants.bc9c1f63.js.br +0 -0
- package/dist/web/public/constants.bc9c1f63.js.gz +0 -0
- package/dist/web/public/i18n.a9840ea2.js +0 -2
- package/dist/web/public/i18n.a9840ea2.js.br +0 -0
- package/dist/web/public/i18n.a9840ea2.js.gz +0 -0
- package/dist/web/public/image-input.e00471c6.js +0 -283
- package/dist/web/public/image-input.e00471c6.js.br +0 -0
- package/dist/web/public/image-input.e00471c6.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
- package/dist/web/public/mobile.111b187d.css +0 -1
- package/dist/web/public/mobile.111b187d.css.br +0 -0
- package/dist/web/public/mobile.111b187d.css.gz +0 -0
- package/dist/web/public/panels-ui.71846fcb.js.br +0 -0
- package/dist/web/public/panels-ui.71846fcb.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.br +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/session-ui.f731602c.js +0 -100
- package/dist/web/public/session-ui.f731602c.js.br +0 -0
- package/dist/web/public/session-ui.f731602c.js.gz +0 -0
- package/dist/web/public/settings-ui.4e33390f.js +0 -97
- package/dist/web/public/settings-ui.4e33390f.js.br +0 -0
- package/dist/web/public/settings-ui.4e33390f.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
- package/dist/web/public/styles.81a72906.css +0 -1
- package/dist/web/public/styles.81a72906.css.br +0 -0
- package/dist/web/public/styles.81a72906.css.gz +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js +0 -1
- package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
- package/dist/web/public/terminal-ui.b7267005.js +0 -2
- package/dist/web/public/terminal-ui.b7267005.js.br +0 -0
- package/dist/web/public/terminal-ui.b7267005.js.gz +0 -0
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Git worktrees for a LOCAL case: list, create and remove the worktrees a
|
|
3
|
+
* repository-backed case keeps under `<repo>/.claude/worktrees/<name>`, so several agents working
|
|
4
|
+
* on one repository each get their own working tree instead of sharing the case folder (sessions
|
|
5
|
+
* in one case share its folder, and two agents editing, building and committing in one checkout
|
|
6
|
+
* step on each other). Routes: `src/web/routes/worktree-routes.ts`; a session starts in one
|
|
7
|
+
* through the existing create paths (`POST /api/quick-start` with `worktree`, or
|
|
8
|
+
* `POST /api/sessions` with the worktree as `workingDir`).
|
|
9
|
+
*
|
|
10
|
+
* WHERE. `<repo>/.claude/worktrees/<name>` on a new branch `worktree-<name>` is Claude Code's own
|
|
11
|
+
* `--worktree` layout (measured in Claude Code 2.1.296: path `<repo>/.claude/worktrees/<name>`,
|
|
12
|
+
* branch `worktree-<name>`, names of letters, digits, `.`, `_`, `-`, at most 64 characters), and
|
|
13
|
+
* Codeman already recognizes it (the worktree pill, `CLAUDE_WORKTREE_PATH` in session-routes.ts).
|
|
14
|
+
* Names here are a strict subset of Claude's (one segment, starting with a letter or digit), so a
|
|
15
|
+
* worktree made here is one `claude --worktree <name>` would also find. The main checkout must not
|
|
16
|
+
* show the worktrees as untracked, so `/.claude/worktrees/` is appended to the repository's LOCAL
|
|
17
|
+
* `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git does not
|
|
18
|
+
* already ignore it. `/.claude/settings.local.json` gets the same treatment: Codeman installs its
|
|
19
|
+
* hooks there for every claude session, and an untracked copy in a worktree makes
|
|
20
|
+
* `git worktree remove` refuse forever (Claude Code itself ignores that file through the global
|
|
21
|
+
* excludes file, which is not guaranteed to be set up on a given machine). The copy of Codeman's
|
|
22
|
+
* agent skill (`<skills dir>/codeman/`, injected while `agentSkillEnabled` is on) is excluded for
|
|
23
|
+
* the same reason, for every skills folder the registry names (`WorktreeOptions.agentSkillDirs`).
|
|
24
|
+
*
|
|
25
|
+
* Split like the other git modules: a PURE half (name and branch validation, `worktree list`
|
|
26
|
+
* parsing, exclude text, the managed-path rule) unit-tested without git, and an IO half that
|
|
27
|
+
* spawns git through an injectable runner.
|
|
28
|
+
*
|
|
29
|
+
* Rules the code keeps and the tests pin:
|
|
30
|
+
* - git is spawned with an argv array, never a shell, with `--` before every path operand, the
|
|
31
|
+
* non-interactive environment of `gitNonInteractiveEnv()`, a timeout that kills the whole
|
|
32
|
+
* process group, and capped output. Read-only calls add `--no-optional-locks`.
|
|
33
|
+
* - Names and branches are validated BEFORE they reach git (charset, length, then
|
|
34
|
+
* `git check-ref-format --branch`, whose answer must equal the input: `@{-1}` style shorthands
|
|
35
|
+
* are refused up front, since `--branch` would expand them).
|
|
36
|
+
* - Only `<repo>/.claude/worktrees/<valid-name>` is ever created or removed. Every component of
|
|
37
|
+
* that path is `lstat`ed (a symlinked `.claude` or `worktrees` directory, or a symlink at the
|
|
38
|
+
* worktree itself, is refused) and its real path must be exactly the expected one, so a
|
|
39
|
+
* repository cannot redirect a create or a remove outside itself. What is left is a same-user
|
|
40
|
+
* race (an agent swapping a directory between the check and git), which is accepted: that
|
|
41
|
+
* agent already runs code as this user.
|
|
42
|
+
* - Removal is `git worktree remove` WITHOUT `--force` (git refuses a tree with modified or
|
|
43
|
+
* untracked files, and that refusal is the answer), refused while a live session runs inside
|
|
44
|
+
* the worktree, and the optional branch delete is `git branch -d` (never `-D`, so an unmerged
|
|
45
|
+
* branch survives).
|
|
46
|
+
* - git never runs for a case at or inside a Docker case workspace (or whose common git dir is
|
|
47
|
+
* there): a container could plant a checkout filter or a hook, and `git worktree add` runs the
|
|
48
|
+
* repository's post-checkout hook and filters on the HOST. On a local case that is acceptable,
|
|
49
|
+
* since the user's agent runs code there anyway. A managed worktree that is, sits inside, or
|
|
50
|
+
* holds a Docker case workspace is never removed either: `git worktree remove` runs a status
|
|
51
|
+
* inside the tree, then deletes it under the container.
|
|
52
|
+
* - A repository that merely encloses the case and is the home folder or above (a dotfiles repo
|
|
53
|
+
* in `$HOME`) is not the case's repository, and a case folder below a repository's top level
|
|
54
|
+
* is refused: the worktrees would not be where the case is.
|
|
55
|
+
*
|
|
56
|
+
* @module git-worktrees
|
|
57
|
+
*/
|
|
58
|
+
import { spawn } from 'node:child_process';
|
|
59
|
+
import { promises as fs } from 'node:fs';
|
|
60
|
+
import { homedir } from 'node:os';
|
|
61
|
+
import { join, resolve, sep } from 'node:path';
|
|
62
|
+
import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js';
|
|
63
|
+
import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js';
|
|
64
|
+
// ─── Limits ──────────────────────────────────────────────────────────────────
|
|
65
|
+
/** Claude Code's own limit for a `--worktree` name (2.1.296). */
|
|
66
|
+
export const MAX_WORKTREE_NAME_LENGTH = 64;
|
|
67
|
+
/** Longest accepted branch name. git allows more; 200 covers every real one. */
|
|
68
|
+
export const MAX_WORKTREE_BRANCH_LENGTH = 200;
|
|
69
|
+
/** A read-only git call (rev-parse, worktree list, check-ignore, check-ref-format). */
|
|
70
|
+
export const WORKTREE_READ_TIMEOUT_MS = 15_000;
|
|
71
|
+
/**
|
|
72
|
+
* `git worktree add` checks out a whole tree and runs the repository's post-checkout hook and
|
|
73
|
+
* smudge filters (Git LFS downloads objects there), so it gets the longest budget.
|
|
74
|
+
*/
|
|
75
|
+
export const WORKTREE_ADD_TIMEOUT_MS = 180_000;
|
|
76
|
+
/** `git worktree remove` runs a status of the tree first, then deletes it. */
|
|
77
|
+
export const WORKTREE_REMOVE_TIMEOUT_MS = 60_000;
|
|
78
|
+
/** Captured stdout. `worktree list` is one short block per worktree. */
|
|
79
|
+
const MAX_STDOUT_BYTES = 1_000_000;
|
|
80
|
+
/** Captured stderr tail. */
|
|
81
|
+
const MAX_STDERR_BYTES = 8_192;
|
|
82
|
+
/** Worktrees listed. A repository with more is unusual; the count says when it was cut. */
|
|
83
|
+
export const MAX_WORKTREES_LISTED = 200;
|
|
84
|
+
/** Path segments of the managed worktrees folder, relative to the repository root. */
|
|
85
|
+
export const WORKTREES_DIR_SEGMENTS = ['.claude', 'worktrees'];
|
|
86
|
+
/** The `info/exclude` line that hides the managed worktrees from the main checkout. */
|
|
87
|
+
export const WORKTREES_EXCLUDE_LINE = '/.claude/worktrees/';
|
|
88
|
+
/** The `info/exclude` line for the hooks file Codeman writes into every claude workspace. */
|
|
89
|
+
export const SETTINGS_LOCAL_EXCLUDE_LINE = '/.claude/settings.local.json';
|
|
90
|
+
/** A workspace-relative skills folder (`.claude/skills`, `.agents/skills`): plain segments only. */
|
|
91
|
+
const SKILLS_DIR_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
|
|
92
|
+
/**
|
|
93
|
+
* The `info/exclude` line for the copy of Codeman's agent skill in `skillsDir` of a workspace
|
|
94
|
+
* (`<skillsDir>/codeman/`, written into the folder a session runs in while `agentSkillEnabled`
|
|
95
|
+
* is on), or null for a folder that is not a plain relative path.
|
|
96
|
+
*/
|
|
97
|
+
export function agentSkillExcludeLine(skillsDir) {
|
|
98
|
+
if (!SKILLS_DIR_RE.test(skillsDir) || skillsDir.split('/').some((s) => s === '.' || s === '..'))
|
|
99
|
+
return null;
|
|
100
|
+
return `/${skillsDir}/codeman/`;
|
|
101
|
+
}
|
|
102
|
+
// ─── Pure: names ─────────────────────────────────────────────────────────────
|
|
103
|
+
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
104
|
+
const BRANCH_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
105
|
+
/**
|
|
106
|
+
* Why `name` cannot be a worktree name, or null when it can. One path segment of letters, digits,
|
|
107
|
+
* `.`, `_` and `-`, starting with a letter or digit, at most 64 characters, never `..` (the
|
|
108
|
+
* session path schema refuses any path containing it), and not ending in `.` or `.lock` (git's
|
|
109
|
+
* own ref rules, since the default branch is derived from the name).
|
|
110
|
+
*/
|
|
111
|
+
export function worktreeNameError(name) {
|
|
112
|
+
if (typeof name !== 'string' || name.length === 0)
|
|
113
|
+
return 'A worktree name is required.';
|
|
114
|
+
if (name.length > MAX_WORKTREE_NAME_LENGTH)
|
|
115
|
+
return `A worktree name is at most ${MAX_WORKTREE_NAME_LENGTH} characters.`;
|
|
116
|
+
if (!WORKTREE_NAME_RE.test(name))
|
|
117
|
+
return 'A worktree name may contain only letters, digits, ".", "_" and "-", and must start with a letter or digit.';
|
|
118
|
+
if (name.includes('..'))
|
|
119
|
+
return 'A worktree name may not contain "..".';
|
|
120
|
+
if (name.endsWith('.') || name.toLowerCase().endsWith('.lock'))
|
|
121
|
+
return 'A worktree name may not end with "." or ".lock".';
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
/** The branch a new worktree gets when the caller names none: Claude Code's `worktree-<name>`. */
|
|
125
|
+
export function defaultWorktreeBranch(name) {
|
|
126
|
+
return `worktree-${name}`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Why `branch` cannot be a new branch name, or null when it passes the charset gate. The final
|
|
130
|
+
* word is `git check-ref-format --branch` (see `createCaseWorktree`); this gate exists so nothing
|
|
131
|
+
* option-shaped, `@{...}`-shaped or otherwise surprising ever reaches git at all.
|
|
132
|
+
*/
|
|
133
|
+
export function worktreeBranchError(branch) {
|
|
134
|
+
if (typeof branch !== 'string' || branch.length === 0)
|
|
135
|
+
return 'A branch name is required.';
|
|
136
|
+
if (branch.length > MAX_WORKTREE_BRANCH_LENGTH)
|
|
137
|
+
return `A branch name is at most ${MAX_WORKTREE_BRANCH_LENGTH} characters.`;
|
|
138
|
+
if (!BRANCH_NAME_RE.test(branch))
|
|
139
|
+
return 'A branch name may contain only letters, digits, ".", "_", "-" and "/", and must start with a letter or digit.';
|
|
140
|
+
if (branch.includes('..') || branch.includes('//'))
|
|
141
|
+
return 'A branch name may not contain ".." or "//".';
|
|
142
|
+
if (branch.endsWith('/') || branch.endsWith('.') || branch.toLowerCase().endsWith('.lock'))
|
|
143
|
+
return 'A branch name may not end with "/", "." or ".lock".';
|
|
144
|
+
if (branch.split('/').some((part) => part.startsWith('.')))
|
|
145
|
+
return 'No part of a branch name may start with ".".';
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** Why `base` cannot be a start point, or null. The same gate Clone Repo uses for a ref. */
|
|
149
|
+
export function worktreeBaseError(base) {
|
|
150
|
+
if (typeof base !== 'string' || !isSafeGitRef(base))
|
|
151
|
+
return 'The base must be a branch, tag or commit (letters, digits, ".", "_", "-", "/" and "+").';
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
// ─── Pure: paths ─────────────────────────────────────────────────────────────
|
|
155
|
+
/** `<repo>/.claude/worktrees`. */
|
|
156
|
+
export function managedWorktreesDir(repoRoot) {
|
|
157
|
+
return join(repoRoot, ...WORKTREES_DIR_SEGMENTS);
|
|
158
|
+
}
|
|
159
|
+
/** `<repo>/.claude/worktrees/<name>`. The caller validates `name` first. */
|
|
160
|
+
export function managedWorktreePath(repoRoot, name) {
|
|
161
|
+
return join(managedWorktreesDir(repoRoot), name);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The managed worktree name of `path`, or null when it is not exactly
|
|
165
|
+
* `<repoRoot>/.claude/worktrees/<valid-name>`. Lexical: both sides must already be real paths.
|
|
166
|
+
*/
|
|
167
|
+
export function managedWorktreeName(repoRoot, path) {
|
|
168
|
+
const dir = managedWorktreesDir(repoRoot) + sep;
|
|
169
|
+
const clean = path.endsWith(sep) && path.length > 1 ? path.slice(0, -1) : path;
|
|
170
|
+
if (!clean.startsWith(dir))
|
|
171
|
+
return null;
|
|
172
|
+
const name = clean.slice(dir.length);
|
|
173
|
+
if (!name || name.includes(sep))
|
|
174
|
+
return null;
|
|
175
|
+
return worktreeNameError(name) === null ? name : null;
|
|
176
|
+
}
|
|
177
|
+
/** True when `child` is `root` or inside it (lexical, on resolved paths). */
|
|
178
|
+
export function isPathWithin(child, root) {
|
|
179
|
+
const c = resolve(child);
|
|
180
|
+
const r = resolve(root);
|
|
181
|
+
return c === r || c.startsWith(r === sep ? r : r + sep);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* `path` as the repository spells it: a path under `alias` (the case folder as its case resolves
|
|
185
|
+
* it, which can run through a symlink) moved under `root` (the repository's real top level), else
|
|
186
|
+
* `path` unchanged. Lexical, so it costs no filesystem call per session. Sessions keep the
|
|
187
|
+
* `workingDir` they were created with, while the worktree paths git lists are real, so without
|
|
188
|
+
* this a session spelled through the case's link counted for no worktree and did not block its
|
|
189
|
+
* removal.
|
|
190
|
+
*/
|
|
191
|
+
export function respellUnder(path, alias, root) {
|
|
192
|
+
const p = resolve(path);
|
|
193
|
+
const a = resolve(alias);
|
|
194
|
+
if (a === resolve(root) || !isPathWithin(p, a))
|
|
195
|
+
return p;
|
|
196
|
+
return join(root, p.slice(a.length));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Undo git's C-style path quoting (`"a\\"b\\303\\251"` → `a"bé`). Paths are printed quoted when
|
|
200
|
+
* they hold a `"`, a `\` or a control character even with `core.quotePath=false`; octal escapes are
|
|
201
|
+
* raw bytes, decoded as UTF-8. Text that is not quoted comes back unchanged.
|
|
202
|
+
*/
|
|
203
|
+
export function unquoteGitPath(text) {
|
|
204
|
+
if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"'))
|
|
205
|
+
return text;
|
|
206
|
+
const body = text.slice(1, -1);
|
|
207
|
+
const bytes = [];
|
|
208
|
+
const simple = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 };
|
|
209
|
+
for (let i = 0; i < body.length; i++) {
|
|
210
|
+
const ch = body[i];
|
|
211
|
+
if (ch !== '\\') {
|
|
212
|
+
for (const b of Buffer.from(ch, 'utf-8'))
|
|
213
|
+
bytes.push(b);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const next = body[i + 1];
|
|
217
|
+
if (next !== undefined && /[0-7]/.test(next)) {
|
|
218
|
+
const oct = /^[0-7]{1,3}/.exec(body.slice(i + 1))[0];
|
|
219
|
+
bytes.push(parseInt(oct, 8) & 0xff);
|
|
220
|
+
i += oct.length;
|
|
221
|
+
}
|
|
222
|
+
else if (next !== undefined && next in simple) {
|
|
223
|
+
bytes.push(simple[next]);
|
|
224
|
+
i += 1;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
bytes.push(92);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return Buffer.from(bytes).toString('utf-8');
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Parse `git worktree list --porcelain` (newline-terminated form: `-z` needs git 2.36, and Ubuntu
|
|
234
|
+
* 22.04 ships 2.34). Records are separated by blank lines; the first is the main worktree. Paths
|
|
235
|
+
* are read through `unquoteGitPath`, which together with `-c core.quotePath=false` keeps non-ASCII
|
|
236
|
+
* repository paths readable.
|
|
237
|
+
*/
|
|
238
|
+
export function parseWorktreeList(text) {
|
|
239
|
+
const out = [];
|
|
240
|
+
let cur = null;
|
|
241
|
+
const flush = () => {
|
|
242
|
+
if (cur)
|
|
243
|
+
out.push(cur);
|
|
244
|
+
cur = null;
|
|
245
|
+
};
|
|
246
|
+
for (const raw of text.split('\n')) {
|
|
247
|
+
const line = raw.replace(/\r$/, '');
|
|
248
|
+
if (!line) {
|
|
249
|
+
flush();
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const space = line.indexOf(' ');
|
|
253
|
+
const key = space === -1 ? line : line.slice(0, space);
|
|
254
|
+
const value = space === -1 ? '' : line.slice(space + 1);
|
|
255
|
+
if (key === 'worktree') {
|
|
256
|
+
flush();
|
|
257
|
+
cur = {
|
|
258
|
+
path: unquoteGitPath(value),
|
|
259
|
+
head: null,
|
|
260
|
+
branch: null,
|
|
261
|
+
detached: false,
|
|
262
|
+
bare: false,
|
|
263
|
+
locked: false,
|
|
264
|
+
prunable: false,
|
|
265
|
+
};
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (!cur)
|
|
269
|
+
continue;
|
|
270
|
+
const entry = cur;
|
|
271
|
+
if (key === 'HEAD')
|
|
272
|
+
entry.head = value || null;
|
|
273
|
+
else if (key === 'branch')
|
|
274
|
+
entry.branch = value.startsWith('refs/heads/') ? value.slice(11) : value || null;
|
|
275
|
+
else if (key === 'detached')
|
|
276
|
+
entry.detached = true;
|
|
277
|
+
else if (key === 'bare')
|
|
278
|
+
entry.bare = true;
|
|
279
|
+
else if (key === 'locked') {
|
|
280
|
+
entry.locked = true;
|
|
281
|
+
if (value)
|
|
282
|
+
entry.lockReason = value;
|
|
283
|
+
}
|
|
284
|
+
else if (key === 'prunable') {
|
|
285
|
+
entry.prunable = true;
|
|
286
|
+
if (value)
|
|
287
|
+
entry.prunableReason = value;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
flush();
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
// ─── Pure: exclude file ──────────────────────────────────────────────────────
|
|
294
|
+
/**
|
|
295
|
+
* What to append to an `info/exclude` holding `existing` so that every line of `lines` is in it:
|
|
296
|
+
* the text ('' when nothing is missing) and the lines it adds. A file that does not end in a
|
|
297
|
+
* newline gets one first, so an appended pattern never fuses with the user's last line.
|
|
298
|
+
*/
|
|
299
|
+
export function excludeAppendText(existing, lines) {
|
|
300
|
+
const present = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
|
|
301
|
+
const added = lines.filter((l) => !present.has(l));
|
|
302
|
+
if (added.length === 0)
|
|
303
|
+
return { text: '', added };
|
|
304
|
+
const lead = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
305
|
+
return {
|
|
306
|
+
text: `${lead}# Added by Codeman: git worktrees and the files Codeman writes into each workspace stay local\n${added.join('\n')}\n`,
|
|
307
|
+
added,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
// ─── Pure: git's answers ─────────────────────────────────────────────────────
|
|
311
|
+
/**
|
|
312
|
+
* The part of git's stderr a user should see: its `fatal:`/`error:` lines (with any `hint:` lines
|
|
313
|
+
* dropped, they repeat what the UI says), sanitized and capped. Shown through `textContent` only.
|
|
314
|
+
*/
|
|
315
|
+
export function gitRefusalText(stderr) {
|
|
316
|
+
const clean = sanitizeGitOutput(stderr, 2000);
|
|
317
|
+
const lines = clean
|
|
318
|
+
.split('\n')
|
|
319
|
+
.map((l) => l.trim())
|
|
320
|
+
.filter((l) => l && !/^hint:/i.test(l));
|
|
321
|
+
const diagnostic = lines.filter((l) => /^(fatal|error|warning):/i.test(l));
|
|
322
|
+
const text = (diagnostic.length ? diagnostic : lines).join('\n');
|
|
323
|
+
return text.length > 1000 ? `${text.slice(0, 1000)}…` : text;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Global options for a call that only reads: no optional index refresh, no filesystem monitor,
|
|
327
|
+
* no path quoting of non-ASCII names. Must precede the subcommand.
|
|
328
|
+
*/
|
|
329
|
+
const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.quotePath=false'];
|
|
330
|
+
/**
|
|
331
|
+
* The production runner: argv only (no shell), the non-interactive environment, captured output
|
|
332
|
+
* caps, and a timeout that signals the whole process group (`detached: true`), because
|
|
333
|
+
* `git worktree add` fans out into checkout children, hooks and filters that a signal to the
|
|
334
|
+
* parent alone would leave running.
|
|
335
|
+
*/
|
|
336
|
+
export const runWorktreeGit = (cwd, args, opts) => new Promise((resolveRun) => {
|
|
337
|
+
let child;
|
|
338
|
+
try {
|
|
339
|
+
child = spawn('git', args, {
|
|
340
|
+
cwd,
|
|
341
|
+
env: { ...gitNonInteractiveEnv(), GIT_OPTIONAL_LOCKS: '0' },
|
|
342
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
343
|
+
detached: true,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(err) });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
let stdout = '';
|
|
351
|
+
let stdoutBytes = 0;
|
|
352
|
+
let stderr = '';
|
|
353
|
+
let timedOut = false;
|
|
354
|
+
let settled = false;
|
|
355
|
+
let killTimer;
|
|
356
|
+
const killTree = (signal) => {
|
|
357
|
+
try {
|
|
358
|
+
if (child.pid)
|
|
359
|
+
process.kill(-child.pid, signal);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
try {
|
|
363
|
+
child.kill(signal);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
/* already gone */
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
const timer = setTimeout(() => {
|
|
371
|
+
timedOut = true;
|
|
372
|
+
killTree('SIGTERM');
|
|
373
|
+
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
|
|
374
|
+
}, opts?.timeoutMs ?? WORKTREE_READ_TIMEOUT_MS);
|
|
375
|
+
child.stdout?.on('data', (chunk) => {
|
|
376
|
+
stdoutBytes += chunk.length;
|
|
377
|
+
if (stdoutBytes <= MAX_STDOUT_BYTES)
|
|
378
|
+
stdout += chunk.toString('utf-8');
|
|
379
|
+
});
|
|
380
|
+
child.stderr?.on('data', (chunk) => {
|
|
381
|
+
stderr += chunk.toString('utf-8');
|
|
382
|
+
if (stderr.length > MAX_STDERR_BYTES * 2)
|
|
383
|
+
stderr = stderr.slice(-MAX_STDERR_BYTES);
|
|
384
|
+
});
|
|
385
|
+
const finish = (result) => {
|
|
386
|
+
if (settled)
|
|
387
|
+
return;
|
|
388
|
+
settled = true;
|
|
389
|
+
clearTimeout(timer);
|
|
390
|
+
if (killTimer)
|
|
391
|
+
clearTimeout(killTimer);
|
|
392
|
+
resolveRun(result);
|
|
393
|
+
};
|
|
394
|
+
child.on('error', (err) => finish({ code: null, stdout, stderr, timedOut, spawnError: String(err) }));
|
|
395
|
+
child.on('close', (code) => finish({ code, stdout, stderr, timedOut }));
|
|
396
|
+
});
|
|
397
|
+
const fail = (code, message, gitMessage) => ({
|
|
398
|
+
ok: false,
|
|
399
|
+
code,
|
|
400
|
+
message,
|
|
401
|
+
...(gitMessage ? { gitMessage } : {}),
|
|
402
|
+
});
|
|
403
|
+
function gitFailure(run, what) {
|
|
404
|
+
if (run.spawnError && /enoent/i.test(run.spawnError))
|
|
405
|
+
return fail('OPERATION_FAILED', 'git is not installed on this machine (or not on the server’s PATH).');
|
|
406
|
+
if (run.timedOut)
|
|
407
|
+
return fail('OPERATION_FAILED', `git timed out while ${what}.`);
|
|
408
|
+
const text = gitRefusalText(run.stderr);
|
|
409
|
+
return fail('OPERATION_FAILED', text ? `git failed while ${what}: ${text}` : `git failed while ${what}.`, text);
|
|
410
|
+
}
|
|
411
|
+
const realOr = async (p) => {
|
|
412
|
+
try {
|
|
413
|
+
return await fs.realpath(p);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return resolve(p);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const DOCKER_REFUSAL = 'Worktrees are not available for a folder inside a Docker case workspace: a container can write there, and git would run its hooks and filters on this machine.';
|
|
420
|
+
const DOCKER_WORKTREE_REFUSAL = 'This worktree is, or holds, a Docker case workspace: git would run inside a folder a container can write, and removing it would delete that workspace. Remove the Docker case first.';
|
|
421
|
+
/**
|
|
422
|
+
* The repository a case folder IS, or why it is not one Codeman makes worktrees for. Never runs
|
|
423
|
+
* git when the folder (or the repository's common git dir) is at or inside a Docker case workspace.
|
|
424
|
+
*/
|
|
425
|
+
export async function resolveCaseRepository(casePath, opts = {}) {
|
|
426
|
+
const git = opts.git ?? runWorktreeGit;
|
|
427
|
+
const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr));
|
|
428
|
+
if (await isInsideAny(casePath, dockerRoots))
|
|
429
|
+
return fail('INVALID_INPUT', DOCKER_REFUSAL);
|
|
430
|
+
const run = await git(casePath, [...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--show-toplevel', '--git-common-dir'], {
|
|
431
|
+
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
|
432
|
+
});
|
|
433
|
+
if (run.code !== 0 || run.spawnError || run.timedOut) {
|
|
434
|
+
if (/must be run in a work tree/i.test(run.stderr)) {
|
|
435
|
+
return fail('INVALID_INPUT', 'This case folder is a bare git repository, which has no work tree to branch from.');
|
|
436
|
+
}
|
|
437
|
+
if (/not a git repository/i.test(run.stderr)) {
|
|
438
|
+
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
|
|
439
|
+
}
|
|
440
|
+
return gitFailure(run, 'reading the repository');
|
|
441
|
+
}
|
|
442
|
+
const [top, common] = run.stdout.split('\n').map((l) => l.trim());
|
|
443
|
+
if (!top || !common)
|
|
444
|
+
return fail('OPERATION_FAILED', 'git did not report the repository root.');
|
|
445
|
+
const [repoRoot, caseReal] = await Promise.all([realOr(top), realOr(casePath)]);
|
|
446
|
+
const commonDir = await realOr(resolve(casePath, common));
|
|
447
|
+
if (await isInsideAny(commonDir, dockerRoots))
|
|
448
|
+
return fail('INVALID_INPUT', DOCKER_REFUSAL);
|
|
449
|
+
if (repoRoot !== caseReal) {
|
|
450
|
+
if (await isUnrelatedAncestor(repoRoot, caseReal, opts.home ?? homedir())) {
|
|
451
|
+
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
|
|
452
|
+
}
|
|
453
|
+
return fail('INVALID_INPUT', `Worktrees need the case folder to be the top of a git repository; this folder is inside ${repoRoot}.`);
|
|
454
|
+
}
|
|
455
|
+
return { ok: true, repo: { repoRoot, commonDir } };
|
|
456
|
+
}
|
|
457
|
+
export async function listCaseWorktrees(repo, opts = {}) {
|
|
458
|
+
const git = opts.git ?? runWorktreeGit;
|
|
459
|
+
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'worktree', 'list', '--porcelain'], {
|
|
460
|
+
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
|
461
|
+
});
|
|
462
|
+
if (run.code !== 0 || run.spawnError || run.timedOut)
|
|
463
|
+
return gitFailure(run, 'listing worktrees');
|
|
464
|
+
const parsed = parseWorktreeList(run.stdout);
|
|
465
|
+
const truncated = parsed.length > MAX_WORKTREES_LISTED;
|
|
466
|
+
const worktrees = parsed.slice(0, MAX_WORKTREES_LISTED).map((wt, index) => {
|
|
467
|
+
const name = index === 0 ? null : managedWorktreeName(repo.repoRoot, wt.path);
|
|
468
|
+
return {
|
|
469
|
+
...wt,
|
|
470
|
+
main: index === 0,
|
|
471
|
+
isCase: wt.path === repo.repoRoot,
|
|
472
|
+
managed: name !== null,
|
|
473
|
+
...(name ? { name } : {}),
|
|
474
|
+
};
|
|
475
|
+
});
|
|
476
|
+
return { ok: true, worktrees, truncated };
|
|
477
|
+
}
|
|
478
|
+
async function lstatState(path) {
|
|
479
|
+
try {
|
|
480
|
+
const st = await fs.lstat(path);
|
|
481
|
+
if (st.isSymbolicLink())
|
|
482
|
+
return 'symlink';
|
|
483
|
+
return st.isDirectory() ? 'dir' : 'other';
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
const code = err.code;
|
|
487
|
+
if (code === 'ENOENT')
|
|
488
|
+
return 'absent';
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Make sure `<repo>/.claude` and `<repo>/.claude/worktrees` are real directories (created when
|
|
494
|
+
* absent, refused when a symlink or a file) whose real path is exactly the lexical one.
|
|
495
|
+
*/
|
|
496
|
+
async function prepareWorktreesDir(repoRoot) {
|
|
497
|
+
let cur = repoRoot;
|
|
498
|
+
for (const segment of WORKTREES_DIR_SEGMENTS) {
|
|
499
|
+
cur = join(cur, segment);
|
|
500
|
+
const state = await lstatState(cur);
|
|
501
|
+
if (state === 'symlink') {
|
|
502
|
+
return fail('INVALID_INPUT', `${cur} is a symlink. Codeman will not create worktrees through a link that could point outside the repository.`);
|
|
503
|
+
}
|
|
504
|
+
if (state === 'other')
|
|
505
|
+
return fail('INVALID_INPUT', `${cur} exists and is not a directory.`);
|
|
506
|
+
if (state === 'absent') {
|
|
507
|
+
try {
|
|
508
|
+
await fs.mkdir(cur);
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
if (err.code !== 'EEXIST') {
|
|
512
|
+
return fail('OPERATION_FAILED', `Could not create ${cur}: ${err.message}`);
|
|
513
|
+
}
|
|
514
|
+
if ((await lstatState(cur)) !== 'dir')
|
|
515
|
+
return fail('INVALID_INPUT', `${cur} is not a plain directory.`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if ((await realOr(cur)) !== cur) {
|
|
520
|
+
return fail('INVALID_INPUT', `${cur} does not resolve to itself; refusing to create a worktree there.`);
|
|
521
|
+
}
|
|
522
|
+
return { ok: true, dir: cur };
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* The managed worktree `name` of `repo` as it is on disk now: every component a real directory
|
|
526
|
+
* (no symlinks), the real path the expected one, and git listing it as a worktree of this
|
|
527
|
+
* repository. Used by removal and by starting a session in it.
|
|
528
|
+
*
|
|
529
|
+
* `allowMissing` (removal only, never a session start) also accepts a worktree whose folder is
|
|
530
|
+
* GONE (deleted by hand, or by an agent) while git still lists it, as prunable, at exactly the
|
|
531
|
+
* managed path: no component on the way may be a symlink or a file, and `missing` says so.
|
|
532
|
+
* Without it such a worktree was a dead end: it could not be removed, and its name could not be
|
|
533
|
+
* used again (the branch exists, then git refuses "a missing but already registered worktree").
|
|
534
|
+
*/
|
|
535
|
+
export async function resolveManagedWorktree(repo, name, opts = {}) {
|
|
536
|
+
const nameError = worktreeNameError(name);
|
|
537
|
+
if (nameError)
|
|
538
|
+
return fail('INVALID_INPUT', nameError);
|
|
539
|
+
const target = managedWorktreePath(repo.repoRoot, name);
|
|
540
|
+
let cur = repo.repoRoot;
|
|
541
|
+
let missing = false;
|
|
542
|
+
for (const segment of [...WORKTREES_DIR_SEGMENTS, name]) {
|
|
543
|
+
cur = join(cur, segment);
|
|
544
|
+
const state = await lstatState(cur);
|
|
545
|
+
if (state === 'absent') {
|
|
546
|
+
if (!opts.allowMissing)
|
|
547
|
+
return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
|
|
548
|
+
// Nothing below an absent folder exists, so nothing below it can redirect anything.
|
|
549
|
+
missing = true;
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
if (state === 'symlink') {
|
|
553
|
+
return fail('INVALID_INPUT', `${cur} is a symlink. Codeman only manages worktrees that are real directories.`);
|
|
554
|
+
}
|
|
555
|
+
if (state === 'other')
|
|
556
|
+
return fail('INVALID_INPUT', `${cur} is not a directory.`);
|
|
557
|
+
}
|
|
558
|
+
if (!missing && (await realOr(target)) !== target) {
|
|
559
|
+
return fail('INVALID_INPUT', `${target} does not resolve to itself; refusing to use it.`);
|
|
560
|
+
}
|
|
561
|
+
const listed = await listCaseWorktrees(repo, opts);
|
|
562
|
+
if (!listed.ok)
|
|
563
|
+
return listed;
|
|
564
|
+
for (const wt of listed.worktrees) {
|
|
565
|
+
if (wt.main)
|
|
566
|
+
continue;
|
|
567
|
+
if (missing) {
|
|
568
|
+
// Lexical only: the folder is gone, so there is no real path to compare. git never calls a
|
|
569
|
+
// LOCKED worktree prunable (prune skips it), so a locked one is taken too, and the caller's
|
|
570
|
+
// lock check then refuses it with git's lock reason.
|
|
571
|
+
if (wt.path === target && (wt.prunable || wt.locked)) {
|
|
572
|
+
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: true };
|
|
573
|
+
}
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (wt.path === target || (await realOr(wt.path)) === target) {
|
|
577
|
+
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: false };
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (missing)
|
|
581
|
+
return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
|
|
582
|
+
return fail('NOT_FOUND', `${target} is not a registered git worktree of this repository (a leftover folder, or one git no longer knows about).`);
|
|
583
|
+
}
|
|
584
|
+
// ─── IO: excludes ────────────────────────────────────────────────────────────
|
|
585
|
+
/**
|
|
586
|
+
* Append each of `lines` that git does not already ignore to `<commonDir>/info/exclude`. Returns
|
|
587
|
+
* the lines written. A symlinked `info` dir or `exclude` file is left alone (reported, never
|
|
588
|
+
* written through).
|
|
589
|
+
*/
|
|
590
|
+
async function ensureExcluded(repo, probes, git) {
|
|
591
|
+
const needed = [];
|
|
592
|
+
for (const { line, probe } of probes) {
|
|
593
|
+
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ignore', '-q', '--', probe], {
|
|
594
|
+
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
|
595
|
+
});
|
|
596
|
+
// 0: ignored. 1: not ignored. Anything else (128) is a git error: assume not ignored, since an
|
|
597
|
+
// extra exclude line is harmless and a missing one leaves the worktrees untracked.
|
|
598
|
+
if (run.code !== 0)
|
|
599
|
+
needed.push(line);
|
|
600
|
+
}
|
|
601
|
+
if (needed.length === 0)
|
|
602
|
+
return { added: [] };
|
|
603
|
+
const infoDir = join(repo.commonDir, 'info');
|
|
604
|
+
const excludePath = join(infoDir, 'exclude');
|
|
605
|
+
try {
|
|
606
|
+
const infoState = await lstatState(infoDir);
|
|
607
|
+
if (infoState === 'absent')
|
|
608
|
+
await fs.mkdir(infoDir);
|
|
609
|
+
else if (infoState !== 'dir')
|
|
610
|
+
return { added: [], warning: `${infoDir} is not a plain directory; nothing was excluded.` };
|
|
611
|
+
const fileState = await lstatState(excludePath);
|
|
612
|
+
if (fileState === 'symlink' || fileState === 'dir') {
|
|
613
|
+
return { added: [], warning: `${excludePath} is not a plain file; nothing was excluded.` };
|
|
614
|
+
}
|
|
615
|
+
const existing = fileState === 'absent' ? '' : await fs.readFile(excludePath, 'utf-8');
|
|
616
|
+
const { text, added } = excludeAppendText(existing, needed);
|
|
617
|
+
if (text)
|
|
618
|
+
await fs.appendFile(excludePath, text, { mode: 0o644 });
|
|
619
|
+
return { added };
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
return { added: [], warning: `Could not update ${excludePath}: ${err.message}` };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// ─── IO: one operation per repository at a time ──────────────────────────────
|
|
626
|
+
const repoLocks = new Map();
|
|
627
|
+
/** Serialize create/remove per repository: two creates must not both pass the "absent" check. */
|
|
628
|
+
async function withRepoLock(repoRoot, fn) {
|
|
629
|
+
const prev = repoLocks.get(repoRoot) ?? Promise.resolve();
|
|
630
|
+
const next = prev.then(fn, fn);
|
|
631
|
+
const tail = next.catch(() => undefined);
|
|
632
|
+
repoLocks.set(repoRoot, tail);
|
|
633
|
+
try {
|
|
634
|
+
return await next;
|
|
635
|
+
}
|
|
636
|
+
finally {
|
|
637
|
+
if (repoLocks.get(repoRoot) === tail)
|
|
638
|
+
repoLocks.delete(repoRoot);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
export async function createCaseWorktree(repo, req, opts = {}) {
|
|
642
|
+
const git = opts.git ?? runWorktreeGit;
|
|
643
|
+
const nameError = worktreeNameError(req.name);
|
|
644
|
+
if (nameError)
|
|
645
|
+
return fail('INVALID_INPUT', nameError);
|
|
646
|
+
const branch = req.branch || defaultWorktreeBranch(req.name);
|
|
647
|
+
const branchError = worktreeBranchError(branch);
|
|
648
|
+
if (branchError)
|
|
649
|
+
return fail('INVALID_INPUT', branchError);
|
|
650
|
+
const base = req.base || 'HEAD';
|
|
651
|
+
const baseError = worktreeBaseError(base);
|
|
652
|
+
if (baseError)
|
|
653
|
+
return fail('INVALID_INPUT', baseError);
|
|
654
|
+
return withRepoLock(repo.repoRoot, async () => {
|
|
655
|
+
// A name already taken says so in those words, before the branch check would report the
|
|
656
|
+
// derived branch instead. Rechecked after the folders are prepared, below.
|
|
657
|
+
try {
|
|
658
|
+
if ((await lstatState(managedWorktreePath(repo.repoRoot, req.name))) !== 'absent') {
|
|
659
|
+
return fail('ALREADY_EXISTS', `A worktree named "${req.name}" already exists in this case.`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
/* unreadable: the check after the folders are prepared reports it */
|
|
664
|
+
}
|
|
665
|
+
// git's own word on the branch name. The output must be the input: `--branch` expands
|
|
666
|
+
// shorthands, and anything it rewrote is not the name the user typed.
|
|
667
|
+
const refFormat = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ref-format', '--branch', branch], {
|
|
668
|
+
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
|
669
|
+
});
|
|
670
|
+
if (refFormat.code !== 0 || refFormat.stdout.trim() !== branch) {
|
|
671
|
+
return fail('INVALID_INPUT', `"${branch}" is not a valid branch name.`, gitRefusalText(refFormat.stderr));
|
|
672
|
+
}
|
|
673
|
+
const existingBranch = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { timeoutMs: WORKTREE_READ_TIMEOUT_MS });
|
|
674
|
+
if (existingBranch.code === 0) {
|
|
675
|
+
return fail('ALREADY_EXISTS', `A branch named "${branch}" already exists. Pick another worktree name or branch.`);
|
|
676
|
+
}
|
|
677
|
+
const resolved = await git(repo.repoRoot,
|
|
678
|
+
// No `--end-of-options` (git 2.30+): the base already passed the ref gate, which refuses a
|
|
679
|
+
// leading `-`, so it cannot be read as an option.
|
|
680
|
+
[...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`], { timeoutMs: WORKTREE_READ_TIMEOUT_MS });
|
|
681
|
+
const head = resolved.stdout.trim();
|
|
682
|
+
if (resolved.code !== 0 || !/^[0-9a-f]{40,64}$/.test(head)) {
|
|
683
|
+
return fail('INVALID_INPUT', `"${base}" is not a commit in this repository (an empty repository has none yet).`);
|
|
684
|
+
}
|
|
685
|
+
const prepared = await prepareWorktreesDir(repo.repoRoot);
|
|
686
|
+
if (!prepared.ok)
|
|
687
|
+
return prepared;
|
|
688
|
+
const target = join(prepared.dir, req.name);
|
|
689
|
+
try {
|
|
690
|
+
if ((await lstatState(target)) !== 'absent') {
|
|
691
|
+
return fail('ALREADY_EXISTS', `${target} already exists. Pick another worktree name.`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
return fail('OPERATION_FAILED', `Could not inspect ${target}: ${err.message}`);
|
|
696
|
+
}
|
|
697
|
+
const skillProbes = [];
|
|
698
|
+
for (const dir of new Set(opts.agentSkillDirs ?? [])) {
|
|
699
|
+
const line = agentSkillExcludeLine(dir);
|
|
700
|
+
if (line)
|
|
701
|
+
skillProbes.push({ line, probe: `${dir}/codeman` });
|
|
702
|
+
}
|
|
703
|
+
const excluded = await ensureExcluded(repo, [
|
|
704
|
+
{ line: WORKTREES_EXCLUDE_LINE, probe: `${WORKTREES_DIR_SEGMENTS.join('/')}/${req.name}` },
|
|
705
|
+
{ line: SETTINGS_LOCAL_EXCLUDE_LINE, probe: '.claude/settings.local.json' },
|
|
706
|
+
...skillProbes,
|
|
707
|
+
], git);
|
|
708
|
+
// The resolved commit, not the ref text, after `--`: nothing here can be read as an option,
|
|
709
|
+
// and `--no-track` keeps a worktree started from `origin/main` from tracking (and pulling) main.
|
|
710
|
+
const add = await git(repo.repoRoot, ['worktree', 'add', '--no-track', '-b', branch, '--', target, head], {
|
|
711
|
+
timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
|
|
712
|
+
});
|
|
713
|
+
if (add.code !== 0 || add.spawnError || add.timedOut) {
|
|
714
|
+
const text = gitRefusalText(add.stderr);
|
|
715
|
+
if (/already exists/i.test(text))
|
|
716
|
+
return fail('ALREADY_EXISTS', `git refused: ${text}`, text);
|
|
717
|
+
return gitFailure(add, 'creating the worktree');
|
|
718
|
+
}
|
|
719
|
+
return {
|
|
720
|
+
ok: true,
|
|
721
|
+
worktree: {
|
|
722
|
+
name: req.name,
|
|
723
|
+
path: target,
|
|
724
|
+
branch,
|
|
725
|
+
base,
|
|
726
|
+
head,
|
|
727
|
+
excludeAdded: excluded.added,
|
|
728
|
+
warnings: excluded.warning ? [excluded.warning] : [],
|
|
729
|
+
},
|
|
730
|
+
};
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
export async function removeCaseWorktree(repo, req, opts = {}) {
|
|
734
|
+
const git = opts.git ?? runWorktreeGit;
|
|
735
|
+
return withRepoLock(repo.repoRoot, async () => {
|
|
736
|
+
// A worktree whose folder is gone is removable too: `git worktree remove` then only drops
|
|
737
|
+
// git's record of it (measured on git 2.43: exit 0, nothing on disk touched).
|
|
738
|
+
const found = await resolveManagedWorktree(repo, req.name, { ...opts, allowMissing: true });
|
|
739
|
+
if (!found.ok)
|
|
740
|
+
return found;
|
|
741
|
+
// The case passed the Docker rule, but the worktree folder itself can be a Docker case's
|
|
742
|
+
// workspace (a Docker case linked to it), or hold one. `git worktree remove` runs a status
|
|
743
|
+
// INSIDE the tree first (container-written files, checkout filters on this machine), then
|
|
744
|
+
// deletes the whole tree, ignored files included, under a container that still mounts it.
|
|
745
|
+
// A folder that is gone has neither problem: git only drops its record.
|
|
746
|
+
if (!found.missing) {
|
|
747
|
+
const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr));
|
|
748
|
+
if (dockerRoots.some((root) => isPathWithin(found.path, root) || isPathWithin(root, found.path))) {
|
|
749
|
+
return fail('INVALID_INPUT', DOCKER_WORKTREE_REFUSAL);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (await req.isInUse(found.path)) {
|
|
753
|
+
return fail('CONFLICT', `A live session is still running in worktree "${req.name}". Close it first, then remove the worktree.`);
|
|
754
|
+
}
|
|
755
|
+
if (found.worktree.locked) {
|
|
756
|
+
return fail('CONFLICT', `Worktree "${req.name}" is locked${found.worktree.lockReason ? ` (${found.worktree.lockReason})` : ''}. Unlock it with git first.`);
|
|
757
|
+
}
|
|
758
|
+
const removed = await git(repo.repoRoot, ['worktree', 'remove', '--', found.path], {
|
|
759
|
+
timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS,
|
|
760
|
+
});
|
|
761
|
+
if (removed.code !== 0 || removed.spawnError || removed.timedOut) {
|
|
762
|
+
if (removed.timedOut || removed.spawnError)
|
|
763
|
+
return gitFailure(removed, 'removing the worktree');
|
|
764
|
+
const text = gitRefusalText(removed.stderr);
|
|
765
|
+
// git's own refusal (modified or untracked files, a lock): Codeman never forces, so this is
|
|
766
|
+
// the answer, in git's words.
|
|
767
|
+
return fail('CONFLICT', text ? `git refused to remove the worktree: ${text}` : 'git refused to remove the worktree.', text);
|
|
768
|
+
}
|
|
769
|
+
const branch = found.worktree.branch;
|
|
770
|
+
let branchDeleted = false;
|
|
771
|
+
let branchError;
|
|
772
|
+
if (req.deleteBranch && branch) {
|
|
773
|
+
const del = await git(repo.repoRoot, ['branch', '-d', '--', branch], { timeoutMs: WORKTREE_READ_TIMEOUT_MS });
|
|
774
|
+
if (del.code === 0)
|
|
775
|
+
branchDeleted = true;
|
|
776
|
+
else
|
|
777
|
+
branchError = gitRefusalText(del.stderr) || 'git refused to delete the branch.';
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
ok: true,
|
|
781
|
+
removed: {
|
|
782
|
+
name: req.name,
|
|
783
|
+
path: found.path,
|
|
784
|
+
branch,
|
|
785
|
+
branchDeleted,
|
|
786
|
+
...(branchError ? { branchError } : {}),
|
|
787
|
+
},
|
|
788
|
+
};
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
//# sourceMappingURL=git-worktrees.js.map
|