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