@ronkovic/aad 0.3.9 → 0.5.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/README.md +332 -14
- package/package.json +6 -1
- package/src/__tests__/e2e/cleanup-e2e.test.ts +186 -0
- package/src/__tests__/e2e/dashboard-api-e2e.test.ts +87 -0
- package/src/__tests__/e2e/pipeline-e2e.test.ts +10 -68
- package/src/__tests__/e2e/resume-e2e.test.ts +9 -11
- package/src/__tests__/e2e/retry-e2e.test.ts +285 -0
- package/src/__tests__/e2e/status-e2e.test.ts +227 -0
- package/src/__tests__/e2e/tdd-pipeline-e2e.test.ts +360 -0
- package/src/__tests__/helpers/index.ts +6 -0
- package/src/__tests__/helpers/mock-claude-provider.ts +53 -0
- package/src/__tests__/helpers/mock-logger.ts +36 -0
- package/src/__tests__/helpers/wait-helpers.ts +34 -0
- package/src/__tests__/integration/pipeline.test.ts +3 -0
- package/src/modules/claude-provider/__tests__/claude-sdk-real-env.test.ts +1 -1
- package/src/modules/claude-provider/__tests__/claude-sdk.adapter.test.ts +6 -0
- package/src/modules/claude-provider/__tests__/provider-registry.test.ts +3 -0
- package/src/modules/cli/__tests__/cleanup.test.ts +73 -0
- package/src/modules/cli/__tests__/resume.test.ts +4 -0
- package/src/modules/cli/__tests__/run.test.ts +37 -0
- package/src/modules/cli/__tests__/status.test.ts +1 -0
- package/src/modules/cli/app.ts +2 -0
- package/src/modules/cli/commands/__tests__/task-dispatch-handler.test.ts +145 -0
- package/src/modules/cli/commands/cleanup.ts +26 -11
- package/src/modules/cli/commands/resume.ts +14 -8
- package/src/modules/cli/commands/run.ts +70 -8
- package/src/modules/cli/commands/task-dispatch-handler.ts +73 -3
- package/src/modules/dashboard/__tests__/api-graph.test.ts +332 -0
- package/src/modules/dashboard/__tests__/api-timeline.test.ts +461 -0
- package/src/modules/dashboard/routes/sse.ts +3 -2
- package/src/modules/dashboard/server.ts +1 -0
- package/src/modules/dashboard/services/sse-broadcaster.ts +29 -0
- package/src/modules/dashboard/ui/dashboard.html +640 -349
- package/src/modules/git-workspace/__tests__/branch-manager.test.ts +52 -0
- package/src/modules/git-workspace/__tests__/dependency-installer.test.ts +77 -0
- package/src/modules/git-workspace/__tests__/git-exec.test.ts +26 -0
- package/src/modules/git-workspace/__tests__/merge-service.test.ts +19 -0
- package/src/modules/git-workspace/__tests__/pr-manager.test.ts +80 -0
- package/src/modules/git-workspace/__tests__/template-copy.test.ts +189 -0
- package/src/modules/git-workspace/__tests__/worktree-cleanup.test.ts +29 -2
- package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +64 -4
- package/src/modules/git-workspace/branch-manager.ts +24 -3
- package/src/modules/git-workspace/dependency-installer.ts +113 -0
- package/src/modules/git-workspace/git-exec.ts +3 -2
- package/src/modules/git-workspace/index.ts +10 -1
- package/src/modules/git-workspace/merge-service.ts +36 -2
- package/src/modules/git-workspace/pr-manager.ts +278 -0
- package/src/modules/git-workspace/template-copy.ts +302 -0
- package/src/modules/git-workspace/worktree-manager.ts +37 -11
- package/src/modules/planning/__tests__/planning-service.test.ts +3 -0
- package/src/modules/planning/__tests__/planning.service.test.ts +149 -0
- package/src/modules/planning/__tests__/project-detection.test.ts +7 -1
- package/src/modules/planning/planning.service.ts +16 -2
- package/src/modules/planning/project-detection.ts +4 -1
- package/src/modules/process-manager/__tests__/process-manager.test.ts +3 -0
- package/src/modules/process-manager/process-manager.ts +2 -1
- package/src/modules/task-execution/__tests__/executor.test.ts +496 -0
- package/src/modules/task-execution/__tests__/tester-verify.test.ts +4 -3
- package/src/modules/task-execution/executor.ts +163 -4
- package/src/modules/task-execution/phases/implementer-green.ts +22 -5
- package/src/modules/task-execution/phases/merge.ts +44 -2
- package/src/modules/task-execution/phases/tester-red.ts +22 -5
- package/src/modules/task-execution/phases/tester-verify.ts +22 -6
- package/src/modules/task-queue/dispatcher.ts +96 -3
- package/src/shared/__tests__/config.test.ts +30 -0
- package/src/shared/__tests__/events.test.ts +42 -16
- package/src/shared/__tests__/prerequisites.test.ts +176 -0
- package/src/shared/__tests__/shutdown-handler.test.ts +96 -0
- package/src/shared/config.ts +10 -0
- package/src/shared/events.ts +5 -0
- package/src/shared/memory-check.ts +2 -2
- package/src/shared/prerequisites.ts +190 -0
- package/src/shared/shutdown-handler.ts +12 -5
- package/src/shared/types.ts +25 -0
- package/templates/CLAUDE.md +122 -0
- package/templates/settings.json +117 -0
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-2.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/progress.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/completed/task-getall-2.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-1.json +0 -13
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-getall-1.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-status-change.json +0 -10
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-1.json +0 -5
- package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-2.json +0 -5
|
@@ -199,6 +199,58 @@ describe("BranchManager", () => {
|
|
|
199
199
|
);
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
test("excludes branches matching excludePatterns (default: /parent)", async () => {
|
|
203
|
+
const branches = ["feat/run-001/task-001", "feat/run-001/parent", "aad-task-x"];
|
|
204
|
+
let callCount = 0;
|
|
205
|
+
mockGitOps.gitExec = mock(async (args: string[]) => {
|
|
206
|
+
callCount++;
|
|
207
|
+
if (args[0] === "branch" && args[1] === "--list") {
|
|
208
|
+
// Only return branches for the first conventional prefix pattern
|
|
209
|
+
if (callCount === 1) {
|
|
210
|
+
return { stdout: branches.slice(0, 2).join("\n"), stderr: "", exitCode: 0 };
|
|
211
|
+
}
|
|
212
|
+
// aad-* pattern
|
|
213
|
+
if (args[2] === "aad-*") {
|
|
214
|
+
return { stdout: branches[2] ?? "", stderr: "", exitCode: 0 };
|
|
215
|
+
}
|
|
216
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
217
|
+
}
|
|
218
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const deleted = await branchManager.cleanupOrphanBranches();
|
|
222
|
+
// feat/run-001/parent はスキップ、他2つは削除
|
|
223
|
+
expect(deleted).not.toContain("feat/run-001/parent");
|
|
224
|
+
expect(deleted.length).toBe(2);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("deletes all branches when excludePatterns is empty array", async () => {
|
|
228
|
+
const branches = ["feat/run-001/parent", "feat/run-001/task-001"];
|
|
229
|
+
mockGitOps.gitExec = mock(async (args: string[]) => {
|
|
230
|
+
if (args[0] === "branch" && args[1] === "--list") {
|
|
231
|
+
if (args[2]?.startsWith("feat/")) {
|
|
232
|
+
return { stdout: branches.join("\n"), stderr: "", exitCode: 0 };
|
|
233
|
+
}
|
|
234
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
235
|
+
}
|
|
236
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const deleted = await branchManager.cleanupOrphanBranches(undefined, {
|
|
240
|
+
force: false,
|
|
241
|
+
excludePatterns: [],
|
|
242
|
+
});
|
|
243
|
+
// parent含めて全削除
|
|
244
|
+
expect(deleted).toContain("feat/run-001/parent");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("supports legacy boolean argument (backward compat)", async () => {
|
|
248
|
+
mockGitOps.gitExec = mock(async () => ({ stdout: "", stderr: "", exitCode: 0 }));
|
|
249
|
+
const deleted = await branchManager.cleanupOrphanBranches(undefined, true);
|
|
250
|
+
expect(deleted).toEqual([]);
|
|
251
|
+
// No error = backward compat OK
|
|
252
|
+
});
|
|
253
|
+
|
|
202
254
|
test("cleans up orphan branches for specific runId", async () => {
|
|
203
255
|
const runId = createRunId("run-001");
|
|
204
256
|
const branches = ["aad-task-run-001-01", "aad-run-run-001"];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { buildInstallCommand } from "../dependency-installer";
|
|
3
|
+
import type { WorkspaceInfo } from "../../../shared/types";
|
|
4
|
+
|
|
5
|
+
describe("buildInstallCommand", () => {
|
|
6
|
+
// Helper to create minimal WorkspaceInfo
|
|
7
|
+
const createWorkspace = (packageManager: WorkspaceInfo["packageManager"]): WorkspaceInfo => ({
|
|
8
|
+
path: "/tmp/test",
|
|
9
|
+
language: "typescript",
|
|
10
|
+
packageManager,
|
|
11
|
+
framework: "none",
|
|
12
|
+
testFramework: "bun-test",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("Node.js ecosystem", () => {
|
|
16
|
+
test("bun → bun install --frozen-lockfile", () => {
|
|
17
|
+
const result = buildInstallCommand(createWorkspace("bun"));
|
|
18
|
+
expect(result).toEqual(["bun", "install", "--frozen-lockfile"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("npm → npm ci", () => {
|
|
22
|
+
const result = buildInstallCommand(createWorkspace("npm"));
|
|
23
|
+
expect(result).toEqual(["npm", "ci"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("yarn → yarn install --frozen-lockfile", () => {
|
|
27
|
+
const result = buildInstallCommand(createWorkspace("yarn"));
|
|
28
|
+
expect(result).toEqual(["yarn", "install", "--frozen-lockfile"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("pnpm → pnpm install --frozen-lockfile", () => {
|
|
32
|
+
const result = buildInstallCommand(createWorkspace("pnpm"));
|
|
33
|
+
expect(result).toEqual(["pnpm", "install", "--frozen-lockfile"]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("Python ecosystem", () => {
|
|
38
|
+
test("uv → uv sync", () => {
|
|
39
|
+
const result = buildInstallCommand(createWorkspace("uv"));
|
|
40
|
+
expect(result).toEqual(["uv", "sync"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("poetry → poetry install --no-interaction", () => {
|
|
44
|
+
const result = buildInstallCommand(createWorkspace("poetry"));
|
|
45
|
+
expect(result).toEqual(["poetry", "install", "--no-interaction"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("pipenv → pipenv install", () => {
|
|
49
|
+
const result = buildInstallCommand(createWorkspace("pipenv"));
|
|
50
|
+
expect(result).toEqual(["pipenv", "install"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("pip → pip install -r requirements.txt", () => {
|
|
54
|
+
const result = buildInstallCommand(createWorkspace("pip"));
|
|
55
|
+
expect(result).toEqual(["pip", "install", "-r", "requirements.txt"]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("Go/Rust ecosystem", () => {
|
|
60
|
+
test("go → go mod download", () => {
|
|
61
|
+
const result = buildInstallCommand(createWorkspace("go"));
|
|
62
|
+
expect(result).toEqual(["go", "mod", "download"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("cargo → cargo fetch", () => {
|
|
66
|
+
const result = buildInstallCommand(createWorkspace("cargo"));
|
|
67
|
+
expect(result).toEqual(["cargo", "fetch"]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("Unknown/No-op package managers", () => {
|
|
72
|
+
test("unknown → null", () => {
|
|
73
|
+
const result = buildInstallCommand(createWorkspace("unknown"));
|
|
74
|
+
expect(result).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -88,4 +88,30 @@ describe("git-exec", () => {
|
|
|
88
88
|
expect(exists).toBe(false);
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
describe("allowNonZeroExit option", () => {
|
|
93
|
+
test("does not throw on non-zero exit when allowNonZeroExit is true", async () => {
|
|
94
|
+
// git diff --cached --quiet は変更なしで exit 0
|
|
95
|
+
const result = await gitExec(["diff", "--cached", "--quiet"], {
|
|
96
|
+
cwd: TEST_DIR,
|
|
97
|
+
allowNonZeroExit: true,
|
|
98
|
+
});
|
|
99
|
+
expect(result.exitCode).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("returns non-zero exitCode without throwing", async () => {
|
|
103
|
+
// 存在しないref → exit 128
|
|
104
|
+
const result = await gitExec(["rev-parse", "--verify", "nonexistent-ref"], {
|
|
105
|
+
cwd: TEST_DIR,
|
|
106
|
+
allowNonZeroExit: true,
|
|
107
|
+
});
|
|
108
|
+
expect(result.exitCode).not.toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("still throws when allowNonZeroExit is false (default)", async () => {
|
|
112
|
+
await expect(
|
|
113
|
+
gitExec(["rev-parse", "--verify", "nonexistent-ref"], { cwd: TEST_DIR })
|
|
114
|
+
).rejects.toThrow("Git command failed");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
91
117
|
});
|
|
@@ -111,6 +111,25 @@ describe("MergeService", () => {
|
|
|
111
111
|
expect(result.message).toContain("Successfully merged");
|
|
112
112
|
}, 60_000);
|
|
113
113
|
|
|
114
|
+
test("sets alreadyUpToDate when task branch has no new commits", async () => {
|
|
115
|
+
const mergeService = await resetWorktree();
|
|
116
|
+
|
|
117
|
+
// taskBranch = 現在のHEADと同じ → Already up to date
|
|
118
|
+
const taskBranch = "task-no-changes";
|
|
119
|
+
await gitExec(["checkout", DEFAULT_BRANCH], { cwd: PARENT_WORKTREE });
|
|
120
|
+
await gitExec(["branch", taskBranch], { cwd: PARENT_WORKTREE });
|
|
121
|
+
|
|
122
|
+
const result = await mergeService.mergeToParent(
|
|
123
|
+
createTaskId("task-noop"),
|
|
124
|
+
taskBranch,
|
|
125
|
+
DEFAULT_BRANCH,
|
|
126
|
+
PARENT_WORKTREE
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(result.success).toBe(true);
|
|
130
|
+
expect(result.alreadyUpToDate).toBe(true);
|
|
131
|
+
}, 60_000);
|
|
132
|
+
|
|
114
133
|
test("detects merge conflicts and aborts merge", async () => {
|
|
115
134
|
const mergeService = await resetWorktree();
|
|
116
135
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { PrManager } from "../pr-manager";
|
|
3
|
+
import type { PrManagerOptions, DraftPrOptions } from "../pr-manager";
|
|
4
|
+
import { GitWorkspaceError } from "@aad/shared/errors";
|
|
5
|
+
|
|
6
|
+
describe("PrManager", () => {
|
|
7
|
+
let options: PrManagerOptions;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
options = {
|
|
11
|
+
repoRoot: process.cwd(),
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("should create instance", () => {
|
|
16
|
+
const manager = new PrManager(options);
|
|
17
|
+
expect(manager).toBeDefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("should check gh installation", async () => {
|
|
21
|
+
const manager = new PrManager(options);
|
|
22
|
+
const installed = await manager.checkGhInstalled();
|
|
23
|
+
// May be true or false depending on environment
|
|
24
|
+
expect(typeof installed).toBe("boolean");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("should throw error if gh not installed when creating draft PR", async () => {
|
|
28
|
+
const manager = new PrManager(options);
|
|
29
|
+
|
|
30
|
+
// Mock checkGhInstalled to return false
|
|
31
|
+
manager.checkGhInstalled = async () => false;
|
|
32
|
+
|
|
33
|
+
const draftOptions: DraftPrOptions = {
|
|
34
|
+
title: "Test PR",
|
|
35
|
+
body: "Test body",
|
|
36
|
+
baseBranch: "main",
|
|
37
|
+
headBranch: "feature/test",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
await expect(manager.createDraftPr(draftOptions)).rejects.toThrow(GitWorkspaceError);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("should throw error if gh not installed when marking PR ready", async () => {
|
|
44
|
+
const manager = new PrManager(options);
|
|
45
|
+
|
|
46
|
+
// Mock checkGhInstalled to return false
|
|
47
|
+
manager.checkGhInstalled = async () => false;
|
|
48
|
+
|
|
49
|
+
await expect(manager.markPrReady(123)).rejects.toThrow(GitWorkspaceError);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("should return null for getPrInfo if gh not installed", async () => {
|
|
53
|
+
const manager = new PrManager(options);
|
|
54
|
+
|
|
55
|
+
// Mock checkGhInstalled to return false
|
|
56
|
+
manager.checkGhInstalled = async () => false;
|
|
57
|
+
|
|
58
|
+
const result = await manager.getPrInfo(123);
|
|
59
|
+
expect(result).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("should return null for findPrByBranch if gh not installed", async () => {
|
|
63
|
+
const manager = new PrManager(options);
|
|
64
|
+
|
|
65
|
+
// Mock checkGhInstalled to return false
|
|
66
|
+
manager.checkGhInstalled = async () => false;
|
|
67
|
+
|
|
68
|
+
const result = await manager.findPrByBranch("feature/test");
|
|
69
|
+
expect(result).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("should throw error if gh not installed when closing PR", async () => {
|
|
73
|
+
const manager = new PrManager(options);
|
|
74
|
+
|
|
75
|
+
// Mock checkGhInstalled to return false
|
|
76
|
+
manager.checkGhInstalled = async () => false;
|
|
77
|
+
|
|
78
|
+
await expect(manager.closePr(123)).rejects.toThrow(GitWorkspaceError);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { copyTemplatesToWorktree, resolveTemplateDir } from "../template-copy";
|
|
5
|
+
import { createLogger } from "../../logging";
|
|
6
|
+
|
|
7
|
+
const testDir = join(import.meta.dir, ".tmp-template-copy-test");
|
|
8
|
+
const projectRoot = join(testDir, "project");
|
|
9
|
+
const templateDir = join(projectRoot, ".aad", "templates");
|
|
10
|
+
const targetDir = join(testDir, "target");
|
|
11
|
+
|
|
12
|
+
const logger = createLogger({ service: "test", debug: false });
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
// Clean up test directory
|
|
16
|
+
await rm(testDir, { recursive: true, force: true });
|
|
17
|
+
await mkdir(testDir, { recursive: true });
|
|
18
|
+
await mkdir(projectRoot, { recursive: true });
|
|
19
|
+
await mkdir(templateDir, { recursive: true });
|
|
20
|
+
await mkdir(targetDir, { recursive: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("copyTemplatesToWorktree", () => {
|
|
24
|
+
test("copies CLAUDE.md with project append", async () => {
|
|
25
|
+
// Setup template and project CLAUDE.md
|
|
26
|
+
await writeFile(join(templateDir, "CLAUDE.md"), "# Template Context\n\nTemplate content.");
|
|
27
|
+
await writeFile(join(projectRoot, "CLAUDE.md"), "# Project Context\n\nProject content.");
|
|
28
|
+
|
|
29
|
+
await copyTemplatesToWorktree({
|
|
30
|
+
targetDir,
|
|
31
|
+
projectRoot,
|
|
32
|
+
templateDir,
|
|
33
|
+
logger,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const targetCLAUDE = await Bun.file(join(targetDir, "CLAUDE.md")).text();
|
|
37
|
+
expect(targetCLAUDE).toContain("Template content");
|
|
38
|
+
expect(targetCLAUDE).toContain("# プロジェクト固有コンテキスト");
|
|
39
|
+
expect(targetCLAUDE).toContain("Project content");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("handles missing template CLAUDE.md", async () => {
|
|
43
|
+
// Only project CLAUDE.md
|
|
44
|
+
await writeFile(join(projectRoot, "CLAUDE.md"), "Project only.");
|
|
45
|
+
|
|
46
|
+
await copyTemplatesToWorktree({
|
|
47
|
+
targetDir,
|
|
48
|
+
projectRoot,
|
|
49
|
+
templateDir,
|
|
50
|
+
logger,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const targetCLAUDE = await Bun.file(join(targetDir, "CLAUDE.md")).text();
|
|
54
|
+
expect(targetCLAUDE).toContain("# プロジェクト固有コンテキスト");
|
|
55
|
+
expect(targetCLAUDE).toContain("Project only");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("copies .claude/ with overlay merge", async () => {
|
|
59
|
+
// Setup project .claude/rules
|
|
60
|
+
await mkdir(join(projectRoot, ".claude", "rules"), { recursive: true });
|
|
61
|
+
await writeFile(join(projectRoot, ".claude", "rules", "project.md"), "Project rule");
|
|
62
|
+
|
|
63
|
+
// Setup template .claude/rules (should overlay)
|
|
64
|
+
await mkdir(join(templateDir, ".claude", "rules"), { recursive: true });
|
|
65
|
+
await writeFile(join(templateDir, ".claude", "rules", "template.md"), "Template rule");
|
|
66
|
+
|
|
67
|
+
await copyTemplatesToWorktree({
|
|
68
|
+
targetDir,
|
|
69
|
+
projectRoot,
|
|
70
|
+
templateDir,
|
|
71
|
+
logger,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Both should exist
|
|
75
|
+
const projectRule = await Bun.file(join(targetDir, ".claude", "rules", "project.md")).text();
|
|
76
|
+
const templateRule = await Bun.file(join(targetDir, ".claude", "rules", "template.md")).text();
|
|
77
|
+
|
|
78
|
+
expect(projectRule).toBe("Project rule");
|
|
79
|
+
expect(templateRule).toBe("Template rule");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("merges settings.json", async () => {
|
|
83
|
+
// Setup project settings
|
|
84
|
+
await mkdir(join(projectRoot, ".claude"), { recursive: true });
|
|
85
|
+
await writeFile(
|
|
86
|
+
join(projectRoot, ".claude", "settings.json"),
|
|
87
|
+
JSON.stringify({ permissions: { allow: ["read"] } })
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Setup template settings (now at root level, not in .claude/)
|
|
91
|
+
await writeFile(
|
|
92
|
+
join(templateDir, "settings.json"),
|
|
93
|
+
JSON.stringify({ permissions: { deny: ["write"] } })
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await copyTemplatesToWorktree({
|
|
97
|
+
targetDir,
|
|
98
|
+
projectRoot,
|
|
99
|
+
templateDir,
|
|
100
|
+
logger,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const targetSettings = JSON.parse(
|
|
104
|
+
await Bun.file(join(targetDir, ".claude", "settings.json")).text()
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(targetSettings.permissions).toBeDefined();
|
|
108
|
+
expect(targetSettings.permissions.allow).toEqual(["read"]);
|
|
109
|
+
expect(targetSettings.permissions.deny).toEqual(["write"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("copies .gitignore from template", async () => {
|
|
113
|
+
await writeFile(join(templateDir, ".gitignore"), "node_modules/\n*.log");
|
|
114
|
+
|
|
115
|
+
await copyTemplatesToWorktree({
|
|
116
|
+
targetDir,
|
|
117
|
+
projectRoot,
|
|
118
|
+
templateDir,
|
|
119
|
+
logger,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const gitignore = await Bun.file(join(targetDir, ".gitignore")).text();
|
|
123
|
+
expect(gitignore).toContain("node_modules/");
|
|
124
|
+
expect(gitignore).toContain("*.log");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("handles missing template directory gracefully", async () => {
|
|
128
|
+
const nonExistentTemplate = join(testDir, "nonexistent");
|
|
129
|
+
|
|
130
|
+
// Should not throw
|
|
131
|
+
await expect(
|
|
132
|
+
copyTemplatesToWorktree({
|
|
133
|
+
targetDir,
|
|
134
|
+
projectRoot,
|
|
135
|
+
templateDir: nonExistentTemplate,
|
|
136
|
+
logger,
|
|
137
|
+
})
|
|
138
|
+
).resolves.toBeUndefined();
|
|
139
|
+
|
|
140
|
+
// Target directory should be created even if templates don't exist
|
|
141
|
+
const { stat } = await import("node:fs/promises");
|
|
142
|
+
const stats = await stat(targetDir);
|
|
143
|
+
expect(stats.isDirectory()).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("preserves existing agent-memory", async () => {
|
|
147
|
+
// Setup project agent-memory
|
|
148
|
+
await mkdir(join(projectRoot, ".claude", "agent-memory", "explorer"), { recursive: true });
|
|
149
|
+
await writeFile(
|
|
150
|
+
join(projectRoot, ".claude", "agent-memory", "explorer", "MEMORY.md"),
|
|
151
|
+
"Existing memory"
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Setup template agent-memory (should NOT overwrite)
|
|
155
|
+
await mkdir(join(templateDir, ".claude", "agent-memory", "explorer"), { recursive: true });
|
|
156
|
+
await writeFile(
|
|
157
|
+
join(templateDir, ".claude", "agent-memory", "explorer", "MEMORY.md"),
|
|
158
|
+
"Template memory"
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await copyTemplatesToWorktree({
|
|
162
|
+
targetDir,
|
|
163
|
+
projectRoot,
|
|
164
|
+
templateDir,
|
|
165
|
+
logger,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const memory = await Bun.file(
|
|
169
|
+
join(targetDir, ".claude", "agent-memory", "explorer", "MEMORY.md")
|
|
170
|
+
).text();
|
|
171
|
+
expect(memory).toBe("Existing memory");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("resolveTemplateDir", () => {
|
|
176
|
+
test("returns local template dir when it exists", () => {
|
|
177
|
+
// This test assumes the actual project has templates/
|
|
178
|
+
const result = resolveTemplateDir(process.cwd());
|
|
179
|
+
expect(result).toBeDefined();
|
|
180
|
+
expect(result).toContain("templates");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("falls back to package templates when local not found", () => {
|
|
184
|
+
const result = resolveTemplateDir("/nonexistent");
|
|
185
|
+
// Should resolve to {packageRoot}/templates, not one level above
|
|
186
|
+
expect(result).toContain("templates");
|
|
187
|
+
expect(result).not.toContain("sandbox/templates");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -100,8 +100,35 @@ describe("cleanupOrphanedFromPreviousRuns", () => {
|
|
|
100
100
|
|
|
101
101
|
const result = await cleanupOrphanedFromPreviousRuns(worktreeManager, logger);
|
|
102
102
|
|
|
103
|
-
// First worktree fails
|
|
104
|
-
|
|
103
|
+
// First worktree fails git remove but succeeds via fallback (force=true)
|
|
104
|
+
// Second worktree succeeds normally
|
|
105
|
+
expect(result.removedWorktrees).toBe(2);
|
|
105
106
|
expect(result.deletedBranches).toBe(2);
|
|
106
107
|
});
|
|
108
|
+
|
|
109
|
+
test("preserves parent worktrees (path matches /parent-xxx)", async () => {
|
|
110
|
+
mockGitOps.gitExec = mock(async (args: string[]) => {
|
|
111
|
+
if (args[0] === "worktree" && args[1] === "list") {
|
|
112
|
+
return {
|
|
113
|
+
stdout: [
|
|
114
|
+
"worktree /repo", "HEAD abc", "branch refs/heads/main", "",
|
|
115
|
+
"worktree /repo/.aad/worktrees/task-001", "HEAD def", "branch refs/heads/aad/r/task-001", "",
|
|
116
|
+
"worktree /repo/.aad/worktrees/parent-run-001", "HEAD ghi", "branch refs/heads/feat/run-001/parent", "",
|
|
117
|
+
].join("\n"),
|
|
118
|
+
stderr: "", exitCode: 0,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (args[0] === "branch" && args[1] === "--list") {
|
|
122
|
+
if (args[2] === "aad/*") return { stdout: " aad/r/task-001\n", stderr: "", exitCode: 0 };
|
|
123
|
+
if (args[2] === "feat/*") return { stdout: " feat/run-001/parent\n", stderr: "", exitCode: 0 };
|
|
124
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
125
|
+
}
|
|
126
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const result = await cleanupOrphanedFromPreviousRuns(worktreeManager, logger);
|
|
130
|
+
|
|
131
|
+
expect(result.removedWorktrees).toBe(1); // task-001 のみ
|
|
132
|
+
expect(result.preservedBranches).toBe(1); // feat/run-001/parent
|
|
133
|
+
});
|
|
107
134
|
});
|
|
@@ -145,9 +145,24 @@ describe("WorktreeManager", () => {
|
|
|
145
145
|
await expect(worktreeManager.removeWorktree(worktreePath)).resolves.toBeUndefined();
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
-
test("
|
|
148
|
+
test("does not attempt fallback cleanup on failure without force", async () => {
|
|
149
149
|
const worktreePath = "/test/worktrees/task-004";
|
|
150
150
|
|
|
151
|
+
mockGitOps.gitExec = mock(async () => {
|
|
152
|
+
throw new Error("git worktree remove failed");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await expect(worktreeManager.removeWorktree(worktreePath, false)).rejects.toThrow(
|
|
156
|
+
GitWorkspaceError
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// Should NOT call fallback rm when force=false
|
|
160
|
+
expect(mockFsOps.rm).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("attempts fallback cleanup on failure with force", async () => {
|
|
164
|
+
const worktreePath = "/test/worktrees/task-005";
|
|
165
|
+
|
|
151
166
|
let callCount = 0;
|
|
152
167
|
mockGitOps.gitExec = mock(async () => {
|
|
153
168
|
callCount++;
|
|
@@ -157,9 +172,8 @@ describe("WorktreeManager", () => {
|
|
|
157
172
|
return { stdout: "", stderr: "", exitCode: 0 };
|
|
158
173
|
});
|
|
159
174
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
);
|
|
175
|
+
// Should succeed via fallback
|
|
176
|
+
await expect(worktreeManager.removeWorktree(worktreePath, true)).resolves.toBeUndefined();
|
|
163
177
|
|
|
164
178
|
expect(mockFsOps.rm).toHaveBeenCalledWith(worktreePath, { recursive: true, force: true });
|
|
165
179
|
expect(mockGitOps.gitExec).toHaveBeenCalledWith(
|
|
@@ -167,6 +181,22 @@ describe("WorktreeManager", () => {
|
|
|
167
181
|
expect.objectContaining({ cwd: repoRoot })
|
|
168
182
|
);
|
|
169
183
|
});
|
|
184
|
+
|
|
185
|
+
test("throws if fallback also fails with force", async () => {
|
|
186
|
+
const worktreePath = "/test/worktrees/task-006";
|
|
187
|
+
|
|
188
|
+
mockGitOps.gitExec = mock(async () => {
|
|
189
|
+
throw new Error("git worktree remove failed");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
mockFsOps.rm = mock(async () => {
|
|
193
|
+
throw new Error("rm failed");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await expect(worktreeManager.removeWorktree(worktreePath, true)).rejects.toThrow(
|
|
197
|
+
GitWorkspaceError
|
|
198
|
+
);
|
|
199
|
+
});
|
|
170
200
|
});
|
|
171
201
|
|
|
172
202
|
describe("listWorktrees", () => {
|
|
@@ -217,6 +247,36 @@ HEAD 789ghi
|
|
|
217
247
|
|
|
218
248
|
expect(worktrees).toEqual([]);
|
|
219
249
|
});
|
|
250
|
+
|
|
251
|
+
test("parses worktree list without trailing newline", async () => {
|
|
252
|
+
const porcelainOutput = `worktree /test/repo
|
|
253
|
+
HEAD abc123
|
|
254
|
+
branch refs/heads/main
|
|
255
|
+
|
|
256
|
+
worktree /test/worktrees/task-001
|
|
257
|
+
HEAD def456
|
|
258
|
+
branch refs/heads/task/001-feature`;
|
|
259
|
+
|
|
260
|
+
mockGitOps.gitExec = mock(async () => ({
|
|
261
|
+
stdout: porcelainOutput,
|
|
262
|
+
stderr: "",
|
|
263
|
+
exitCode: 0
|
|
264
|
+
}));
|
|
265
|
+
|
|
266
|
+
const worktrees = await worktreeManager.listWorktrees();
|
|
267
|
+
|
|
268
|
+
expect(worktrees).toHaveLength(2);
|
|
269
|
+
expect(worktrees[0]).toEqual({
|
|
270
|
+
path: "/test/repo",
|
|
271
|
+
branch: "refs/heads/main",
|
|
272
|
+
head: "abc123",
|
|
273
|
+
});
|
|
274
|
+
expect(worktrees[1]).toEqual({
|
|
275
|
+
path: "/test/worktrees/task-001",
|
|
276
|
+
branch: "refs/heads/task/001-feature",
|
|
277
|
+
head: "def456",
|
|
278
|
+
});
|
|
279
|
+
});
|
|
220
280
|
});
|
|
221
281
|
|
|
222
282
|
describe("verifyWorktree", () => {
|
|
@@ -9,6 +9,11 @@ export interface BranchManagerOptions {
|
|
|
9
9
|
gitOps?: GitOps;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export interface CleanupBranchOptions {
|
|
13
|
+
force?: boolean;
|
|
14
|
+
excludePatterns?: RegExp[];
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
/**
|
|
13
18
|
* Manage git branches for parallel task execution
|
|
14
19
|
*/
|
|
@@ -136,7 +141,15 @@ export class BranchManager {
|
|
|
136
141
|
/**
|
|
137
142
|
* Cleanup orphaned AAD branches (branches without worktrees)
|
|
138
143
|
*/
|
|
139
|
-
async cleanupOrphanBranches(
|
|
144
|
+
async cleanupOrphanBranches(
|
|
145
|
+
runId?: RunId,
|
|
146
|
+
forceOrOptions?: boolean | CleanupBranchOptions,
|
|
147
|
+
): Promise<string[]> {
|
|
148
|
+
// Support both legacy boolean signature and new options object
|
|
149
|
+
const options: CleanupBranchOptions = typeof forceOrOptions === "boolean"
|
|
150
|
+
? { force: forceOrOptions, excludePatterns: [/\/parent$/] }
|
|
151
|
+
: { force: forceOrOptions?.force ?? false, excludePatterns: forceOrOptions?.excludePatterns ?? [/\/parent$/] };
|
|
152
|
+
|
|
140
153
|
// Support aad/*, conventional commit prefixes (feat/*, fix/*, etc.), and legacy aad-* patterns
|
|
141
154
|
const conventionalPrefixes = ["aad", "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore"];
|
|
142
155
|
const patterns = runId
|
|
@@ -149,7 +162,7 @@ export class BranchManager {
|
|
|
149
162
|
"aad-*",
|
|
150
163
|
];
|
|
151
164
|
|
|
152
|
-
this.logger?.info({ patterns, force }, "Cleaning up orphan branches");
|
|
165
|
+
this.logger?.info({ patterns, force: options.force, excludePatterns: options.excludePatterns }, "Cleaning up orphan branches");
|
|
153
166
|
|
|
154
167
|
const deleted: string[] = [];
|
|
155
168
|
|
|
@@ -157,9 +170,17 @@ export class BranchManager {
|
|
|
157
170
|
const branches = await this.listBranches(pattern);
|
|
158
171
|
|
|
159
172
|
for (const branch of branches) {
|
|
173
|
+
// Check if branch matches any exclude pattern
|
|
174
|
+
const excludePatterns = options.excludePatterns ?? [/\/parent$/];
|
|
175
|
+
const isExcluded = excludePatterns.some((pattern) => pattern.test(branch));
|
|
176
|
+
if (isExcluded) {
|
|
177
|
+
this.logger?.info({ branch }, "Preserving parent branch (contains commit history)");
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
160
181
|
try {
|
|
161
182
|
// Delete branch (force if requested)
|
|
162
|
-
await this.deleteBranch(branch, force);
|
|
183
|
+
await this.deleteBranch(branch, options.force);
|
|
163
184
|
deleted.push(branch);
|
|
164
185
|
this.logger?.info({ branch }, "Deleted orphan branch");
|
|
165
186
|
} catch (error) {
|