@hellcoder/companion 0.96.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.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,813 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ // ─── Mock git-utils module ─────────────────────────────────────────────────
4
+ // Mocked before imports so every `import` of git-utils gets the mock.
5
+ vi.mock("../git-utils.js", () => ({
6
+ getRepoInfo: vi.fn(() => null),
7
+ listBranches: vi.fn(() => []),
8
+ listWorktrees: vi.fn(() => []),
9
+ ensureWorktree: vi.fn(() => ({
10
+ worktreePath: "/worktrees/feat",
11
+ branch: "feat",
12
+ actualBranch: "feat",
13
+ isNew: true,
14
+ })),
15
+ gitFetch: vi.fn(() => ({ success: true, output: "" })),
16
+ gitPull: vi.fn(() => ({ success: true, output: "" })),
17
+ removeWorktree: vi.fn(() => ({ removed: true })),
18
+ }));
19
+
20
+ // ─── Mock child_process for the git pull ahead/behind count ────────────────
21
+ vi.mock("node:child_process", () => ({
22
+ execSync: vi.fn(() => "0\t0"),
23
+ }));
24
+
25
+ // ─── Mock github-pr module for the PR status route ─────────────────────────
26
+ vi.mock("../github-pr.js", () => ({
27
+ isGhAvailable: vi.fn(() => false),
28
+ fetchPRInfoAsync: vi.fn(async () => null),
29
+ }));
30
+
31
+ import { Hono } from "hono";
32
+ import * as gitUtils from "../git-utils.js";
33
+ import { execSync } from "node:child_process";
34
+ import { registerGitRoutes } from "./git-routes.js";
35
+ import * as githubPr from "../github-pr.js";
36
+
37
+ // ─── Helpers ───────────────────────────────────────────────────────────────
38
+
39
+ /** Build a fresh Hono app with the git routes registered. */
40
+ function createApp(prPoller?: Parameters<typeof registerGitRoutes>[1]) {
41
+ const app = new Hono();
42
+ registerGitRoutes(app, prPoller);
43
+ return app;
44
+ }
45
+
46
+ /** Shorthand for POST/DELETE requests with a JSON body. */
47
+ function jsonRequest(
48
+ path: string,
49
+ body: Record<string, unknown>,
50
+ method: "POST" | "DELETE" = "POST",
51
+ ) {
52
+ return new Request(`http://localhost${path}`, {
53
+ method,
54
+ headers: { "Content-Type": "application/json" },
55
+ body: JSON.stringify(body),
56
+ });
57
+ }
58
+
59
+ // ─── Test Suite ────────────────────────────────────────────────────────────
60
+
61
+ let app: Hono;
62
+
63
+ beforeEach(() => {
64
+ vi.clearAllMocks();
65
+ app = createApp();
66
+ });
67
+
68
+ // ═══════════════════════════════════════════════════════════════════════════
69
+ // GET /git/repo-info
70
+ // ═══════════════════════════════════════════════════════════════════════════
71
+
72
+ describe("GET /git/repo-info", () => {
73
+ it("returns 400 when path query parameter is missing", async () => {
74
+ // The route requires a `path` query param; omitting it should yield 400
75
+ const res = await app.request("/git/repo-info");
76
+
77
+ expect(res.status).toBe(400);
78
+ const body = await res.json();
79
+ expect(body.error).toBe("path required");
80
+ });
81
+
82
+ it("returns 400 when the path is not a git repository", async () => {
83
+ // getRepoInfo returns null for non-git directories
84
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue(null);
85
+
86
+ const res = await app.request("/git/repo-info?path=/tmp/not-a-repo");
87
+
88
+ expect(res.status).toBe(400);
89
+ const body = await res.json();
90
+ expect(body.error).toBe("Not a git repository");
91
+ expect(gitUtils.getRepoInfo).toHaveBeenCalledWith("/tmp/not-a-repo");
92
+ });
93
+
94
+ it("returns repo info on success", async () => {
95
+ // When getRepoInfo finds a valid repo it returns a GitRepoInfo object
96
+ const mockInfo = {
97
+ repoRoot: "/home/user/project",
98
+ repoName: "project",
99
+ currentBranch: "main",
100
+ defaultBranch: "main",
101
+ isWorktree: false,
102
+ };
103
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue(mockInfo);
104
+
105
+ const res = await app.request(
106
+ `/git/repo-info?path=${encodeURIComponent("/home/user/project")}`,
107
+ );
108
+
109
+ expect(res.status).toBe(200);
110
+ const body = await res.json();
111
+ expect(body).toEqual(mockInfo);
112
+ expect(gitUtils.getRepoInfo).toHaveBeenCalledWith("/home/user/project");
113
+ });
114
+
115
+ it("passes the raw path value to getRepoInfo", async () => {
116
+ // Ensure URL-decoded paths are forwarded correctly
117
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue(null);
118
+
119
+ await app.request(
120
+ `/git/repo-info?path=${encodeURIComponent("/path/with spaces/repo")}`,
121
+ );
122
+
123
+ expect(gitUtils.getRepoInfo).toHaveBeenCalledWith(
124
+ "/path/with spaces/repo",
125
+ );
126
+ });
127
+ });
128
+
129
+ // ═══════════════════════════════════════════════════════════════════════════
130
+ // GET /git/branches
131
+ // ═══════════════════════════════════════════════════════════════════════════
132
+
133
+ describe("GET /git/branches", () => {
134
+ it("returns 400 when repoRoot query parameter is missing", async () => {
135
+ const res = await app.request("/git/branches");
136
+
137
+ expect(res.status).toBe(400);
138
+ const body = await res.json();
139
+ expect(body.error).toBe("repoRoot required");
140
+ });
141
+
142
+ it("returns an empty list when there are no branches", async () => {
143
+ vi.mocked(gitUtils.listBranches).mockReturnValue([]);
144
+
145
+ const res = await app.request("/git/branches?repoRoot=/repo");
146
+
147
+ expect(res.status).toBe(200);
148
+ const body = await res.json();
149
+ expect(body).toEqual([]);
150
+ expect(gitUtils.listBranches).toHaveBeenCalledWith("/repo");
151
+ });
152
+
153
+ it("returns branch info on success", async () => {
154
+ const branches = [
155
+ {
156
+ name: "main",
157
+ isCurrent: true,
158
+ isRemote: false,
159
+ worktreePath: null,
160
+ ahead: 0,
161
+ behind: 0,
162
+ },
163
+ {
164
+ name: "feature/login",
165
+ isCurrent: false,
166
+ isRemote: false,
167
+ worktreePath: null,
168
+ ahead: 2,
169
+ behind: 1,
170
+ },
171
+ ];
172
+ vi.mocked(gitUtils.listBranches).mockReturnValue(branches);
173
+
174
+ const res = await app.request("/git/branches?repoRoot=/repo");
175
+
176
+ expect(res.status).toBe(200);
177
+ const body = await res.json();
178
+ expect(body).toEqual(branches);
179
+ });
180
+
181
+ it("returns 500 when listBranches throws an error", async () => {
182
+ // The route wraps listBranches in a try/catch and returns 500 on failure
183
+ vi.mocked(gitUtils.listBranches).mockImplementation(() => {
184
+ throw new Error("fatal: not a git repository");
185
+ });
186
+
187
+ const res = await app.request("/git/branches?repoRoot=/bad-path");
188
+
189
+ expect(res.status).toBe(500);
190
+ const body = await res.json();
191
+ expect(body.error).toBe("fatal: not a git repository");
192
+ });
193
+
194
+ it("handles non-Error throws gracefully by stringifying", async () => {
195
+ // When a non-Error value is thrown, it should be stringified
196
+ vi.mocked(gitUtils.listBranches).mockImplementation(() => {
197
+ throw "unexpected string error"; // eslint-disable-line no-throw-literal
198
+ });
199
+
200
+ const res = await app.request("/git/branches?repoRoot=/bad-path");
201
+
202
+ expect(res.status).toBe(500);
203
+ const body = await res.json();
204
+ expect(body.error).toBe("unexpected string error");
205
+ });
206
+ });
207
+
208
+ // ═══════════════════════════════════════════════════════════════════════════
209
+ // POST /git/fetch
210
+ // ═══════════════════════════════════════════════════════════════════════════
211
+
212
+ describe("POST /git/fetch", () => {
213
+ it("returns 400 when repoRoot is missing from body", async () => {
214
+ const res = await app.request(jsonRequest("/git/fetch", {}));
215
+
216
+ expect(res.status).toBe(400);
217
+ const body = await res.json();
218
+ expect(body.error).toBe("repoRoot required");
219
+ });
220
+
221
+ it("returns fetch result on success", async () => {
222
+ vi.mocked(gitUtils.gitFetch).mockReturnValue({
223
+ success: true,
224
+ output: "From origin\n * branch main -> FETCH_HEAD",
225
+ });
226
+
227
+ const res = await app.request(
228
+ jsonRequest("/git/fetch", { repoRoot: "/repo" }),
229
+ );
230
+
231
+ expect(res.status).toBe(200);
232
+ const body = await res.json();
233
+ expect(body.success).toBe(true);
234
+ expect(body.output).toContain("FETCH_HEAD");
235
+ expect(gitUtils.gitFetch).toHaveBeenCalledWith("/repo");
236
+ });
237
+
238
+ it("returns failure result when gitFetch reports failure", async () => {
239
+ vi.mocked(gitUtils.gitFetch).mockReturnValue({
240
+ success: false,
241
+ output: "fatal: could not read from remote repository",
242
+ });
243
+
244
+ const res = await app.request(
245
+ jsonRequest("/git/fetch", { repoRoot: "/repo" }),
246
+ );
247
+
248
+ expect(res.status).toBe(200);
249
+ const body = await res.json();
250
+ expect(body.success).toBe(false);
251
+ expect(body.output).toContain("could not read from remote repository");
252
+ });
253
+
254
+ it("handles malformed JSON body gracefully", async () => {
255
+ // When the body is not valid JSON, the route catches the parse error
256
+ // and falls through to the missing repoRoot check
257
+ const res = await app.request(
258
+ new Request("http://localhost/git/fetch", {
259
+ method: "POST",
260
+ headers: { "Content-Type": "application/json" },
261
+ body: "not json",
262
+ }),
263
+ );
264
+
265
+ expect(res.status).toBe(400);
266
+ const body = await res.json();
267
+ expect(body.error).toBe("repoRoot required");
268
+ });
269
+ });
270
+
271
+ // ═══════════════════════════════════════════════════════════════════════════
272
+ // GET /git/worktrees
273
+ // ═══════════════════════════════════════════════════════════════════════════
274
+
275
+ describe("GET /git/worktrees", () => {
276
+ it("returns 400 when repoRoot query parameter is missing", async () => {
277
+ const res = await app.request("/git/worktrees");
278
+
279
+ expect(res.status).toBe(400);
280
+ const body = await res.json();
281
+ expect(body.error).toBe("repoRoot required");
282
+ });
283
+
284
+ it("returns an empty list when no worktrees exist", async () => {
285
+ vi.mocked(gitUtils.listWorktrees).mockReturnValue([]);
286
+
287
+ const res = await app.request("/git/worktrees?repoRoot=/repo");
288
+
289
+ expect(res.status).toBe(200);
290
+ const body = await res.json();
291
+ expect(body).toEqual([]);
292
+ expect(gitUtils.listWorktrees).toHaveBeenCalledWith("/repo");
293
+ });
294
+
295
+ it("returns worktree info on success", async () => {
296
+ const worktrees = [
297
+ {
298
+ path: "/repo",
299
+ branch: "main",
300
+ head: "abc123",
301
+ isMainWorktree: true,
302
+ isDirty: false,
303
+ },
304
+ {
305
+ path: "/worktrees/feat",
306
+ branch: "feature/login",
307
+ head: "def456",
308
+ isMainWorktree: false,
309
+ isDirty: true,
310
+ },
311
+ ];
312
+ vi.mocked(gitUtils.listWorktrees).mockReturnValue(worktrees);
313
+
314
+ const res = await app.request("/git/worktrees?repoRoot=/repo");
315
+
316
+ expect(res.status).toBe(200);
317
+ const body = await res.json();
318
+ expect(body).toEqual(worktrees);
319
+ });
320
+ });
321
+
322
+ // ═══════════════════════════════════════════════════════════════════════════
323
+ // POST /git/worktree (create/ensure)
324
+ // ═══════════════════════════════════════════════════════════════════════════
325
+
326
+ describe("POST /git/worktree", () => {
327
+ it("returns 400 when repoRoot is missing", async () => {
328
+ const res = await app.request(
329
+ jsonRequest("/git/worktree", { branch: "feat" }),
330
+ );
331
+
332
+ expect(res.status).toBe(400);
333
+ const body = await res.json();
334
+ expect(body.error).toBe("repoRoot and branch required");
335
+ });
336
+
337
+ it("returns 400 when branch is missing", async () => {
338
+ const res = await app.request(
339
+ jsonRequest("/git/worktree", { repoRoot: "/repo" }),
340
+ );
341
+
342
+ expect(res.status).toBe(400);
343
+ const body = await res.json();
344
+ expect(body.error).toBe("repoRoot and branch required");
345
+ });
346
+
347
+ it("returns 400 when both repoRoot and branch are missing", async () => {
348
+ const res = await app.request(jsonRequest("/git/worktree", {}));
349
+
350
+ expect(res.status).toBe(400);
351
+ const body = await res.json();
352
+ expect(body.error).toBe("repoRoot and branch required");
353
+ });
354
+
355
+ it("creates a worktree with minimal required params", async () => {
356
+ const result = {
357
+ worktreePath: "/worktrees/feat",
358
+ branch: "feat",
359
+ actualBranch: "feat",
360
+ isNew: true,
361
+ };
362
+ vi.mocked(gitUtils.ensureWorktree).mockReturnValue(result);
363
+
364
+ const res = await app.request(
365
+ jsonRequest("/git/worktree", { repoRoot: "/repo", branch: "feat" }),
366
+ );
367
+
368
+ expect(res.status).toBe(200);
369
+ const body = await res.json();
370
+ expect(body).toEqual(result);
371
+ expect(gitUtils.ensureWorktree).toHaveBeenCalledWith("/repo", "feat", {
372
+ baseBranch: undefined,
373
+ createBranch: undefined,
374
+ });
375
+ });
376
+
377
+ it("passes optional baseBranch and createBranch to ensureWorktree", async () => {
378
+ // The route should forward optional parameters from the request body
379
+ vi.mocked(gitUtils.ensureWorktree).mockReturnValue({
380
+ worktreePath: "/worktrees/new-feat",
381
+ branch: "new-feat",
382
+ actualBranch: "new-feat",
383
+ isNew: true,
384
+ });
385
+
386
+ const res = await app.request(
387
+ jsonRequest("/git/worktree", {
388
+ repoRoot: "/repo",
389
+ branch: "new-feat",
390
+ baseBranch: "develop",
391
+ createBranch: true,
392
+ }),
393
+ );
394
+
395
+ expect(res.status).toBe(200);
396
+ expect(gitUtils.ensureWorktree).toHaveBeenCalledWith(
397
+ "/repo",
398
+ "new-feat",
399
+ {
400
+ baseBranch: "develop",
401
+ createBranch: true,
402
+ },
403
+ );
404
+ });
405
+
406
+ it("returns an existing worktree when isNew is false", async () => {
407
+ // ensureWorktree may return an existing worktree rather than creating a new one
408
+ vi.mocked(gitUtils.ensureWorktree).mockReturnValue({
409
+ worktreePath: "/worktrees/feat",
410
+ branch: "feat",
411
+ actualBranch: "feat",
412
+ isNew: false,
413
+ });
414
+
415
+ const res = await app.request(
416
+ jsonRequest("/git/worktree", { repoRoot: "/repo", branch: "feat" }),
417
+ );
418
+
419
+ expect(res.status).toBe(200);
420
+ const body = await res.json();
421
+ expect(body.isNew).toBe(false);
422
+ });
423
+
424
+ it("handles malformed JSON body gracefully", async () => {
425
+ const res = await app.request(
426
+ new Request("http://localhost/git/worktree", {
427
+ method: "POST",
428
+ headers: { "Content-Type": "application/json" },
429
+ body: "{{bad json",
430
+ }),
431
+ );
432
+
433
+ expect(res.status).toBe(400);
434
+ const body = await res.json();
435
+ expect(body.error).toBe("repoRoot and branch required");
436
+ });
437
+ });
438
+
439
+ // ═══════════════════════════════════════════════════════════════════════════
440
+ // DELETE /git/worktree
441
+ // ═══════════════════════════════════════════════════════════════════════════
442
+
443
+ describe("DELETE /git/worktree", () => {
444
+ it("returns 400 when repoRoot is missing", async () => {
445
+ const res = await app.request(
446
+ jsonRequest("/git/worktree", { worktreePath: "/wt" }, "DELETE"),
447
+ );
448
+
449
+ expect(res.status).toBe(400);
450
+ const body = await res.json();
451
+ expect(body.error).toBe("repoRoot and worktreePath required");
452
+ });
453
+
454
+ it("returns 400 when worktreePath is missing", async () => {
455
+ const res = await app.request(
456
+ jsonRequest("/git/worktree", { repoRoot: "/repo" }, "DELETE"),
457
+ );
458
+
459
+ expect(res.status).toBe(400);
460
+ const body = await res.json();
461
+ expect(body.error).toBe("repoRoot and worktreePath required");
462
+ });
463
+
464
+ it("returns 400 when both repoRoot and worktreePath are missing", async () => {
465
+ const res = await app.request(
466
+ jsonRequest("/git/worktree", {}, "DELETE"),
467
+ );
468
+
469
+ expect(res.status).toBe(400);
470
+ const body = await res.json();
471
+ expect(body.error).toBe("repoRoot and worktreePath required");
472
+ });
473
+
474
+ it("removes a worktree without force", async () => {
475
+ vi.mocked(gitUtils.removeWorktree).mockReturnValue({ removed: true });
476
+
477
+ const res = await app.request(
478
+ jsonRequest(
479
+ "/git/worktree",
480
+ { repoRoot: "/repo", worktreePath: "/worktrees/feat" },
481
+ "DELETE",
482
+ ),
483
+ );
484
+
485
+ expect(res.status).toBe(200);
486
+ const body = await res.json();
487
+ expect(body.removed).toBe(true);
488
+ expect(gitUtils.removeWorktree).toHaveBeenCalledWith(
489
+ "/repo",
490
+ "/worktrees/feat",
491
+ { force: undefined },
492
+ );
493
+ });
494
+
495
+ it("passes force option when provided", async () => {
496
+ vi.mocked(gitUtils.removeWorktree).mockReturnValue({ removed: true });
497
+
498
+ const res = await app.request(
499
+ jsonRequest(
500
+ "/git/worktree",
501
+ { repoRoot: "/repo", worktreePath: "/worktrees/feat", force: true },
502
+ "DELETE",
503
+ ),
504
+ );
505
+
506
+ expect(res.status).toBe(200);
507
+ expect(gitUtils.removeWorktree).toHaveBeenCalledWith(
508
+ "/repo",
509
+ "/worktrees/feat",
510
+ { force: true },
511
+ );
512
+ });
513
+
514
+ it("returns failure reason when worktree removal fails", async () => {
515
+ // removeWorktree returns { removed: false, reason: "..." } on failure
516
+ vi.mocked(gitUtils.removeWorktree).mockReturnValue({
517
+ removed: false,
518
+ reason: "worktree is dirty",
519
+ });
520
+
521
+ const res = await app.request(
522
+ jsonRequest(
523
+ "/git/worktree",
524
+ { repoRoot: "/repo", worktreePath: "/worktrees/feat" },
525
+ "DELETE",
526
+ ),
527
+ );
528
+
529
+ expect(res.status).toBe(200);
530
+ const body = await res.json();
531
+ expect(body.removed).toBe(false);
532
+ expect(body.reason).toBe("worktree is dirty");
533
+ });
534
+
535
+ it("handles malformed JSON body gracefully", async () => {
536
+ const res = await app.request(
537
+ new Request("http://localhost/git/worktree", {
538
+ method: "DELETE",
539
+ headers: { "Content-Type": "application/json" },
540
+ body: "invalid",
541
+ }),
542
+ );
543
+
544
+ expect(res.status).toBe(400);
545
+ const body = await res.json();
546
+ expect(body.error).toBe("repoRoot and worktreePath required");
547
+ });
548
+ });
549
+
550
+ // ═══════════════════════════════════════════════════════════════════════════
551
+ // POST /git/pull
552
+ // ═══════════════════════════════════════════════════════════════════════════
553
+
554
+ describe("POST /git/pull", () => {
555
+ it("returns 400 when cwd is missing from body", async () => {
556
+ const res = await app.request(jsonRequest("/git/pull", {}));
557
+
558
+ expect(res.status).toBe(400);
559
+ const body = await res.json();
560
+ expect(body.error).toBe("cwd required");
561
+ });
562
+
563
+ it("returns pull result with ahead/behind counts on success", async () => {
564
+ // After pulling, the route runs `git rev-list` to get ahead/behind counts
565
+ vi.mocked(gitUtils.gitPull).mockReturnValue({
566
+ success: true,
567
+ output: "Already up to date.",
568
+ });
569
+ vi.mocked(execSync).mockReturnValue("3\t5" as any);
570
+
571
+ const res = await app.request(jsonRequest("/git/pull", { cwd: "/repo" }));
572
+
573
+ expect(res.status).toBe(200);
574
+ const body = await res.json();
575
+ expect(body.success).toBe(true);
576
+ expect(body.output).toBe("Already up to date.");
577
+ // execSync returns "behind\tahead" — the route parses [behind, ahead]
578
+ expect(body.git_behind).toBe(3);
579
+ expect(body.git_ahead).toBe(5);
580
+ expect(gitUtils.gitPull).toHaveBeenCalledWith("/repo");
581
+ expect(execSync).toHaveBeenCalledWith(
582
+ "git rev-list --left-right --count @{upstream}...HEAD",
583
+ { cwd: "/repo", encoding: "utf-8", timeout: 3000 },
584
+ );
585
+ });
586
+
587
+ it("returns zeros for ahead/behind when execSync throws (no upstream)", async () => {
588
+ // When there's no upstream tracking branch, execSync throws and
589
+ // the route silently catches the error and defaults to 0/0
590
+ vi.mocked(gitUtils.gitPull).mockReturnValue({
591
+ success: true,
592
+ output: "Already up to date.",
593
+ });
594
+ vi.mocked(execSync).mockImplementation(() => {
595
+ throw new Error("fatal: no upstream configured");
596
+ });
597
+
598
+ const res = await app.request(jsonRequest("/git/pull", { cwd: "/repo" }));
599
+
600
+ expect(res.status).toBe(200);
601
+ const body = await res.json();
602
+ expect(body.success).toBe(true);
603
+ expect(body.git_ahead).toBe(0);
604
+ expect(body.git_behind).toBe(0);
605
+ });
606
+
607
+ it("returns pull failure result from gitPull", async () => {
608
+ // gitPull itself can report failure (e.g. merge conflicts)
609
+ vi.mocked(gitUtils.gitPull).mockReturnValue({
610
+ success: false,
611
+ output: "CONFLICT (content): Merge conflict in file.txt",
612
+ });
613
+ vi.mocked(execSync).mockReturnValue("0\t0" as any);
614
+
615
+ const res = await app.request(jsonRequest("/git/pull", { cwd: "/repo" }));
616
+
617
+ expect(res.status).toBe(200);
618
+ const body = await res.json();
619
+ expect(body.success).toBe(false);
620
+ expect(body.output).toContain("CONFLICT");
621
+ // ahead/behind still get computed even when pull fails
622
+ expect(body.git_ahead).toBe(0);
623
+ expect(body.git_behind).toBe(0);
624
+ });
625
+
626
+ it("handles tab-separated output with extra whitespace", async () => {
627
+ // Verify the route's split/parse logic handles various whitespace in execSync output
628
+ vi.mocked(gitUtils.gitPull).mockReturnValue({
629
+ success: true,
630
+ output: "",
631
+ });
632
+ vi.mocked(execSync).mockReturnValue("12\t7" as any);
633
+
634
+ const res = await app.request(jsonRequest("/git/pull", { cwd: "/repo" }));
635
+
636
+ const body = await res.json();
637
+ expect(body.git_behind).toBe(12);
638
+ expect(body.git_ahead).toBe(7);
639
+ });
640
+
641
+ it("handles malformed JSON body gracefully", async () => {
642
+ const res = await app.request(
643
+ new Request("http://localhost/git/pull", {
644
+ method: "POST",
645
+ headers: { "Content-Type": "application/json" },
646
+ body: "nope",
647
+ }),
648
+ );
649
+
650
+ expect(res.status).toBe(400);
651
+ const body = await res.json();
652
+ expect(body.error).toBe("cwd required");
653
+ });
654
+ });
655
+
656
+ // ═══════════════════════════════════════════════════════════════════════════
657
+ // GET /git/pr-status
658
+ // ═══════════════════════════════════════════════════════════════════════════
659
+
660
+ describe("GET /git/pr-status", () => {
661
+ it("returns 400 when cwd is missing", async () => {
662
+ const res = await app.request("/git/pr-status?branch=main");
663
+
664
+ expect(res.status).toBe(400);
665
+ const body = await res.json();
666
+ expect(body.error).toBe("cwd and branch required");
667
+ });
668
+
669
+ it("returns 400 when branch is missing", async () => {
670
+ const res = await app.request("/git/pr-status?cwd=/repo");
671
+
672
+ expect(res.status).toBe(400);
673
+ const body = await res.json();
674
+ expect(body.error).toBe("cwd and branch required");
675
+ });
676
+
677
+ it("returns 400 when both cwd and branch are missing", async () => {
678
+ const res = await app.request("/git/pr-status");
679
+
680
+ expect(res.status).toBe(400);
681
+ const body = await res.json();
682
+ expect(body.error).toBe("cwd and branch required");
683
+ });
684
+
685
+ it("returns cached data from prPoller when available", async () => {
686
+ // When a prPoller is provided and has cached data, the route returns
687
+ // it immediately without calling github-pr functions
688
+ const cachedData = {
689
+ available: true,
690
+ pr: {
691
+ number: 42,
692
+ title: "Add feature",
693
+ url: "https://github.com/org/repo/pull/42",
694
+ state: "OPEN" as const,
695
+ isDraft: false,
696
+ reviewDecision: null,
697
+ additions: 10,
698
+ deletions: 5,
699
+ changedFiles: 2,
700
+ checks: [],
701
+ checksSummary: { total: 0, success: 0, failure: 0, pending: 0 },
702
+ reviewThreads: { total: 0, resolved: 0, unresolved: 0 },
703
+ },
704
+ };
705
+ const mockPoller = {
706
+ getCached: vi.fn(() => cachedData),
707
+ };
708
+ const appWithPoller = createApp(mockPoller as any);
709
+
710
+ const res = await appWithPoller.request(
711
+ "/git/pr-status?cwd=/repo&branch=feat",
712
+ );
713
+
714
+ expect(res.status).toBe(200);
715
+ const body = await res.json();
716
+ expect(body).toEqual(cachedData);
717
+ expect(mockPoller.getCached).toHaveBeenCalledWith("/repo", "feat");
718
+ });
719
+
720
+ it("falls through to github-pr when prPoller has no cache", async () => {
721
+ // prPoller.getCached returns null -> route falls through to dynamic import path
722
+ const mockPoller = {
723
+ getCached: vi.fn(() => null),
724
+ };
725
+ const appWithPoller = createApp(mockPoller as any);
726
+ vi.mocked(githubPr.isGhAvailable).mockReturnValue(false);
727
+
728
+ const res = await appWithPoller.request(
729
+ "/git/pr-status?cwd=/repo&branch=feat",
730
+ );
731
+
732
+ expect(res.status).toBe(200);
733
+ const body = await res.json();
734
+ expect(body).toEqual({ available: false, pr: null });
735
+ expect(mockPoller.getCached).toHaveBeenCalledWith("/repo", "feat");
736
+ });
737
+
738
+ it("returns unavailable when gh CLI is not installed (no prPoller)", async () => {
739
+ // Without a prPoller, the route goes directly to the dynamic import path
740
+ // and isGhAvailable returns false
741
+ vi.mocked(githubPr.isGhAvailable).mockReturnValue(false);
742
+
743
+ const res = await app.request("/git/pr-status?cwd=/repo&branch=main");
744
+
745
+ expect(res.status).toBe(200);
746
+ const body = await res.json();
747
+ expect(body.available).toBe(false);
748
+ expect(body.pr).toBeNull();
749
+ });
750
+
751
+ it("fetches PR info when gh is available and no poller cache", async () => {
752
+ // When gh is available, the route calls fetchPRInfoAsync and returns the result
753
+ const mockPr = {
754
+ number: 99,
755
+ title: "Fix bug",
756
+ url: "https://github.com/org/repo/pull/99",
757
+ state: "OPEN" as const,
758
+ isDraft: false,
759
+ reviewDecision: "APPROVED" as const,
760
+ additions: 20,
761
+ deletions: 3,
762
+ changedFiles: 1,
763
+ checks: [{ name: "ci", status: "completed", conclusion: "success" }],
764
+ checksSummary: { total: 1, success: 1, failure: 0, pending: 0 },
765
+ reviewThreads: { total: 2, resolved: 2, unresolved: 0 },
766
+ };
767
+ vi.mocked(githubPr.isGhAvailable).mockReturnValue(true);
768
+ vi.mocked(githubPr.fetchPRInfoAsync).mockResolvedValue(mockPr);
769
+
770
+ const res = await app.request("/git/pr-status?cwd=/repo&branch=fix-bug");
771
+
772
+ expect(res.status).toBe(200);
773
+ const body = await res.json();
774
+ expect(body.available).toBe(true);
775
+ expect(body.pr).toEqual(mockPr);
776
+ expect(githubPr.fetchPRInfoAsync).toHaveBeenCalledWith(
777
+ "/repo",
778
+ "fix-bug",
779
+ );
780
+ });
781
+
782
+ it("returns null PR when fetchPRInfoAsync finds no PR for the branch", async () => {
783
+ // gh is available but there's no PR for this branch
784
+ vi.mocked(githubPr.isGhAvailable).mockReturnValue(true);
785
+ vi.mocked(githubPr.fetchPRInfoAsync).mockResolvedValue(null);
786
+
787
+ const res = await app.request(
788
+ "/git/pr-status?cwd=/repo&branch=no-pr-here",
789
+ );
790
+
791
+ expect(res.status).toBe(200);
792
+ const body = await res.json();
793
+ expect(body.available).toBe(true);
794
+ expect(body.pr).toBeNull();
795
+ });
796
+
797
+ it("skips prPoller entirely when none is provided", async () => {
798
+ // When registerGitRoutes is called without a prPoller argument,
799
+ // the route goes straight to the github-pr import path
800
+ vi.mocked(githubPr.isGhAvailable).mockReturnValue(true);
801
+ vi.mocked(githubPr.fetchPRInfoAsync).mockResolvedValue(null);
802
+
803
+ const res = await app.request(
804
+ "/git/pr-status?cwd=/repo&branch=some-branch",
805
+ );
806
+
807
+ expect(res.status).toBe(200);
808
+ const body = await res.json();
809
+ expect(body.available).toBe(true);
810
+ // Verify github-pr was called (would not be if poller had returned cached)
811
+ expect(githubPr.isGhAvailable).toHaveBeenCalled();
812
+ });
813
+ });