@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,938 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ // ─── Hoisted mocks ───────────────────────────────────────────────────────────
4
+
5
+ const mockHomedir = vi.hoisted(() => {
6
+ let dir = "/fake/home";
7
+ return { get: () => dir, set: (d: string) => { dir = d; } };
8
+ });
9
+
10
+ const mockExecSync = vi.hoisted(() => vi.fn());
11
+ const mockExistsSync = vi.hoisted(() => vi.fn());
12
+ const mockMkdirSync = vi.hoisted(() => vi.fn());
13
+
14
+ vi.mock("node:os", () => ({ homedir: () => mockHomedir.get() }));
15
+ vi.mock("node:child_process", () => ({ execSync: mockExecSync }));
16
+ vi.mock("node:fs", () => ({
17
+ existsSync: mockExistsSync,
18
+ mkdirSync: mockMkdirSync,
19
+ }));
20
+
21
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
22
+
23
+ function mockGitCommand(pattern: string | RegExp, result: string) {
24
+ mockExecSync.mockImplementation((cmd: string) => {
25
+ if (typeof pattern === "string" ? cmd.includes(pattern) : pattern.test(cmd)) {
26
+ return result;
27
+ }
28
+ throw new Error(`Unexpected git command: ${cmd}`);
29
+ });
30
+ }
31
+
32
+ function mockGitCommands(map: Record<string, string | Error>) {
33
+ mockExecSync.mockImplementation((cmd: string) => {
34
+ for (const [pattern, result] of Object.entries(map)) {
35
+ if (cmd.includes(pattern)) {
36
+ if (result instanceof Error) throw result;
37
+ return result;
38
+ }
39
+ }
40
+ throw new Error(`Unmocked git command: ${cmd}`);
41
+ });
42
+ }
43
+
44
+ // ─── Dynamic import with module reset ────────────────────────────────────────
45
+
46
+ let gitUtils: typeof import("./git-utils.js");
47
+
48
+ beforeEach(async () => {
49
+ vi.resetModules();
50
+ mockExecSync.mockReset();
51
+ mockExistsSync.mockReset();
52
+ mockMkdirSync.mockReset();
53
+ mockHomedir.set("/fake/home");
54
+ gitUtils = await import("./git-utils.js");
55
+ });
56
+
57
+ // ─── getRepoInfo ─────────────────────────────────────────────────────────────
58
+
59
+ describe("getRepoInfo", () => {
60
+ it("returns null for a non-git directory", () => {
61
+ mockExecSync.mockImplementation(() => {
62
+ throw new Error("fatal: not a git repository");
63
+ });
64
+
65
+ const result = gitUtils.getRepoInfo("/tmp/not-a-repo");
66
+ expect(result).toBeNull();
67
+ });
68
+
69
+ it("returns correct repo info for a standard git repo", () => {
70
+ mockGitCommands({
71
+ "rev-parse --show-toplevel": "/home/user/my-project",
72
+ "rev-parse --abbrev-ref HEAD": "feat/cool-feature",
73
+ "rev-parse --git-dir": ".git",
74
+ "symbolic-ref refs/remotes/origin/HEAD": "refs/remotes/origin/main",
75
+ });
76
+
77
+ const result = gitUtils.getRepoInfo("/home/user/my-project");
78
+ expect(result).toEqual({
79
+ repoRoot: "/home/user/my-project",
80
+ repoName: "my-project",
81
+ currentBranch: "feat/cool-feature",
82
+ defaultBranch: "main",
83
+ isWorktree: false,
84
+ });
85
+ });
86
+
87
+ it("detects worktree when git-dir contains /worktrees/", () => {
88
+ mockGitCommands({
89
+ "rev-parse --show-toplevel": "/fake/home/.companion/worktrees/proj/feat--x",
90
+ "rev-parse --abbrev-ref HEAD": "feat/x",
91
+ "rev-parse --git-dir": "/home/user/proj/.git/worktrees/feat--x",
92
+ "symbolic-ref refs/remotes/origin/HEAD": "refs/remotes/origin/main",
93
+ });
94
+
95
+ const result = gitUtils.getRepoInfo("/fake/home/.companion/worktrees/proj/feat--x");
96
+ expect(result).not.toBeNull();
97
+ expect(result!.isWorktree).toBe(true);
98
+ });
99
+
100
+ it("falls back to 'HEAD' when branch detection fails", () => {
101
+ mockExecSync.mockImplementation((cmd: string) => {
102
+ if (cmd.includes("rev-parse --show-toplevel")) return "/repo";
103
+ if (cmd.includes("rev-parse --abbrev-ref HEAD")) throw new Error("detached HEAD");
104
+ if (cmd.includes("rev-parse --git-dir")) return ".git";
105
+ if (cmd.includes("symbolic-ref refs/remotes/origin/HEAD")) return "refs/remotes/origin/main";
106
+ throw new Error(`Unmocked: ${cmd}`);
107
+ });
108
+
109
+ const result = gitUtils.getRepoInfo("/repo");
110
+ expect(result).not.toBeNull();
111
+ expect(result!.currentBranch).toBe("HEAD");
112
+ });
113
+
114
+ it("resolves default branch via origin HEAD", () => {
115
+ mockGitCommands({
116
+ "rev-parse --show-toplevel": "/repo",
117
+ "rev-parse --abbrev-ref HEAD": "develop",
118
+ "rev-parse --git-dir": ".git",
119
+ "symbolic-ref refs/remotes/origin/HEAD": "refs/remotes/origin/develop",
120
+ });
121
+
122
+ const result = gitUtils.getRepoInfo("/repo");
123
+ expect(result!.defaultBranch).toBe("develop");
124
+ });
125
+
126
+ it("falls back to 'main' when origin HEAD and master are unavailable", () => {
127
+ mockExecSync.mockImplementation((cmd: string) => {
128
+ if (cmd.includes("rev-parse --show-toplevel")) return "/repo";
129
+ if (cmd.includes("rev-parse --abbrev-ref HEAD")) return "feature";
130
+ if (cmd.includes("rev-parse --git-dir")) return ".git";
131
+ if (cmd.includes("symbolic-ref refs/remotes/origin/HEAD")) throw new Error("no origin");
132
+ if (cmd.includes("branch --list main master")) return "";
133
+ throw new Error(`Unmocked: ${cmd}`);
134
+ });
135
+
136
+ const result = gitUtils.getRepoInfo("/repo");
137
+ expect(result!.defaultBranch).toBe("main");
138
+ });
139
+
140
+ it("falls back to 'master' when origin HEAD fails and only master exists", () => {
141
+ mockExecSync.mockImplementation((cmd: string) => {
142
+ if (cmd.includes("rev-parse --show-toplevel")) return "/repo";
143
+ if (cmd.includes("rev-parse --abbrev-ref HEAD")) return "feature";
144
+ if (cmd.includes("rev-parse --git-dir")) return ".git";
145
+ if (cmd.includes("symbolic-ref refs/remotes/origin/HEAD")) throw new Error("no origin");
146
+ if (cmd.includes("branch --list main master")) return " master";
147
+ throw new Error(`Unmocked: ${cmd}`);
148
+ });
149
+
150
+ const result = gitUtils.getRepoInfo("/repo");
151
+ expect(result!.defaultBranch).toBe("master");
152
+ });
153
+ });
154
+
155
+ // ─── listBranches ────────────────────────────────────────────────────────────
156
+
157
+ describe("listBranches", () => {
158
+ it("parses local branches with current marker", () => {
159
+ mockExecSync.mockImplementation((cmd: string) => {
160
+ if (cmd.includes("worktree list --porcelain")) return "";
161
+ if (cmd.includes("for-each-ref") && cmd.includes("refs/heads/")) {
162
+ return "main\t*\nfeat/login\t ";
163
+ }
164
+ if (cmd.includes("for-each-ref") && cmd.includes("refs/remotes/origin/")) return "";
165
+ if (cmd.includes("rev-list --left-right --count")) return "0\t0";
166
+ throw new Error(`Unmocked: ${cmd}`);
167
+ });
168
+
169
+ const branches = gitUtils.listBranches("/repo");
170
+ const main = branches.find((b) => b.name === "main");
171
+ const feat = branches.find((b) => b.name === "feat/login");
172
+
173
+ expect(main).toBeDefined();
174
+ expect(main!.isCurrent).toBe(true);
175
+ expect(main!.isRemote).toBe(false);
176
+
177
+ expect(feat).toBeDefined();
178
+ expect(feat!.isCurrent).toBe(false);
179
+ });
180
+
181
+ it("includes remote-only branches", () => {
182
+ mockExecSync.mockImplementation((cmd: string) => {
183
+ if (cmd.includes("worktree list --porcelain")) return "";
184
+ if (cmd.includes("for-each-ref") && cmd.includes("refs/heads/")) {
185
+ return "main\t*";
186
+ }
187
+ if (cmd.includes("for-each-ref") && cmd.includes("refs/remotes/origin/")) {
188
+ return "origin/feat/remote-branch";
189
+ }
190
+ if (cmd.includes("rev-list --left-right --count")) return "0\t0";
191
+ throw new Error(`Unmocked: ${cmd}`);
192
+ });
193
+
194
+ const branches = gitUtils.listBranches("/repo");
195
+ const remote = branches.find((b) => b.name === "feat/remote-branch");
196
+
197
+ expect(remote).toBeDefined();
198
+ expect(remote!.isRemote).toBe(true);
199
+ expect(remote!.isCurrent).toBe(false);
200
+ expect(remote!.ahead).toBe(0);
201
+ expect(remote!.behind).toBe(0);
202
+ });
203
+
204
+ it("excludes origin/HEAD from remote branches", () => {
205
+ mockExecSync.mockImplementation((cmd: string) => {
206
+ if (cmd.includes("worktree list --porcelain")) return "";
207
+ if (cmd.includes("for-each-ref") && cmd.includes("refs/heads/")) return "";
208
+ if (cmd.includes("for-each-ref") && cmd.includes("refs/remotes/origin/")) {
209
+ return "origin/HEAD\norigin/main";
210
+ }
211
+ if (cmd.includes("rev-list --left-right --count")) return "0\t0";
212
+ throw new Error(`Unmocked: ${cmd}`);
213
+ });
214
+
215
+ const branches = gitUtils.listBranches("/repo");
216
+ expect(branches.find((b) => b.name === "HEAD")).toBeUndefined();
217
+ expect(branches.find((b) => b.name === "main")).toBeDefined();
218
+ });
219
+
220
+ it("includes ahead/behind counts for local branches", () => {
221
+ mockExecSync.mockImplementation((cmd: string) => {
222
+ if (cmd.includes("worktree list --porcelain")) return "";
223
+ if (cmd.includes("for-each-ref") && cmd.includes("refs/heads/")) {
224
+ return "dev\t ";
225
+ }
226
+ if (cmd.includes("for-each-ref") && cmd.includes("refs/remotes/origin/")) return "";
227
+ if (cmd.includes("rev-list --left-right --count")) return "3\t5";
228
+ throw new Error(`Unmocked: ${cmd}`);
229
+ });
230
+
231
+ const branches = gitUtils.listBranches("/repo");
232
+ const dev = branches.find((b) => b.name === "dev");
233
+ expect(dev).toBeDefined();
234
+ // In the source: [behind, ahead] = raw.split(...).map(Number)
235
+ expect(dev!.ahead).toBe(5);
236
+ expect(dev!.behind).toBe(3);
237
+ });
238
+
239
+ it("returns empty array on git failure", () => {
240
+ mockExecSync.mockImplementation(() => {
241
+ throw new Error("git failed");
242
+ });
243
+
244
+ const branches = gitUtils.listBranches("/repo");
245
+ expect(branches).toEqual([]);
246
+ });
247
+ });
248
+
249
+ // ─── listWorktrees ───────────────────────────────────────────────────────────
250
+
251
+ describe("listWorktrees", () => {
252
+ it("parses porcelain output correctly", () => {
253
+ const porcelain = [
254
+ "worktree /home/user/project",
255
+ "HEAD abc1234567890abcdef1234567890abcdef123456",
256
+ "branch refs/heads/main",
257
+ "",
258
+ "worktree /fake/home/.companion/worktrees/project/feat--x",
259
+ "HEAD def4567890abcdef1234567890abcdef12345678",
260
+ "branch refs/heads/feat/x",
261
+ "",
262
+ ].join("\n");
263
+
264
+ mockExecSync.mockImplementation((cmd: string) => {
265
+ if (cmd.includes("worktree list --porcelain")) return porcelain;
266
+ // isWorktreeDirty calls
267
+ if (cmd.includes("status --porcelain")) return "";
268
+ throw new Error(`Unmocked: ${cmd}`);
269
+ });
270
+ mockExistsSync.mockReturnValue(true);
271
+
272
+ const worktrees = gitUtils.listWorktrees("/home/user/project");
273
+ expect(worktrees).toHaveLength(2);
274
+ expect(worktrees[0].path).toBe("/home/user/project");
275
+ expect(worktrees[1].path).toBe("/fake/home/.companion/worktrees/project/feat--x");
276
+ });
277
+
278
+ it("marks first worktree as main", () => {
279
+ const porcelain = [
280
+ "worktree /home/user/project",
281
+ "HEAD abc123",
282
+ "branch refs/heads/main",
283
+ "",
284
+ "worktree /tmp/wt",
285
+ "HEAD def456",
286
+ "branch refs/heads/other",
287
+ "",
288
+ ].join("\n");
289
+
290
+ mockExecSync.mockImplementation((cmd: string) => {
291
+ if (cmd.includes("worktree list --porcelain")) return porcelain;
292
+ if (cmd.includes("status --porcelain")) return "";
293
+ throw new Error(`Unmocked: ${cmd}`);
294
+ });
295
+ mockExistsSync.mockReturnValue(true);
296
+
297
+ const worktrees = gitUtils.listWorktrees("/home/user/project");
298
+ expect(worktrees[0].isMainWorktree).toBe(true);
299
+ expect(worktrees[1].isMainWorktree).toBe(false);
300
+ });
301
+
302
+ it("strips refs/heads/ from branch names", () => {
303
+ const porcelain = [
304
+ "worktree /repo",
305
+ "HEAD abc123",
306
+ "branch refs/heads/feat/something",
307
+ "",
308
+ ].join("\n");
309
+
310
+ mockExecSync.mockImplementation((cmd: string) => {
311
+ if (cmd.includes("worktree list --porcelain")) return porcelain;
312
+ if (cmd.includes("status --porcelain")) return "";
313
+ throw new Error(`Unmocked: ${cmd}`);
314
+ });
315
+ mockExistsSync.mockReturnValue(true);
316
+
317
+ const worktrees = gitUtils.listWorktrees("/repo");
318
+ expect(worktrees[0].branch).toBe("feat/something");
319
+ });
320
+
321
+ it("returns empty array on failure", () => {
322
+ mockExecSync.mockImplementation(() => {
323
+ throw new Error("git failed");
324
+ });
325
+
326
+ const worktrees = gitUtils.listWorktrees("/repo");
327
+ expect(worktrees).toEqual([]);
328
+ });
329
+ });
330
+
331
+ // ─── ensureWorktree ──────────────────────────────────────────────────────────
332
+
333
+ describe("ensureWorktree", () => {
334
+ it("returns existing worktree without creating a new one", () => {
335
+ const porcelain = [
336
+ "worktree /repo",
337
+ "HEAD abc123",
338
+ "branch refs/heads/main",
339
+ "",
340
+ "worktree /existing/path",
341
+ "HEAD def456",
342
+ "branch refs/heads/feat/existing",
343
+ "",
344
+ ].join("\n");
345
+
346
+ mockExecSync.mockImplementation((cmd: string) => {
347
+ if (cmd.includes("worktree list --porcelain")) return porcelain;
348
+ if (cmd.includes("status --porcelain")) return "";
349
+ throw new Error(`Unmocked: ${cmd}`);
350
+ });
351
+ mockExistsSync.mockReturnValue(true);
352
+
353
+ const result = gitUtils.ensureWorktree("/repo", "feat/existing");
354
+ expect(result.worktreePath).toBe("/existing/path");
355
+ expect(result.branch).toBe("feat/existing");
356
+ expect(result.actualBranch).toBe("feat/existing");
357
+ expect(result.isNew).toBe(false);
358
+ // Should NOT have called worktree add
359
+ const addCalls = mockExecSync.mock.calls.filter((c: unknown[]) =>
360
+ (c[0] as string).includes("worktree add"),
361
+ );
362
+ expect(addCalls).toHaveLength(0);
363
+ });
364
+
365
+ it("creates worktree for an existing local branch", () => {
366
+ mockExecSync.mockImplementation((cmd: string) => {
367
+ // listWorktrees
368
+ if (cmd.includes("worktree list --porcelain")) {
369
+ return "worktree /repo\nHEAD abc\nbranch refs/heads/main\n";
370
+ }
371
+ if (cmd.includes("status --porcelain")) return "";
372
+ // Branch exists locally
373
+ if (cmd.includes("rev-parse --verify refs/heads/feat/local")) return "abc123";
374
+ // worktree add
375
+ if (cmd.includes("worktree add")) return "";
376
+ throw new Error(`Unmocked: ${cmd}`);
377
+ });
378
+ // Target path doesn't exist yet (no suffix needed)
379
+ mockExistsSync.mockReturnValue(false);
380
+
381
+ const result = gitUtils.ensureWorktree("/repo", "feat/local");
382
+ expect(result.worktreePath).toBe("/fake/home/.companion/worktrees/repo/feat--local");
383
+ expect(result.actualBranch).toBe("feat/local");
384
+ expect(result.isNew).toBe(false);
385
+
386
+ const addCall = mockExecSync.mock.calls.find((c: unknown[]) =>
387
+ (c[0] as string).includes("worktree add"),
388
+ );
389
+ expect(addCall).toBeDefined();
390
+ // Should NOT have -b flag for existing branch
391
+ expect((addCall![0] as string)).not.toContain("-b ");
392
+ });
393
+
394
+ it("creates tracking branch from remote", () => {
395
+ mockExecSync.mockImplementation((cmd: string) => {
396
+ if (cmd.includes("worktree list --porcelain")) {
397
+ return "worktree /repo\nHEAD abc\nbranch refs/heads/main\n";
398
+ }
399
+ if (cmd.includes("status --porcelain")) return "";
400
+ // Local branch does NOT exist
401
+ if (cmd.includes("rev-parse --verify refs/heads/feat/remote"))
402
+ throw new Error("not found");
403
+ // Remote branch exists
404
+ if (cmd.includes("rev-parse --verify refs/remotes/origin/feat/remote"))
405
+ return "def456";
406
+ // worktree add -b
407
+ if (cmd.includes("worktree add -b")) return "";
408
+ throw new Error(`Unmocked: ${cmd}`);
409
+ });
410
+ // Target path doesn't exist yet
411
+ mockExistsSync.mockReturnValue(false);
412
+
413
+ const result = gitUtils.ensureWorktree("/repo", "feat/remote");
414
+ expect(result.actualBranch).toBe("feat/remote");
415
+ expect(result.isNew).toBe(false);
416
+
417
+ const addCall = mockExecSync.mock.calls.find((c: unknown[]) =>
418
+ (c[0] as string).includes("worktree add -b"),
419
+ );
420
+ expect(addCall).toBeDefined();
421
+ expect((addCall![0] as string)).toContain("origin/feat/remote");
422
+ });
423
+
424
+ it("creates new branch from origin/base when branch does not exist anywhere", () => {
425
+ // When neither the requested branch nor its remote counterpart exist,
426
+ // but origin/{baseBranch} is available (after fetch), use origin/{baseBranch}
427
+ // as the start point instead of the potentially stale local ref.
428
+ mockExecSync.mockImplementation((cmd: string) => {
429
+ if (cmd.includes("worktree list --porcelain")) {
430
+ return "worktree /repo\nHEAD abc\nbranch refs/heads/main\n";
431
+ }
432
+ if (cmd.includes("status --porcelain")) return "";
433
+ // Neither local nor remote branch exists for feat/new
434
+ if (cmd.includes("rev-parse --verify refs/heads/feat/new")) throw new Error("not found");
435
+ if (cmd.includes("rev-parse --verify refs/remotes/origin/feat/new")) throw new Error("not found");
436
+ // Remote ref for the base branch exists (up-to-date after fetch)
437
+ if (cmd.includes("rev-parse --verify refs/remotes/origin/develop")) return "abc123";
438
+ // worktree add -b
439
+ if (cmd.includes("worktree add -b")) return "";
440
+ throw new Error(`Unmocked: ${cmd}`);
441
+ });
442
+ // Target path doesn't exist yet
443
+ mockExistsSync.mockReturnValue(false);
444
+
445
+ const result = gitUtils.ensureWorktree("/repo", "feat/new", { baseBranch: "develop" });
446
+ expect(result.isNew).toBe(true);
447
+ expect(result.branch).toBe("feat/new");
448
+ expect(result.actualBranch).toBe("feat/new");
449
+
450
+ const addCall = mockExecSync.mock.calls.find((c: unknown[]) =>
451
+ (c[0] as string).includes("worktree add -b"),
452
+ );
453
+ expect(addCall).toBeDefined();
454
+ // Should use origin/develop (remote ref), NOT local develop
455
+ expect((addCall![0] as string)).toContain("origin/develop");
456
+ });
457
+
458
+ it("falls back to local base branch when origin ref does not exist", () => {
459
+ // When origin/{baseBranch} is not available (e.g. no remote), fall back
460
+ // to the local base branch ref.
461
+ mockExecSync.mockImplementation((cmd: string) => {
462
+ if (cmd.includes("worktree list --porcelain")) {
463
+ return "worktree /repo\nHEAD abc\nbranch refs/heads/main\n";
464
+ }
465
+ if (cmd.includes("status --porcelain")) return "";
466
+ // Neither local nor remote branch exists for feat/new, and no origin/develop
467
+ if (cmd.includes("rev-parse --verify")) throw new Error("not found");
468
+ // worktree add -b
469
+ if (cmd.includes("worktree add -b")) return "";
470
+ throw new Error(`Unmocked: ${cmd}`);
471
+ });
472
+ // Target path doesn't exist yet
473
+ mockExistsSync.mockReturnValue(false);
474
+
475
+ const result = gitUtils.ensureWorktree("/repo", "feat/new", { baseBranch: "develop" });
476
+ expect(result.isNew).toBe(true);
477
+
478
+ const addCall = mockExecSync.mock.calls.find((c: unknown[]) =>
479
+ (c[0] as string).includes("worktree add -b"),
480
+ );
481
+ expect(addCall).toBeDefined();
482
+ // Should fall back to local "develop" since origin/develop doesn't exist
483
+ expect((addCall![0] as string)).toContain("develop");
484
+ expect((addCall![0] as string)).not.toContain("origin/develop");
485
+ });
486
+
487
+ it("throws when createBranch=false and branch does not exist", () => {
488
+ mockExecSync.mockImplementation((cmd: string) => {
489
+ if (cmd.includes("worktree list --porcelain")) {
490
+ return "worktree /repo\nHEAD abc\nbranch refs/heads/main\n";
491
+ }
492
+ if (cmd.includes("status --porcelain")) return "";
493
+ if (cmd.includes("rev-parse --verify")) throw new Error("not found");
494
+ throw new Error(`Unmocked: ${cmd}`);
495
+ });
496
+ // Target path doesn't exist yet
497
+ mockExistsSync.mockReturnValue(false);
498
+
499
+ expect(() =>
500
+ gitUtils.ensureWorktree("/repo", "feat/missing", { createBranch: false }),
501
+ ).toThrow('Branch "feat/missing" does not exist and createBranch is false');
502
+ });
503
+
504
+ it("calls mkdirSync with recursive option when creating worktree", () => {
505
+ mockExecSync.mockImplementation((cmd: string) => {
506
+ if (cmd.includes("worktree list --porcelain")) {
507
+ return "worktree /repo\nHEAD abc\nbranch refs/heads/main\n";
508
+ }
509
+ if (cmd.includes("status --porcelain")) return "";
510
+ if (cmd.includes("rev-parse --verify refs/heads/feat/new")) return "abc";
511
+ if (cmd.includes("worktree add")) return "";
512
+ throw new Error(`Unmocked: ${cmd}`);
513
+ });
514
+ // Target path doesn't exist yet
515
+ mockExistsSync.mockReturnValue(false);
516
+
517
+ gitUtils.ensureWorktree("/repo", "feat/new");
518
+
519
+ expect(mockMkdirSync).toHaveBeenCalledWith(
520
+ "/fake/home/.companion/worktrees/repo",
521
+ { recursive: true },
522
+ );
523
+ });
524
+
525
+ it("does not reuse the main worktree even when branch matches", () => {
526
+ // Main worktree is on "main", and we request a worktree for "main"
527
+ const porcelain = [
528
+ "worktree /repo",
529
+ "HEAD abc123",
530
+ "branch refs/heads/main",
531
+ "",
532
+ ].join("\n");
533
+
534
+ mockExecSync.mockImplementation((cmd: string) => {
535
+ if (cmd.includes("worktree list --porcelain")) return porcelain;
536
+ if (cmd.includes("status --porcelain")) return "";
537
+ if (cmd.includes("rev-parse HEAD")) return "abc123";
538
+ // generateUniqueWorktreeBranch checks for existing branches (random suffix)
539
+ if (/rev-parse --verify refs\/heads\/main-wt-\d{4}/.test(cmd)) throw new Error("not found");
540
+ if (cmd.includes("worktree add -b")) return "";
541
+ throw new Error(`Unmocked: ${cmd}`);
542
+ });
543
+ // Target path doesn't exist yet
544
+ mockExistsSync.mockReturnValue(false);
545
+
546
+ const result = gitUtils.ensureWorktree("/repo", "main");
547
+ // Should NOT return the main repo path
548
+ expect(result.worktreePath).not.toBe("/repo");
549
+ expect(result.worktreePath).toBe("/fake/home/.companion/worktrees/repo/main");
550
+ expect(result.branch).toBe("main");
551
+ expect(result.actualBranch).toMatch(/^main-wt-\d{4}$/);
552
+ // Should create a branch-tracking worktree
553
+ const addCall = mockExecSync.mock.calls.find((c: unknown[]) =>
554
+ (c[0] as string).includes("worktree add -b"),
555
+ );
556
+ expect(addCall).toBeDefined();
557
+ expect((addCall![0] as string)).toMatch(/main-wt-\d{4}/);
558
+ expect((addCall![0] as string)).toContain("abc123");
559
+ });
560
+
561
+ it("creates unique paths with random suffix when base path exists", () => {
562
+ mockExecSync.mockImplementation((cmd: string) => {
563
+ if (cmd.includes("worktree list --porcelain")) {
564
+ return "worktree /repo\nHEAD abc\nbranch refs/heads/main\n";
565
+ }
566
+ if (cmd.includes("status --porcelain")) return "";
567
+ if (cmd.includes("rev-parse --verify refs/heads/feat/x")) return "abc123";
568
+ if (cmd.includes("worktree add")) return "";
569
+ throw new Error(`Unmocked: ${cmd}`);
570
+ });
571
+ // Base path exists, random suffix path does not
572
+ const basePath = "/fake/home/.companion/worktrees/repo/feat--x";
573
+ mockExistsSync.mockImplementation((path: string) => {
574
+ if (path === basePath) return true;
575
+ return false; // Any random-suffixed path is free
576
+ });
577
+
578
+ const result = gitUtils.ensureWorktree("/repo", "feat/x");
579
+ expect(result.worktreePath).toMatch(new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d{4}$`));
580
+ });
581
+
582
+ it("creates branch-tracking worktree when forceNew=true and worktree already exists", () => {
583
+ const porcelain = [
584
+ "worktree /repo",
585
+ "HEAD abc123",
586
+ "branch refs/heads/main",
587
+ "",
588
+ "worktree /existing/wt",
589
+ "HEAD def456",
590
+ "branch refs/heads/feat/existing",
591
+ "",
592
+ ].join("\n");
593
+
594
+ mockExecSync.mockImplementation((cmd: string) => {
595
+ if (cmd.includes("worktree list --porcelain")) return porcelain;
596
+ if (cmd.includes("status --porcelain")) return "";
597
+ if (cmd.includes("rev-parse HEAD")) return "def456";
598
+ // generateUniqueWorktreeBranch checks (random suffix)
599
+ if (/rev-parse --verify refs\/heads\/feat\/existing-wt-\d{4}/.test(cmd)) throw new Error("not found");
600
+ if (cmd.includes("worktree add -b")) return "";
601
+ throw new Error(`Unmocked: ${cmd}`);
602
+ });
603
+ // Target path doesn't exist yet
604
+ mockExistsSync.mockReturnValue(false);
605
+
606
+ const result = gitUtils.ensureWorktree("/repo", "feat/existing", { forceNew: true });
607
+ expect(result.worktreePath).toBe("/fake/home/.companion/worktrees/repo/feat--existing");
608
+ expect(result.branch).toBe("feat/existing");
609
+ expect(result.actualBranch).toMatch(/^feat\/existing-wt-\d{4}$/);
610
+
611
+ const addCall = mockExecSync.mock.calls.find((c: unknown[]) =>
612
+ (c[0] as string).includes("worktree add -b"),
613
+ );
614
+ expect(addCall).toBeDefined();
615
+ expect((addCall![0] as string)).toMatch(/feat\/existing-wt-\d{4}/);
616
+ });
617
+
618
+ it("generates unique branch when forceNew=true and branch exists locally but no worktree uses it", () => {
619
+ // Main repo is on a different branch (feat/other), not on "main"
620
+ const porcelain = [
621
+ "worktree /repo",
622
+ "HEAD abc123",
623
+ "branch refs/heads/feat/other",
624
+ "",
625
+ ].join("\n");
626
+
627
+ mockExecSync.mockImplementation((cmd: string) => {
628
+ if (cmd.includes("worktree list --porcelain")) return porcelain;
629
+ if (cmd.includes("status --porcelain")) return "";
630
+ // "main" exists as a local branch
631
+ if (cmd.includes("rev-parse --verify refs/heads/main") && !cmd.includes("-wt-")) return "aaa111";
632
+ // rev-parse for the commit hash (git() uses cwd, not -C)
633
+ if (cmd === "git rev-parse refs/heads/main") return "aaa111";
634
+ // generateUniqueWorktreeBranch checks (random suffix)
635
+ if (/rev-parse --verify refs\/heads\/main-wt-\d{4}/.test(cmd)) throw new Error("not found");
636
+ if (cmd.includes("worktree add -b")) return "";
637
+ throw new Error(`Unmocked: ${cmd}`);
638
+ });
639
+ mockExistsSync.mockReturnValue(false);
640
+
641
+ const result = gitUtils.ensureWorktree("/repo", "main", { forceNew: true });
642
+ expect(result.worktreePath).toBe("/fake/home/.companion/worktrees/repo/main");
643
+ expect(result.branch).toBe("main");
644
+ // Should get a unique branch, NOT the raw "main" branch
645
+ expect(result.actualBranch).toMatch(/^main-wt-\d{4}$/);
646
+
647
+ const addCall = mockExecSync.mock.calls.find((c: unknown[]) =>
648
+ (c[0] as string).includes("worktree add -b"),
649
+ );
650
+ expect(addCall).toBeDefined();
651
+ expect((addCall![0] as string)).toMatch(/main-wt-\d{4}/);
652
+ });
653
+
654
+ it("generates unique branch when forceNew=true and only remote branch exists", () => {
655
+ // No worktree on "main", main repo on different branch
656
+ const porcelain = [
657
+ "worktree /repo",
658
+ "HEAD abc123",
659
+ "branch refs/heads/feat/other",
660
+ "",
661
+ ].join("\n");
662
+
663
+ mockExecSync.mockImplementation((cmd: string) => {
664
+ if (cmd.includes("worktree list --porcelain")) return porcelain;
665
+ if (cmd.includes("status --porcelain")) return "";
666
+ // "main" does NOT exist locally
667
+ if (cmd.includes("rev-parse --verify refs/heads/main") && !cmd.includes("-wt-") && !cmd.includes("remotes")) throw new Error("not found");
668
+ // "main" exists on remote
669
+ if (cmd.includes("rev-parse --verify refs/remotes/origin/main")) return "bbb222";
670
+ // generateUniqueWorktreeBranch checks
671
+ if (/rev-parse --verify refs\/heads\/main-wt-\d{4}/.test(cmd)) throw new Error("not found");
672
+ if (cmd.includes("worktree add -b")) return "";
673
+ throw new Error(`Unmocked: ${cmd}`);
674
+ });
675
+ mockExistsSync.mockReturnValue(false);
676
+
677
+ const result = gitUtils.ensureWorktree("/repo", "main", { forceNew: true });
678
+ expect(result.branch).toBe("main");
679
+ expect(result.actualBranch).toMatch(/^main-wt-\d{4}$/);
680
+
681
+ const addCall = mockExecSync.mock.calls.find((c: unknown[]) =>
682
+ (c[0] as string).includes("worktree add -b"),
683
+ );
684
+ expect(addCall).toBeDefined();
685
+ expect((addCall![0] as string)).toMatch(/main-wt-\d{4}/);
686
+ expect((addCall![0] as string)).toContain("origin/main");
687
+ });
688
+ });
689
+
690
+ // ─── generateUniqueWorktreeBranch ────────────────────────────────────────────
691
+
692
+ describe("generateUniqueWorktreeBranch", () => {
693
+ it("returns branch-wt-{random4digit} when no suffixed branches exist", () => {
694
+ mockExecSync.mockImplementation((cmd: string) => {
695
+ if (cmd.includes("rev-parse --verify refs/heads/main-wt-")) throw new Error("not found");
696
+ throw new Error(`Unmocked: ${cmd}`);
697
+ });
698
+
699
+ const result = gitUtils.generateUniqueWorktreeBranch("/repo", "main");
700
+ expect(result).toMatch(/^main-wt-\d{4}$/);
701
+ });
702
+
703
+ it("retries with a new random suffix on collision", () => {
704
+ // Mock Math.random to return deterministic values
705
+ const origRandom = Math.random;
706
+ const randomValues = [0.5, 0.7]; // → suffixes 5500, 7300
707
+ let callIdx = 0;
708
+ Math.random = () => randomValues[callIdx++] ?? origRandom();
709
+
710
+ mockExecSync.mockImplementation((cmd: string) => {
711
+ // First candidate (5500) already exists
712
+ if (cmd.includes("rev-parse --verify refs/heads/feat/x-wt-5500")) return "abc";
713
+ // Second candidate (7300) is free
714
+ if (cmd.includes("rev-parse --verify refs/heads/feat/x-wt-7300")) throw new Error("not found");
715
+ throw new Error(`Unmocked: ${cmd}`);
716
+ });
717
+
718
+ const result = gitUtils.generateUniqueWorktreeBranch("/repo", "feat/x");
719
+ expect(result).toBe("feat/x-wt-7300");
720
+
721
+ Math.random = origRandom;
722
+ });
723
+ });
724
+
725
+ // ─── removeWorktree ──────────────────────────────────────────────────────────
726
+
727
+ describe("removeWorktree", () => {
728
+ it("prunes when worktree path does not exist on disk", () => {
729
+ mockExistsSync.mockReturnValue(false);
730
+ mockGitCommand("worktree prune", "");
731
+
732
+ const result = gitUtils.removeWorktree("/repo", "/gone/path");
733
+ expect(result.removed).toBe(true);
734
+ expect(result.reason).toBeUndefined();
735
+
736
+ const pruneCalls = mockExecSync.mock.calls.filter((c: unknown[]) =>
737
+ (c[0] as string).includes("worktree prune"),
738
+ );
739
+ expect(pruneCalls).toHaveLength(1);
740
+ });
741
+
742
+ it("deletes branchToDelete after pruning a missing worktree", () => {
743
+ mockExistsSync.mockReturnValue(false);
744
+ mockGitCommands({
745
+ "worktree prune": "",
746
+ "branch -D main-wt-2": "",
747
+ });
748
+
749
+ const result = gitUtils.removeWorktree("/repo", "/gone/path", { branchToDelete: "main-wt-2" });
750
+ expect(result.removed).toBe(true);
751
+
752
+ const branchDeleteCalls = mockExecSync.mock.calls.filter((c: unknown[]) =>
753
+ (c[0] as string).includes("branch -D main-wt-2"),
754
+ );
755
+ expect(branchDeleteCalls).toHaveLength(1);
756
+ });
757
+
758
+ it("deletes branchToDelete after successful worktree removal", () => {
759
+ mockExistsSync.mockReturnValue(true);
760
+ mockExecSync.mockImplementation((cmd: string) => {
761
+ if (cmd.includes("status --porcelain")) return "";
762
+ if (cmd.includes("worktree remove")) return "";
763
+ if (cmd.includes("branch -D feat-wt-3")) return "";
764
+ throw new Error(`Unmocked: ${cmd}`);
765
+ });
766
+
767
+ const result = gitUtils.removeWorktree("/repo", "/wt/path", { branchToDelete: "feat-wt-3" });
768
+ expect(result.removed).toBe(true);
769
+
770
+ const branchDeleteCalls = mockExecSync.mock.calls.filter((c: unknown[]) =>
771
+ (c[0] as string).includes("branch -D feat-wt-3"),
772
+ );
773
+ expect(branchDeleteCalls).toHaveLength(1);
774
+ });
775
+
776
+ it("refuses to remove dirty worktree without force", () => {
777
+ mockExistsSync.mockReturnValue(true);
778
+ mockGitCommand("status --porcelain", " M dirty-file.ts");
779
+
780
+ const result = gitUtils.removeWorktree("/repo", "/wt/path");
781
+ expect(result.removed).toBe(false);
782
+ expect(result.reason).toContain("uncommitted changes");
783
+ });
784
+
785
+ it("force-removes dirty worktree", () => {
786
+ // existsSync: first call for removeWorktree check, second for isWorktreeDirty
787
+ mockExistsSync.mockReturnValue(true);
788
+ mockExecSync.mockImplementation((cmd: string) => {
789
+ if (cmd.includes("status --porcelain")) return " M dirty.ts";
790
+ if (cmd.includes("worktree remove") && cmd.includes("--force")) return "";
791
+ throw new Error(`Unmocked: ${cmd}`);
792
+ });
793
+
794
+ const result = gitUtils.removeWorktree("/repo", "/wt/path", { force: true });
795
+ expect(result.removed).toBe(true);
796
+ });
797
+
798
+ it("returns reason on error during removal", () => {
799
+ mockExistsSync.mockReturnValue(true);
800
+ mockExecSync.mockImplementation((cmd: string) => {
801
+ if (cmd.includes("status --porcelain")) return "";
802
+ if (cmd.includes("worktree remove"))
803
+ throw new Error("worktree is locked");
804
+ throw new Error(`Unmocked: ${cmd}`);
805
+ });
806
+
807
+ const result = gitUtils.removeWorktree("/repo", "/wt/path");
808
+ expect(result.removed).toBe(false);
809
+ expect(result.reason).toContain("worktree is locked");
810
+ });
811
+ });
812
+
813
+ // ─── isWorktreeDirty ─────────────────────────────────────────────────────────
814
+
815
+ describe("isWorktreeDirty", () => {
816
+ it("returns false when path does not exist", () => {
817
+ mockExistsSync.mockReturnValue(false);
818
+
819
+ expect(gitUtils.isWorktreeDirty("/nonexistent")).toBe(false);
820
+ });
821
+
822
+ it("returns false when status is empty", () => {
823
+ mockExistsSync.mockReturnValue(true);
824
+ mockGitCommand("status --porcelain", "");
825
+
826
+ expect(gitUtils.isWorktreeDirty("/clean/repo")).toBe(false);
827
+ });
828
+
829
+ it("returns true when status has output", () => {
830
+ mockExistsSync.mockReturnValue(true);
831
+ mockGitCommand("status --porcelain", " M file.ts\n?? new-file.ts");
832
+
833
+ expect(gitUtils.isWorktreeDirty("/dirty/repo")).toBe(true);
834
+ });
835
+ });
836
+
837
+ // ─── getBranchStatus ─────────────────────────────────────────────────────────
838
+
839
+ describe("getBranchStatus", () => {
840
+ it("parses ahead/behind counts correctly", () => {
841
+ mockGitCommand("rev-list --left-right --count", "7\t12");
842
+
843
+ const status = gitUtils.getBranchStatus("/repo", "feat/branch");
844
+ // Source: [behind, ahead] = raw.split(...).map(Number)
845
+ expect(status.ahead).toBe(12);
846
+ expect(status.behind).toBe(7);
847
+ });
848
+
849
+ it("returns 0/0 when there is no upstream", () => {
850
+ mockExecSync.mockImplementation(() => {
851
+ throw new Error("no upstream configured");
852
+ });
853
+
854
+ const status = gitUtils.getBranchStatus("/repo", "local-only");
855
+ expect(status.ahead).toBe(0);
856
+ expect(status.behind).toBe(0);
857
+ });
858
+
859
+ it("handles zero ahead/behind", () => {
860
+ mockGitCommand("rev-list --left-right --count", "0\t0");
861
+
862
+ const status = gitUtils.getBranchStatus("/repo", "main");
863
+ expect(status.ahead).toBe(0);
864
+ expect(status.behind).toBe(0);
865
+ });
866
+ });
867
+
868
+ // ─── checkoutOrCreateBranch ─────────────────────────────────────────────────
869
+
870
+ describe("checkoutOrCreateBranch", () => {
871
+ it("checks out an existing branch without creating", () => {
872
+ mockGitCommand("checkout feat/existing", "Switched to branch 'feat/existing'");
873
+
874
+ const result = gitUtils.checkoutOrCreateBranch("/repo", "feat/existing");
875
+ expect(result.created).toBe(false);
876
+ });
877
+
878
+ it("creates branch from origin/defaultBranch when checkout fails and createBranch=true", () => {
879
+ // Checkout fails (branch doesn't exist), but origin/main is available
880
+ mockExecSync.mockImplementation((cmd: string) => {
881
+ if (cmd.includes("checkout feat/new") && !cmd.includes("-b"))
882
+ throw new Error("error: pathspec 'feat/new' did not match any file(s) known to git");
883
+ if (cmd.includes("rev-parse --verify refs/remotes/origin/main")) return "abc123";
884
+ if (cmd.includes("checkout -b feat/new origin/main")) return "";
885
+ throw new Error(`Unmocked: ${cmd}`);
886
+ });
887
+
888
+ const result = gitUtils.checkoutOrCreateBranch("/repo", "feat/new", {
889
+ createBranch: true,
890
+ defaultBranch: "main",
891
+ });
892
+ expect(result.created).toBe(true);
893
+
894
+ // Verify the correct git command was called
895
+ const createCall = mockExecSync.mock.calls.find((c: unknown[]) =>
896
+ (c[0] as string).includes("checkout -b"),
897
+ );
898
+ expect(createCall).toBeDefined();
899
+ expect((createCall![0] as string)).toContain("origin/main");
900
+ });
901
+
902
+ it("falls back to local defaultBranch when origin ref does not exist", () => {
903
+ // Checkout fails, and origin/main is not available either
904
+ mockExecSync.mockImplementation((cmd: string) => {
905
+ if (cmd.includes("checkout feat/new") && !cmd.includes("-b"))
906
+ throw new Error("error: pathspec 'feat/new' did not match any file(s) known to git");
907
+ if (cmd.includes("rev-parse --verify refs/remotes/origin/main"))
908
+ throw new Error("not found");
909
+ if (cmd.includes("checkout -b feat/new main")) return "";
910
+ throw new Error(`Unmocked: ${cmd}`);
911
+ });
912
+
913
+ const result = gitUtils.checkoutOrCreateBranch("/repo", "feat/new", {
914
+ createBranch: true,
915
+ defaultBranch: "main",
916
+ });
917
+ expect(result.created).toBe(true);
918
+
919
+ const createCall = mockExecSync.mock.calls.find((c: unknown[]) =>
920
+ (c[0] as string).includes("checkout -b"),
921
+ );
922
+ expect(createCall).toBeDefined();
923
+ expect((createCall![0] as string)).toContain("main");
924
+ expect((createCall![0] as string)).not.toContain("origin/main");
925
+ });
926
+
927
+ it("throws when branch does not exist and createBranch is not set", () => {
928
+ mockExecSync.mockImplementation((cmd: string) => {
929
+ if (cmd.includes("checkout feat/missing"))
930
+ throw new Error("error: pathspec 'feat/missing' did not match any file(s) known to git");
931
+ throw new Error(`Unmocked: ${cmd}`);
932
+ });
933
+
934
+ expect(() =>
935
+ gitUtils.checkoutOrCreateBranch("/repo", "feat/missing"),
936
+ ).toThrow('Branch "feat/missing" does not exist');
937
+ });
938
+ });