@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.
Files changed (132) hide show
  1. package/README.md +332 -14
  2. package/package.json +6 -1
  3. package/src/__tests__/e2e/cleanup-e2e.test.ts +186 -0
  4. package/src/__tests__/e2e/dashboard-api-e2e.test.ts +87 -0
  5. package/src/__tests__/e2e/pipeline-e2e.test.ts +10 -68
  6. package/src/__tests__/e2e/resume-e2e.test.ts +9 -11
  7. package/src/__tests__/e2e/retry-e2e.test.ts +285 -0
  8. package/src/__tests__/e2e/status-e2e.test.ts +227 -0
  9. package/src/__tests__/e2e/tdd-pipeline-e2e.test.ts +360 -0
  10. package/src/__tests__/helpers/index.ts +6 -0
  11. package/src/__tests__/helpers/mock-claude-provider.ts +53 -0
  12. package/src/__tests__/helpers/mock-logger.ts +36 -0
  13. package/src/__tests__/helpers/wait-helpers.ts +34 -0
  14. package/src/__tests__/integration/pipeline.test.ts +3 -0
  15. package/src/modules/claude-provider/__tests__/claude-sdk-real-env.test.ts +1 -1
  16. package/src/modules/claude-provider/__tests__/claude-sdk.adapter.test.ts +6 -0
  17. package/src/modules/claude-provider/__tests__/provider-registry.test.ts +3 -0
  18. package/src/modules/cli/__tests__/cleanup.test.ts +73 -0
  19. package/src/modules/cli/__tests__/resume.test.ts +4 -0
  20. package/src/modules/cli/__tests__/run.test.ts +37 -0
  21. package/src/modules/cli/__tests__/status.test.ts +1 -0
  22. package/src/modules/cli/app.ts +2 -0
  23. package/src/modules/cli/commands/__tests__/task-dispatch-handler.test.ts +145 -0
  24. package/src/modules/cli/commands/cleanup.ts +26 -11
  25. package/src/modules/cli/commands/resume.ts +14 -8
  26. package/src/modules/cli/commands/run.ts +70 -8
  27. package/src/modules/cli/commands/task-dispatch-handler.ts +73 -3
  28. package/src/modules/dashboard/__tests__/api-graph.test.ts +332 -0
  29. package/src/modules/dashboard/__tests__/api-timeline.test.ts +461 -0
  30. package/src/modules/dashboard/routes/sse.ts +3 -2
  31. package/src/modules/dashboard/server.ts +1 -0
  32. package/src/modules/dashboard/services/sse-broadcaster.ts +29 -0
  33. package/src/modules/dashboard/ui/dashboard.html +640 -349
  34. package/src/modules/git-workspace/__tests__/branch-manager.test.ts +52 -0
  35. package/src/modules/git-workspace/__tests__/dependency-installer.test.ts +77 -0
  36. package/src/modules/git-workspace/__tests__/git-exec.test.ts +26 -0
  37. package/src/modules/git-workspace/__tests__/merge-service.test.ts +19 -0
  38. package/src/modules/git-workspace/__tests__/pr-manager.test.ts +80 -0
  39. package/src/modules/git-workspace/__tests__/template-copy.test.ts +189 -0
  40. package/src/modules/git-workspace/__tests__/worktree-cleanup.test.ts +29 -2
  41. package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +64 -4
  42. package/src/modules/git-workspace/branch-manager.ts +24 -3
  43. package/src/modules/git-workspace/dependency-installer.ts +113 -0
  44. package/src/modules/git-workspace/git-exec.ts +3 -2
  45. package/src/modules/git-workspace/index.ts +10 -1
  46. package/src/modules/git-workspace/merge-service.ts +36 -2
  47. package/src/modules/git-workspace/pr-manager.ts +278 -0
  48. package/src/modules/git-workspace/template-copy.ts +302 -0
  49. package/src/modules/git-workspace/worktree-manager.ts +37 -11
  50. package/src/modules/planning/__tests__/planning-service.test.ts +3 -0
  51. package/src/modules/planning/__tests__/planning.service.test.ts +149 -0
  52. package/src/modules/planning/__tests__/project-detection.test.ts +7 -1
  53. package/src/modules/planning/planning.service.ts +16 -2
  54. package/src/modules/planning/project-detection.ts +4 -1
  55. package/src/modules/process-manager/__tests__/process-manager.test.ts +3 -0
  56. package/src/modules/process-manager/process-manager.ts +2 -1
  57. package/src/modules/task-execution/__tests__/executor.test.ts +496 -0
  58. package/src/modules/task-execution/__tests__/tester-verify.test.ts +4 -3
  59. package/src/modules/task-execution/executor.ts +163 -4
  60. package/src/modules/task-execution/phases/implementer-green.ts +22 -5
  61. package/src/modules/task-execution/phases/merge.ts +44 -2
  62. package/src/modules/task-execution/phases/tester-red.ts +22 -5
  63. package/src/modules/task-execution/phases/tester-verify.ts +22 -6
  64. package/src/modules/task-queue/dispatcher.ts +96 -3
  65. package/src/shared/__tests__/config.test.ts +30 -0
  66. package/src/shared/__tests__/events.test.ts +42 -16
  67. package/src/shared/__tests__/prerequisites.test.ts +176 -0
  68. package/src/shared/__tests__/shutdown-handler.test.ts +96 -0
  69. package/src/shared/config.ts +10 -0
  70. package/src/shared/events.ts +5 -0
  71. package/src/shared/memory-check.ts +2 -2
  72. package/src/shared/prerequisites.ts +190 -0
  73. package/src/shared/shutdown-handler.ts +12 -5
  74. package/src/shared/types.ts +25 -0
  75. package/templates/CLAUDE.md +122 -0
  76. package/templates/settings.json +117 -0
  77. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/progress.json +0 -10
  78. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/completed/task-getall-2.json +0 -10
  79. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-1.json +0 -13
  80. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-getall-1.json +0 -10
  81. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-status-change.json +0 -10
  82. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-1.json +0 -5
  83. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-2.json +0 -5
  84. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/progress.json +0 -10
  85. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/completed/task-getall-2.json +0 -10
  86. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-1.json +0 -13
  87. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-getall-1.json +0 -10
  88. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-status-change.json +0 -10
  89. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-1.json +0 -5
  90. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-2.json +0 -5
  91. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/progress.json +0 -10
  92. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/completed/task-getall-2.json +0 -10
  93. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-1.json +0 -13
  94. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-getall-1.json +0 -10
  95. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-status-change.json +0 -10
  96. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-1.json +0 -5
  97. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-2.json +0 -5
  98. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/progress.json +0 -10
  99. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/completed/task-getall-2.json +0 -10
  100. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-1.json +0 -13
  101. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-getall-1.json +0 -10
  102. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-status-change.json +0 -10
  103. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-1.json +0 -5
  104. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-2.json +0 -5
  105. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/progress.json +0 -10
  106. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/completed/task-getall-2.json +0 -10
  107. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-1.json +0 -13
  108. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-getall-1.json +0 -10
  109. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-status-change.json +0 -10
  110. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-1.json +0 -5
  111. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-2.json +0 -5
  112. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/progress.json +0 -10
  113. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/completed/task-getall-2.json +0 -10
  114. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-1.json +0 -13
  115. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-getall-1.json +0 -10
  116. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-status-change.json +0 -10
  117. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-1.json +0 -5
  118. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-2.json +0 -5
  119. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/progress.json +0 -10
  120. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/completed/task-getall-2.json +0 -10
  121. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-1.json +0 -13
  122. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-getall-1.json +0 -10
  123. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-status-change.json +0 -10
  124. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-1.json +0 -5
  125. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-2.json +0 -5
  126. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/progress.json +0 -10
  127. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/completed/task-getall-2.json +0 -10
  128. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-1.json +0 -13
  129. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-getall-1.json +0 -10
  130. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-status-change.json +0 -10
  131. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-1.json +0 -5
  132. 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, second succeeds
104
- expect(result.removedWorktrees).toBe(1);
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("attempts fallback cleanup on failure", async () => {
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
- await expect(worktreeManager.removeWorktree(worktreePath)).rejects.toThrow(
161
- GitWorkspaceError
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(runId?: RunId, force = false): Promise<string[]> {
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) {