beth-copilot 1.0.18 → 1.1.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 (291) hide show
  1. package/CHANGELOG.md +41 -28
  2. package/README.md +87 -247
  3. package/bin/cli.js +158 -358
  4. package/dist/__tests__/smoke.test.d.ts +8 -0
  5. package/dist/__tests__/smoke.test.d.ts.map +1 -0
  6. package/dist/__tests__/smoke.test.js +49 -0
  7. package/dist/__tests__/smoke.test.js.map +1 -0
  8. package/dist/cli/commands/beads.e2e.test.d.ts +13 -0
  9. package/dist/cli/commands/beads.e2e.test.d.ts.map +1 -0
  10. package/dist/cli/commands/beads.e2e.test.js +526 -0
  11. package/dist/cli/commands/beads.e2e.test.js.map +1 -0
  12. package/dist/cli/commands/cli-edge-cases.e2e.test.d.ts +32 -0
  13. package/dist/cli/commands/cli-edge-cases.e2e.test.d.ts.map +1 -0
  14. package/dist/cli/commands/cli-edge-cases.e2e.test.js +162 -0
  15. package/dist/cli/commands/cli-edge-cases.e2e.test.js.map +1 -0
  16. package/dist/cli/commands/close.d.ts +89 -0
  17. package/dist/cli/commands/close.d.ts.map +1 -0
  18. package/dist/cli/commands/close.e2e.test.d.ts +27 -0
  19. package/dist/cli/commands/close.e2e.test.d.ts.map +1 -0
  20. package/dist/cli/commands/close.e2e.test.js +252 -0
  21. package/dist/cli/commands/close.e2e.test.js.map +1 -0
  22. package/dist/cli/commands/close.js +309 -0
  23. package/dist/cli/commands/close.js.map +1 -0
  24. package/dist/cli/commands/close.test.d.ts +15 -0
  25. package/dist/cli/commands/close.test.d.ts.map +1 -0
  26. package/dist/cli/commands/close.test.js +634 -0
  27. package/dist/cli/commands/close.test.js.map +1 -0
  28. package/dist/cli/commands/doctor.d.ts +23 -0
  29. package/dist/cli/commands/doctor.d.ts.map +1 -1
  30. package/dist/cli/commands/doctor.js +93 -0
  31. package/dist/cli/commands/doctor.js.map +1 -1
  32. package/dist/cli/commands/doctor.test.js +209 -0
  33. package/dist/cli/commands/doctor.test.js.map +1 -1
  34. package/dist/cli/commands/framework-isolation.test.d.ts +30 -0
  35. package/dist/cli/commands/framework-isolation.test.d.ts.map +1 -0
  36. package/dist/cli/commands/framework-isolation.test.js +119 -0
  37. package/dist/cli/commands/framework-isolation.test.js.map +1 -0
  38. package/dist/cli/commands/help.e2e.test.js +4 -4
  39. package/dist/cli/commands/help.e2e.test.js.map +1 -1
  40. package/dist/cli/commands/init-logic.e2e.test.d.ts +37 -0
  41. package/dist/cli/commands/init-logic.e2e.test.d.ts.map +1 -0
  42. package/dist/cli/commands/init-logic.e2e.test.js +305 -0
  43. package/dist/cli/commands/init-logic.e2e.test.js.map +1 -0
  44. package/dist/cli/commands/land.d.ts +142 -0
  45. package/dist/cli/commands/land.d.ts.map +1 -0
  46. package/dist/cli/commands/land.js +647 -0
  47. package/dist/cli/commands/land.js.map +1 -0
  48. package/dist/cli/commands/land.test.d.ts +20 -0
  49. package/dist/cli/commands/land.test.d.ts.map +1 -0
  50. package/dist/cli/commands/land.test.js +622 -0
  51. package/dist/cli/commands/land.test.js.map +1 -0
  52. package/dist/cli/commands/mcp.e2e.test.js +22 -29
  53. package/dist/cli/commands/mcp.e2e.test.js.map +1 -1
  54. package/dist/cli/commands/pipeline.e2e.test.js +20 -20
  55. package/dist/cli/commands/pipeline.e2e.test.js.map +1 -1
  56. package/dist/cli/commands/pre-push-guard.d.ts +84 -0
  57. package/dist/cli/commands/pre-push-guard.d.ts.map +1 -0
  58. package/dist/cli/commands/pre-push-guard.e2e.test.d.ts +24 -0
  59. package/dist/cli/commands/pre-push-guard.e2e.test.d.ts.map +1 -0
  60. package/dist/cli/commands/pre-push-guard.e2e.test.js +171 -0
  61. package/dist/cli/commands/pre-push-guard.e2e.test.js.map +1 -0
  62. package/dist/cli/commands/pre-push-guard.js +257 -0
  63. package/dist/cli/commands/pre-push-guard.js.map +1 -0
  64. package/dist/cli/commands/pre-push-guard.test.d.ts +15 -0
  65. package/dist/cli/commands/pre-push-guard.test.d.ts.map +1 -0
  66. package/dist/cli/commands/pre-push-guard.test.js +397 -0
  67. package/dist/cli/commands/pre-push-guard.test.js.map +1 -0
  68. package/dist/cli/commands/quickstart-expanded.e2e.test.d.ts +23 -0
  69. package/dist/cli/commands/quickstart-expanded.e2e.test.d.ts.map +1 -0
  70. package/dist/cli/commands/quickstart-expanded.e2e.test.js +179 -0
  71. package/dist/cli/commands/quickstart-expanded.e2e.test.js.map +1 -0
  72. package/dist/cli/commands/quickstart.d.ts.map +1 -1
  73. package/dist/cli/commands/quickstart.js +7 -23
  74. package/dist/cli/commands/quickstart.js.map +1 -1
  75. package/dist/cli/commands/quickstart.test.js +40 -67
  76. package/dist/cli/commands/quickstart.test.js.map +1 -1
  77. package/dist/core/agents/suite.test.js +4 -2
  78. package/dist/core/agents/suite.test.js.map +1 -1
  79. package/dist/core/agents/tools.test.js +5 -1
  80. package/dist/core/agents/tools.test.js.map +1 -1
  81. package/dist/index.d.ts +3 -10
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +5 -10
  84. package/dist/index.js.map +1 -1
  85. package/package.json +15 -9
  86. package/sbom.json +2011 -819
  87. package/templates/.github/agents/beth.agent.md +220 -66
  88. package/templates/.github/agents/developer.agent.md +53 -90
  89. package/templates/.github/agents/product-manager.agent.md +15 -68
  90. package/templates/.github/agents/researcher.agent.md +20 -71
  91. package/templates/.github/agents/security-reviewer.agent.md +29 -81
  92. package/templates/.github/agents/tester.agent.md +40 -69
  93. package/templates/.github/agents/ux-designer.agent.md +20 -74
  94. package/templates/.github/copilot-instructions.md +217 -225
  95. package/templates/AGENTS.md +108 -20
  96. package/templates/mcp.json.example +0 -3
  97. package/dist/cli/commands/client-config.d.ts +0 -31
  98. package/dist/cli/commands/client-config.d.ts.map +0 -1
  99. package/dist/cli/commands/client-config.e2e.test.d.ts +0 -15
  100. package/dist/cli/commands/client-config.e2e.test.d.ts.map +0 -1
  101. package/dist/cli/commands/client-config.e2e.test.js +0 -556
  102. package/dist/cli/commands/client-config.e2e.test.js.map +0 -1
  103. package/dist/cli/commands/client-config.js +0 -73
  104. package/dist/cli/commands/client-config.js.map +0 -1
  105. package/dist/cli/commands/client-config.test.d.ts +0 -6
  106. package/dist/cli/commands/client-config.test.d.ts.map +0 -1
  107. package/dist/cli/commands/client-config.test.js +0 -133
  108. package/dist/cli/commands/client-config.test.js.map +0 -1
  109. package/dist/cli/commands/init-quickstart.e2e.test.d.ts +0 -11
  110. package/dist/cli/commands/init-quickstart.e2e.test.d.ts.map +0 -1
  111. package/dist/cli/commands/init-quickstart.e2e.test.js +0 -221
  112. package/dist/cli/commands/init-quickstart.e2e.test.js.map +0 -1
  113. package/dist/core/context.d.ts +0 -171
  114. package/dist/core/context.d.ts.map +0 -1
  115. package/dist/core/context.js +0 -353
  116. package/dist/core/context.js.map +0 -1
  117. package/dist/core/context.test.d.ts +0 -8
  118. package/dist/core/context.test.d.ts.map +0 -1
  119. package/dist/core/context.test.js +0 -253
  120. package/dist/core/context.test.js.map +0 -1
  121. package/dist/core/handoffs.d.ts +0 -151
  122. package/dist/core/handoffs.d.ts.map +0 -1
  123. package/dist/core/handoffs.js +0 -220
  124. package/dist/core/handoffs.js.map +0 -1
  125. package/dist/core/handoffs.test.d.ts +0 -8
  126. package/dist/core/handoffs.test.d.ts.map +0 -1
  127. package/dist/core/handoffs.test.js +0 -231
  128. package/dist/core/handoffs.test.js.map +0 -1
  129. package/dist/core/orchestrator.d.ts +0 -246
  130. package/dist/core/orchestrator.d.ts.map +0 -1
  131. package/dist/core/orchestrator.js +0 -514
  132. package/dist/core/orchestrator.js.map +0 -1
  133. package/dist/core/orchestrator.test.d.ts +0 -8
  134. package/dist/core/orchestrator.test.d.ts.map +0 -1
  135. package/dist/core/orchestrator.test.js +0 -517
  136. package/dist/core/orchestrator.test.js.map +0 -1
  137. package/dist/core/router.d.ts +0 -102
  138. package/dist/core/router.d.ts.map +0 -1
  139. package/dist/core/router.js +0 -178
  140. package/dist/core/router.js.map +0 -1
  141. package/dist/core/router.test.d.ts +0 -8
  142. package/dist/core/router.test.d.ts.map +0 -1
  143. package/dist/core/router.test.js +0 -215
  144. package/dist/core/router.test.js.map +0 -1
  145. package/dist/init.test.js +0 -288
  146. package/dist/providers/azure.d.ts +0 -147
  147. package/dist/providers/azure.d.ts.map +0 -1
  148. package/dist/providers/azure.js +0 -491
  149. package/dist/providers/azure.js.map +0 -1
  150. package/dist/providers/azure.test.d.ts +0 -11
  151. package/dist/providers/azure.test.d.ts.map +0 -1
  152. package/dist/providers/azure.test.js +0 -330
  153. package/dist/providers/azure.test.js.map +0 -1
  154. package/dist/providers/config.d.ts +0 -87
  155. package/dist/providers/config.d.ts.map +0 -1
  156. package/dist/providers/config.js +0 -193
  157. package/dist/providers/config.js.map +0 -1
  158. package/dist/providers/config.test.d.ts +0 -7
  159. package/dist/providers/config.test.d.ts.map +0 -1
  160. package/dist/providers/config.test.js +0 -370
  161. package/dist/providers/config.test.js.map +0 -1
  162. package/dist/providers/index.d.ts +0 -18
  163. package/dist/providers/index.d.ts.map +0 -1
  164. package/dist/providers/index.js +0 -14
  165. package/dist/providers/index.js.map +0 -1
  166. package/dist/providers/interface.d.ts +0 -191
  167. package/dist/providers/interface.d.ts.map +0 -1
  168. package/dist/providers/interface.js +0 -94
  169. package/dist/providers/interface.js.map +0 -1
  170. package/dist/providers/retry.d.ts +0 -128
  171. package/dist/providers/retry.d.ts.map +0 -1
  172. package/dist/providers/retry.js +0 -205
  173. package/dist/providers/retry.js.map +0 -1
  174. package/dist/providers/retry.test.d.ts +0 -7
  175. package/dist/providers/retry.test.d.ts.map +0 -1
  176. package/dist/providers/retry.test.js +0 -439
  177. package/dist/providers/retry.test.js.map +0 -1
  178. package/dist/providers/streaming.d.ts +0 -157
  179. package/dist/providers/streaming.d.ts.map +0 -1
  180. package/dist/providers/streaming.js +0 -233
  181. package/dist/providers/streaming.js.map +0 -1
  182. package/dist/providers/streaming.test.d.ts +0 -7
  183. package/dist/providers/streaming.test.d.ts.map +0 -1
  184. package/dist/providers/streaming.test.js +0 -372
  185. package/dist/providers/streaming.test.js.map +0 -1
  186. package/dist/providers/types.d.ts +0 -209
  187. package/dist/providers/types.d.ts.map +0 -1
  188. package/dist/providers/types.js +0 -53
  189. package/dist/providers/types.js.map +0 -1
  190. package/dist/providers/types.test.d.ts +0 -7
  191. package/dist/providers/types.test.d.ts.map +0 -1
  192. package/dist/providers/types.test.js +0 -141
  193. package/dist/providers/types.test.js.map +0 -1
  194. package/dist/tools/cli/beads.d.ts +0 -27
  195. package/dist/tools/cli/beads.d.ts.map +0 -1
  196. package/dist/tools/cli/beads.js +0 -172
  197. package/dist/tools/cli/beads.js.map +0 -1
  198. package/dist/tools/cli/beads.test.d.ts +0 -8
  199. package/dist/tools/cli/beads.test.d.ts.map +0 -1
  200. package/dist/tools/cli/beads.test.js +0 -264
  201. package/dist/tools/cli/beads.test.js.map +0 -1
  202. package/dist/tools/cli/editFile.d.ts +0 -17
  203. package/dist/tools/cli/editFile.d.ts.map +0 -1
  204. package/dist/tools/cli/editFile.js +0 -125
  205. package/dist/tools/cli/editFile.js.map +0 -1
  206. package/dist/tools/cli/editFile.test.d.ts +0 -8
  207. package/dist/tools/cli/editFile.test.d.ts.map +0 -1
  208. package/dist/tools/cli/editFile.test.js +0 -177
  209. package/dist/tools/cli/editFile.test.js.map +0 -1
  210. package/dist/tools/cli/readFile.d.ts +0 -25
  211. package/dist/tools/cli/readFile.d.ts.map +0 -1
  212. package/dist/tools/cli/readFile.js +0 -118
  213. package/dist/tools/cli/readFile.js.map +0 -1
  214. package/dist/tools/cli/readFile.test.d.ts +0 -8
  215. package/dist/tools/cli/readFile.test.d.ts.map +0 -1
  216. package/dist/tools/cli/readFile.test.js +0 -194
  217. package/dist/tools/cli/readFile.test.js.map +0 -1
  218. package/dist/tools/cli/search.d.ts +0 -16
  219. package/dist/tools/cli/search.d.ts.map +0 -1
  220. package/dist/tools/cli/search.js +0 -261
  221. package/dist/tools/cli/search.js.map +0 -1
  222. package/dist/tools/cli/search.test.d.ts +0 -8
  223. package/dist/tools/cli/search.test.d.ts.map +0 -1
  224. package/dist/tools/cli/search.test.js +0 -172
  225. package/dist/tools/cli/search.test.js.map +0 -1
  226. package/dist/tools/cli/subagent.d.ts +0 -43
  227. package/dist/tools/cli/subagent.d.ts.map +0 -1
  228. package/dist/tools/cli/subagent.js +0 -99
  229. package/dist/tools/cli/subagent.js.map +0 -1
  230. package/dist/tools/cli/subagent.test.d.ts +0 -8
  231. package/dist/tools/cli/subagent.test.d.ts.map +0 -1
  232. package/dist/tools/cli/subagent.test.js +0 -190
  233. package/dist/tools/cli/subagent.test.js.map +0 -1
  234. package/dist/tools/cli/terminal.d.ts +0 -19
  235. package/dist/tools/cli/terminal.d.ts.map +0 -1
  236. package/dist/tools/cli/terminal.js +0 -164
  237. package/dist/tools/cli/terminal.js.map +0 -1
  238. package/dist/tools/cli/terminal.test.d.ts +0 -8
  239. package/dist/tools/cli/terminal.test.d.ts.map +0 -1
  240. package/dist/tools/cli/terminal.test.js +0 -161
  241. package/dist/tools/cli/terminal.test.js.map +0 -1
  242. package/dist/tools/index.d.ts +0 -25
  243. package/dist/tools/index.d.ts.map +0 -1
  244. package/dist/tools/index.js +0 -41
  245. package/dist/tools/index.js.map +0 -1
  246. package/dist/tools/interface.d.ts +0 -64
  247. package/dist/tools/interface.d.ts.map +0 -1
  248. package/dist/tools/interface.js +0 -37
  249. package/dist/tools/interface.js.map +0 -1
  250. package/dist/tools/interface.test.d.ts +0 -7
  251. package/dist/tools/interface.test.d.ts.map +0 -1
  252. package/dist/tools/interface.test.js +0 -179
  253. package/dist/tools/interface.test.js.map +0 -1
  254. package/dist/tools/mcp/bridge.d.ts +0 -48
  255. package/dist/tools/mcp/bridge.d.ts.map +0 -1
  256. package/dist/tools/mcp/bridge.js +0 -128
  257. package/dist/tools/mcp/bridge.js.map +0 -1
  258. package/dist/tools/mcp/bridge.test.d.ts +0 -8
  259. package/dist/tools/mcp/bridge.test.d.ts.map +0 -1
  260. package/dist/tools/mcp/bridge.test.js +0 -300
  261. package/dist/tools/mcp/bridge.test.js.map +0 -1
  262. package/dist/tools/mcp/client.d.ts +0 -135
  263. package/dist/tools/mcp/client.d.ts.map +0 -1
  264. package/dist/tools/mcp/client.js +0 -263
  265. package/dist/tools/mcp/client.js.map +0 -1
  266. package/dist/tools/mcp/client.test.d.ts +0 -8
  267. package/dist/tools/mcp/client.test.d.ts.map +0 -1
  268. package/dist/tools/mcp/client.test.js +0 -390
  269. package/dist/tools/mcp/client.test.js.map +0 -1
  270. package/dist/tools/registry.d.ts +0 -82
  271. package/dist/tools/registry.d.ts.map +0 -1
  272. package/dist/tools/registry.js +0 -99
  273. package/dist/tools/registry.js.map +0 -1
  274. package/dist/tools/registry.test.d.ts +0 -7
  275. package/dist/tools/registry.test.d.ts.map +0 -1
  276. package/dist/tools/registry.test.js +0 -199
  277. package/dist/tools/registry.test.js.map +0 -1
  278. package/dist/tools/suite.test.d.ts +0 -11
  279. package/dist/tools/suite.test.d.ts.map +0 -1
  280. package/dist/tools/suite.test.js +0 -119
  281. package/dist/tools/suite.test.js.map +0 -1
  282. package/dist/tools/types.d.ts +0 -75
  283. package/dist/tools/types.d.ts.map +0 -1
  284. package/dist/tools/types.js +0 -30
  285. package/dist/tools/types.js.map +0 -1
  286. package/dist/tools/types.test.d.ts +0 -7
  287. package/dist/tools/types.test.d.ts.map +0 -1
  288. package/dist/tools/types.test.js +0 -178
  289. package/dist/tools/types.test.js.map +0 -1
  290. package/templates/.vscode/mcp.json +0 -20
  291. package/templates/CLAUDE.md +0 -129
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Land Command Tests
3
+ *
4
+ * Tests the partial session completion automation ("landing the plane").
5
+ * This command handles the git-mechanical steps only — see land.ts header
6
+ * for what remains manual.
7
+ *
8
+ * Covers:
9
+ * - Argument parsing (--skip-tests, --message, --force, --dry-run)
10
+ * - Branch detection and epic ID extraction
11
+ * - Protected branch blocking
12
+ * - Git state checks (uncommitted, staged, unpushed)
13
+ * - Test execution pass/fail handling
14
+ * - Beads backup
15
+ * - Git operations (add, commit, pull rebase, push)
16
+ * - Full landing sequence orchestration
17
+ * - Dry-run mode
18
+ */
19
+ export {};
20
+ //# sourceMappingURL=land.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"land.test.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/land.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG"}
@@ -0,0 +1,622 @@
1
+ /**
2
+ * Land Command Tests
3
+ *
4
+ * Tests the partial session completion automation ("landing the plane").
5
+ * This command handles the git-mechanical steps only — see land.ts header
6
+ * for what remains manual.
7
+ *
8
+ * Covers:
9
+ * - Argument parsing (--skip-tests, --message, --force, --dry-run)
10
+ * - Branch detection and epic ID extraction
11
+ * - Protected branch blocking
12
+ * - Git state checks (uncommitted, staged, unpushed)
13
+ * - Test execution pass/fail handling
14
+ * - Beads backup
15
+ * - Git operations (add, commit, pull rebase, push)
16
+ * - Full landing sequence orchestration
17
+ * - Dry-run mode
18
+ */
19
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
20
+ import * as child_process from 'child_process';
21
+ // Mock child_process before importing the module under test
22
+ vi.mock('child_process', () => ({
23
+ execFileSync: vi.fn(),
24
+ }));
25
+ // Import after mocking
26
+ import { parseLandArgs, getCurrentBranch, extractEpicId, isProtectedBranch, hasUncommittedChanges, hasStagedChanges, hasUnpushedCommits, runTests, runBeadsBackup, gitAddAll, gitCommit, remoteBranchExists, gitRebaseAbort, gitPullRebase, gitPush, isUpToDateWithOrigin, executeLanding, } from './land.js';
27
+ const mockedExecFileSync = vi.mocked(child_process.execFileSync);
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ // Suppress console output in tests
31
+ vi.spyOn(console, 'log').mockImplementation(() => { });
32
+ vi.spyOn(console, 'error').mockImplementation(() => { });
33
+ });
34
+ // ─── parseLandArgs ──────────────────────────────────────────────────────────
35
+ describe('parseLandArgs', () => {
36
+ it('returns empty options for no args', () => {
37
+ expect(parseLandArgs([])).toEqual({});
38
+ });
39
+ it('parses --skip-tests', () => {
40
+ const opts = parseLandArgs(['--skip-tests']);
41
+ expect(opts.skipTests).toBe(true);
42
+ });
43
+ it('parses --skip-backup', () => {
44
+ const opts = parseLandArgs(['--skip-backup']);
45
+ expect(opts.skipBackup).toBe(true);
46
+ });
47
+ it('parses --force and -f', () => {
48
+ expect(parseLandArgs(['--force']).force).toBe(true);
49
+ expect(parseLandArgs(['-f']).force).toBe(true);
50
+ });
51
+ it('parses --dry-run', () => {
52
+ expect(parseLandArgs(['--dry-run']).dryRun).toBe(true);
53
+ });
54
+ it('parses --message with separate value', () => {
55
+ const opts = parseLandArgs(['--message', 'my commit msg']);
56
+ expect(opts.message).toBe('my commit msg');
57
+ });
58
+ it('parses -m with separate value', () => {
59
+ const opts = parseLandArgs(['-m', 'short msg']);
60
+ expect(opts.message).toBe('short msg');
61
+ });
62
+ it('parses --message=value', () => {
63
+ const opts = parseLandArgs(['--message=inline msg']);
64
+ expect(opts.message).toBe('inline msg');
65
+ });
66
+ it('combines multiple flags', () => {
67
+ const opts = parseLandArgs(['--skip-tests', '--force', '-m', 'combo']);
68
+ expect(opts.skipTests).toBe(true);
69
+ expect(opts.force).toBe(true);
70
+ expect(opts.message).toBe('combo');
71
+ });
72
+ });
73
+ // ─── extractEpicId ──────────────────────────────────────────────────────────
74
+ describe('extractEpicId', () => {
75
+ it('extracts epic ID from epic branch', () => {
76
+ expect(extractEpicId('epic/beth-z9n')).toBe('beth-z9n');
77
+ expect(extractEpicId('epic/beth-abc123')).toBe('beth-abc123');
78
+ expect(extractEpicId('epic/hq-xyz')).toBe('hq-xyz');
79
+ });
80
+ it('returns null for non-epic branches', () => {
81
+ expect(extractEpicId('main')).toBeNull();
82
+ expect(extractEpicId('master')).toBeNull();
83
+ expect(extractEpicId('feature/something')).toBeNull();
84
+ expect(extractEpicId('release/v1.0.0')).toBeNull();
85
+ });
86
+ it('returns null for malformed epic branches', () => {
87
+ expect(extractEpicId('epic/')).toBeNull();
88
+ expect(extractEpicId('epic/BETH-Z9N')).toBeNull();
89
+ expect(extractEpicId('epic/beth-z9n.1')).toBeNull(); // dotted IDs are children, not epics
90
+ });
91
+ });
92
+ // ─── isProtectedBranch ──────────────────────────────────────────────────────
93
+ describe('isProtectedBranch', () => {
94
+ it('identifies main as protected', () => {
95
+ expect(isProtectedBranch('main')).toBe(true);
96
+ });
97
+ it('identifies master as protected', () => {
98
+ expect(isProtectedBranch('master')).toBe(true);
99
+ });
100
+ it('allows epic branches', () => {
101
+ expect(isProtectedBranch('epic/beth-z9n')).toBe(false);
102
+ });
103
+ it('allows feature branches', () => {
104
+ expect(isProtectedBranch('feature/something')).toBe(false);
105
+ });
106
+ });
107
+ // ─── getCurrentBranch ───────────────────────────────────────────────────────
108
+ describe('getCurrentBranch', () => {
109
+ it('returns branch name from git output', () => {
110
+ mockedExecFileSync.mockReturnValue('epic/beth-z9n\n');
111
+ expect(getCurrentBranch()).toBe('epic/beth-z9n');
112
+ });
113
+ it('returns null for empty output (detached HEAD)', () => {
114
+ mockedExecFileSync.mockReturnValue('\n');
115
+ expect(getCurrentBranch()).toBeNull();
116
+ });
117
+ it('returns null when git fails', () => {
118
+ mockedExecFileSync.mockImplementation(() => {
119
+ throw new Error('not a git repo');
120
+ });
121
+ expect(getCurrentBranch()).toBeNull();
122
+ });
123
+ });
124
+ // ─── hasUncommittedChanges ──────────────────────────────────────────────────
125
+ describe('hasUncommittedChanges', () => {
126
+ it('returns true when there are uncommitted changes', () => {
127
+ mockedExecFileSync.mockReturnValue(' M src/foo.ts\n');
128
+ expect(hasUncommittedChanges()).toBe(true);
129
+ });
130
+ it('returns false when working tree is clean', () => {
131
+ mockedExecFileSync.mockReturnValue('');
132
+ expect(hasUncommittedChanges()).toBe(false);
133
+ });
134
+ it('returns false on git error', () => {
135
+ mockedExecFileSync.mockImplementation(() => {
136
+ throw new Error('fail');
137
+ });
138
+ expect(hasUncommittedChanges()).toBe(false);
139
+ });
140
+ });
141
+ // ─── hasStagedChanges ───────────────────────────────────────────────────────
142
+ describe('hasStagedChanges', () => {
143
+ it('returns false when no staged changes (exit 0)', () => {
144
+ mockedExecFileSync.mockReturnValue('');
145
+ expect(hasStagedChanges()).toBe(false);
146
+ });
147
+ it('returns true when there are staged changes (exit 1)', () => {
148
+ mockedExecFileSync.mockImplementation(() => {
149
+ const err = new Error('diff found');
150
+ err.status = 1;
151
+ throw err;
152
+ });
153
+ expect(hasStagedChanges()).toBe(true);
154
+ });
155
+ it('returns false on unexpected git errors (not exit 1)', () => {
156
+ mockedExecFileSync.mockImplementation(() => {
157
+ const err = new Error('not a git repo');
158
+ err.status = 128;
159
+ throw err;
160
+ });
161
+ expect(hasStagedChanges()).toBe(false);
162
+ });
163
+ });
164
+ // ─── hasUnpushedCommits ─────────────────────────────────────────────────────
165
+ describe('hasUnpushedCommits', () => {
166
+ it('returns true when there are unpushed commits', () => {
167
+ // First call: show-ref succeeds (remote exists)
168
+ // Second call: git log returns commits
169
+ mockedExecFileSync
170
+ .mockReturnValueOnce('') // show-ref
171
+ .mockReturnValueOnce('abc1234 some commit\ndef5678 another\n'); // git log
172
+ expect(hasUnpushedCommits('epic/beth-z9n')).toBe(true);
173
+ });
174
+ it('returns false when all commits are pushed', () => {
175
+ mockedExecFileSync
176
+ .mockReturnValueOnce('') // show-ref
177
+ .mockReturnValueOnce(''); // git log (no unpushed)
178
+ expect(hasUnpushedCommits('epic/beth-z9n')).toBe(false);
179
+ });
180
+ it('returns true when remote branch does not exist', () => {
181
+ mockedExecFileSync.mockImplementation(() => {
182
+ throw new Error('not found');
183
+ });
184
+ expect(hasUnpushedCommits('epic/beth-new')).toBe(true);
185
+ });
186
+ });
187
+ // ─── runTests ───────────────────────────────────────────────────────────────
188
+ describe('runTests', () => {
189
+ it('returns passed=true on success', () => {
190
+ mockedExecFileSync.mockReturnValue('Tests: 361 passed, 1 skipped\n');
191
+ const result = runTests();
192
+ expect(result.passed).toBe(true);
193
+ expect(result.output).toContain('361 passed');
194
+ });
195
+ it('returns passed=false on test failure', () => {
196
+ const error = new Error('test failure');
197
+ error.stdout = 'FAIL src/foo.test.ts';
198
+ error.stderr = 'AssertionError: expected true to be false';
199
+ mockedExecFileSync.mockImplementation(() => {
200
+ throw error;
201
+ });
202
+ const result = runTests();
203
+ expect(result.passed).toBe(false);
204
+ expect(result.output).toContain('FAIL');
205
+ });
206
+ it('calls npm test with correct args', () => {
207
+ mockedExecFileSync.mockReturnValue('ok');
208
+ runTests();
209
+ expect(mockedExecFileSync).toHaveBeenCalledWith('npm', ['test'], expect.objectContaining({ encoding: 'utf-8' }));
210
+ });
211
+ });
212
+ // ─── runBeadsBackup ─────────────────────────────────────────────────────────
213
+ describe('runBeadsBackup', () => {
214
+ it('returns success=true when bd backup works', () => {
215
+ mockedExecFileSync.mockReturnValue('Backup complete\n');
216
+ const result = runBeadsBackup();
217
+ expect(result.success).toBe(true);
218
+ });
219
+ it('returns success=false when bd is not available', () => {
220
+ mockedExecFileSync.mockImplementation(() => {
221
+ throw new Error('command not found');
222
+ });
223
+ const result = runBeadsBackup();
224
+ expect(result.success).toBe(false);
225
+ });
226
+ });
227
+ // ─── gitAddAll ──────────────────────────────────────────────────────────────
228
+ describe('gitAddAll', () => {
229
+ it('returns true on success', () => {
230
+ mockedExecFileSync.mockReturnValue('');
231
+ expect(gitAddAll()).toBe(true);
232
+ });
233
+ it('returns false on failure', () => {
234
+ mockedExecFileSync.mockImplementation(() => {
235
+ throw new Error('fail');
236
+ });
237
+ expect(gitAddAll()).toBe(false);
238
+ });
239
+ });
240
+ // ─── gitCommit ──────────────────────────────────────────────────────────────
241
+ describe('gitCommit', () => {
242
+ it('returns true on success', () => {
243
+ mockedExecFileSync.mockReturnValue('');
244
+ expect(gitCommit('test msg')).toBe(true);
245
+ expect(mockedExecFileSync).toHaveBeenCalledWith('git', ['commit', '-m', 'test msg'], expect.any(Object));
246
+ });
247
+ it('returns false on failure', () => {
248
+ mockedExecFileSync.mockImplementation(() => {
249
+ throw new Error('nothing to commit');
250
+ });
251
+ expect(gitCommit('test msg')).toBe(false);
252
+ });
253
+ });
254
+ // ─── remoteBranchExists ─────────────────────────────────────────────────────
255
+ describe('remoteBranchExists', () => {
256
+ it('returns true when remote branch exists', () => {
257
+ mockedExecFileSync.mockReturnValue('');
258
+ expect(remoteBranchExists('epic/beth-z9n')).toBe(true);
259
+ expect(mockedExecFileSync).toHaveBeenCalledWith('git', ['show-ref', '--verify', '--quiet', 'refs/remotes/origin/epic/beth-z9n'], expect.any(Object));
260
+ });
261
+ it('returns false when remote branch does not exist', () => {
262
+ mockedExecFileSync.mockImplementation(() => {
263
+ throw new Error('not found');
264
+ });
265
+ expect(remoteBranchExists('epic/beth-z9n')).toBe(false);
266
+ });
267
+ });
268
+ // ─── gitRebaseAbort ─────────────────────────────────────────────────────────
269
+ describe('gitRebaseAbort', () => {
270
+ it('calls git rebase --abort', () => {
271
+ mockedExecFileSync.mockReturnValue('');
272
+ gitRebaseAbort();
273
+ expect(mockedExecFileSync).toHaveBeenCalledWith('git', ['rebase', '--abort'], expect.any(Object));
274
+ });
275
+ it('does not throw when no rebase in progress', () => {
276
+ mockedExecFileSync.mockImplementation(() => {
277
+ throw new Error('No rebase in progress');
278
+ });
279
+ expect(() => gitRebaseAbort()).not.toThrow();
280
+ });
281
+ });
282
+ // ─── gitPullRebase ──────────────────────────────────────────────────────────
283
+ describe('gitPullRebase', () => {
284
+ it('returns success=true when rebase works', () => {
285
+ mockedExecFileSync.mockReturnValue('Already up to date.\n');
286
+ const result = gitPullRebase('epic/beth-z9n');
287
+ expect(result.success).toBe(true);
288
+ expect(mockedExecFileSync).toHaveBeenCalledWith('git', ['pull', 'origin', 'epic/beth-z9n', '--rebase'], expect.any(Object));
289
+ });
290
+ it('returns success=false on conflict', () => {
291
+ const error = new Error('conflict');
292
+ error.stderr = 'CONFLICT (content): Merge conflict in foo.ts';
293
+ mockedExecFileSync.mockImplementation(() => {
294
+ throw error;
295
+ });
296
+ const result = gitPullRebase('epic/beth-z9n');
297
+ expect(result.success).toBe(false);
298
+ });
299
+ });
300
+ // ─── gitPush ────────────────────────────────────────────────────────────────
301
+ describe('gitPush', () => {
302
+ it('returns success=true on push success', () => {
303
+ mockedExecFileSync.mockReturnValue('');
304
+ const result = gitPush('epic/beth-z9n');
305
+ expect(result.success).toBe(true);
306
+ expect(mockedExecFileSync).toHaveBeenCalledWith('git', ['push', 'origin', 'epic/beth-z9n'], expect.any(Object));
307
+ });
308
+ it('returns success=false on push failure', () => {
309
+ const error = new Error('rejected');
310
+ error.stderr = 'rejected (non-fast-forward)';
311
+ mockedExecFileSync.mockImplementation(() => {
312
+ throw error;
313
+ });
314
+ const result = gitPush('epic/beth-z9n');
315
+ expect(result.success).toBe(false);
316
+ });
317
+ });
318
+ // ─── isUpToDateWithOrigin ───────────────────────────────────────────────────
319
+ describe('isUpToDateWithOrigin', () => {
320
+ it('returns true when branch is in sync', () => {
321
+ mockedExecFileSync
322
+ .mockReturnValueOnce('') // fetch
323
+ .mockReturnValueOnce('abc123\n') // rev-parse HEAD
324
+ .mockReturnValueOnce('abc123\n'); // rev-parse origin/branch
325
+ expect(isUpToDateWithOrigin('epic/beth-z9n')).toBe(true);
326
+ });
327
+ it('returns false when local is ahead', () => {
328
+ mockedExecFileSync
329
+ .mockReturnValueOnce('') // fetch
330
+ .mockReturnValueOnce('abc123\n') // rev-parse HEAD
331
+ .mockReturnValueOnce('def456\n'); // rev-parse origin/branch
332
+ expect(isUpToDateWithOrigin('epic/beth-z9n')).toBe(false);
333
+ });
334
+ it('returns false when remote ref does not exist', () => {
335
+ mockedExecFileSync
336
+ .mockReturnValueOnce('') // fetch
337
+ .mockReturnValueOnce('abc123\n') // rev-parse HEAD
338
+ .mockImplementationOnce(() => { throw new Error('unknown revision'); }); // rev-parse origin/branch fails
339
+ expect(isUpToDateWithOrigin('epic/beth-z9n')).toBe(false);
340
+ });
341
+ it('returns false on fetch error', () => {
342
+ mockedExecFileSync.mockImplementation(() => {
343
+ throw new Error('network error');
344
+ });
345
+ expect(isUpToDateWithOrigin('epic/beth-z9n')).toBe(false);
346
+ });
347
+ });
348
+ // ─── executeLanding ─────────────────────────────────────────────────────────
349
+ describe('executeLanding', () => {
350
+ it('fails when not in git repo', () => {
351
+ // getCurrentBranch returns null
352
+ mockedExecFileSync.mockImplementation(() => {
353
+ throw new Error('not a git repo');
354
+ });
355
+ const result = executeLanding();
356
+ expect(result.success).toBe(false);
357
+ expect(result.steps[0].status).toBe('fail');
358
+ expect(result.steps[0].message).toContain('Not in a git repository');
359
+ });
360
+ it('fails on protected branch (main)', () => {
361
+ mockedExecFileSync.mockReturnValueOnce('main\n');
362
+ const result = executeLanding();
363
+ expect(result.success).toBe(false);
364
+ expect(result.steps[0].status).toBe('fail');
365
+ expect(result.steps[0].message).toContain('protected branch');
366
+ });
367
+ it('fails on protected branch (master)', () => {
368
+ mockedExecFileSync.mockReturnValueOnce('master\n');
369
+ const result = executeLanding();
370
+ expect(result.success).toBe(false);
371
+ expect(result.steps[0].status).toBe('fail');
372
+ expect(result.steps[0].message).toContain('protected branch');
373
+ });
374
+ it('warns on non-epic branch but continues', () => {
375
+ // Setup: on feature branch, no changes, no unpushed
376
+ mockedExecFileSync
377
+ .mockReturnValueOnce('feature/something\n') // getCurrentBranch
378
+ .mockReturnValueOnce('Tests passed\n') // npm test
379
+ .mockReturnValueOnce('Backup ok\n') // bd backup
380
+ .mockReturnValueOnce('') // hasUncommittedChanges (git status --porcelain)
381
+ .mockImplementation(() => { throw new Error('no remote'); }); // hasUnpushedCommits
382
+ const result = executeLanding();
383
+ expect(result.steps[0].status).toBe('warn');
384
+ expect(result.steps[0].message).toContain("doesn't follow epic");
385
+ });
386
+ it('extracts epic ID from branch', () => {
387
+ // Setup: on epic branch, no changes, no unpushed
388
+ mockedExecFileSync
389
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
390
+ .mockReturnValueOnce('Tests: 361 passed\n') // npm test
391
+ .mockReturnValueOnce('Backup ok\n') // bd backup
392
+ .mockReturnValueOnce('') // hasUncommittedChanges
393
+ .mockImplementation(() => {
394
+ throw new Error('no remote');
395
+ });
396
+ const result = executeLanding();
397
+ expect(result.epicId).toBe('beth-z9n');
398
+ expect(result.branch).toBe('epic/beth-z9n');
399
+ });
400
+ it('stops on test failure without --force', () => {
401
+ const testError = new Error('test fail');
402
+ testError.stdout = 'FAIL src/foo.test.ts';
403
+ testError.stderr = '';
404
+ mockedExecFileSync
405
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
406
+ .mockImplementationOnce(() => { throw testError; }); // npm test fails
407
+ const result = executeLanding();
408
+ expect(result.success).toBe(false);
409
+ const testStep = result.steps.find((s) => s.step === 'Tests');
410
+ expect(testStep?.status).toBe('fail');
411
+ });
412
+ it('continues on test failure with --force', () => {
413
+ const testError = new Error('test fail');
414
+ testError.stdout = 'FAIL src/foo.test.ts';
415
+ testError.stderr = '';
416
+ mockedExecFileSync
417
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
418
+ .mockImplementationOnce(() => { throw testError; }) // npm test fails
419
+ .mockReturnValueOnce('Backup ok\n') // bd backup
420
+ .mockReturnValueOnce('') // hasUncommittedChanges (clean)
421
+ .mockImplementation(() => { throw new Error('no remote'); }); // hasUnpushedCommits
422
+ const landResult = executeLanding({ force: true });
423
+ // Should continue past test failure
424
+ const testStep = landResult.steps.find((s) => s.step === 'Tests');
425
+ expect(testStep?.status).toBe('fail');
426
+ // But should still have executed further steps
427
+ expect(landResult.steps.length).toBeGreaterThan(2);
428
+ });
429
+ it('skips tests with --skip-tests', () => {
430
+ mockedExecFileSync
431
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
432
+ .mockReturnValueOnce('Backup ok\n') // bd backup
433
+ .mockReturnValueOnce('') // hasUncommittedChanges
434
+ .mockImplementation(() => { throw new Error('no remote'); }); // hasUnpushedCommits
435
+ const result = executeLanding({ skipTests: true });
436
+ const testStep = result.steps.find((s) => s.step === 'Tests');
437
+ expect(testStep?.status).toBe('skip');
438
+ });
439
+ it('skips backup with --skip-backup', () => {
440
+ mockedExecFileSync
441
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
442
+ .mockReturnValueOnce('Tests passed\n') // npm test
443
+ .mockReturnValueOnce('') // hasUncommittedChanges
444
+ .mockImplementation(() => { throw new Error('no remote'); }); // hasUnpushedCommits
445
+ const result = executeLanding({ skipBackup: true });
446
+ const backupStep = result.steps.find((s) => s.step === 'Beads backup');
447
+ expect(backupStep?.status).toBe('skip');
448
+ });
449
+ it('reports clean tree as success', () => {
450
+ mockedExecFileSync
451
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
452
+ .mockReturnValueOnce('Tests passed\n') // npm test
453
+ .mockReturnValueOnce('Backup ok\n') // bd backup
454
+ .mockReturnValueOnce('') // hasUncommittedChanges (clean)
455
+ .mockReturnValueOnce('') // hasUnpushedCommits: show-ref
456
+ .mockReturnValueOnce(''); // hasUnpushedCommits: git log (nothing)
457
+ const result = executeLanding();
458
+ expect(result.success).toBe(true);
459
+ const gitStatus = result.steps.find((s) => s.step === 'Git status');
460
+ expect(gitStatus?.status).toBe('pass');
461
+ });
462
+ it('dry run does not execute git operations', () => {
463
+ mockedExecFileSync
464
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch (always runs)
465
+ .mockReturnValueOnce(' M foo.ts\n') // hasUncommittedChanges
466
+ .mockImplementation(() => { throw new Error('no remote'); }); // hasUnpushedCommits
467
+ executeLanding({ dryRun: true, skipTests: true, skipBackup: true });
468
+ // Should not have called git add, commit, push
469
+ const addCall = mockedExecFileSync.mock.calls.find((c) => c[0] === 'git' && c[1][0] === 'add');
470
+ expect(addCall).toBeUndefined();
471
+ const pushCall = mockedExecFileSync.mock.calls.find((c) => c[0] === 'git' && c[1][0] === 'push');
472
+ expect(pushCall).toBeUndefined();
473
+ });
474
+ it('uses custom commit message', () => {
475
+ mockedExecFileSync
476
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
477
+ .mockReturnValueOnce('Tests passed\n') // npm test
478
+ .mockReturnValueOnce('Backup ok\n') // bd backup
479
+ .mockReturnValueOnce(' M foo.ts\n') // hasUncommittedChanges
480
+ .mockImplementationOnce(() => { throw new Error('no remote'); }) // hasUnpushedCommits: show-ref fails
481
+ .mockReturnValueOnce('') // git add -A
482
+ .mockReturnValueOnce('') // git commit
483
+ .mockImplementationOnce(() => { throw new Error('no remote'); }) // remoteBranchExists: show-ref fails (new branch)
484
+ .mockReturnValueOnce('') // git push
485
+ .mockReturnValueOnce('') // isUpToDateWithOrigin: fetch
486
+ .mockReturnValueOnce('abc123\n') // isUpToDateWithOrigin: rev-parse HEAD
487
+ .mockReturnValueOnce('abc123\n'); // isUpToDateWithOrigin: rev-parse origin/branch
488
+ executeLanding({ message: 'custom: my changes' });
489
+ const commitCall = mockedExecFileSync.mock.calls.find((c) => c[0] === 'git' && c[1][0] === 'commit');
490
+ expect(commitCall).toBeDefined();
491
+ expect(commitCall[1][2]).toBe('custom: my changes');
492
+ });
493
+ it('defaults commit message to epic ID prefix', () => {
494
+ mockedExecFileSync
495
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
496
+ .mockReturnValueOnce('Tests passed\n') // npm test
497
+ .mockReturnValueOnce('Backup ok\n') // bd backup
498
+ .mockReturnValueOnce(' M foo.ts\n') // hasUncommittedChanges
499
+ .mockImplementationOnce(() => { throw new Error('no remote'); }) // hasUnpushedCommits
500
+ .mockReturnValueOnce('') // git add -A
501
+ .mockReturnValueOnce('') // git commit
502
+ .mockImplementationOnce(() => { throw new Error('no remote'); }) // remoteBranchExists: no remote
503
+ .mockReturnValueOnce('') // git push
504
+ .mockReturnValueOnce('') // fetch
505
+ .mockReturnValueOnce('## epic/beth-z9n...origin/epic/beth-z9n\n'); // status
506
+ executeLanding();
507
+ const commitCall = mockedExecFileSync.mock.calls.find((c) => c[0] === 'git' && c[1][0] === 'commit');
508
+ expect(commitCall).toBeDefined();
509
+ expect(commitCall[1][2]).toBe('beth-z9n: session work');
510
+ });
511
+ it('full successful landing sequence (new branch, no remote)', () => {
512
+ mockedExecFileSync
513
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
514
+ .mockReturnValueOnce('Tests: 361 passed, 1 skipped\n') // npm test
515
+ .mockReturnValueOnce('Backup complete\n') // bd backup
516
+ .mockReturnValueOnce(' M src/land.ts\n') // hasUncommittedChanges
517
+ .mockImplementationOnce(() => { throw new Error('no remote'); }) // hasUnpushedCommits
518
+ .mockReturnValueOnce('') // git add -A
519
+ .mockReturnValueOnce('') // git commit
520
+ .mockImplementationOnce(() => { throw new Error('no remote'); }) // remoteBranchExists: no remote
521
+ .mockReturnValueOnce('') // git push
522
+ .mockReturnValueOnce('') // fetch
523
+ .mockReturnValueOnce('## epic/beth-z9n...origin/epic/beth-z9n\n'); // status
524
+ const result = executeLanding({ message: 'beth-z9n: land command' });
525
+ expect(result.success).toBe(true);
526
+ expect(result.branch).toBe('epic/beth-z9n');
527
+ expect(result.epicId).toBe('beth-z9n');
528
+ // Verify step sequence
529
+ const stepNames = result.steps.map((s) => s.step);
530
+ expect(stepNames).toContain('Branch check');
531
+ expect(stepNames).toContain('Tests');
532
+ expect(stepNames).toContain('Beads backup');
533
+ expect(stepNames).toContain('Stage changes');
534
+ expect(stepNames).toContain('Commit');
535
+ expect(stepNames).toContain('Pull rebase');
536
+ expect(stepNames).toContain('Push');
537
+ expect(stepNames).toContain('Verify');
538
+ // All steps should pass (or warn for non-critical)
539
+ const failures = result.steps.filter((s) => s.status === 'fail');
540
+ expect(failures).toHaveLength(0);
541
+ });
542
+ it('beads backup failure is non-blocking', () => {
543
+ mockedExecFileSync
544
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
545
+ .mockReturnValueOnce('Tests passed\n') // npm test
546
+ .mockImplementationOnce(() => { throw new Error('bd not found'); }) // bd backup fails
547
+ .mockReturnValueOnce('') // hasUncommittedChanges (clean)
548
+ .mockReturnValueOnce('') // show-ref
549
+ .mockReturnValueOnce(''); // git log (no unpushed)
550
+ const result = executeLanding();
551
+ expect(result.success).toBe(true);
552
+ const backupStep = result.steps.find((s) => s.step === 'Beads backup');
553
+ expect(backupStep?.status).toBe('warn');
554
+ });
555
+ it('push failure marks landing as failed', () => {
556
+ const pushError = new Error('rejected');
557
+ pushError.stderr = 'rejected (non-fast-forward)';
558
+ mockedExecFileSync
559
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
560
+ .mockReturnValueOnce('Tests passed\n') // npm test
561
+ .mockReturnValueOnce('Backup ok\n') // bd backup
562
+ .mockReturnValueOnce(' M foo.ts\n') // hasUncommittedChanges
563
+ .mockImplementationOnce(() => { throw new Error('no remote'); }) // hasUnpushedCommits
564
+ .mockReturnValueOnce('') // git add
565
+ .mockReturnValueOnce('') // git commit
566
+ .mockImplementationOnce(() => { throw new Error('no remote'); }) // remoteBranchExists: no remote
567
+ .mockImplementationOnce(() => { throw pushError; }); // git push fails
568
+ const result = executeLanding({ message: 'test' });
569
+ expect(result.success).toBe(false);
570
+ const pushStep = result.steps.find((s) => s.step === 'Push');
571
+ expect(pushStep?.status).toBe('fail');
572
+ });
573
+ it('full successful landing with existing remote branch', () => {
574
+ mockedExecFileSync
575
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
576
+ .mockReturnValueOnce('Tests passed\n') // npm test
577
+ .mockReturnValueOnce('Backup ok\n') // bd backup
578
+ .mockReturnValueOnce(' M foo.ts\n') // hasUncommittedChanges
579
+ .mockReturnValueOnce('') // hasUnpushedCommits: show-ref succeeds (remote exists)
580
+ .mockReturnValueOnce('abc123 commit msg\n') // hasUnpushedCommits: git log (has unpushed)
581
+ .mockReturnValueOnce('') // git add -A
582
+ .mockReturnValueOnce('') // git commit
583
+ .mockReturnValueOnce('') // remoteBranchExists: show-ref succeeds
584
+ .mockReturnValueOnce('Already up to date.\n') // gitPullRebase succeeds
585
+ .mockReturnValueOnce('') // git push
586
+ .mockReturnValueOnce('') // isUpToDateWithOrigin: fetch
587
+ .mockReturnValueOnce('abc123\n') // isUpToDateWithOrigin: rev-parse HEAD
588
+ .mockReturnValueOnce('abc123\n'); // isUpToDateWithOrigin: rev-parse origin/branch
589
+ const result = executeLanding({ message: 'beth-z9n: new work' });
590
+ expect(result.success).toBe(true);
591
+ const pullStep = result.steps.find((s) => s.step === 'Pull rebase');
592
+ expect(pullStep?.status).toBe('pass');
593
+ });
594
+ it('rebase conflict aborts landing and cleans up', () => {
595
+ const rebaseError = new Error('conflict');
596
+ rebaseError.stderr = 'CONFLICT (content): Merge conflict in src/foo.ts\nAutomatic merge failed; fix conflicts and then commit.';
597
+ mockedExecFileSync
598
+ .mockReturnValueOnce('epic/beth-z9n\n') // getCurrentBranch
599
+ .mockReturnValueOnce('Tests passed\n') // npm test
600
+ .mockReturnValueOnce('Backup ok\n') // bd backup
601
+ .mockReturnValueOnce(' M foo.ts\n') // hasUncommittedChanges
602
+ .mockReturnValueOnce('') // hasUnpushedCommits: show-ref succeeds (remote exists)
603
+ .mockReturnValueOnce('abc123 commit msg\n') // hasUnpushedCommits: git log (has unpushed)
604
+ .mockReturnValueOnce('') // git add -A
605
+ .mockReturnValueOnce('') // git commit
606
+ .mockReturnValueOnce('') // remoteBranchExists: show-ref succeeds (remote exists)
607
+ .mockImplementationOnce(() => { throw rebaseError; }) // gitPullRebase fails (conflict)
608
+ .mockReturnValueOnce(''); // gitRebaseAbort
609
+ const result = executeLanding({ message: 'test' });
610
+ expect(result.success).toBe(false);
611
+ const pullStep = result.steps.find((s) => s.step === 'Pull rebase');
612
+ expect(pullStep?.status).toBe('fail');
613
+ expect(pullStep?.message).toContain('Rebase conflict');
614
+ // Verify git rebase --abort was called
615
+ const abortCall = mockedExecFileSync.mock.calls.find((c) => c[0] === 'git' && c[1][0] === 'rebase' && c[1][1] === '--abort');
616
+ expect(abortCall).toBeDefined();
617
+ // Verify push was NOT attempted
618
+ const pushCall = mockedExecFileSync.mock.calls.find((c) => c[0] === 'git' && c[1][0] === 'push');
619
+ expect(pushCall).toBeUndefined();
620
+ });
621
+ });
622
+ //# sourceMappingURL=land.test.js.map