@ronkovic/aad 0.3.8 → 0.4.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 +292 -12
- package/package.json +6 -1
- package/src/__tests__/e2e/pipeline-e2e.test.ts +1 -0
- package/src/__tests__/e2e/resume-e2e.test.ts +2 -0
- package/src/__tests__/integration/pipeline.test.ts +1 -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 +2 -0
- package/src/modules/claude-provider/__tests__/provider-registry.test.ts +1 -0
- package/src/modules/cli/__tests__/cleanup.test.ts +72 -0
- package/src/modules/cli/__tests__/resume.test.ts +1 -0
- package/src/modules/cli/__tests__/run.test.ts +1 -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 +3 -2
- package/src/modules/cli/commands/run.ts +57 -7
- 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 +143 -18
- 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 +40 -10
- 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 +1 -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 +1 -0
- package/src/modules/task-execution/__tests__/executor.test.ts +86 -0
- package/src/modules/task-execution/__tests__/tester-verify.test.ts +4 -3
- package/src/modules/task-execution/executor.ts +87 -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 +50 -1
- package/src/shared/__tests__/prerequisites.test.ts +176 -0
- package/src/shared/config.ts +6 -0
- package/src/shared/prerequisites.ts +190 -0
- package/src/shared/types.ts +13 -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
|
@@ -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) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency Installer
|
|
3
|
+
* Installs package dependencies in task worktrees after creation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { WorkspaceInfo } from "../../shared/types";
|
|
7
|
+
import type { Logger } from "pino";
|
|
8
|
+
|
|
9
|
+
export interface InstallResult {
|
|
10
|
+
success: boolean;
|
|
11
|
+
output: string;
|
|
12
|
+
duration: number;
|
|
13
|
+
skipped: boolean; // Go/Rust等で明示的にスキップした場合
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* パッケージマネージャからインストールコマンドを導出
|
|
18
|
+
* Returns null if no install command is available (unknown or no-op).
|
|
19
|
+
*/
|
|
20
|
+
export function buildInstallCommand(workspace: WorkspaceInfo): string[] | null {
|
|
21
|
+
switch (workspace.packageManager) {
|
|
22
|
+
// Node.js 系
|
|
23
|
+
case "bun":
|
|
24
|
+
return ["bun", "install", "--frozen-lockfile"];
|
|
25
|
+
case "npm":
|
|
26
|
+
return ["npm", "ci"];
|
|
27
|
+
case "yarn":
|
|
28
|
+
return ["yarn", "install", "--frozen-lockfile"];
|
|
29
|
+
case "pnpm":
|
|
30
|
+
return ["pnpm", "install", "--frozen-lockfile"];
|
|
31
|
+
|
|
32
|
+
// Python 系
|
|
33
|
+
case "uv":
|
|
34
|
+
return ["uv", "sync"];
|
|
35
|
+
case "poetry":
|
|
36
|
+
return ["poetry", "install", "--no-interaction"];
|
|
37
|
+
case "pipenv":
|
|
38
|
+
return ["pipenv", "install"];
|
|
39
|
+
case "pip":
|
|
40
|
+
return ["pip", "install", "-r", "requirements.txt"];
|
|
41
|
+
|
|
42
|
+
// Go / Rust (テスト実行時に自動ダウンロードするが、先にfetchしておく)
|
|
43
|
+
case "go":
|
|
44
|
+
return ["go", "mod", "download"];
|
|
45
|
+
case "cargo":
|
|
46
|
+
return ["cargo", "fetch"];
|
|
47
|
+
|
|
48
|
+
case "unknown":
|
|
49
|
+
default:
|
|
50
|
+
return null; // インストール不要またはスキップ
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* worktreeの依存パッケージをインストール
|
|
56
|
+
* Non-fatal: 失敗してもエラーをthrowせず、InstallResultで報告。
|
|
57
|
+
* Claude CLIエージェントは自分でinstallコマンドを実行できるため、ここでの失敗は致命的ではない。
|
|
58
|
+
*/
|
|
59
|
+
export async function installDependencies(
|
|
60
|
+
workspace: WorkspaceInfo,
|
|
61
|
+
logger: Logger,
|
|
62
|
+
timeout?: number
|
|
63
|
+
): Promise<InstallResult> {
|
|
64
|
+
const startTime = Date.now();
|
|
65
|
+
const command = buildInstallCommand(workspace);
|
|
66
|
+
|
|
67
|
+
if (!command) {
|
|
68
|
+
logger.debug(
|
|
69
|
+
{ packageManager: workspace.packageManager },
|
|
70
|
+
"No install command for package manager, skipping"
|
|
71
|
+
);
|
|
72
|
+
return { success: true, output: "", duration: 0, skipped: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const [cmd, ...args] = command;
|
|
76
|
+
logger.info({ cmd, args, cwd: workspace.path }, "Installing dependencies");
|
|
77
|
+
|
|
78
|
+
// Bun.spawn で実行(default-spawner.ts と同パターン)
|
|
79
|
+
const proc = Bun.spawn([cmd!, ...args], {
|
|
80
|
+
cwd: workspace.path,
|
|
81
|
+
stdout: "pipe",
|
|
82
|
+
stderr: "pipe",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const installTimeout = timeout ?? 180000; // デフォルト3分
|
|
86
|
+
const timer = setTimeout(() => proc.kill(), installTimeout);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
90
|
+
new Response(proc.stdout).text(),
|
|
91
|
+
new Response(proc.stderr).text(),
|
|
92
|
+
proc.exited,
|
|
93
|
+
]);
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
const duration = Date.now() - startTime;
|
|
96
|
+
|
|
97
|
+
if (exitCode !== 0) {
|
|
98
|
+
logger.warn(
|
|
99
|
+
{ exitCode, stderr: stderr.slice(0, 500) },
|
|
100
|
+
"Dependency install failed"
|
|
101
|
+
);
|
|
102
|
+
return { success: false, output: stderr || stdout, duration, skipped: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
logger.info({ duration }, "Dependencies installed successfully");
|
|
106
|
+
return { success: true, output: stdout, duration, skipped: false };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
const duration = Date.now() - startTime;
|
|
110
|
+
logger.warn({ error }, "Dependency install error");
|
|
111
|
+
return { success: false, output: String(error), duration, skipped: false };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -5,6 +5,7 @@ export interface GitExecOptions {
|
|
|
5
5
|
cwd?: string;
|
|
6
6
|
timeout?: number;
|
|
7
7
|
logger?: Logger;
|
|
8
|
+
allowNonZeroExit?: boolean;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export interface GitExecResult {
|
|
@@ -21,7 +22,7 @@ export async function gitExec(
|
|
|
21
22
|
args: string[],
|
|
22
23
|
options: GitExecOptions = {}
|
|
23
24
|
): Promise<GitExecResult> {
|
|
24
|
-
const { cwd = process.cwd(), timeout = 30000, logger } = options;
|
|
25
|
+
const { cwd = process.cwd(), timeout = 30000, logger, allowNonZeroExit = false } = options;
|
|
25
26
|
|
|
26
27
|
logger?.debug({ args, cwd }, "Executing git command");
|
|
27
28
|
|
|
@@ -45,7 +46,7 @@ export async function gitExec(
|
|
|
45
46
|
|
|
46
47
|
clearTimeout(timeoutId);
|
|
47
48
|
|
|
48
|
-
if (exitCode !== 0) {
|
|
49
|
+
if (exitCode !== 0 && !allowNonZeroExit) {
|
|
49
50
|
logger?.error({ args, exitCode, stderr }, "Git command failed");
|
|
50
51
|
throw new GitWorkspaceError("Git command failed", {
|
|
51
52
|
args,
|
|
@@ -5,7 +5,7 @@ export { WorktreeManager, cleanupOrphanedFromPreviousRuns } from "./worktree-man
|
|
|
5
5
|
export type { WorktreeManagerOptions, WorktreeManagerFsOps } from "./worktree-manager";
|
|
6
6
|
|
|
7
7
|
export { BranchManager } from "./branch-manager";
|
|
8
|
-
export type { BranchManagerOptions } from "./branch-manager";
|
|
8
|
+
export type { BranchManagerOptions, CleanupBranchOptions } from "./branch-manager";
|
|
9
9
|
|
|
10
10
|
export { MergeService } from "./merge-service";
|
|
11
11
|
export type { MergeServiceOptions, MergeResult } from "./merge-service";
|
|
@@ -15,3 +15,12 @@ export type { ClaudeSettings } from "./settings-merge";
|
|
|
15
15
|
|
|
16
16
|
export { harvestMemory } from "./memory-sync";
|
|
17
17
|
export type { MemorySyncOptions, MemorySyncFsOps } from "./memory-sync";
|
|
18
|
+
|
|
19
|
+
export { copyTemplatesToWorktree, resolveTemplateDir } from "./template-copy";
|
|
20
|
+
export type { CopyTemplatesOptions } from "./template-copy";
|
|
21
|
+
|
|
22
|
+
export { PrManager } from "./pr-manager";
|
|
23
|
+
export type { PrManagerOptions } from "./pr-manager";
|
|
24
|
+
|
|
25
|
+
export { installDependencies, buildInstallCommand } from "./dependency-installer";
|
|
26
|
+
export type { InstallResult } from "./dependency-installer";
|
|
@@ -14,6 +14,7 @@ export interface MergeResult {
|
|
|
14
14
|
success: boolean;
|
|
15
15
|
conflicts?: string[];
|
|
16
16
|
message?: string;
|
|
17
|
+
alreadyUpToDate?: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -68,25 +69,54 @@ export class MergeService {
|
|
|
68
69
|
try {
|
|
69
70
|
await lock.acquire();
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
// Check for stale MERGE_HEAD (interrupted previous merge) and abort
|
|
73
|
+
const mergeHeadPath = join(gitDir, "MERGE_HEAD");
|
|
74
|
+
try {
|
|
75
|
+
const mergeHeadExists = await Bun.file(mergeHeadPath).exists();
|
|
76
|
+
if (mergeHeadExists) {
|
|
77
|
+
this.logger?.warn({ taskId, parentWorktree }, "Stale MERGE_HEAD detected, aborting previous merge");
|
|
78
|
+
await gitExec(["merge", "--abort"], { cwd: parentWorktree, logger: this.logger });
|
|
79
|
+
}
|
|
80
|
+
} catch (_cleanupError) {
|
|
81
|
+
// Ignore cleanup errors
|
|
82
|
+
}
|
|
72
83
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
this.logger?.info({ taskId, taskBranch, parentBranch, parentWorktree }, "Merging task to parent");
|
|
85
|
+
|
|
86
|
+
// Before merge: commit any untracked files in parent worktree
|
|
87
|
+
// (shell parity: worker-executor.sh L388-393)
|
|
88
|
+
const status = await gitExec(["status", "--porcelain"], {
|
|
89
|
+
cwd: parentWorktree,
|
|
90
|
+
logger: this.logger,
|
|
91
|
+
});
|
|
92
|
+
if (status.stdout.trim() !== "") {
|
|
93
|
+
this.logger?.info({ parentWorktree }, "Committing untracked files before merge");
|
|
94
|
+
await gitExec(["add", "-A"], { cwd: parentWorktree, logger: this.logger });
|
|
95
|
+
await gitExec(
|
|
96
|
+
["commit", "--no-gpg-sign", "-m", "chore: add template files before merge"],
|
|
97
|
+
{ cwd: parentWorktree, logger: this.logger }
|
|
98
|
+
);
|
|
99
|
+
}
|
|
78
100
|
|
|
79
|
-
//
|
|
101
|
+
// Worktrees share the same git object store, so we can merge the task
|
|
102
|
+
// branch directly without fetching. This is more reliable than
|
|
103
|
+
// `git fetch <repoRoot> <branch>` + `git merge FETCH_HEAD`.
|
|
80
104
|
try {
|
|
81
|
-
await gitExec(
|
|
82
|
-
["merge", "--no-ff", "-m", `Merge task ${taskId as string}: ${taskBranch}`,
|
|
105
|
+
const mergeOutput = await gitExec(
|
|
106
|
+
["merge", "--no-ff", "-m", `Merge task ${taskId as string}: ${taskBranch}`, taskBranch],
|
|
83
107
|
{ cwd: parentWorktree, logger: this.logger }
|
|
84
108
|
);
|
|
85
109
|
|
|
86
|
-
|
|
110
|
+
const alreadyUpToDate = mergeOutput.stdout.includes("Already up to date");
|
|
111
|
+
if (alreadyUpToDate) {
|
|
112
|
+
this.logger?.warn({ taskId, taskBranch }, "No new commits on task branch");
|
|
113
|
+
} else {
|
|
114
|
+
this.logger?.info({ taskId, taskBranch }, "Merge succeeded");
|
|
115
|
+
}
|
|
87
116
|
|
|
88
117
|
return {
|
|
89
118
|
success: true,
|
|
119
|
+
alreadyUpToDate,
|
|
90
120
|
message: `Successfully merged ${taskBranch} into ${parentBranch}`,
|
|
91
121
|
};
|
|
92
122
|
} catch (error) {
|