oh-my-copilot 4.13.2 → 4.13.21
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bridge/cli.cjs +2522 -949
- package/bridge/mcp-server.cjs +97 -9
- package/bridge/runtime-cli.cjs +1256 -133
- package/bridge/team.js +1241 -65
- package/dist/__tests__/auto-slash-aliases.test.js +91 -0
- package/dist/__tests__/auto-slash-aliases.test.js.map +1 -1
- package/dist/__tests__/hud/labels.test.d.ts +2 -0
- package/dist/__tests__/hud/labels.test.d.ts.map +1 -0
- package/dist/__tests__/hud/labels.test.js +158 -0
- package/dist/__tests__/hud/labels.test.js.map +1 -0
- package/dist/__tests__/hud/render-rate-limits-priority.test.js +26 -0
- package/dist/__tests__/hud/render-rate-limits-priority.test.js.map +1 -1
- package/dist/__tests__/hud-marketplace-resolution.test.js +34 -0
- package/dist/__tests__/hud-marketplace-resolution.test.js.map +1 -1
- package/dist/__tests__/installer-hud-skip.test.js +6 -0
- package/dist/__tests__/installer-hud-skip.test.js.map +1 -1
- package/dist/__tests__/installer-omc-reference.test.js +1 -1
- package/dist/__tests__/installer-omc-reference.test.js.map +1 -1
- package/dist/__tests__/mnemosyne/detector.test.js +2 -0
- package/dist/__tests__/mnemosyne/detector.test.js.map +1 -1
- package/dist/__tests__/notepad.test.js +2 -2
- package/dist/__tests__/notepad.test.js.map +1 -1
- package/dist/__tests__/pre-tool-enforcer.test.js +84 -1
- package/dist/__tests__/pre-tool-enforcer.test.js.map +1 -1
- package/dist/__tests__/ralph-prd-mandatory.test.js +11 -1
- package/dist/__tests__/ralph-prd-mandatory.test.js.map +1 -1
- package/dist/__tests__/ralph-prd.test.js +40 -2
- package/dist/__tests__/ralph-prd.test.js.map +1 -1
- package/dist/__tests__/skills.test.js +363 -4
- package/dist/__tests__/skills.test.js.map +1 -1
- package/dist/__tests__/state-root-resolution.test.js +56 -6
- package/dist/__tests__/state-root-resolution.test.js.map +1 -1
- package/dist/autoresearch/__tests__/runtime.test.js +225 -28
- package/dist/autoresearch/__tests__/runtime.test.js.map +1 -1
- package/dist/autoresearch/runtime.d.ts.map +1 -1
- package/dist/autoresearch/runtime.js +9 -5
- package/dist/autoresearch/runtime.js.map +1 -1
- package/dist/cli/autoresearch.d.ts +1 -1
- package/dist/cli/autoresearch.d.ts.map +1 -1
- package/dist/cli/autoresearch.js +4 -4
- package/dist/cli/commands/team.d.ts +1 -0
- package/dist/cli/commands/team.d.ts.map +1 -1
- package/dist/cli/commands/team.js +14 -2
- package/dist/cli/commands/team.js.map +1 -1
- package/dist/cli/index.js +3 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/team.d.ts +6 -0
- package/dist/cli/team.d.ts.map +1 -1
- package/dist/cli/team.js +23 -1
- package/dist/cli/team.js.map +1 -1
- package/dist/config/__tests__/loader.test.js.map +1 -1
- package/dist/features/boulder-state/constants.d.ts +1 -1
- package/dist/features/boulder-state/constants.d.ts.map +1 -1
- package/dist/features/builtin-skills/skills.d.ts.map +1 -1
- package/dist/features/builtin-skills/skills.js +11 -3
- package/dist/features/builtin-skills/skills.js.map +1 -1
- package/dist/features/builtin-skills/types.d.ts +7 -0
- package/dist/features/builtin-skills/types.d.ts.map +1 -1
- package/dist/features/magic-keywords.d.ts.map +1 -1
- package/dist/features/magic-keywords.js +30 -1
- package/dist/features/magic-keywords.js.map +1 -1
- package/dist/features/rate-limit-wait/tmux-detector.d.ts +4 -4
- package/dist/features/rate-limit-wait/tmux-detector.js +9 -9
- package/dist/features/rate-limit-wait/tmux-detector.js.map +1 -1
- package/dist/hooks/__tests__/bridge-routing.test.js +156 -0
- package/dist/hooks/__tests__/bridge-routing.test.js.map +1 -1
- package/dist/hooks/auto-slash-command/constants.d.ts.map +1 -1
- package/dist/hooks/auto-slash-command/constants.js +1 -0
- package/dist/hooks/auto-slash-command/constants.js.map +1 -1
- package/dist/hooks/auto-slash-command/executor.d.ts.map +1 -1
- package/dist/hooks/auto-slash-command/executor.js +29 -0
- package/dist/hooks/auto-slash-command/executor.js.map +1 -1
- package/dist/hooks/bridge.d.ts.map +1 -1
- package/dist/hooks/bridge.js +184 -1
- package/dist/hooks/bridge.js.map +1 -1
- package/dist/hooks/learner/detector.js +1 -1
- package/dist/hooks/mode-registry/__tests__/session-isolation.test.js +16 -0
- package/dist/hooks/mode-registry/__tests__/session-isolation.test.js.map +1 -1
- package/dist/hooks/notepad/index.d.ts +4 -1
- package/dist/hooks/notepad/index.d.ts.map +1 -1
- package/dist/hooks/notepad/index.js +7 -4
- package/dist/hooks/notepad/index.js.map +1 -1
- package/dist/hooks/persistent-mode/__tests__/ralph-verification-flow.test.js +70 -3
- package/dist/hooks/persistent-mode/__tests__/ralph-verification-flow.test.js.map +1 -1
- package/dist/hooks/persistent-mode/index.d.ts.map +1 -1
- package/dist/hooks/persistent-mode/index.js +10 -9
- package/dist/hooks/persistent-mode/index.js.map +1 -1
- package/dist/hooks/persistent-mode/stop-hook-blocking.test.js +64 -9
- package/dist/hooks/persistent-mode/stop-hook-blocking.test.js.map +1 -1
- package/dist/hooks/project-memory/__tests__/integration.test.js +117 -3
- package/dist/hooks/project-memory/__tests__/integration.test.js.map +1 -1
- package/dist/hooks/project-memory/__tests__/storage.test.js +11 -11
- package/dist/hooks/project-memory/__tests__/storage.test.js.map +1 -1
- package/dist/hooks/project-memory/index.d.ts.map +1 -1
- package/dist/hooks/project-memory/index.js +24 -12
- package/dist/hooks/project-memory/index.js.map +1 -1
- package/dist/hooks/ralph/index.d.ts +1 -1
- package/dist/hooks/ralph/index.d.ts.map +1 -1
- package/dist/hooks/ralph/index.js +1 -1
- package/dist/hooks/ralph/index.js.map +1 -1
- package/dist/hooks/ralph/loop.d.ts +6 -6
- package/dist/hooks/ralph/loop.d.ts.map +1 -1
- package/dist/hooks/ralph/loop.js +18 -18
- package/dist/hooks/ralph/loop.js.map +1 -1
- package/dist/hooks/ralph/prd.d.ts +23 -12
- package/dist/hooks/ralph/prd.d.ts.map +1 -1
- package/dist/hooks/ralph/prd.js +88 -42
- package/dist/hooks/ralph/prd.js.map +1 -1
- package/dist/hooks/session-end/__tests__/mode-state-cleanup.test.js +20 -0
- package/dist/hooks/session-end/__tests__/mode-state-cleanup.test.js.map +1 -1
- package/dist/hooks/session-end/index.d.ts +9 -0
- package/dist/hooks/session-end/index.d.ts.map +1 -1
- package/dist/hooks/session-end/index.js +67 -0
- package/dist/hooks/session-end/index.js.map +1 -1
- package/dist/hooks/skill-bridge.cjs +6 -5
- package/dist/hooks/skill-state/index.d.ts.map +1 -1
- package/dist/hooks/skill-state/index.js +1 -0
- package/dist/hooks/skill-state/index.js.map +1 -1
- package/dist/hud/__tests__/git-status.test.js +18 -0
- package/dist/hud/__tests__/git-status.test.js.map +1 -1
- package/dist/hud/elements/background.d.ts +3 -3
- package/dist/hud/elements/background.d.ts.map +1 -1
- package/dist/hud/elements/background.js +5 -4
- package/dist/hud/elements/background.js.map +1 -1
- package/dist/hud/elements/call-counts.d.ts +2 -2
- package/dist/hud/elements/call-counts.d.ts.map +1 -1
- package/dist/hud/elements/call-counts.js +7 -6
- package/dist/hud/elements/call-counts.js.map +1 -1
- package/dist/hud/elements/context.d.ts +3 -3
- package/dist/hud/elements/context.d.ts.map +1 -1
- package/dist/hud/elements/context.js +5 -4
- package/dist/hud/elements/context.js.map +1 -1
- package/dist/hud/elements/git.d.ts +2 -1
- package/dist/hud/elements/git.d.ts.map +1 -1
- package/dist/hud/elements/git.js +7 -6
- package/dist/hud/elements/git.js.map +1 -1
- package/dist/hud/elements/ralph.d.ts +2 -2
- package/dist/hud/elements/ralph.d.ts.map +1 -1
- package/dist/hud/elements/ralph.js +3 -2
- package/dist/hud/elements/ralph.js.map +1 -1
- package/dist/hud/elements/thinking.d.ts +2 -2
- package/dist/hud/elements/thinking.d.ts.map +1 -1
- package/dist/hud/elements/thinking.js +3 -2
- package/dist/hud/elements/thinking.js.map +1 -1
- package/dist/hud/elements/token-usage.d.ts +2 -2
- package/dist/hud/elements/token-usage.d.ts.map +1 -1
- package/dist/hud/elements/token-usage.js +3 -2
- package/dist/hud/elements/token-usage.js.map +1 -1
- package/dist/hud/render.d.ts.map +1 -1
- package/dist/hud/render.js +11 -10
- package/dist/hud/render.js.map +1 -1
- package/dist/hud/state.d.ts.map +1 -1
- package/dist/hud/state.js +18 -1
- package/dist/hud/state.js.map +1 -1
- package/dist/hud/types.d.ts +26 -0
- package/dist/hud/types.d.ts.map +1 -1
- package/dist/hud/types.js +58 -0
- package/dist/hud/types.js.map +1 -1
- package/dist/installer/__tests__/hud-cache-wrapper.test.d.ts +2 -0
- package/dist/installer/__tests__/hud-cache-wrapper.test.d.ts.map +1 -0
- package/dist/installer/__tests__/hud-cache-wrapper.test.js +235 -0
- package/dist/installer/__tests__/hud-cache-wrapper.test.js.map +1 -0
- package/dist/installer/__tests__/standalone-hook-reconcile.test.js +2 -0
- package/dist/installer/__tests__/standalone-hook-reconcile.test.js.map +1 -1
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/index.js +19 -7
- package/dist/installer/index.js.map +1 -1
- package/dist/lib/__tests__/worktree-paths.test.js +162 -39
- package/dist/lib/__tests__/worktree-paths.test.js.map +1 -1
- package/dist/lib/worktree-paths.d.ts +87 -9
- package/dist/lib/worktree-paths.d.ts.map +1 -1
- package/dist/lib/worktree-paths.js +209 -27
- package/dist/lib/worktree-paths.js.map +1 -1
- package/dist/team/__tests__/auto-merge-restart.test.d.ts +2 -0
- package/dist/team/__tests__/auto-merge-restart.test.d.ts.map +1 -0
- package/dist/team/__tests__/auto-merge-restart.test.js +128 -0
- package/dist/team/__tests__/auto-merge-restart.test.js.map +1 -0
- package/dist/team/__tests__/auto-merge.perf.test.d.ts +2 -0
- package/dist/team/__tests__/auto-merge.perf.test.d.ts.map +1 -0
- package/dist/team/__tests__/auto-merge.perf.test.js +85 -0
- package/dist/team/__tests__/auto-merge.perf.test.js.map +1 -0
- package/dist/team/__tests__/conflict-mailbox.test.d.ts +2 -0
- package/dist/team/__tests__/conflict-mailbox.test.d.ts.map +1 -0
- package/dist/team/__tests__/conflict-mailbox.test.js +238 -0
- package/dist/team/__tests__/conflict-mailbox.test.js.map +1 -0
- package/dist/team/__tests__/helpers/git-fixture.d.ts +72 -0
- package/dist/team/__tests__/helpers/git-fixture.d.ts.map +1 -0
- package/dist/team/__tests__/helpers/git-fixture.js +252 -0
- package/dist/team/__tests__/helpers/git-fixture.js.map +1 -0
- package/dist/team/__tests__/leader-inbox.test.d.ts +2 -0
- package/dist/team/__tests__/leader-inbox.test.d.ts.map +1 -0
- package/dist/team/__tests__/leader-inbox.test.js +152 -0
- package/dist/team/__tests__/leader-inbox.test.js.map +1 -0
- package/dist/team/__tests__/merge-orchestrator.test.d.ts +2 -0
- package/dist/team/__tests__/merge-orchestrator.test.d.ts.map +1 -0
- package/dist/team/__tests__/merge-orchestrator.test.js +779 -0
- package/dist/team/__tests__/merge-orchestrator.test.js.map +1 -0
- package/dist/team/__tests__/rebase-smoke.test.d.ts +2 -0
- package/dist/team/__tests__/rebase-smoke.test.d.ts.map +1 -0
- package/dist/team/__tests__/rebase-smoke.test.js +272 -0
- package/dist/team/__tests__/rebase-smoke.test.js.map +1 -0
- package/dist/team/__tests__/runtime-cli.test.js +9 -1
- package/dist/team/__tests__/runtime-cli.test.js.map +1 -1
- package/dist/team/__tests__/runtime-v2.dispatch.test.js +150 -1
- package/dist/team/__tests__/runtime-v2.dispatch.test.js.map +1 -1
- package/dist/team/__tests__/teardown-invariant.test.d.ts +2 -0
- package/dist/team/__tests__/teardown-invariant.test.d.ts.map +1 -0
- package/dist/team/__tests__/teardown-invariant.test.js +176 -0
- package/dist/team/__tests__/teardown-invariant.test.js.map +1 -0
- package/dist/team/__tests__/worker-commit-cadence.test.d.ts +2 -0
- package/dist/team/__tests__/worker-commit-cadence.test.d.ts.map +1 -0
- package/dist/team/__tests__/worker-commit-cadence.test.js +315 -0
- package/dist/team/__tests__/worker-commit-cadence.test.js.map +1 -0
- package/dist/team/__tests__/worktree-runtime-e2e.test.d.ts +2 -0
- package/dist/team/__tests__/worktree-runtime-e2e.test.d.ts.map +1 -0
- package/dist/team/__tests__/worktree-runtime-e2e.test.js +182 -0
- package/dist/team/__tests__/worktree-runtime-e2e.test.js.map +1 -0
- package/dist/team/conflict-mailbox.d.ts +49 -0
- package/dist/team/conflict-mailbox.d.ts.map +1 -0
- package/dist/team/conflict-mailbox.js +98 -0
- package/dist/team/conflict-mailbox.js.map +1 -0
- package/dist/team/leader-inbox.d.ts +28 -0
- package/dist/team/leader-inbox.d.ts.map +1 -0
- package/dist/team/leader-inbox.js +68 -0
- package/dist/team/leader-inbox.js.map +1 -0
- package/dist/team/merge-coordinator.d.ts +6 -0
- package/dist/team/merge-coordinator.d.ts.map +1 -1
- package/dist/team/merge-coordinator.js +6 -2
- package/dist/team/merge-coordinator.js.map +1 -1
- package/dist/team/merge-orchestrator.d.ts +41 -0
- package/dist/team/merge-orchestrator.d.ts.map +1 -0
- package/dist/team/merge-orchestrator.js +736 -0
- package/dist/team/merge-orchestrator.js.map +1 -0
- package/dist/team/runtime-cli.d.ts +1 -0
- package/dist/team/runtime-cli.d.ts.map +1 -1
- package/dist/team/runtime-cli.js +15 -2
- package/dist/team/runtime-cli.js.map +1 -1
- package/dist/team/runtime-flags.d.ts +5 -0
- package/dist/team/runtime-flags.d.ts.map +1 -0
- package/dist/team/runtime-flags.js +12 -0
- package/dist/team/runtime-flags.js.map +1 -0
- package/dist/team/runtime-v2.d.ts +7 -1
- package/dist/team/runtime-v2.d.ts.map +1 -1
- package/dist/team/runtime-v2.js +185 -8
- package/dist/team/runtime-v2.js.map +1 -1
- package/dist/team/worker-bootstrap.d.ts +4 -0
- package/dist/team/worker-bootstrap.d.ts.map +1 -1
- package/dist/team/worker-bootstrap.js +10 -1
- package/dist/team/worker-bootstrap.js.map +1 -1
- package/dist/team/worker-commit-cadence.d.ts +70 -0
- package/dist/team/worker-commit-cadence.d.ts.map +1 -0
- package/dist/team/worker-commit-cadence.js +271 -0
- package/dist/team/worker-commit-cadence.js.map +1 -0
- package/dist/tools/__tests__/memory-tools.test.js +4 -4
- package/dist/tools/__tests__/memory-tools.test.js.map +1 -1
- package/dist/tools/__tests__/state-tools.test.js +13 -1
- package/dist/tools/__tests__/state-tools.test.js.map +1 -1
- package/docs/GETTING-STARTED.md +415 -0
- package/docs/MIGRATION.md +66 -66
- package/docs/REFERENCE.md +21 -4
- package/docs/company-context-interface.md +5 -16
- package/docs/copilot-instructions.md +1 -1
- package/package.json +1 -1
- package/scripts/lib/hud-cache-wrapper.sh +122 -0
- package/scripts/plugin-setup.mjs +10 -1
- package/scripts/post-tool-verifier.mjs +118 -5
- package/scripts/pre-tool-enforcer.mjs +54 -6
- package/scripts/session-start.mjs +171 -3
- package/skills/AGENTS.md +22 -91
- package/skills/ai-slop-cleaner/SKILL.md +118 -120
- package/skills/ask/SKILL.md +6 -8
- package/skills/autopilot/SKILL.md +1 -28
- package/skills/autoresearch/SKILL.md +5 -5
- package/skills/cancel/SKILL.md +13 -13
- package/skills/cccg/SKILL.md +1 -0
- package/skills/configure-notifications/SKILL.md +80 -215
- package/skills/debug/SKILL.md +3 -2
- package/skills/deep-dive/SKILL.md +20 -9
- package/skills/deep-interview/SKILL.md +32 -4
- package/skills/deepinit/SKILL.md +1 -0
- package/skills/external-context/SKILL.md +2 -1
- package/skills/hud/SKILL.md +22 -52
- package/skills/learner/SKILL.md +90 -57
- package/skills/mcp-setup/SKILL.md +45 -60
- package/skills/omc-doctor/SKILL.md +1 -1
- package/skills/omc-reference/SKILL.md +6 -6
- package/skills/omc-setup/SKILL.md +30 -74
- package/skills/omc-setup/phases/01-install-claude-md.md +97 -0
- package/skills/omc-setup/phases/02-configure.md +25 -40
- package/skills/omc-setup/phases/03-integrations.md +100 -69
- package/skills/omc-setup/phases/04-welcome.md +102 -44
- package/skills/omc-teams/SKILL.md +63 -26
- package/skills/plan/SKILL.md +11 -9
- package/skills/ralph/SKILL.md +42 -41
- package/skills/ralplan/SKILL.md +3 -10
- package/skills/release/SKILL.md +168 -57
- package/skills/remember/SKILL.md +2 -1
- package/skills/sciomc/SKILL.md +12 -11
- package/skills/self-improve/SKILL.md +4 -4
- package/skills/setup/SKILL.md +1 -0
- package/skills/skill/SKILL.md +21 -20
- package/skills/skillify/SKILL.md +34 -3
- package/skills/team/SKILL.md +62 -44
- package/skills/ultraqa/SKILL.md +1 -2
- package/skills/ultrawork/SKILL.md +28 -21
- package/skills/verify/SKILL.md +2 -1
- package/skills/writer-memory/SKILL.md +2 -1
- package/templates/hooks/pre-tool-use.mjs +144 -45
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
// Unit tests for src/team/merge-orchestrator.ts
|
|
2
|
+
//
|
|
3
|
+
// All git invocations are mocked via vi.mock('node:child_process'). We never
|
|
4
|
+
// spawn a real git process — the orchestrator's behaviour is fully driven by
|
|
5
|
+
// the mocked exec*Sync surface.
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
const mocks = vi.hoisted(() => {
|
|
11
|
+
const calls = [];
|
|
12
|
+
// Programmable behaviour for git invocations keyed by a string match.
|
|
13
|
+
// Each handler returns: a string (stdout for success), or throws an Error
|
|
14
|
+
// with optional .status and .stdout properties (mimicking child_process).
|
|
15
|
+
const handlers = [];
|
|
16
|
+
const reset = () => {
|
|
17
|
+
calls.length = 0;
|
|
18
|
+
handlers.length = 0;
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
calls,
|
|
22
|
+
handlers,
|
|
23
|
+
reset,
|
|
24
|
+
execFileSync: vi.fn((cmd, args, opts) => {
|
|
25
|
+
calls.push({ cmd, args, cwd: opts?.cwd });
|
|
26
|
+
for (const h of handlers) {
|
|
27
|
+
if (h.match(args, opts?.cwd)) {
|
|
28
|
+
const r = h.handler(args, opts?.cwd);
|
|
29
|
+
if (typeof r === 'string') {
|
|
30
|
+
// Return a Buffer when no encoding requested, else a string.
|
|
31
|
+
return opts?.encoding ? r : Buffer.from(r);
|
|
32
|
+
}
|
|
33
|
+
if ('throwStatus' in r) {
|
|
34
|
+
const err = new Error(`git ${args.join(' ')} exited ${r.throwStatus}`);
|
|
35
|
+
err.status = r.throwStatus;
|
|
36
|
+
err.stdout = r.stdout ?? '';
|
|
37
|
+
err.stderr = r.stderr ?? '';
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
if ('throw' in r) {
|
|
41
|
+
throw new Error(r.message ?? `git ${args.join(' ')} failed`);
|
|
42
|
+
}
|
|
43
|
+
if ('stdout' in r) {
|
|
44
|
+
return opts?.encoding ? r.stdout : Buffer.from(r.stdout);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Unhandled — return empty.
|
|
49
|
+
return opts?.encoding ? '' : Buffer.from('');
|
|
50
|
+
}),
|
|
51
|
+
exec: vi.fn(),
|
|
52
|
+
execSync: vi.fn(),
|
|
53
|
+
execFile: vi.fn(),
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
vi.mock('node:child_process', () => ({
|
|
57
|
+
execFileSync: mocks.execFileSync,
|
|
58
|
+
exec: mocks.exec,
|
|
59
|
+
execSync: mocks.execSync,
|
|
60
|
+
execFile: mocks.execFile,
|
|
61
|
+
}));
|
|
62
|
+
// Re-mount the same mock for the unprefixed module name (some callers import
|
|
63
|
+
// 'child_process' rather than 'node:child_process').
|
|
64
|
+
vi.mock('child_process', () => ({
|
|
65
|
+
execFileSync: mocks.execFileSync,
|
|
66
|
+
exec: mocks.exec,
|
|
67
|
+
execSync: mocks.execSync,
|
|
68
|
+
execFile: mocks.execFile,
|
|
69
|
+
}));
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Imports of the SUT (after mocks are installed).
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
import { startMergeOrchestrator, recoverFromRestart, } from '../merge-orchestrator.js';
|
|
74
|
+
import { sanitizeName } from '../tmux-session.js';
|
|
75
|
+
import { atomicWriteJson } from '../fs-utils.js';
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Helpers
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
function makeRepoRoot() {
|
|
80
|
+
const dir = mkdtempSync(join(tmpdir(), 'merge-orchestrator-test-'));
|
|
81
|
+
return dir;
|
|
82
|
+
}
|
|
83
|
+
function defaultConfig(repoRoot) {
|
|
84
|
+
return {
|
|
85
|
+
teamName: 'demo-team',
|
|
86
|
+
repoRoot,
|
|
87
|
+
leaderBranch: 'feat/integration',
|
|
88
|
+
cwd: repoRoot,
|
|
89
|
+
pollIntervalMs: 50,
|
|
90
|
+
drainTimeoutMs: 500,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function on(match, handler) {
|
|
94
|
+
mocks.handlers.push({ match, handler });
|
|
95
|
+
}
|
|
96
|
+
function defaultHappyPath(_repoRoot, leaderBranch) {
|
|
97
|
+
// git worktree add — succeeds.
|
|
98
|
+
on((args) => args[0] === 'worktree' && args[1] === 'add', () => '');
|
|
99
|
+
// git worktree list — return empty by default; caller can override before.
|
|
100
|
+
on((args) => args[0] === 'worktree' && args[1] === 'list', () => '');
|
|
101
|
+
// git rev-parse refs/heads/{leaderBranch} — return a stable head sha.
|
|
102
|
+
on((args, _cwd) => args[0] === 'rev-parse' && args[1] === `refs/heads/${leaderBranch}`, () => 'leader-head-sha\n');
|
|
103
|
+
// git fetch — succeed silently.
|
|
104
|
+
on((args) => args[0] === 'fetch', () => '');
|
|
105
|
+
// git reset --hard — succeed.
|
|
106
|
+
on((args) => args[0] === 'reset' && args[1] === '--hard', () => '');
|
|
107
|
+
// git diff-index --quiet HEAD --
|
|
108
|
+
on((args) => args[0] === 'diff-index', () => '');
|
|
109
|
+
// git checkout
|
|
110
|
+
on((args) => args[0] === 'checkout', () => '');
|
|
111
|
+
// git merge-base
|
|
112
|
+
on((args) => args[0] === 'merge-base', () => 'merge-base-sha\n');
|
|
113
|
+
}
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
mocks.reset();
|
|
116
|
+
process.env.OMC_RUNTIME_V2 = '1';
|
|
117
|
+
});
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
delete process.env.OMC_RUNTIME_V2;
|
|
120
|
+
});
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// M3: branch-name guard
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
describe('M3 leader-branch guard', () => {
|
|
125
|
+
it('rejects "main"', async () => {
|
|
126
|
+
const repoRoot = makeRepoRoot();
|
|
127
|
+
try {
|
|
128
|
+
const cfg = { ...defaultConfig(repoRoot), leaderBranch: 'main' };
|
|
129
|
+
await expect(startMergeOrchestrator(cfg)).rejects.toThrow(/main\/master/);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
it('rejects "MAIN" (case-insensitive)', async () => {
|
|
136
|
+
const repoRoot = makeRepoRoot();
|
|
137
|
+
try {
|
|
138
|
+
const cfg = { ...defaultConfig(repoRoot), leaderBranch: 'MAIN' };
|
|
139
|
+
await expect(startMergeOrchestrator(cfg)).rejects.toThrow(/main\/master/);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
it('rejects "master"', async () => {
|
|
146
|
+
const repoRoot = makeRepoRoot();
|
|
147
|
+
try {
|
|
148
|
+
const cfg = { ...defaultConfig(repoRoot), leaderBranch: 'master' };
|
|
149
|
+
await expect(startMergeOrchestrator(cfg)).rejects.toThrow(/main\/master/);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
it('rejects "refs/heads/main"', async () => {
|
|
156
|
+
const repoRoot = makeRepoRoot();
|
|
157
|
+
try {
|
|
158
|
+
const cfg = { ...defaultConfig(repoRoot), leaderBranch: 'refs/heads/main' };
|
|
159
|
+
await expect(startMergeOrchestrator(cfg)).rejects.toThrow(/main\/master/);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
it('accepts a feature branch', async () => {
|
|
166
|
+
const repoRoot = makeRepoRoot();
|
|
167
|
+
try {
|
|
168
|
+
const cfg = defaultConfig(repoRoot);
|
|
169
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
170
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
171
|
+
expect(handle.getState().workers).toEqual([]);
|
|
172
|
+
await handle.drainAndStop();
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Branch-name flag-injection guard (validateBranchName)
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
describe('validateBranchName guard', () => {
|
|
183
|
+
it('rejects leader branch name that looks like a flag (--upload-pack=...)', async () => {
|
|
184
|
+
const repoRoot = makeRepoRoot();
|
|
185
|
+
try {
|
|
186
|
+
const cfg = { ...defaultConfig(repoRoot), leaderBranch: '--upload-pack=evil' };
|
|
187
|
+
await expect(startMergeOrchestrator(cfg)).rejects.toThrow(/Invalid branch name/);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
it('rejects leader branch name starting with -e/payload', async () => {
|
|
194
|
+
const repoRoot = makeRepoRoot();
|
|
195
|
+
try {
|
|
196
|
+
const cfg = { ...defaultConfig(repoRoot), leaderBranch: '-e/payload' };
|
|
197
|
+
await expect(startMergeOrchestrator(cfg)).rejects.toThrow(/Invalid branch name/);
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// M5: v2 gate
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
describe('M5 v2 gate', () => {
|
|
208
|
+
it('allows unset OMC_RUNTIME_V2 because runtime v2 is default-on', async () => {
|
|
209
|
+
delete process.env.OMC_RUNTIME_V2;
|
|
210
|
+
const repoRoot = makeRepoRoot();
|
|
211
|
+
try {
|
|
212
|
+
const cfg = defaultConfig(repoRoot);
|
|
213
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
214
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
215
|
+
await handle.drainAndStop();
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
it('throws when OMC_RUNTIME_V2=0', async () => {
|
|
222
|
+
process.env.OMC_RUNTIME_V2 = '0';
|
|
223
|
+
const repoRoot = makeRepoRoot();
|
|
224
|
+
try {
|
|
225
|
+
const cfg = defaultConfig(repoRoot);
|
|
226
|
+
await expect(startMergeOrchestrator(cfg)).rejects.toThrow(/runtime v2/);
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// SHA-change detection + merge driver
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
describe('commit watcher + auto-merge', () => {
|
|
237
|
+
it('detects a SHA change and triggers a merge', async () => {
|
|
238
|
+
const repoRoot = makeRepoRoot();
|
|
239
|
+
try {
|
|
240
|
+
const cfg = defaultConfig(repoRoot);
|
|
241
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
242
|
+
// Seed: worker branch HEAD reads as sha-A initially, then sha-B on the
|
|
243
|
+
// next call.
|
|
244
|
+
const workerName = 'alice';
|
|
245
|
+
const branchName = `omc-team/demo-team/${sanitizeName(workerName)}`;
|
|
246
|
+
let revParseCount = 0;
|
|
247
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchName}`, () => {
|
|
248
|
+
revParseCount += 1;
|
|
249
|
+
return revParseCount === 1 ? 'sha-A\n' : 'sha-B\n';
|
|
250
|
+
});
|
|
251
|
+
// merge-tree returns clean (exit 0).
|
|
252
|
+
on((args) => args[0] === 'merge-tree', () => '');
|
|
253
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
254
|
+
await handle.registerWorker(workerName);
|
|
255
|
+
// Wait for the poller to fire at least twice.
|
|
256
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
257
|
+
const mergeAttempts = mocks.calls.filter((c) => c.cmd === 'git' && c.args[0] === 'merge' && c.args[1] === '--no-ff');
|
|
258
|
+
expect(mergeAttempts.length).toBeGreaterThanOrEqual(1);
|
|
259
|
+
await handle.drainAndStop();
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
it('persists lastShas atomically after observing a change', async () => {
|
|
266
|
+
const repoRoot = makeRepoRoot();
|
|
267
|
+
try {
|
|
268
|
+
const cfg = defaultConfig(repoRoot);
|
|
269
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
270
|
+
const workerName = 'bob';
|
|
271
|
+
const branchName = `omc-team/demo-team/${sanitizeName(workerName)}`;
|
|
272
|
+
let revParseCount = 0;
|
|
273
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchName}`, () => {
|
|
274
|
+
revParseCount += 1;
|
|
275
|
+
return revParseCount === 1 ? 'seed-sha\n' : 'next-sha\n';
|
|
276
|
+
});
|
|
277
|
+
on((args) => args[0] === 'merge-tree', () => '');
|
|
278
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
279
|
+
await handle.registerWorker(workerName);
|
|
280
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
281
|
+
const persistedPath = join(repoRoot, '.omcp', 'state', 'team', sanitizeName(cfg.teamName), 'auto-merge-state.json');
|
|
282
|
+
expect(existsSync(persistedPath)).toBe(true);
|
|
283
|
+
const parsed = JSON.parse(readFileSync(persistedPath, 'utf-8'));
|
|
284
|
+
expect(parsed.lastShas[workerName]).toMatch(/sha/);
|
|
285
|
+
await handle.drainAndStop();
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
it('handles multiple workers independently', async () => {
|
|
292
|
+
const repoRoot = makeRepoRoot();
|
|
293
|
+
try {
|
|
294
|
+
const cfg = defaultConfig(repoRoot);
|
|
295
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
296
|
+
const branchA = `omc-team/demo-team/${sanitizeName('alice')}`;
|
|
297
|
+
const branchB = `omc-team/demo-team/${sanitizeName('bob')}`;
|
|
298
|
+
let aCount = 0;
|
|
299
|
+
let bCount = 0;
|
|
300
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchA}`, () => {
|
|
301
|
+
aCount += 1;
|
|
302
|
+
return aCount === 1 ? 'a-sha-0\n' : 'a-sha-1\n';
|
|
303
|
+
});
|
|
304
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchB}`, () => {
|
|
305
|
+
bCount += 1;
|
|
306
|
+
// Bob never advances — stays at the same sha.
|
|
307
|
+
return 'b-sha-0\n';
|
|
308
|
+
});
|
|
309
|
+
on((args) => args[0] === 'merge-tree', () => '');
|
|
310
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
311
|
+
await handle.registerWorker('alice');
|
|
312
|
+
await handle.registerWorker('bob');
|
|
313
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
314
|
+
const mergeAttempts = mocks.calls.filter((c) => c.cmd === 'git' && c.args[0] === 'merge' && c.args[1] === '--no-ff');
|
|
315
|
+
// Alice's branch changed → at least one merge attempt; Bob never advanced.
|
|
316
|
+
expect(mergeAttempts.length).toBeGreaterThanOrEqual(1);
|
|
317
|
+
// Make sure bob never triggered a merge (only alice's branch was the merge arg).
|
|
318
|
+
const aliceMerges = mergeAttempts.filter((c) => c.args.some((a) => a.includes('alice')));
|
|
319
|
+
const bobMerges = mergeAttempts.filter((c) => c.args.some((a) => a.includes('bob')));
|
|
320
|
+
expect(aliceMerges.length).toBeGreaterThanOrEqual(1);
|
|
321
|
+
expect(bobMerges.length).toBe(0);
|
|
322
|
+
await handle.drainAndStop();
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
it('runs preflight reset before each merge', async () => {
|
|
329
|
+
const repoRoot = makeRepoRoot();
|
|
330
|
+
try {
|
|
331
|
+
const cfg = defaultConfig(repoRoot);
|
|
332
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
333
|
+
const branchName = `omc-team/demo-team/${sanitizeName('alice')}`;
|
|
334
|
+
let count = 0;
|
|
335
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchName}`, () => {
|
|
336
|
+
count += 1;
|
|
337
|
+
return count === 1 ? 'sha-A\n' : 'sha-B\n';
|
|
338
|
+
});
|
|
339
|
+
on((args) => args[0] === 'merge-tree', () => '');
|
|
340
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
341
|
+
await handle.registerWorker('alice');
|
|
342
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
343
|
+
const resetCalls = mocks.calls.filter((c) => c.cmd === 'git' && c.args[0] === 'reset' && c.args[1] === '--hard');
|
|
344
|
+
expect(resetCalls.length).toBeGreaterThanOrEqual(1);
|
|
345
|
+
await handle.drainAndStop();
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
it('serialises concurrent merges via the mutex', async () => {
|
|
352
|
+
const repoRoot = makeRepoRoot();
|
|
353
|
+
try {
|
|
354
|
+
const cfg = defaultConfig(repoRoot);
|
|
355
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
356
|
+
// Both workers advance concurrently. Merges must not interleave —
|
|
357
|
+
// each merge sequence is preflight-reset → merge-tree → checkout → merge.
|
|
358
|
+
const branchA = `omc-team/demo-team/${sanitizeName('alice')}`;
|
|
359
|
+
const branchB = `omc-team/demo-team/${sanitizeName('bob')}`;
|
|
360
|
+
let aCount = 0;
|
|
361
|
+
let bCount = 0;
|
|
362
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchA}`, () => {
|
|
363
|
+
aCount += 1;
|
|
364
|
+
return aCount === 1 ? 'a-0\n' : 'a-1\n';
|
|
365
|
+
});
|
|
366
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchB}`, () => {
|
|
367
|
+
bCount += 1;
|
|
368
|
+
return bCount === 1 ? 'b-0\n' : 'b-1\n';
|
|
369
|
+
});
|
|
370
|
+
on((args) => args[0] === 'merge-tree', () => '');
|
|
371
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
372
|
+
await handle.registerWorker('alice');
|
|
373
|
+
await handle.registerWorker('bob');
|
|
374
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
375
|
+
// Inspect the call order — for each merge, we expect a preflight reset
|
|
376
|
+
// before the actual merge call. With a working mutex the pattern should
|
|
377
|
+
// be: reset, [merge-tree, ...], merge, ...reset, [merge-tree...], merge.
|
|
378
|
+
const sequence = mocks.calls
|
|
379
|
+
.filter((c) => c.cmd === 'git')
|
|
380
|
+
.map((c) => c.args[0]);
|
|
381
|
+
// We don't assert exact ordering, but we DO assert that the number of
|
|
382
|
+
// 'merge' (--no-ff) calls equals the number of preceding 'reset --hard'
|
|
383
|
+
// calls (within tolerance) — i.e. one preflight per merge.
|
|
384
|
+
const merges = sequence.filter((s) => s === 'merge').length;
|
|
385
|
+
const resets = sequence.filter((s) => s === 'reset').length;
|
|
386
|
+
expect(resets).toBeGreaterThanOrEqual(merges);
|
|
387
|
+
await handle.drainAndStop();
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
it('records merge_conflict event when checkMergeConflicts reports conflicts', async () => {
|
|
394
|
+
const repoRoot = makeRepoRoot();
|
|
395
|
+
try {
|
|
396
|
+
const cfg = defaultConfig(repoRoot);
|
|
397
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
398
|
+
const branchName = `omc-team/demo-team/${sanitizeName('alice')}`;
|
|
399
|
+
let count = 0;
|
|
400
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchName}`, () => {
|
|
401
|
+
count += 1;
|
|
402
|
+
return count === 1 ? 'old\n' : 'new\n';
|
|
403
|
+
});
|
|
404
|
+
// merge-tree returns conflict (exit 1 with stdout listing CONFLICT).
|
|
405
|
+
on((args) => args[0] === 'merge-tree', () => ({
|
|
406
|
+
throwStatus: 1,
|
|
407
|
+
stdout: 'CONFLICT (content): Merge conflict in foo.ts\nfoo\n',
|
|
408
|
+
}));
|
|
409
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
410
|
+
await handle.registerWorker('alice');
|
|
411
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
412
|
+
const eventLog = join(repoRoot, '.omcp', 'state', 'team', sanitizeName(cfg.teamName), 'orchestrator-events.jsonl');
|
|
413
|
+
expect(existsSync(eventLog)).toBe(true);
|
|
414
|
+
const lines = readFileSync(eventLog, 'utf-8')
|
|
415
|
+
.trim()
|
|
416
|
+
.split('\n')
|
|
417
|
+
.map((l) => JSON.parse(l));
|
|
418
|
+
expect(lines.some((e) => e.type === 'merge_conflict')).toBe(true);
|
|
419
|
+
// No actual merge should have been invoked when conflicts are detected
|
|
420
|
+
// pre-flight.
|
|
421
|
+
const merges = mocks.calls.filter((c) => c.cmd === 'git' && c.args[0] === 'merge' && c.args[1] === '--no-ff');
|
|
422
|
+
expect(merges.length).toBe(0);
|
|
423
|
+
await handle.drainAndStop();
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
it('backs off polling for chronically-failing workers', async () => {
|
|
430
|
+
const repoRoot = makeRepoRoot();
|
|
431
|
+
try {
|
|
432
|
+
const cfg = defaultConfig(repoRoot);
|
|
433
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
434
|
+
const branchName = `omc-team/demo-team/${sanitizeName('alice')}`;
|
|
435
|
+
let count = 0;
|
|
436
|
+
// Throw on every rev-parse for the worker branch.
|
|
437
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchName}`, () => {
|
|
438
|
+
count += 1;
|
|
439
|
+
return { throw: true, message: 'rev-parse failed' };
|
|
440
|
+
});
|
|
441
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
442
|
+
await handle.registerWorker('alice');
|
|
443
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
444
|
+
// Backoff means count grows sub-linearly with poll ticks.
|
|
445
|
+
// We can't assert exact counts (timer jitter), but we can assert that
|
|
446
|
+
// the poller did NOT call rev-parse on every single tick.
|
|
447
|
+
// pollIntervalMs=50 → ~8 ticks in 400ms. With backoff after a few
|
|
448
|
+
// failures, count should be << 8.
|
|
449
|
+
expect(count).toBeGreaterThanOrEqual(1);
|
|
450
|
+
expect(count).toBeLessThanOrEqual(8);
|
|
451
|
+
await handle.drainAndStop();
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
// M1: existing-rebase short-circuit
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
describe('M1 existing-rebase short-circuit', () => {
|
|
462
|
+
it('skips rebase fan-out when .git/rebase-merge exists in the other worker worktree', async () => {
|
|
463
|
+
const repoRoot = makeRepoRoot();
|
|
464
|
+
try {
|
|
465
|
+
const cfg = defaultConfig(repoRoot);
|
|
466
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
467
|
+
// Create a fake worktree dir with .git/rebase-merge for "bob".
|
|
468
|
+
const bobWtPath = join(repoRoot, '.omcp', 'team', sanitizeName(cfg.teamName), 'worktrees', sanitizeName('bob'));
|
|
469
|
+
mkdirSync(join(bobWtPath, '.git', 'rebase-merge'), { recursive: true });
|
|
470
|
+
const branchA = `omc-team/demo-team/${sanitizeName('alice')}`;
|
|
471
|
+
const branchB = `omc-team/demo-team/${sanitizeName('bob')}`;
|
|
472
|
+
let aCount = 0;
|
|
473
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchA}`, () => {
|
|
474
|
+
aCount += 1;
|
|
475
|
+
return aCount === 1 ? 'a-0\n' : 'a-1\n';
|
|
476
|
+
});
|
|
477
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchB}`, () => 'b-0\n');
|
|
478
|
+
on((args) => args[0] === 'merge-tree', () => '');
|
|
479
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
480
|
+
await handle.registerWorker('alice');
|
|
481
|
+
await handle.registerWorker('bob');
|
|
482
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
483
|
+
// No `git rebase` should have been invoked in bob's worktree.
|
|
484
|
+
const rebaseCalls = mocks.calls.filter((c) => c.cmd === 'git' && c.args[0] === 'rebase' && c.cwd === bobWtPath);
|
|
485
|
+
expect(rebaseCalls.length).toBe(0);
|
|
486
|
+
// The skip event should be in the orchestrator event log.
|
|
487
|
+
const eventLog = join(repoRoot, '.omcp', 'state', 'team', sanitizeName(cfg.teamName), 'orchestrator-events.jsonl');
|
|
488
|
+
const lines = existsSync(eventLog)
|
|
489
|
+
? readFileSync(eventLog, 'utf-8')
|
|
490
|
+
.trim()
|
|
491
|
+
.split('\n')
|
|
492
|
+
.map((l) => JSON.parse(l))
|
|
493
|
+
: [];
|
|
494
|
+
expect(lines.some((e) => e.type === 'rebase_skipped_in_progress' &&
|
|
495
|
+
e.worker === 'bob' &&
|
|
496
|
+
e.reason === 'rebase-already-in-progress')).toBe(true);
|
|
497
|
+
await handle.drainAndStop();
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// M4: dirty-tree audit on rebase resolution
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
describe('M4 dirty-tree audit', () => {
|
|
508
|
+
it('appends an audit message when worker worktree is dirty after rebase resolution', async () => {
|
|
509
|
+
const repoRoot = makeRepoRoot();
|
|
510
|
+
try {
|
|
511
|
+
const cfg = defaultConfig(repoRoot);
|
|
512
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
513
|
+
// Pre-stage: bob's worktree exists and we'll simulate a rebase that
|
|
514
|
+
// conflicts (rebase command throws), then we remove .git/rebase-merge to
|
|
515
|
+
// simulate the worker resolving it. The status mock returns dirty files.
|
|
516
|
+
const bobWtPath = join(repoRoot, '.omcp', 'team', sanitizeName(cfg.teamName), 'worktrees', sanitizeName('bob'));
|
|
517
|
+
mkdirSync(bobWtPath, { recursive: true });
|
|
518
|
+
const branchA = `omc-team/demo-team/${sanitizeName('alice')}`;
|
|
519
|
+
const branchB = `omc-team/demo-team/${sanitizeName('bob')}`;
|
|
520
|
+
let aCount = 0;
|
|
521
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchA}`, () => {
|
|
522
|
+
aCount += 1;
|
|
523
|
+
return aCount === 1 ? 'a-0\n' : 'a-1\n';
|
|
524
|
+
});
|
|
525
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchB}`, () => 'b-0\n');
|
|
526
|
+
on((args) => args[0] === 'merge-tree', () => '');
|
|
527
|
+
// git rebase in bob's worktree: throw to simulate conflict.
|
|
528
|
+
// Side-effect: drop a fake .git/rebase-merge dir to simulate rebase state.
|
|
529
|
+
on((args, cwd) => args[0] === 'rebase' &&
|
|
530
|
+
args[1] === cfg.leaderBranch &&
|
|
531
|
+
cwd === bobWtPath, () => {
|
|
532
|
+
mkdirSync(join(bobWtPath, '.git', 'rebase-merge'), { recursive: true });
|
|
533
|
+
return { throw: true, message: 'rebase conflict' };
|
|
534
|
+
});
|
|
535
|
+
// git status --porcelain in bob's worktree:
|
|
536
|
+
// First call (after rebase failure) → return UU lines.
|
|
537
|
+
// Subsequent calls (after resolution) → return dirty (M-modified) files.
|
|
538
|
+
let statusCount = 0;
|
|
539
|
+
on((args, cwd) => args[0] === 'status' && args[1] === '--porcelain' && cwd === bobWtPath, () => {
|
|
540
|
+
statusCount += 1;
|
|
541
|
+
if (statusCount === 1) {
|
|
542
|
+
return 'UU foo.ts\n';
|
|
543
|
+
}
|
|
544
|
+
return ' M scratch.txt\n M extra.md\n';
|
|
545
|
+
});
|
|
546
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
547
|
+
await handle.registerWorker('alice');
|
|
548
|
+
await handle.registerWorker('bob');
|
|
549
|
+
// Wait for the merge + rebase fan-out to fire.
|
|
550
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
551
|
+
// Now simulate bob resolving the rebase by removing .git/rebase-merge.
|
|
552
|
+
rmSync(join(bobWtPath, '.git', 'rebase-merge'), { recursive: true, force: true });
|
|
553
|
+
// Wait for the resolution watcher to detect.
|
|
554
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
555
|
+
// Inbox should contain the audit message.
|
|
556
|
+
const inboxPath = join(repoRoot, '.omcp', 'state', 'team', cfg.teamName, 'workers', 'bob', 'inbox.md');
|
|
557
|
+
expect(existsSync(inboxPath)).toBe(true);
|
|
558
|
+
const inboxContent = readFileSync(inboxPath, 'utf-8');
|
|
559
|
+
expect(inboxContent).toContain('Auto-commit audit');
|
|
560
|
+
expect(inboxContent).toMatch(/scratch\.txt|extra\.md/);
|
|
561
|
+
await handle.drainAndStop();
|
|
562
|
+
}
|
|
563
|
+
finally {
|
|
564
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// recoverFromRestart (M6)
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
describe('M6 recoverFromRestart', () => {
|
|
572
|
+
it('loads persisted SHA state and reports orphaned rebases', async () => {
|
|
573
|
+
const repoRoot = makeRepoRoot();
|
|
574
|
+
try {
|
|
575
|
+
const cfg = defaultConfig(repoRoot);
|
|
576
|
+
// Seed persisted state.
|
|
577
|
+
const persistedPath = join(repoRoot, '.omcp', 'state', 'team', sanitizeName(cfg.teamName), 'auto-merge-state.json');
|
|
578
|
+
mkdirSync(join(repoRoot, '.omcp', 'state', 'team', sanitizeName(cfg.teamName)), {
|
|
579
|
+
recursive: true,
|
|
580
|
+
});
|
|
581
|
+
atomicWriteJson(persistedPath, { lastShas: { alice: 'sha-1', bob: 'sha-2' } });
|
|
582
|
+
// Seed worktrees.json metadata.
|
|
583
|
+
const worktreesMetaPath = join(repoRoot, '.omcp', 'state', 'team', sanitizeName(cfg.teamName), 'worktrees.json');
|
|
584
|
+
const aliceWtPath = join(repoRoot, '.omcp', 'team', sanitizeName(cfg.teamName), 'worktrees', 'alice');
|
|
585
|
+
const bobWtPath = join(repoRoot, '.omcp', 'team', sanitizeName(cfg.teamName), 'worktrees', 'bob');
|
|
586
|
+
mkdirSync(aliceWtPath, { recursive: true });
|
|
587
|
+
mkdirSync(bobWtPath, { recursive: true });
|
|
588
|
+
// Bob is mid-rebase.
|
|
589
|
+
mkdirSync(join(bobWtPath, '.git', 'rebase-merge'), { recursive: true });
|
|
590
|
+
atomicWriteJson(worktreesMetaPath, [
|
|
591
|
+
{
|
|
592
|
+
path: aliceWtPath,
|
|
593
|
+
branch: 'omc-team/demo-team/alice',
|
|
594
|
+
workerName: 'alice',
|
|
595
|
+
teamName: cfg.teamName,
|
|
596
|
+
createdAt: new Date().toISOString(),
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
path: bobWtPath,
|
|
600
|
+
branch: 'omc-team/demo-team/bob',
|
|
601
|
+
workerName: 'bob',
|
|
602
|
+
teamName: cfg.teamName,
|
|
603
|
+
createdAt: new Date().toISOString(),
|
|
604
|
+
},
|
|
605
|
+
]);
|
|
606
|
+
const result = await recoverFromRestart(cfg);
|
|
607
|
+
expect(result.persistedShasLoaded).toBe(2);
|
|
608
|
+
expect(result.orphanedRebases).toEqual(['bob']);
|
|
609
|
+
// Bob should have received the recovery message.
|
|
610
|
+
const bobInbox = join(repoRoot, '.omcp', 'state', 'team', cfg.teamName, 'workers', 'bob', 'inbox.md');
|
|
611
|
+
expect(existsSync(bobInbox)).toBe(true);
|
|
612
|
+
const content = readFileSync(bobInbox, 'utf-8');
|
|
613
|
+
expect(content).toContain('Runtime restart recovery');
|
|
614
|
+
}
|
|
615
|
+
finally {
|
|
616
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
// drainAndStop
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
describe('drainAndStop', () => {
|
|
624
|
+
it('returns no unmerged when all workers are up to date', async () => {
|
|
625
|
+
const repoRoot = makeRepoRoot();
|
|
626
|
+
try {
|
|
627
|
+
const cfg = defaultConfig(repoRoot);
|
|
628
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
629
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
630
|
+
const result = await handle.drainAndStop();
|
|
631
|
+
expect(result.unmerged).toEqual([]);
|
|
632
|
+
}
|
|
633
|
+
finally {
|
|
634
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
it('audits unmerged workers when the drain timeout fires', async () => {
|
|
638
|
+
const repoRoot = makeRepoRoot();
|
|
639
|
+
try {
|
|
640
|
+
const cfg = { ...defaultConfig(repoRoot), drainTimeoutMs: 50 };
|
|
641
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
642
|
+
const branchName = `omc-team/demo-team/${sanitizeName('alice')}`;
|
|
643
|
+
// Alice has a SHA change on first poll, but the merge will conflict.
|
|
644
|
+
let count = 0;
|
|
645
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchName}`, () => {
|
|
646
|
+
count += 1;
|
|
647
|
+
return count === 1 ? 'old\n' : 'new\n';
|
|
648
|
+
});
|
|
649
|
+
on((args) => args[0] === 'merge-tree', () => ({
|
|
650
|
+
throwStatus: 1,
|
|
651
|
+
stdout: 'CONFLICT (content): Merge conflict in foo.ts\n',
|
|
652
|
+
}));
|
|
653
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
654
|
+
await handle.registerWorker('alice');
|
|
655
|
+
// Wait long enough for the watcher to observe the SHA change & try to merge.
|
|
656
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
657
|
+
const result = await handle.drainAndStop();
|
|
658
|
+
// Alice's lastObservedSha differs from lastMergedSha (merge conflict),
|
|
659
|
+
// so she's reported as unmerged.
|
|
660
|
+
expect(result.unmerged.length).toBeGreaterThanOrEqual(1);
|
|
661
|
+
expect(result.unmerged[0].workerName).toBe('alice');
|
|
662
|
+
// Teardown audit row should have been written.
|
|
663
|
+
const auditPath = join(repoRoot, '.omcp', 'state', 'team', sanitizeName(cfg.teamName), 'teardown-audit.jsonl');
|
|
664
|
+
expect(existsSync(auditPath)).toBe(true);
|
|
665
|
+
const auditContent = readFileSync(auditPath, 'utf-8');
|
|
666
|
+
expect(auditContent).toContain('"type":"unmerged_at_shutdown"');
|
|
667
|
+
expect(auditContent).toContain('"worker":"alice"');
|
|
668
|
+
}
|
|
669
|
+
finally {
|
|
670
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
// registerWorker / unregisterWorker
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
describe('worker registration', () => {
|
|
678
|
+
it('seeds lastSha from current HEAD on register', async () => {
|
|
679
|
+
const repoRoot = makeRepoRoot();
|
|
680
|
+
try {
|
|
681
|
+
const cfg = defaultConfig(repoRoot);
|
|
682
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
683
|
+
const branchName = `omc-team/demo-team/${sanitizeName('alice')}`;
|
|
684
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchName}`, () => 'seeded-sha\n');
|
|
685
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
686
|
+
await handle.registerWorker('alice');
|
|
687
|
+
const state = handle.getState();
|
|
688
|
+
expect(state.workers).toContain('alice');
|
|
689
|
+
expect(state.lastShas['alice']).toBe('seeded-sha');
|
|
690
|
+
await handle.drainAndStop();
|
|
691
|
+
}
|
|
692
|
+
finally {
|
|
693
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
it('is idempotent — registering twice is a no-op', async () => {
|
|
697
|
+
const repoRoot = makeRepoRoot();
|
|
698
|
+
try {
|
|
699
|
+
const cfg = defaultConfig(repoRoot);
|
|
700
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
701
|
+
on((args) => args[0] === 'rev-parse' && args[1].startsWith('refs/heads/omc-team'), () => 'sha\n');
|
|
702
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
703
|
+
await handle.registerWorker('alice');
|
|
704
|
+
await handle.registerWorker('alice');
|
|
705
|
+
expect(handle.getState().workers.length).toBe(1);
|
|
706
|
+
await handle.drainAndStop();
|
|
707
|
+
}
|
|
708
|
+
finally {
|
|
709
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
it('unregisterWorker removes from state', async () => {
|
|
713
|
+
const repoRoot = makeRepoRoot();
|
|
714
|
+
try {
|
|
715
|
+
const cfg = defaultConfig(repoRoot);
|
|
716
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
717
|
+
on((args) => args[0] === 'rev-parse' && args[1].startsWith('refs/heads/omc-team'), () => 'sha\n');
|
|
718
|
+
const handle = await startMergeOrchestrator(cfg);
|
|
719
|
+
await handle.registerWorker('alice');
|
|
720
|
+
await handle.unregisterWorker('alice');
|
|
721
|
+
expect(handle.getState().workers).toEqual([]);
|
|
722
|
+
await handle.drainAndStop();
|
|
723
|
+
}
|
|
724
|
+
finally {
|
|
725
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
// ---------------------------------------------------------------------------
|
|
730
|
+
// FIX 5: drain race — fan-out is suppressed once `stopped` is set
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
describe('drainAndStop suppresses fan-out rebase', () => {
|
|
733
|
+
it('emits no rebase_triggered events for merges that complete during drain', async () => {
|
|
734
|
+
const repoRoot = makeRepoRoot();
|
|
735
|
+
try {
|
|
736
|
+
const cfg = { ...defaultConfig(repoRoot), drainTimeoutMs: 500 };
|
|
737
|
+
defaultHappyPath(repoRoot, cfg.leaderBranch);
|
|
738
|
+
const branchA = `omc-team/demo-team/${sanitizeName('alice')}`;
|
|
739
|
+
const branchB = `omc-team/demo-team/${sanitizeName('bob')}`;
|
|
740
|
+
// Alice's branch advances during drain (lastObservedSha != lastMergedSha
|
|
741
|
+
// when we call drainAndStop).
|
|
742
|
+
let aliceCount = 0;
|
|
743
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchA}`, () => {
|
|
744
|
+
aliceCount += 1;
|
|
745
|
+
return aliceCount === 1 ? 'a-0\n' : 'a-1\n';
|
|
746
|
+
});
|
|
747
|
+
on((args) => args[0] === 'rev-parse' && args[1] === `refs/heads/${branchB}`, () => 'b-0\n');
|
|
748
|
+
on((args) => args[0] === 'merge-tree', () => '');
|
|
749
|
+
// Use a long poll interval so the regular poller does not run a merge
|
|
750
|
+
// before drainAndStop fires — the drain itself must execute the merge,
|
|
751
|
+
// exercising the post-stop fan-out guard.
|
|
752
|
+
const handle = await startMergeOrchestrator({
|
|
753
|
+
...cfg,
|
|
754
|
+
pollIntervalMs: 10000,
|
|
755
|
+
});
|
|
756
|
+
await handle.registerWorker('alice');
|
|
757
|
+
await handle.registerWorker('bob');
|
|
758
|
+
// Force lastObservedSha to differ from lastMergedSha by triggering one
|
|
759
|
+
// rev-parse cycle on alice.
|
|
760
|
+
await handle.drainAndStop();
|
|
761
|
+
// Read the orchestrator event log: there must be no rebase_triggered or
|
|
762
|
+
// rebase_succeeded events emitted (fan-out is suppressed after stop).
|
|
763
|
+
const eventLog = join(repoRoot, '.omcp', 'state', 'team', sanitizeName(cfg.teamName), 'orchestrator-events.jsonl');
|
|
764
|
+
const lines = existsSync(eventLog)
|
|
765
|
+
? readFileSync(eventLog, 'utf-8')
|
|
766
|
+
.trim()
|
|
767
|
+
.split('\n')
|
|
768
|
+
.filter((l) => l.length > 0)
|
|
769
|
+
.map((l) => JSON.parse(l))
|
|
770
|
+
: [];
|
|
771
|
+
const fanOutTypes = lines.filter((e) => e.type === 'rebase_triggered' || e.type === 'rebase_succeeded' || e.type === 'rebase_conflict');
|
|
772
|
+
expect(fanOutTypes).toEqual([]);
|
|
773
|
+
}
|
|
774
|
+
finally {
|
|
775
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
//# sourceMappingURL=merge-orchestrator.test.js.map
|