@ronkovic/aad 0.3.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/LICENSE +21 -0
- package/README.md +312 -0
- package/bin/aad.js +2 -0
- package/package.json +78 -0
- package/src/__tests__/e2e/pipeline-e2e.test.ts +279 -0
- package/src/__tests__/e2e/resume-e2e.test.ts +200 -0
- package/src/__tests__/integration/cli-smoke.test.ts +175 -0
- package/src/__tests__/integration/pipeline.test.ts +346 -0
- package/src/bun-imports.d.ts +14 -0
- package/src/main.ts +52 -0
- package/src/modules/claude-provider/__tests__/claude-cli.adapter.test.ts +277 -0
- package/src/modules/claude-provider/__tests__/claude-sdk-real-env.test.ts +127 -0
- package/src/modules/claude-provider/__tests__/claude-sdk.adapter.test.ts +347 -0
- package/src/modules/claude-provider/__tests__/effort-strategy.test.ts +212 -0
- package/src/modules/claude-provider/__tests__/provider-registry.test.ts +251 -0
- package/src/modules/claude-provider/__tests__/retry.test.ts +201 -0
- package/src/modules/claude-provider/claude-cli.adapter.ts +156 -0
- package/src/modules/claude-provider/claude-provider.port.ts +35 -0
- package/src/modules/claude-provider/claude-sdk.adapter.ts +217 -0
- package/src/modules/claude-provider/effort-strategy.ts +94 -0
- package/src/modules/claude-provider/index.ts +32 -0
- package/src/modules/claude-provider/provider-registry.ts +92 -0
- package/src/modules/claude-provider/retry.ts +81 -0
- package/src/modules/cli/__tests__/app.test.ts +160 -0
- package/src/modules/cli/__tests__/cleanup.test.ts +111 -0
- package/src/modules/cli/__tests__/commands.test.ts +186 -0
- package/src/modules/cli/__tests__/output.test.ts +329 -0
- package/src/modules/cli/__tests__/resume.test.ts +324 -0
- package/src/modules/cli/__tests__/run.test.ts +168 -0
- package/src/modules/cli/__tests__/shutdown.test.ts +168 -0
- package/src/modules/cli/__tests__/status.test.ts +144 -0
- package/src/modules/cli/app.ts +241 -0
- package/src/modules/cli/commands/cleanup.ts +120 -0
- package/src/modules/cli/commands/resume.ts +156 -0
- package/src/modules/cli/commands/run.ts +322 -0
- package/src/modules/cli/commands/status.ts +101 -0
- package/src/modules/cli/index.ts +29 -0
- package/src/modules/cli/output.ts +256 -0
- package/src/modules/cli/shutdown.ts +122 -0
- package/src/modules/dashboard/__tests__/api-routes.test.ts +204 -0
- package/src/modules/dashboard/__tests__/file-watcher.test.ts +34 -0
- package/src/modules/dashboard/__tests__/server.test.ts +120 -0
- package/src/modules/dashboard/__tests__/sse-broadcaster.test.ts +163 -0
- package/src/modules/dashboard/__tests__/sse-routes.test.ts +58 -0
- package/src/modules/dashboard/__tests__/state-aggregator.test.ts +330 -0
- package/src/modules/dashboard/index.ts +8 -0
- package/src/modules/dashboard/routes/api.ts +84 -0
- package/src/modules/dashboard/routes/sse.ts +37 -0
- package/src/modules/dashboard/server.ts +111 -0
- package/src/modules/dashboard/services/file-watcher.ts +36 -0
- package/src/modules/dashboard/services/sse-broadcaster.ts +81 -0
- package/src/modules/dashboard/services/state-aggregator.ts +132 -0
- package/src/modules/dashboard/ui/dashboard.html +405 -0
- package/src/modules/git-workspace/__tests__/branch-manager.test.ts +335 -0
- package/src/modules/git-workspace/__tests__/git-exec.test.ts +91 -0
- package/src/modules/git-workspace/__tests__/memory-sync.test.ts +273 -0
- package/src/modules/git-workspace/__tests__/merge-service.test.ts +286 -0
- package/src/modules/git-workspace/__tests__/settings-merge.test.ts +163 -0
- package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +247 -0
- package/src/modules/git-workspace/branch-manager.ts +191 -0
- package/src/modules/git-workspace/git-exec.ts +124 -0
- package/src/modules/git-workspace/index.ts +17 -0
- package/src/modules/git-workspace/memory-sync.ts +89 -0
- package/src/modules/git-workspace/merge-service.ts +156 -0
- package/src/modules/git-workspace/settings-merge.ts +95 -0
- package/src/modules/git-workspace/worktree-manager.ts +199 -0
- package/src/modules/logging/__tests__/log-store.test.ts +242 -0
- package/src/modules/logging/__tests__/logger.test.ts +81 -0
- package/src/modules/logging/__tests__/sse-transport.test.ts +93 -0
- package/src/modules/logging/index.ts +7 -0
- package/src/modules/logging/log-store.ts +80 -0
- package/src/modules/logging/logger.ts +55 -0
- package/src/modules/logging/transports/sse-transport.ts +28 -0
- package/src/modules/multi-repo/__tests__/multi-repo-planner.test.ts +93 -0
- package/src/modules/multi-repo/__tests__/repo-context.test.ts +79 -0
- package/src/modules/multi-repo/index.ts +12 -0
- package/src/modules/multi-repo/multi-repo-planner.ts +112 -0
- package/src/modules/multi-repo/repo-context.ts +71 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/progress.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/completed/task-getall-2.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-1.json +13 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-getall-1.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-status-change.json +10 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-1.json +5 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-2.json +5 -0
- package/src/modules/persistence/__tests__/file-lock.test.ts +141 -0
- package/src/modules/persistence/__tests__/index.test.ts +38 -0
- package/src/modules/persistence/__tests__/stores.test.ts +594 -0
- package/src/modules/persistence/file-lock.ts +158 -0
- package/src/modules/persistence/fs-run-store.ts +73 -0
- package/src/modules/persistence/fs-task-store.ts +152 -0
- package/src/modules/persistence/fs-worker-store.ts +116 -0
- package/src/modules/persistence/in-memory-stores.ts +98 -0
- package/src/modules/persistence/index.ts +60 -0
- package/src/modules/persistence/stores.port.ts +60 -0
- package/src/modules/planning/__tests__/file-conflict-validator.test.ts +256 -0
- package/src/modules/planning/__tests__/planning-service.test.ts +366 -0
- package/src/modules/planning/__tests__/project-detection.test.ts +707 -0
- package/src/modules/planning/file-conflict-validator.ts +135 -0
- package/src/modules/planning/index.ts +40 -0
- package/src/modules/planning/planning.service.ts +262 -0
- package/src/modules/planning/project-detection.ts +525 -0
- package/src/modules/plugin/__tests__/plugin-loader.test.ts +83 -0
- package/src/modules/plugin/__tests__/plugin-manager.test.ts +187 -0
- package/src/modules/plugin/index.ts +3 -0
- package/src/modules/plugin/plugin-loader.ts +46 -0
- package/src/modules/plugin/plugin-manager.ts +90 -0
- package/src/modules/plugin/plugin.types.ts +37 -0
- package/src/modules/process-manager/__tests__/process-manager.test.ts +210 -0
- package/src/modules/process-manager/__tests__/worker.test.ts +89 -0
- package/src/modules/process-manager/index.ts +5 -0
- package/src/modules/process-manager/process-manager.ts +193 -0
- package/src/modules/process-manager/worker.ts +106 -0
- package/src/modules/task-execution/__tests__/default-spawner.test.ts +154 -0
- package/src/modules/task-execution/__tests__/executor.test.ts +760 -0
- package/src/modules/task-execution/__tests__/implementer-green.test.ts +286 -0
- package/src/modules/task-execution/__tests__/merge-phase.test.ts +368 -0
- package/src/modules/task-execution/__tests__/reviewer.test.ts +302 -0
- package/src/modules/task-execution/__tests__/tester-red.test.ts +281 -0
- package/src/modules/task-execution/__tests__/tester-verify.test.ts +313 -0
- package/src/modules/task-execution/executor.ts +303 -0
- package/src/modules/task-execution/index.ts +45 -0
- package/src/modules/task-execution/phases/default-spawner.ts +49 -0
- package/src/modules/task-execution/phases/implementer-green.ts +100 -0
- package/src/modules/task-execution/phases/merge.ts +122 -0
- package/src/modules/task-execution/phases/reviewer.ts +160 -0
- package/src/modules/task-execution/phases/tester-red.ts +100 -0
- package/src/modules/task-execution/phases/tester-verify.ts +120 -0
- package/src/modules/task-queue/__tests__/dependency-resolver.test.ts +456 -0
- package/src/modules/task-queue/__tests__/dispatcher.test.ts +824 -0
- package/src/modules/task-queue/__tests__/task-plan.test.ts +122 -0
- package/src/modules/task-queue/__tests__/task.test.ts +130 -0
- package/src/modules/task-queue/dependency-resolver.ts +171 -0
- package/src/modules/task-queue/dispatcher.ts +372 -0
- package/src/modules/task-queue/index.ts +16 -0
- package/src/modules/task-queue/task-plan.ts +40 -0
- package/src/modules/task-queue/task.ts +67 -0
- package/src/shared/__tests__/config.test.ts +204 -0
- package/src/shared/__tests__/errors.test.ts +285 -0
- package/src/shared/__tests__/events.test.ts +496 -0
- package/src/shared/__tests__/types.test.ts +360 -0
- package/src/shared/config.ts +133 -0
- package/src/shared/errors.ts +128 -0
- package/src/shared/events.ts +171 -0
- package/src/shared/types.ts +143 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { rm, mkdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { MergeService } from "../merge-service";
|
|
5
|
+
import { gitExec } from "../git-exec";
|
|
6
|
+
import { createTaskId } from "@aad/shared/types";
|
|
7
|
+
|
|
8
|
+
const TEST_DIR = join(import.meta.dir, ".tmp-merge-service-test");
|
|
9
|
+
const REPO_DIR = join(TEST_DIR, "repo");
|
|
10
|
+
const PARENT_WORKTREE = join(TEST_DIR, "parent-worktree");
|
|
11
|
+
let DEFAULT_BRANCH = "main";
|
|
12
|
+
|
|
13
|
+
/** Reset worktree to clean state before each test */
|
|
14
|
+
async function resetWorktree(): Promise<MergeService> {
|
|
15
|
+
const mergeService = new MergeService({ repoRoot: REPO_DIR });
|
|
16
|
+
|
|
17
|
+
// Abort any in-progress merge
|
|
18
|
+
try {
|
|
19
|
+
await gitExec(["merge", "--abort"], { cwd: PARENT_WORKTREE });
|
|
20
|
+
} catch {
|
|
21
|
+
// No merge in progress
|
|
22
|
+
}
|
|
23
|
+
await gitExec(["checkout", DEFAULT_BRANCH], { cwd: PARENT_WORKTREE });
|
|
24
|
+
await gitExec(["reset", "--hard", "origin/" + DEFAULT_BRANCH], { cwd: PARENT_WORKTREE });
|
|
25
|
+
await gitExec(["clean", "-fd"], { cwd: PARENT_WORKTREE });
|
|
26
|
+
|
|
27
|
+
// Batch delete local task branches
|
|
28
|
+
try {
|
|
29
|
+
const branches = await gitExec(["branch", "--list", "task-*"], { cwd: PARENT_WORKTREE });
|
|
30
|
+
const names = branches.stdout
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((b) => b.trim().replace("* ", ""))
|
|
33
|
+
.filter((b) => b && b !== DEFAULT_BRANCH);
|
|
34
|
+
if (names.length > 0) {
|
|
35
|
+
await gitExec(["branch", "-D", ...names], { cwd: PARENT_WORKTREE });
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Ignore
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Batch delete remote task branches from bare repo
|
|
42
|
+
try {
|
|
43
|
+
const refs = await gitExec(
|
|
44
|
+
["for-each-ref", "--format=%(refname:short)", "refs/heads/task-*"],
|
|
45
|
+
{ cwd: REPO_DIR }
|
|
46
|
+
);
|
|
47
|
+
const names = refs.stdout.split("\n").filter(Boolean);
|
|
48
|
+
if (names.length > 0) {
|
|
49
|
+
await gitExec(["branch", "-D", ...names], { cwd: REPO_DIR });
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Ignore
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return mergeService;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("MergeService", () => {
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
61
|
+
await mkdir(REPO_DIR, { recursive: true });
|
|
62
|
+
await mkdir(PARENT_WORKTREE, { recursive: true });
|
|
63
|
+
|
|
64
|
+
// Initialize main repo with explicit branch
|
|
65
|
+
await gitExec(["init", "--bare"], { cwd: REPO_DIR });
|
|
66
|
+
await gitExec(["symbolic-ref", "HEAD", "refs/heads/main"], { cwd: REPO_DIR });
|
|
67
|
+
|
|
68
|
+
// Clone to parent worktree
|
|
69
|
+
await gitExec(["clone", REPO_DIR, PARENT_WORKTREE], { cwd: TEST_DIR });
|
|
70
|
+
await gitExec(["config", "user.name", "Test User"], { cwd: PARENT_WORKTREE });
|
|
71
|
+
await gitExec(["config", "user.email", "test@example.com"], { cwd: PARENT_WORKTREE });
|
|
72
|
+
await gitExec(["config", "commit.gpgsign", "false"], { cwd: PARENT_WORKTREE });
|
|
73
|
+
|
|
74
|
+
// Create initial commit
|
|
75
|
+
await Bun.write(join(PARENT_WORKTREE, "README.md"), "# Test Repo\n");
|
|
76
|
+
await gitExec(["add", "README.md"], { cwd: PARENT_WORKTREE });
|
|
77
|
+
await gitExec(["commit", "-m", "Initial commit"], { cwd: PARENT_WORKTREE });
|
|
78
|
+
|
|
79
|
+
const branchResult = await gitExec(["branch", "--show-current"], { cwd: PARENT_WORKTREE });
|
|
80
|
+
DEFAULT_BRANCH = branchResult.stdout.trim();
|
|
81
|
+
|
|
82
|
+
await gitExec(["push", "origin", "HEAD"], { cwd: PARENT_WORKTREE });
|
|
83
|
+
}, 30_000);
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("mergeToParent", () => {
|
|
90
|
+
test("successfully merges task branch without conflicts", async () => {
|
|
91
|
+
const mergeService = await resetWorktree();
|
|
92
|
+
|
|
93
|
+
const taskBranch = "task-feature-1";
|
|
94
|
+
await gitExec(["checkout", "-b", taskBranch], { cwd: PARENT_WORKTREE });
|
|
95
|
+
await Bun.write(join(PARENT_WORKTREE, "feature.ts"), "export const feature = 1;\n");
|
|
96
|
+
await gitExec(["add", "feature.ts"], { cwd: PARENT_WORKTREE });
|
|
97
|
+
await gitExec(["commit", "-m", "Add feature"], { cwd: PARENT_WORKTREE });
|
|
98
|
+
await gitExec(["push", "origin", taskBranch], { cwd: PARENT_WORKTREE });
|
|
99
|
+
|
|
100
|
+
await gitExec(["checkout", "HEAD~1"], { cwd: PARENT_WORKTREE });
|
|
101
|
+
|
|
102
|
+
const result = await mergeService.mergeToParent(
|
|
103
|
+
createTaskId("task-1"),
|
|
104
|
+
taskBranch,
|
|
105
|
+
DEFAULT_BRANCH,
|
|
106
|
+
PARENT_WORKTREE
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(result.success).toBe(true);
|
|
110
|
+
expect(result.conflicts).toBeUndefined();
|
|
111
|
+
expect(result.message).toContain("Successfully merged");
|
|
112
|
+
}, 60_000);
|
|
113
|
+
|
|
114
|
+
test("detects merge conflicts and aborts merge", async () => {
|
|
115
|
+
const mergeService = await resetWorktree();
|
|
116
|
+
|
|
117
|
+
await gitExec(["checkout", DEFAULT_BRANCH], { cwd: PARENT_WORKTREE });
|
|
118
|
+
await Bun.write(join(PARENT_WORKTREE, "conflict.ts"), "export const version = 1;\n");
|
|
119
|
+
await gitExec(["add", "conflict.ts"], { cwd: PARENT_WORKTREE });
|
|
120
|
+
await gitExec(["commit", "-m", "Add conflict file in main"], { cwd: PARENT_WORKTREE });
|
|
121
|
+
|
|
122
|
+
const taskBranch = "task-conflict";
|
|
123
|
+
await gitExec(["checkout", "HEAD~1"], { cwd: PARENT_WORKTREE });
|
|
124
|
+
await gitExec(["checkout", "-b", taskBranch], { cwd: PARENT_WORKTREE });
|
|
125
|
+
await Bun.write(join(PARENT_WORKTREE, "conflict.ts"), "export const version = 2;\n");
|
|
126
|
+
await gitExec(["add", "conflict.ts"], { cwd: PARENT_WORKTREE });
|
|
127
|
+
await gitExec(["commit", "-m", "Add conflict file in task"], { cwd: PARENT_WORKTREE });
|
|
128
|
+
await gitExec(["push", "origin", taskBranch], { cwd: PARENT_WORKTREE });
|
|
129
|
+
|
|
130
|
+
await gitExec(["checkout", DEFAULT_BRANCH], { cwd: PARENT_WORKTREE });
|
|
131
|
+
|
|
132
|
+
const result = await mergeService.mergeToParent(
|
|
133
|
+
createTaskId("task-conflict"),
|
|
134
|
+
taskBranch,
|
|
135
|
+
DEFAULT_BRANCH,
|
|
136
|
+
PARENT_WORKTREE
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(result.success).toBe(false);
|
|
140
|
+
expect(result.conflicts).toBeDefined();
|
|
141
|
+
expect(result.conflicts?.length).toBeGreaterThan(0);
|
|
142
|
+
expect(result.conflicts).toContain("conflict.ts");
|
|
143
|
+
expect(result.message).toContain("Merge conflict");
|
|
144
|
+
}, 60_000);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("detectConflicts", () => {
|
|
148
|
+
test("returns empty array when no conflicts", async () => {
|
|
149
|
+
await resetWorktree();
|
|
150
|
+
const mergeService = new MergeService({ repoRoot: REPO_DIR });
|
|
151
|
+
|
|
152
|
+
const conflicts = await mergeService.detectConflicts(PARENT_WORKTREE);
|
|
153
|
+
expect(conflicts).toEqual([]);
|
|
154
|
+
}, 30_000);
|
|
155
|
+
|
|
156
|
+
test("returns conflicted files list", async () => {
|
|
157
|
+
const mergeService = await resetWorktree();
|
|
158
|
+
|
|
159
|
+
await gitExec(["checkout", DEFAULT_BRANCH], { cwd: PARENT_WORKTREE });
|
|
160
|
+
const taskBranch = "task-detect-conflict";
|
|
161
|
+
await gitExec(["checkout", "-b", taskBranch], { cwd: PARENT_WORKTREE });
|
|
162
|
+
await Bun.write(join(PARENT_WORKTREE, "detect.ts"), "const value = 1;\n");
|
|
163
|
+
await gitExec(["add", "detect.ts"], { cwd: PARENT_WORKTREE });
|
|
164
|
+
await gitExec(["commit", "-m", "Task version"], { cwd: PARENT_WORKTREE });
|
|
165
|
+
|
|
166
|
+
await gitExec(["checkout", DEFAULT_BRANCH], { cwd: PARENT_WORKTREE });
|
|
167
|
+
await Bun.write(join(PARENT_WORKTREE, "detect.ts"), "const value = 2;\n");
|
|
168
|
+
await gitExec(["add", "detect.ts"], { cwd: PARENT_WORKTREE });
|
|
169
|
+
await gitExec(["commit", "-m", "Main version"], { cwd: PARENT_WORKTREE });
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
await gitExec(["merge", "--no-ff", taskBranch], { cwd: PARENT_WORKTREE });
|
|
173
|
+
} catch {
|
|
174
|
+
// Expected to fail
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const conflicts = await mergeService.detectConflicts(PARENT_WORKTREE);
|
|
178
|
+
|
|
179
|
+
expect(conflicts.length).toBeGreaterThan(0);
|
|
180
|
+
expect(conflicts).toContain("detect.ts");
|
|
181
|
+
|
|
182
|
+
await gitExec(["merge", "--abort"], { cwd: PARENT_WORKTREE });
|
|
183
|
+
}, 60_000);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("fetchDependencyChanges", () => {
|
|
187
|
+
test("fetches and merges dependency branch", async () => {
|
|
188
|
+
await resetWorktree();
|
|
189
|
+
const mergeService = new MergeService({ repoRoot: REPO_DIR });
|
|
190
|
+
|
|
191
|
+
const depBranch = "task-dependency";
|
|
192
|
+
await gitExec(["checkout", DEFAULT_BRANCH], { cwd: PARENT_WORKTREE });
|
|
193
|
+
await gitExec(["checkout", "-b", depBranch], { cwd: PARENT_WORKTREE });
|
|
194
|
+
await Bun.write(join(PARENT_WORKTREE, "dep.ts"), "export const dep = true;\n");
|
|
195
|
+
await gitExec(["add", "dep.ts"], { cwd: PARENT_WORKTREE });
|
|
196
|
+
await gitExec(["commit", "-m", "Add dependency"], { cwd: PARENT_WORKTREE });
|
|
197
|
+
await gitExec(["push", "origin", depBranch], { cwd: PARENT_WORKTREE });
|
|
198
|
+
|
|
199
|
+
await gitExec(["checkout", DEFAULT_BRANCH], { cwd: PARENT_WORKTREE });
|
|
200
|
+
await gitExec(["checkout", "-b", "task-target"], { cwd: PARENT_WORKTREE });
|
|
201
|
+
|
|
202
|
+
await mergeService.fetchDependencyChanges(depBranch, PARENT_WORKTREE);
|
|
203
|
+
|
|
204
|
+
const depFile = Bun.file(join(PARENT_WORKTREE, "dep.ts"));
|
|
205
|
+
const exists = await depFile.exists();
|
|
206
|
+
expect(exists).toBe(true);
|
|
207
|
+
}, 60_000);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("getChangedFiles", () => {
|
|
211
|
+
test("returns list of changed files", async () => {
|
|
212
|
+
await resetWorktree();
|
|
213
|
+
const mergeService = new MergeService({ repoRoot: REPO_DIR });
|
|
214
|
+
|
|
215
|
+
const testBranch = "task-changes";
|
|
216
|
+
await gitExec(["checkout", DEFAULT_BRANCH], { cwd: PARENT_WORKTREE });
|
|
217
|
+
await gitExec(["checkout", "-b", testBranch], { cwd: PARENT_WORKTREE });
|
|
218
|
+
|
|
219
|
+
await Bun.write(join(PARENT_WORKTREE, "change1.ts"), "// Change 1\n");
|
|
220
|
+
await Bun.write(join(PARENT_WORKTREE, "change2.ts"), "// Change 2\n");
|
|
221
|
+
await gitExec(["add", "change1.ts", "change2.ts"], { cwd: PARENT_WORKTREE });
|
|
222
|
+
await gitExec(["commit", "-m", "Add changes"], { cwd: PARENT_WORKTREE });
|
|
223
|
+
await gitExec(["push", "origin", testBranch], { cwd: PARENT_WORKTREE });
|
|
224
|
+
|
|
225
|
+
const files = await mergeService.getChangedFiles(testBranch, DEFAULT_BRANCH);
|
|
226
|
+
|
|
227
|
+
expect(files).toContain("change1.ts");
|
|
228
|
+
expect(files).toContain("change2.ts");
|
|
229
|
+
expect(files.length).toBeGreaterThanOrEqual(2);
|
|
230
|
+
}, 60_000);
|
|
231
|
+
|
|
232
|
+
test("returns empty array when no changes", async () => {
|
|
233
|
+
await resetWorktree();
|
|
234
|
+
const mergeService = new MergeService({ repoRoot: REPO_DIR });
|
|
235
|
+
|
|
236
|
+
const files = await mergeService.getChangedFiles(DEFAULT_BRANCH, DEFAULT_BRANCH);
|
|
237
|
+
expect(files).toEqual([]);
|
|
238
|
+
}, 30_000);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("hasConflictingChanges", () => {
|
|
242
|
+
test("detects conflicting and non-conflicting changes", async () => {
|
|
243
|
+
const mergeService = await resetWorktree();
|
|
244
|
+
|
|
245
|
+
await gitExec(["checkout", DEFAULT_BRANCH], { cwd: PARENT_WORKTREE });
|
|
246
|
+
const baseCommit = await gitExec(["rev-parse", "HEAD"], { cwd: PARENT_WORKTREE });
|
|
247
|
+
const baseRef = baseCommit.stdout.trim();
|
|
248
|
+
|
|
249
|
+
// Branch 1: modifies shared.ts + unique file
|
|
250
|
+
const branch1 = "task-conflict-check-1";
|
|
251
|
+
await gitExec(["checkout", "-b", branch1, baseRef], { cwd: PARENT_WORKTREE });
|
|
252
|
+
await Bun.write(join(PARENT_WORKTREE, "shared.ts"), "const v = 1;\n");
|
|
253
|
+
await Bun.write(join(PARENT_WORKTREE, "only-in-branch1.ts"), "// Only in branch 1\n");
|
|
254
|
+
await gitExec(["add", "shared.ts", "only-in-branch1.ts"], { cwd: PARENT_WORKTREE });
|
|
255
|
+
await gitExec(["commit", "-m", "Branch 1 changes"], { cwd: PARENT_WORKTREE });
|
|
256
|
+
await gitExec(["push", "origin", branch1], { cwd: PARENT_WORKTREE });
|
|
257
|
+
|
|
258
|
+
// Branch 2: modifies shared.ts (conflict) + unique file (no conflict)
|
|
259
|
+
await gitExec(["checkout", baseRef], { cwd: PARENT_WORKTREE });
|
|
260
|
+
const branch2 = "task-conflict-check-2";
|
|
261
|
+
await gitExec(["checkout", "-b", branch2], { cwd: PARENT_WORKTREE });
|
|
262
|
+
await Bun.write(join(PARENT_WORKTREE, "shared.ts"), "const v = 2;\n");
|
|
263
|
+
await Bun.write(join(PARENT_WORKTREE, "only-in-branch2.ts"), "// Only in branch 2\n");
|
|
264
|
+
await gitExec(["add", "shared.ts", "only-in-branch2.ts"], { cwd: PARENT_WORKTREE });
|
|
265
|
+
await gitExec(["commit", "-m", "Branch 2 changes"], { cwd: PARENT_WORKTREE });
|
|
266
|
+
await gitExec(["push", "origin", branch2], { cwd: PARENT_WORKTREE });
|
|
267
|
+
|
|
268
|
+
// Branch 3: only unique file (no overlap with branch1)
|
|
269
|
+
await gitExec(["checkout", baseRef], { cwd: PARENT_WORKTREE });
|
|
270
|
+
const branch3 = "task-conflict-check-3";
|
|
271
|
+
await gitExec(["checkout", "-b", branch3], { cwd: PARENT_WORKTREE });
|
|
272
|
+
await Bun.write(join(PARENT_WORKTREE, "only-in-branch3.ts"), "// Only in branch 3\n");
|
|
273
|
+
await gitExec(["add", "only-in-branch3.ts"], { cwd: PARENT_WORKTREE });
|
|
274
|
+
await gitExec(["commit", "-m", "Branch 3 unique file"], { cwd: PARENT_WORKTREE });
|
|
275
|
+
await gitExec(["push", "origin", branch3], { cwd: PARENT_WORKTREE });
|
|
276
|
+
|
|
277
|
+
// Branches 1 & 2 both modify shared.ts → conflict
|
|
278
|
+
const hasConflict = await mergeService.hasConflictingChanges(branch1, branch2, baseRef);
|
|
279
|
+
expect(hasConflict).toBe(true);
|
|
280
|
+
|
|
281
|
+
// Branches 1 & 3 modify different files → no conflict
|
|
282
|
+
const noConflict = await mergeService.hasConflictingChanges(branch1, branch3, baseRef);
|
|
283
|
+
expect(noConflict).toBe(false);
|
|
284
|
+
}, 90_000);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { mergeSettings } from "../settings-merge";
|
|
3
|
+
import type { ClaudeSettings } from "../settings-merge";
|
|
4
|
+
|
|
5
|
+
describe("settings-merge", () => {
|
|
6
|
+
describe("mergeSettings", () => {
|
|
7
|
+
test("merges simple objects", () => {
|
|
8
|
+
const base: ClaudeSettings = {
|
|
9
|
+
debug: false,
|
|
10
|
+
timeout: 30000,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const override: ClaudeSettings = {
|
|
14
|
+
debug: true,
|
|
15
|
+
workers: 4,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const merged = mergeSettings(base, override);
|
|
19
|
+
|
|
20
|
+
expect(merged.debug).toBe(true);
|
|
21
|
+
expect(merged.timeout).toBe(30000);
|
|
22
|
+
expect(merged.workers).toBe(4);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("performs deep merge for nested objects", () => {
|
|
26
|
+
const base: ClaudeSettings = {
|
|
27
|
+
models: {
|
|
28
|
+
default: "sonnet",
|
|
29
|
+
tester: "haiku",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const override: ClaudeSettings = {
|
|
34
|
+
models: {
|
|
35
|
+
default: "opus",
|
|
36
|
+
reviewer: "sonnet",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const merged = mergeSettings(base, override);
|
|
41
|
+
|
|
42
|
+
expect(merged.models).toEqual({
|
|
43
|
+
default: "opus",
|
|
44
|
+
tester: "haiku",
|
|
45
|
+
reviewer: "sonnet",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("unions arrays (deduplicates)", () => {
|
|
50
|
+
const base: ClaudeSettings = {
|
|
51
|
+
permissions: ["read", "write"] as unknown as Record<string, unknown>,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const override: ClaudeSettings = {
|
|
55
|
+
permissions: ["write", "execute"] as unknown as Record<string, unknown>,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const merged = mergeSettings(base, override);
|
|
59
|
+
|
|
60
|
+
expect(Array.isArray(merged.permissions)).toBe(true);
|
|
61
|
+
expect(merged.permissions).toHaveLength(3);
|
|
62
|
+
expect(merged.permissions).toEqual(expect.arrayContaining(["read", "write", "execute"]));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("overrides primitives", () => {
|
|
66
|
+
const base: ClaudeSettings = {
|
|
67
|
+
timeout: 10000,
|
|
68
|
+
enabled: false,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const override: ClaudeSettings = {
|
|
72
|
+
timeout: 20000,
|
|
73
|
+
enabled: true,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const merged = mergeSettings(base, override);
|
|
77
|
+
|
|
78
|
+
expect(merged.timeout).toBe(20000);
|
|
79
|
+
expect(merged.enabled).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("handles undefined and null values", () => {
|
|
83
|
+
const base: ClaudeSettings = {
|
|
84
|
+
debug: true,
|
|
85
|
+
timeout: 30000,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const override: ClaudeSettings = {
|
|
89
|
+
debug: undefined,
|
|
90
|
+
timeout: null as any,
|
|
91
|
+
workers: 4,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const merged = mergeSettings(base, override);
|
|
95
|
+
|
|
96
|
+
expect(merged.debug).toBe(true);
|
|
97
|
+
expect(merged.timeout).toBe(30000);
|
|
98
|
+
expect(merged.workers).toBe(4);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("handles complex nested structure", () => {
|
|
102
|
+
const base: ClaudeSettings = {
|
|
103
|
+
env: {
|
|
104
|
+
PATH: "/usr/bin",
|
|
105
|
+
HOME: "/home/user",
|
|
106
|
+
},
|
|
107
|
+
hooks: {
|
|
108
|
+
preCommit: ["lint", "test"],
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const override: ClaudeSettings = {
|
|
113
|
+
env: {
|
|
114
|
+
PATH: "/usr/local/bin",
|
|
115
|
+
NODE_ENV: "production",
|
|
116
|
+
},
|
|
117
|
+
hooks: {
|
|
118
|
+
preCommit: ["test", "build"],
|
|
119
|
+
postCommit: ["deploy"],
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const merged = mergeSettings(base, override);
|
|
124
|
+
|
|
125
|
+
expect(merged.env).toEqual({
|
|
126
|
+
PATH: "/usr/local/bin",
|
|
127
|
+
HOME: "/home/user",
|
|
128
|
+
NODE_ENV: "production",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(merged.hooks).toEqual({
|
|
132
|
+
preCommit: expect.arrayContaining(["lint", "test", "build"]),
|
|
133
|
+
postCommit: ["deploy"],
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("preserves base when override is empty", () => {
|
|
138
|
+
const base: ClaudeSettings = {
|
|
139
|
+
debug: true,
|
|
140
|
+
timeout: 30000,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const override: ClaudeSettings = {};
|
|
144
|
+
|
|
145
|
+
const merged = mergeSettings(base, override);
|
|
146
|
+
|
|
147
|
+
expect(merged).toEqual(base);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("uses override when base is empty", () => {
|
|
151
|
+
const base: ClaudeSettings = {};
|
|
152
|
+
|
|
153
|
+
const override: ClaudeSettings = {
|
|
154
|
+
debug: true,
|
|
155
|
+
timeout: 30000,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const merged = mergeSettings(base, override);
|
|
159
|
+
|
|
160
|
+
expect(merged).toEqual(override);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
|
2
|
+
import { WorktreeManager } from "../worktree-manager";
|
|
3
|
+
import { createTaskId, createRunId } from "@aad/shared/types";
|
|
4
|
+
import { GitWorkspaceError } from "@aad/shared/errors";
|
|
5
|
+
import { pino } from "pino";
|
|
6
|
+
import type { GitOps } from "../git-exec";
|
|
7
|
+
import type { WorktreeManagerFsOps } from "../worktree-manager";
|
|
8
|
+
|
|
9
|
+
describe("WorktreeManager", () => {
|
|
10
|
+
let worktreeManager: WorktreeManager;
|
|
11
|
+
const repoRoot = "/test/repo";
|
|
12
|
+
const worktreeBase = "/test/worktrees";
|
|
13
|
+
|
|
14
|
+
let mockGitOps: GitOps;
|
|
15
|
+
let mockFsOps: WorktreeManagerFsOps;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
mockGitOps = {
|
|
19
|
+
gitExec: mock(async () => ({ stdout: "", stderr: "", exitCode: 0 })),
|
|
20
|
+
branchExists: mock(async () => false),
|
|
21
|
+
};
|
|
22
|
+
mockFsOps = {
|
|
23
|
+
mkdir: mock(async () => undefined) as any,
|
|
24
|
+
rm: mock(async () => {}) as any,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
worktreeManager = new WorktreeManager({
|
|
28
|
+
repoRoot,
|
|
29
|
+
worktreeBase,
|
|
30
|
+
logger: pino({ level: "silent" }),
|
|
31
|
+
gitOps: mockGitOps,
|
|
32
|
+
fsOps: mockFsOps,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("createTaskWorktree", () => {
|
|
37
|
+
test("creates worktree with new branch", async () => {
|
|
38
|
+
const taskId = createTaskId("task-001");
|
|
39
|
+
const branch = "task/001-feature";
|
|
40
|
+
|
|
41
|
+
const worktreePath = await worktreeManager.createTaskWorktree(taskId, branch);
|
|
42
|
+
|
|
43
|
+
expect(worktreePath).toBe("/test/worktrees/task-001");
|
|
44
|
+
expect(mockFsOps.mkdir).toHaveBeenCalledWith(worktreeBase, { recursive: true });
|
|
45
|
+
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
46
|
+
["worktree", "add", "-b", branch, "/test/worktrees/task-001", "HEAD"],
|
|
47
|
+
expect.objectContaining({ cwd: repoRoot })
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("throws GitWorkspaceError on failure", async () => {
|
|
52
|
+
const taskId = createTaskId("task-002");
|
|
53
|
+
const branch = "task/002-fail";
|
|
54
|
+
|
|
55
|
+
mockGitOps.gitExec = mock(() => Promise.reject(new Error("git worktree add failed")));
|
|
56
|
+
|
|
57
|
+
await expect(
|
|
58
|
+
worktreeManager.createTaskWorktree(taskId, branch)
|
|
59
|
+
).rejects.toThrow(GitWorkspaceError);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("ensures worktree base directory exists", async () => {
|
|
63
|
+
const taskId = createTaskId("task-003");
|
|
64
|
+
const branch = "task/003-new";
|
|
65
|
+
|
|
66
|
+
await worktreeManager.createTaskWorktree(taskId, branch);
|
|
67
|
+
|
|
68
|
+
expect(mockFsOps.mkdir).toHaveBeenCalledTimes(1);
|
|
69
|
+
expect(mockFsOps.mkdir).toHaveBeenCalledWith(worktreeBase, { recursive: true });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("createParentWorktree", () => {
|
|
74
|
+
test("creates worktree on existing branch", async () => {
|
|
75
|
+
const runId = createRunId("run-001");
|
|
76
|
+
const parentBranch = "main";
|
|
77
|
+
|
|
78
|
+
const worktreePath = await worktreeManager.createParentWorktree(runId, parentBranch);
|
|
79
|
+
|
|
80
|
+
expect(worktreePath).toBe("/test/worktrees/parent-run-001");
|
|
81
|
+
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
82
|
+
["worktree", "add", "/test/worktrees/parent-run-001", parentBranch],
|
|
83
|
+
expect.objectContaining({ cwd: repoRoot })
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("throws GitWorkspaceError on failure", async () => {
|
|
88
|
+
const runId = createRunId("run-002");
|
|
89
|
+
const parentBranch = "main";
|
|
90
|
+
|
|
91
|
+
mockGitOps.gitExec = mock(() => Promise.reject(new Error("branch not found")));
|
|
92
|
+
|
|
93
|
+
await expect(
|
|
94
|
+
worktreeManager.createParentWorktree(runId, parentBranch)
|
|
95
|
+
).rejects.toThrow(GitWorkspaceError);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("removeWorktree", () => {
|
|
100
|
+
test("removes worktree successfully", async () => {
|
|
101
|
+
const worktreePath = "/test/worktrees/task-001";
|
|
102
|
+
|
|
103
|
+
await worktreeManager.removeWorktree(worktreePath);
|
|
104
|
+
|
|
105
|
+
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
106
|
+
["worktree", "remove", worktreePath],
|
|
107
|
+
expect.objectContaining({ cwd: repoRoot })
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("removes worktree with force flag", async () => {
|
|
112
|
+
const worktreePath = "/test/worktrees/task-002";
|
|
113
|
+
|
|
114
|
+
await worktreeManager.removeWorktree(worktreePath, true);
|
|
115
|
+
|
|
116
|
+
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
117
|
+
["worktree", "remove", worktreePath, "--force"],
|
|
118
|
+
expect.objectContaining({ cwd: repoRoot })
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("ignores error if worktree already removed", async () => {
|
|
123
|
+
const worktreePath = "/test/worktrees/task-003";
|
|
124
|
+
|
|
125
|
+
const error = new GitWorkspaceError("git error", {
|
|
126
|
+
stderr: "error: 'task-003' is not a working tree",
|
|
127
|
+
});
|
|
128
|
+
mockGitOps.gitExec = mock(() => Promise.reject(error));
|
|
129
|
+
|
|
130
|
+
await expect(worktreeManager.removeWorktree(worktreePath)).resolves.toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("attempts fallback cleanup on failure", async () => {
|
|
134
|
+
const worktreePath = "/test/worktrees/task-004";
|
|
135
|
+
|
|
136
|
+
let callCount = 0;
|
|
137
|
+
mockGitOps.gitExec = mock(async () => {
|
|
138
|
+
callCount++;
|
|
139
|
+
if (callCount === 1) {
|
|
140
|
+
throw new Error("git worktree remove failed");
|
|
141
|
+
}
|
|
142
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await expect(worktreeManager.removeWorktree(worktreePath)).rejects.toThrow(
|
|
146
|
+
GitWorkspaceError
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(mockFsOps.rm).toHaveBeenCalledWith(worktreePath, { recursive: true, force: true });
|
|
150
|
+
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
151
|
+
["worktree", "prune"],
|
|
152
|
+
expect.objectContaining({ cwd: repoRoot })
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("listWorktrees", () => {
|
|
158
|
+
test("parses worktree list output", async () => {
|
|
159
|
+
const porcelainOutput = `worktree /test/repo
|
|
160
|
+
HEAD abc123
|
|
161
|
+
branch refs/heads/main
|
|
162
|
+
|
|
163
|
+
worktree /test/worktrees/task-001
|
|
164
|
+
HEAD def456
|
|
165
|
+
branch refs/heads/task/001-feature
|
|
166
|
+
|
|
167
|
+
worktree /test/worktrees/task-002
|
|
168
|
+
HEAD 789ghi
|
|
169
|
+
|
|
170
|
+
`;
|
|
171
|
+
|
|
172
|
+
mockGitOps.gitExec = mock(async () => ({
|
|
173
|
+
stdout: porcelainOutput,
|
|
174
|
+
stderr: "",
|
|
175
|
+
exitCode: 0
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
const worktrees = await worktreeManager.listWorktrees();
|
|
179
|
+
|
|
180
|
+
expect(worktrees).toHaveLength(3);
|
|
181
|
+
expect(worktrees[0]).toEqual({
|
|
182
|
+
path: "/test/repo",
|
|
183
|
+
branch: "refs/heads/main",
|
|
184
|
+
head: "abc123",
|
|
185
|
+
});
|
|
186
|
+
expect(worktrees[1]).toEqual({
|
|
187
|
+
path: "/test/worktrees/task-001",
|
|
188
|
+
branch: "refs/heads/task/001-feature",
|
|
189
|
+
head: "def456",
|
|
190
|
+
});
|
|
191
|
+
expect(worktrees[2]).toEqual({
|
|
192
|
+
path: "/test/worktrees/task-002",
|
|
193
|
+
branch: "(detached)",
|
|
194
|
+
head: "789ghi",
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("returns empty array when no worktrees", async () => {
|
|
199
|
+
mockGitOps.gitExec = mock(async () => ({ stdout: "", stderr: "", exitCode: 0 }));
|
|
200
|
+
|
|
201
|
+
const worktrees = await worktreeManager.listWorktrees();
|
|
202
|
+
|
|
203
|
+
expect(worktrees).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("verifyWorktree", () => {
|
|
208
|
+
test("returns true for valid worktree", async () => {
|
|
209
|
+
const worktreePath = "/test/worktrees/task-001";
|
|
210
|
+
|
|
211
|
+
mockGitOps.gitExec = mock(async () => ({
|
|
212
|
+
stdout: "/test/worktrees/task-001/.git",
|
|
213
|
+
stderr: "",
|
|
214
|
+
exitCode: 0,
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
const isValid = await worktreeManager.verifyWorktree(worktreePath);
|
|
218
|
+
|
|
219
|
+
expect(isValid).toBe(true);
|
|
220
|
+
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
221
|
+
["rev-parse", "--git-dir"],
|
|
222
|
+
expect.objectContaining({ cwd: worktreePath })
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("returns false for invalid worktree", async () => {
|
|
227
|
+
const worktreePath = "/test/worktrees/invalid";
|
|
228
|
+
|
|
229
|
+
mockGitOps.gitExec = mock(() => Promise.reject(new Error("not a git repository")));
|
|
230
|
+
|
|
231
|
+
const isValid = await worktreeManager.verifyWorktree(worktreePath);
|
|
232
|
+
|
|
233
|
+
expect(isValid).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("pruneWorktrees", () => {
|
|
238
|
+
test("runs git worktree prune", async () => {
|
|
239
|
+
await worktreeManager.pruneWorktrees();
|
|
240
|
+
|
|
241
|
+
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
242
|
+
["worktree", "prune"],
|
|
243
|
+
expect.objectContaining({ cwd: repoRoot })
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|