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.
- package/CHANGELOG.md +41 -28
- package/README.md +87 -247
- package/bin/cli.js +158 -358
- package/dist/__tests__/smoke.test.d.ts +8 -0
- package/dist/__tests__/smoke.test.d.ts.map +1 -0
- package/dist/__tests__/smoke.test.js +49 -0
- package/dist/__tests__/smoke.test.js.map +1 -0
- package/dist/cli/commands/beads.e2e.test.d.ts +13 -0
- package/dist/cli/commands/beads.e2e.test.d.ts.map +1 -0
- package/dist/cli/commands/beads.e2e.test.js +526 -0
- package/dist/cli/commands/beads.e2e.test.js.map +1 -0
- package/dist/cli/commands/cli-edge-cases.e2e.test.d.ts +32 -0
- package/dist/cli/commands/cli-edge-cases.e2e.test.d.ts.map +1 -0
- package/dist/cli/commands/cli-edge-cases.e2e.test.js +162 -0
- package/dist/cli/commands/cli-edge-cases.e2e.test.js.map +1 -0
- package/dist/cli/commands/close.d.ts +89 -0
- package/dist/cli/commands/close.d.ts.map +1 -0
- package/dist/cli/commands/close.e2e.test.d.ts +27 -0
- package/dist/cli/commands/close.e2e.test.d.ts.map +1 -0
- package/dist/cli/commands/close.e2e.test.js +252 -0
- package/dist/cli/commands/close.e2e.test.js.map +1 -0
- package/dist/cli/commands/close.js +309 -0
- package/dist/cli/commands/close.js.map +1 -0
- package/dist/cli/commands/close.test.d.ts +15 -0
- package/dist/cli/commands/close.test.d.ts.map +1 -0
- package/dist/cli/commands/close.test.js +634 -0
- package/dist/cli/commands/close.test.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +23 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +93 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/doctor.test.js +209 -0
- package/dist/cli/commands/doctor.test.js.map +1 -1
- package/dist/cli/commands/framework-isolation.test.d.ts +30 -0
- package/dist/cli/commands/framework-isolation.test.d.ts.map +1 -0
- package/dist/cli/commands/framework-isolation.test.js +119 -0
- package/dist/cli/commands/framework-isolation.test.js.map +1 -0
- package/dist/cli/commands/help.e2e.test.js +4 -4
- package/dist/cli/commands/help.e2e.test.js.map +1 -1
- package/dist/cli/commands/init-logic.e2e.test.d.ts +37 -0
- package/dist/cli/commands/init-logic.e2e.test.d.ts.map +1 -0
- package/dist/cli/commands/init-logic.e2e.test.js +305 -0
- package/dist/cli/commands/init-logic.e2e.test.js.map +1 -0
- package/dist/cli/commands/land.d.ts +142 -0
- package/dist/cli/commands/land.d.ts.map +1 -0
- package/dist/cli/commands/land.js +647 -0
- package/dist/cli/commands/land.js.map +1 -0
- package/dist/cli/commands/land.test.d.ts +20 -0
- package/dist/cli/commands/land.test.d.ts.map +1 -0
- package/dist/cli/commands/land.test.js +622 -0
- package/dist/cli/commands/land.test.js.map +1 -0
- package/dist/cli/commands/mcp.e2e.test.js +22 -29
- package/dist/cli/commands/mcp.e2e.test.js.map +1 -1
- package/dist/cli/commands/pipeline.e2e.test.js +20 -20
- package/dist/cli/commands/pipeline.e2e.test.js.map +1 -1
- package/dist/cli/commands/pre-push-guard.d.ts +84 -0
- package/dist/cli/commands/pre-push-guard.d.ts.map +1 -0
- package/dist/cli/commands/pre-push-guard.e2e.test.d.ts +24 -0
- package/dist/cli/commands/pre-push-guard.e2e.test.d.ts.map +1 -0
- package/dist/cli/commands/pre-push-guard.e2e.test.js +171 -0
- package/dist/cli/commands/pre-push-guard.e2e.test.js.map +1 -0
- package/dist/cli/commands/pre-push-guard.js +257 -0
- package/dist/cli/commands/pre-push-guard.js.map +1 -0
- package/dist/cli/commands/pre-push-guard.test.d.ts +15 -0
- package/dist/cli/commands/pre-push-guard.test.d.ts.map +1 -0
- package/dist/cli/commands/pre-push-guard.test.js +397 -0
- package/dist/cli/commands/pre-push-guard.test.js.map +1 -0
- package/dist/cli/commands/quickstart-expanded.e2e.test.d.ts +23 -0
- package/dist/cli/commands/quickstart-expanded.e2e.test.d.ts.map +1 -0
- package/dist/cli/commands/quickstart-expanded.e2e.test.js +179 -0
- package/dist/cli/commands/quickstart-expanded.e2e.test.js.map +1 -0
- package/dist/cli/commands/quickstart.d.ts.map +1 -1
- package/dist/cli/commands/quickstart.js +7 -23
- package/dist/cli/commands/quickstart.js.map +1 -1
- package/dist/cli/commands/quickstart.test.js +40 -67
- package/dist/cli/commands/quickstart.test.js.map +1 -1
- package/dist/core/agents/suite.test.js +4 -2
- package/dist/core/agents/suite.test.js.map +1 -1
- package/dist/core/agents/tools.test.js +5 -1
- package/dist/core/agents/tools.test.js.map +1 -1
- package/dist/index.d.ts +3 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -10
- package/dist/index.js.map +1 -1
- package/package.json +15 -9
- package/sbom.json +2011 -819
- package/templates/.github/agents/beth.agent.md +220 -66
- package/templates/.github/agents/developer.agent.md +53 -90
- package/templates/.github/agents/product-manager.agent.md +15 -68
- package/templates/.github/agents/researcher.agent.md +20 -71
- package/templates/.github/agents/security-reviewer.agent.md +29 -81
- package/templates/.github/agents/tester.agent.md +40 -69
- package/templates/.github/agents/ux-designer.agent.md +20 -74
- package/templates/.github/copilot-instructions.md +217 -225
- package/templates/AGENTS.md +108 -20
- package/templates/mcp.json.example +0 -3
- package/dist/cli/commands/client-config.d.ts +0 -31
- package/dist/cli/commands/client-config.d.ts.map +0 -1
- package/dist/cli/commands/client-config.e2e.test.d.ts +0 -15
- package/dist/cli/commands/client-config.e2e.test.d.ts.map +0 -1
- package/dist/cli/commands/client-config.e2e.test.js +0 -556
- package/dist/cli/commands/client-config.e2e.test.js.map +0 -1
- package/dist/cli/commands/client-config.js +0 -73
- package/dist/cli/commands/client-config.js.map +0 -1
- package/dist/cli/commands/client-config.test.d.ts +0 -6
- package/dist/cli/commands/client-config.test.d.ts.map +0 -1
- package/dist/cli/commands/client-config.test.js +0 -133
- package/dist/cli/commands/client-config.test.js.map +0 -1
- package/dist/cli/commands/init-quickstart.e2e.test.d.ts +0 -11
- package/dist/cli/commands/init-quickstart.e2e.test.d.ts.map +0 -1
- package/dist/cli/commands/init-quickstart.e2e.test.js +0 -221
- package/dist/cli/commands/init-quickstart.e2e.test.js.map +0 -1
- package/dist/core/context.d.ts +0 -171
- package/dist/core/context.d.ts.map +0 -1
- package/dist/core/context.js +0 -353
- package/dist/core/context.js.map +0 -1
- package/dist/core/context.test.d.ts +0 -8
- package/dist/core/context.test.d.ts.map +0 -1
- package/dist/core/context.test.js +0 -253
- package/dist/core/context.test.js.map +0 -1
- package/dist/core/handoffs.d.ts +0 -151
- package/dist/core/handoffs.d.ts.map +0 -1
- package/dist/core/handoffs.js +0 -220
- package/dist/core/handoffs.js.map +0 -1
- package/dist/core/handoffs.test.d.ts +0 -8
- package/dist/core/handoffs.test.d.ts.map +0 -1
- package/dist/core/handoffs.test.js +0 -231
- package/dist/core/handoffs.test.js.map +0 -1
- package/dist/core/orchestrator.d.ts +0 -246
- package/dist/core/orchestrator.d.ts.map +0 -1
- package/dist/core/orchestrator.js +0 -514
- package/dist/core/orchestrator.js.map +0 -1
- package/dist/core/orchestrator.test.d.ts +0 -8
- package/dist/core/orchestrator.test.d.ts.map +0 -1
- package/dist/core/orchestrator.test.js +0 -517
- package/dist/core/orchestrator.test.js.map +0 -1
- package/dist/core/router.d.ts +0 -102
- package/dist/core/router.d.ts.map +0 -1
- package/dist/core/router.js +0 -178
- package/dist/core/router.js.map +0 -1
- package/dist/core/router.test.d.ts +0 -8
- package/dist/core/router.test.d.ts.map +0 -1
- package/dist/core/router.test.js +0 -215
- package/dist/core/router.test.js.map +0 -1
- package/dist/init.test.js +0 -288
- package/dist/providers/azure.d.ts +0 -147
- package/dist/providers/azure.d.ts.map +0 -1
- package/dist/providers/azure.js +0 -491
- package/dist/providers/azure.js.map +0 -1
- package/dist/providers/azure.test.d.ts +0 -11
- package/dist/providers/azure.test.d.ts.map +0 -1
- package/dist/providers/azure.test.js +0 -330
- package/dist/providers/azure.test.js.map +0 -1
- package/dist/providers/config.d.ts +0 -87
- package/dist/providers/config.d.ts.map +0 -1
- package/dist/providers/config.js +0 -193
- package/dist/providers/config.js.map +0 -1
- package/dist/providers/config.test.d.ts +0 -7
- package/dist/providers/config.test.d.ts.map +0 -1
- package/dist/providers/config.test.js +0 -370
- package/dist/providers/config.test.js.map +0 -1
- package/dist/providers/index.d.ts +0 -18
- package/dist/providers/index.d.ts.map +0 -1
- package/dist/providers/index.js +0 -14
- package/dist/providers/index.js.map +0 -1
- package/dist/providers/interface.d.ts +0 -191
- package/dist/providers/interface.d.ts.map +0 -1
- package/dist/providers/interface.js +0 -94
- package/dist/providers/interface.js.map +0 -1
- package/dist/providers/retry.d.ts +0 -128
- package/dist/providers/retry.d.ts.map +0 -1
- package/dist/providers/retry.js +0 -205
- package/dist/providers/retry.js.map +0 -1
- package/dist/providers/retry.test.d.ts +0 -7
- package/dist/providers/retry.test.d.ts.map +0 -1
- package/dist/providers/retry.test.js +0 -439
- package/dist/providers/retry.test.js.map +0 -1
- package/dist/providers/streaming.d.ts +0 -157
- package/dist/providers/streaming.d.ts.map +0 -1
- package/dist/providers/streaming.js +0 -233
- package/dist/providers/streaming.js.map +0 -1
- package/dist/providers/streaming.test.d.ts +0 -7
- package/dist/providers/streaming.test.d.ts.map +0 -1
- package/dist/providers/streaming.test.js +0 -372
- package/dist/providers/streaming.test.js.map +0 -1
- package/dist/providers/types.d.ts +0 -209
- package/dist/providers/types.d.ts.map +0 -1
- package/dist/providers/types.js +0 -53
- package/dist/providers/types.js.map +0 -1
- package/dist/providers/types.test.d.ts +0 -7
- package/dist/providers/types.test.d.ts.map +0 -1
- package/dist/providers/types.test.js +0 -141
- package/dist/providers/types.test.js.map +0 -1
- package/dist/tools/cli/beads.d.ts +0 -27
- package/dist/tools/cli/beads.d.ts.map +0 -1
- package/dist/tools/cli/beads.js +0 -172
- package/dist/tools/cli/beads.js.map +0 -1
- package/dist/tools/cli/beads.test.d.ts +0 -8
- package/dist/tools/cli/beads.test.d.ts.map +0 -1
- package/dist/tools/cli/beads.test.js +0 -264
- package/dist/tools/cli/beads.test.js.map +0 -1
- package/dist/tools/cli/editFile.d.ts +0 -17
- package/dist/tools/cli/editFile.d.ts.map +0 -1
- package/dist/tools/cli/editFile.js +0 -125
- package/dist/tools/cli/editFile.js.map +0 -1
- package/dist/tools/cli/editFile.test.d.ts +0 -8
- package/dist/tools/cli/editFile.test.d.ts.map +0 -1
- package/dist/tools/cli/editFile.test.js +0 -177
- package/dist/tools/cli/editFile.test.js.map +0 -1
- package/dist/tools/cli/readFile.d.ts +0 -25
- package/dist/tools/cli/readFile.d.ts.map +0 -1
- package/dist/tools/cli/readFile.js +0 -118
- package/dist/tools/cli/readFile.js.map +0 -1
- package/dist/tools/cli/readFile.test.d.ts +0 -8
- package/dist/tools/cli/readFile.test.d.ts.map +0 -1
- package/dist/tools/cli/readFile.test.js +0 -194
- package/dist/tools/cli/readFile.test.js.map +0 -1
- package/dist/tools/cli/search.d.ts +0 -16
- package/dist/tools/cli/search.d.ts.map +0 -1
- package/dist/tools/cli/search.js +0 -261
- package/dist/tools/cli/search.js.map +0 -1
- package/dist/tools/cli/search.test.d.ts +0 -8
- package/dist/tools/cli/search.test.d.ts.map +0 -1
- package/dist/tools/cli/search.test.js +0 -172
- package/dist/tools/cli/search.test.js.map +0 -1
- package/dist/tools/cli/subagent.d.ts +0 -43
- package/dist/tools/cli/subagent.d.ts.map +0 -1
- package/dist/tools/cli/subagent.js +0 -99
- package/dist/tools/cli/subagent.js.map +0 -1
- package/dist/tools/cli/subagent.test.d.ts +0 -8
- package/dist/tools/cli/subagent.test.d.ts.map +0 -1
- package/dist/tools/cli/subagent.test.js +0 -190
- package/dist/tools/cli/subagent.test.js.map +0 -1
- package/dist/tools/cli/terminal.d.ts +0 -19
- package/dist/tools/cli/terminal.d.ts.map +0 -1
- package/dist/tools/cli/terminal.js +0 -164
- package/dist/tools/cli/terminal.js.map +0 -1
- package/dist/tools/cli/terminal.test.d.ts +0 -8
- package/dist/tools/cli/terminal.test.d.ts.map +0 -1
- package/dist/tools/cli/terminal.test.js +0 -161
- package/dist/tools/cli/terminal.test.js.map +0 -1
- package/dist/tools/index.d.ts +0 -25
- package/dist/tools/index.d.ts.map +0 -1
- package/dist/tools/index.js +0 -41
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/interface.d.ts +0 -64
- package/dist/tools/interface.d.ts.map +0 -1
- package/dist/tools/interface.js +0 -37
- package/dist/tools/interface.js.map +0 -1
- package/dist/tools/interface.test.d.ts +0 -7
- package/dist/tools/interface.test.d.ts.map +0 -1
- package/dist/tools/interface.test.js +0 -179
- package/dist/tools/interface.test.js.map +0 -1
- package/dist/tools/mcp/bridge.d.ts +0 -48
- package/dist/tools/mcp/bridge.d.ts.map +0 -1
- package/dist/tools/mcp/bridge.js +0 -128
- package/dist/tools/mcp/bridge.js.map +0 -1
- package/dist/tools/mcp/bridge.test.d.ts +0 -8
- package/dist/tools/mcp/bridge.test.d.ts.map +0 -1
- package/dist/tools/mcp/bridge.test.js +0 -300
- package/dist/tools/mcp/bridge.test.js.map +0 -1
- package/dist/tools/mcp/client.d.ts +0 -135
- package/dist/tools/mcp/client.d.ts.map +0 -1
- package/dist/tools/mcp/client.js +0 -263
- package/dist/tools/mcp/client.js.map +0 -1
- package/dist/tools/mcp/client.test.d.ts +0 -8
- package/dist/tools/mcp/client.test.d.ts.map +0 -1
- package/dist/tools/mcp/client.test.js +0 -390
- package/dist/tools/mcp/client.test.js.map +0 -1
- package/dist/tools/registry.d.ts +0 -82
- package/dist/tools/registry.d.ts.map +0 -1
- package/dist/tools/registry.js +0 -99
- package/dist/tools/registry.js.map +0 -1
- package/dist/tools/registry.test.d.ts +0 -7
- package/dist/tools/registry.test.d.ts.map +0 -1
- package/dist/tools/registry.test.js +0 -199
- package/dist/tools/registry.test.js.map +0 -1
- package/dist/tools/suite.test.d.ts +0 -11
- package/dist/tools/suite.test.d.ts.map +0 -1
- package/dist/tools/suite.test.js +0 -119
- package/dist/tools/suite.test.js.map +0 -1
- package/dist/tools/types.d.ts +0 -75
- package/dist/tools/types.d.ts.map +0 -1
- package/dist/tools/types.js +0 -30
- package/dist/tools/types.js.map +0 -1
- package/dist/tools/types.test.d.ts +0 -7
- package/dist/tools/types.test.d.ts.map +0 -1
- package/dist/tools/types.test.js +0 -178
- package/dist/tools/types.test.js.map +0 -1
- package/templates/.vscode/mcp.json +0 -20
- 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
|