@ronkovic/aad 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bin/aad.js +2 -0
  4. package/package.json +78 -0
  5. package/src/__tests__/e2e/pipeline-e2e.test.ts +279 -0
  6. package/src/__tests__/e2e/resume-e2e.test.ts +200 -0
  7. package/src/__tests__/integration/cli-smoke.test.ts +175 -0
  8. package/src/__tests__/integration/pipeline.test.ts +346 -0
  9. package/src/bun-imports.d.ts +14 -0
  10. package/src/main.ts +52 -0
  11. package/src/modules/claude-provider/__tests__/claude-cli.adapter.test.ts +277 -0
  12. package/src/modules/claude-provider/__tests__/claude-sdk-real-env.test.ts +127 -0
  13. package/src/modules/claude-provider/__tests__/claude-sdk.adapter.test.ts +347 -0
  14. package/src/modules/claude-provider/__tests__/effort-strategy.test.ts +212 -0
  15. package/src/modules/claude-provider/__tests__/provider-registry.test.ts +251 -0
  16. package/src/modules/claude-provider/__tests__/retry.test.ts +201 -0
  17. package/src/modules/claude-provider/claude-cli.adapter.ts +156 -0
  18. package/src/modules/claude-provider/claude-provider.port.ts +35 -0
  19. package/src/modules/claude-provider/claude-sdk.adapter.ts +217 -0
  20. package/src/modules/claude-provider/effort-strategy.ts +94 -0
  21. package/src/modules/claude-provider/index.ts +32 -0
  22. package/src/modules/claude-provider/provider-registry.ts +92 -0
  23. package/src/modules/claude-provider/retry.ts +81 -0
  24. package/src/modules/cli/__tests__/app.test.ts +160 -0
  25. package/src/modules/cli/__tests__/cleanup.test.ts +111 -0
  26. package/src/modules/cli/__tests__/commands.test.ts +186 -0
  27. package/src/modules/cli/__tests__/output.test.ts +329 -0
  28. package/src/modules/cli/__tests__/resume.test.ts +324 -0
  29. package/src/modules/cli/__tests__/run.test.ts +168 -0
  30. package/src/modules/cli/__tests__/shutdown.test.ts +168 -0
  31. package/src/modules/cli/__tests__/status.test.ts +144 -0
  32. package/src/modules/cli/app.ts +241 -0
  33. package/src/modules/cli/commands/cleanup.ts +120 -0
  34. package/src/modules/cli/commands/resume.ts +156 -0
  35. package/src/modules/cli/commands/run.ts +322 -0
  36. package/src/modules/cli/commands/status.ts +101 -0
  37. package/src/modules/cli/index.ts +29 -0
  38. package/src/modules/cli/output.ts +256 -0
  39. package/src/modules/cli/shutdown.ts +122 -0
  40. package/src/modules/dashboard/__tests__/api-routes.test.ts +204 -0
  41. package/src/modules/dashboard/__tests__/file-watcher.test.ts +34 -0
  42. package/src/modules/dashboard/__tests__/server.test.ts +120 -0
  43. package/src/modules/dashboard/__tests__/sse-broadcaster.test.ts +163 -0
  44. package/src/modules/dashboard/__tests__/sse-routes.test.ts +58 -0
  45. package/src/modules/dashboard/__tests__/state-aggregator.test.ts +330 -0
  46. package/src/modules/dashboard/index.ts +8 -0
  47. package/src/modules/dashboard/routes/api.ts +84 -0
  48. package/src/modules/dashboard/routes/sse.ts +37 -0
  49. package/src/modules/dashboard/server.ts +111 -0
  50. package/src/modules/dashboard/services/file-watcher.ts +36 -0
  51. package/src/modules/dashboard/services/sse-broadcaster.ts +81 -0
  52. package/src/modules/dashboard/services/state-aggregator.ts +132 -0
  53. package/src/modules/dashboard/ui/dashboard.html +405 -0
  54. package/src/modules/git-workspace/__tests__/branch-manager.test.ts +335 -0
  55. package/src/modules/git-workspace/__tests__/git-exec.test.ts +91 -0
  56. package/src/modules/git-workspace/__tests__/memory-sync.test.ts +273 -0
  57. package/src/modules/git-workspace/__tests__/merge-service.test.ts +286 -0
  58. package/src/modules/git-workspace/__tests__/settings-merge.test.ts +163 -0
  59. package/src/modules/git-workspace/__tests__/worktree-manager.test.ts +247 -0
  60. package/src/modules/git-workspace/branch-manager.ts +191 -0
  61. package/src/modules/git-workspace/git-exec.ts +124 -0
  62. package/src/modules/git-workspace/index.ts +17 -0
  63. package/src/modules/git-workspace/memory-sync.ts +89 -0
  64. package/src/modules/git-workspace/merge-service.ts +156 -0
  65. package/src/modules/git-workspace/settings-merge.ts +95 -0
  66. package/src/modules/git-workspace/worktree-manager.ts +199 -0
  67. package/src/modules/logging/__tests__/log-store.test.ts +242 -0
  68. package/src/modules/logging/__tests__/logger.test.ts +81 -0
  69. package/src/modules/logging/__tests__/sse-transport.test.ts +93 -0
  70. package/src/modules/logging/index.ts +7 -0
  71. package/src/modules/logging/log-store.ts +80 -0
  72. package/src/modules/logging/logger.ts +55 -0
  73. package/src/modules/logging/transports/sse-transport.ts +28 -0
  74. package/src/modules/multi-repo/__tests__/multi-repo-planner.test.ts +93 -0
  75. package/src/modules/multi-repo/__tests__/repo-context.test.ts +79 -0
  76. package/src/modules/multi-repo/index.ts +12 -0
  77. package/src/modules/multi-repo/multi-repo-planner.ts +112 -0
  78. package/src/modules/multi-repo/repo-context.ts +71 -0
  79. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/progress.json +10 -0
  80. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/completed/task-getall-2.json +10 -0
  81. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-1.json +13 -0
  82. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-getall-1.json +10 -0
  83. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/queue/pending/task-status-change.json +10 -0
  84. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-1.json +5 -0
  85. package/src/modules/persistence/__tests__/.tmp-stores-test-81991/workers/worker-2.json +5 -0
  86. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/progress.json +10 -0
  87. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/completed/task-getall-2.json +10 -0
  88. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-1.json +13 -0
  89. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-getall-1.json +10 -0
  90. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/queue/pending/task-status-change.json +10 -0
  91. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-1.json +5 -0
  92. package/src/modules/persistence/__tests__/.tmp-stores-test-82469/workers/worker-2.json +5 -0
  93. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/progress.json +10 -0
  94. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/completed/task-getall-2.json +10 -0
  95. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-1.json +13 -0
  96. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-getall-1.json +10 -0
  97. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/queue/pending/task-status-change.json +10 -0
  98. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-1.json +5 -0
  99. package/src/modules/persistence/__tests__/.tmp-stores-test-85301/workers/worker-2.json +5 -0
  100. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/progress.json +10 -0
  101. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/completed/task-getall-2.json +10 -0
  102. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-1.json +13 -0
  103. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-getall-1.json +10 -0
  104. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/queue/pending/task-status-change.json +10 -0
  105. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-1.json +5 -0
  106. package/src/modules/persistence/__tests__/.tmp-stores-test-85759/workers/worker-2.json +5 -0
  107. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/progress.json +10 -0
  108. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/completed/task-getall-2.json +10 -0
  109. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-1.json +13 -0
  110. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-getall-1.json +10 -0
  111. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/queue/pending/task-status-change.json +10 -0
  112. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-1.json +5 -0
  113. package/src/modules/persistence/__tests__/.tmp-stores-test-86184/workers/worker-2.json +5 -0
  114. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/progress.json +10 -0
  115. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/completed/task-getall-2.json +10 -0
  116. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-1.json +13 -0
  117. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-getall-1.json +10 -0
  118. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/queue/pending/task-status-change.json +10 -0
  119. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-1.json +5 -0
  120. package/src/modules/persistence/__tests__/.tmp-stores-test-88026/workers/worker-2.json +5 -0
  121. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/progress.json +10 -0
  122. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/completed/task-getall-2.json +10 -0
  123. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-1.json +13 -0
  124. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-getall-1.json +10 -0
  125. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/queue/pending/task-status-change.json +10 -0
  126. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-1.json +5 -0
  127. package/src/modules/persistence/__tests__/.tmp-stores-test-89475/workers/worker-2.json +5 -0
  128. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/progress.json +10 -0
  129. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/completed/task-getall-2.json +10 -0
  130. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-1.json +13 -0
  131. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-getall-1.json +10 -0
  132. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/queue/pending/task-status-change.json +10 -0
  133. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-1.json +5 -0
  134. package/src/modules/persistence/__tests__/.tmp-stores-test-89924/workers/worker-2.json +5 -0
  135. package/src/modules/persistence/__tests__/file-lock.test.ts +141 -0
  136. package/src/modules/persistence/__tests__/index.test.ts +38 -0
  137. package/src/modules/persistence/__tests__/stores.test.ts +594 -0
  138. package/src/modules/persistence/file-lock.ts +158 -0
  139. package/src/modules/persistence/fs-run-store.ts +73 -0
  140. package/src/modules/persistence/fs-task-store.ts +152 -0
  141. package/src/modules/persistence/fs-worker-store.ts +116 -0
  142. package/src/modules/persistence/in-memory-stores.ts +98 -0
  143. package/src/modules/persistence/index.ts +60 -0
  144. package/src/modules/persistence/stores.port.ts +60 -0
  145. package/src/modules/planning/__tests__/file-conflict-validator.test.ts +256 -0
  146. package/src/modules/planning/__tests__/planning-service.test.ts +366 -0
  147. package/src/modules/planning/__tests__/project-detection.test.ts +707 -0
  148. package/src/modules/planning/file-conflict-validator.ts +135 -0
  149. package/src/modules/planning/index.ts +40 -0
  150. package/src/modules/planning/planning.service.ts +262 -0
  151. package/src/modules/planning/project-detection.ts +525 -0
  152. package/src/modules/plugin/__tests__/plugin-loader.test.ts +83 -0
  153. package/src/modules/plugin/__tests__/plugin-manager.test.ts +187 -0
  154. package/src/modules/plugin/index.ts +3 -0
  155. package/src/modules/plugin/plugin-loader.ts +46 -0
  156. package/src/modules/plugin/plugin-manager.ts +90 -0
  157. package/src/modules/plugin/plugin.types.ts +37 -0
  158. package/src/modules/process-manager/__tests__/process-manager.test.ts +210 -0
  159. package/src/modules/process-manager/__tests__/worker.test.ts +89 -0
  160. package/src/modules/process-manager/index.ts +5 -0
  161. package/src/modules/process-manager/process-manager.ts +193 -0
  162. package/src/modules/process-manager/worker.ts +106 -0
  163. package/src/modules/task-execution/__tests__/default-spawner.test.ts +154 -0
  164. package/src/modules/task-execution/__tests__/executor.test.ts +760 -0
  165. package/src/modules/task-execution/__tests__/implementer-green.test.ts +286 -0
  166. package/src/modules/task-execution/__tests__/merge-phase.test.ts +368 -0
  167. package/src/modules/task-execution/__tests__/reviewer.test.ts +302 -0
  168. package/src/modules/task-execution/__tests__/tester-red.test.ts +281 -0
  169. package/src/modules/task-execution/__tests__/tester-verify.test.ts +313 -0
  170. package/src/modules/task-execution/executor.ts +303 -0
  171. package/src/modules/task-execution/index.ts +45 -0
  172. package/src/modules/task-execution/phases/default-spawner.ts +49 -0
  173. package/src/modules/task-execution/phases/implementer-green.ts +100 -0
  174. package/src/modules/task-execution/phases/merge.ts +122 -0
  175. package/src/modules/task-execution/phases/reviewer.ts +160 -0
  176. package/src/modules/task-execution/phases/tester-red.ts +100 -0
  177. package/src/modules/task-execution/phases/tester-verify.ts +120 -0
  178. package/src/modules/task-queue/__tests__/dependency-resolver.test.ts +456 -0
  179. package/src/modules/task-queue/__tests__/dispatcher.test.ts +824 -0
  180. package/src/modules/task-queue/__tests__/task-plan.test.ts +122 -0
  181. package/src/modules/task-queue/__tests__/task.test.ts +130 -0
  182. package/src/modules/task-queue/dependency-resolver.ts +171 -0
  183. package/src/modules/task-queue/dispatcher.ts +372 -0
  184. package/src/modules/task-queue/index.ts +16 -0
  185. package/src/modules/task-queue/task-plan.ts +40 -0
  186. package/src/modules/task-queue/task.ts +67 -0
  187. package/src/shared/__tests__/config.test.ts +204 -0
  188. package/src/shared/__tests__/errors.test.ts +285 -0
  189. package/src/shared/__tests__/events.test.ts +496 -0
  190. package/src/shared/__tests__/types.test.ts +360 -0
  191. package/src/shared/config.ts +133 -0
  192. package/src/shared/errors.ts +128 -0
  193. package/src/shared/events.ts +171 -0
  194. package/src/shared/types.ts +143 -0
  195. package/tsconfig.json +30 -0
@@ -0,0 +1,335 @@
1
+ import { describe, test, expect, beforeEach, mock } from "bun:test";
2
+ import { BranchManager } from "../branch-manager";
3
+ import { createTaskId, createRunId } from "@aad/shared/types";
4
+ import { GitWorkspaceError } from "@aad/shared/errors";
5
+ import { pino } from "pino";
6
+ import type { GitOps } from "../git-exec";
7
+
8
+ describe("BranchManager", () => {
9
+ let branchManager: BranchManager;
10
+ const repoRoot = "/test/repo";
11
+
12
+ let mockGitOps: GitOps;
13
+
14
+ beforeEach(() => {
15
+ mockGitOps = {
16
+ gitExec: mock(async () => ({ stdout: "", stderr: "", exitCode: 0 })),
17
+ branchExists: mock(async () => false),
18
+ };
19
+
20
+ branchManager = new BranchManager({
21
+ repoRoot,
22
+ logger: pino({ level: "silent" }),
23
+ gitOps: mockGitOps,
24
+ });
25
+ });
26
+
27
+ describe("createParentBranch", () => {
28
+ test("creates parent branch from base branch", async () => {
29
+ const runId = createRunId("run-001");
30
+ const baseBranch = "main";
31
+
32
+ const branchName = await branchManager.createParentBranch(runId, baseBranch);
33
+
34
+ expect(branchName).toBe("aad-run-run-001");
35
+ expect(mockGitOps.branchExists).toHaveBeenCalledWith("aad-run-run-001", repoRoot, expect.anything());
36
+ expect(mockGitOps.gitExec).toHaveBeenCalledWith(
37
+ ["checkout", "-b", "aad-run-run-001", baseBranch],
38
+ expect.objectContaining({ cwd: repoRoot })
39
+ );
40
+ });
41
+
42
+ test("throws error if branch already exists", async () => {
43
+ const runId = createRunId("run-002");
44
+ const baseBranch = "main";
45
+
46
+ mockGitOps.branchExists = mock(async () => true);
47
+
48
+ await expect(
49
+ branchManager.createParentBranch(runId, baseBranch)
50
+ ).rejects.toThrow("Parent branch already exists");
51
+ });
52
+
53
+ test("throws GitWorkspaceError on git failure", async () => {
54
+ const runId = createRunId("run-003");
55
+ const baseBranch = "main";
56
+
57
+ mockGitOps.gitExec = mock(() => Promise.reject(new Error("git checkout failed")));
58
+
59
+ await expect(
60
+ branchManager.createParentBranch(runId, baseBranch)
61
+ ).rejects.toThrow(GitWorkspaceError);
62
+ });
63
+ });
64
+
65
+ describe("createTaskBranch", () => {
66
+ test("creates task branch from parent branch", async () => {
67
+ const taskId = createTaskId("task-001");
68
+ const parentBranch = "aad-run-run-001";
69
+
70
+ const branchName = await branchManager.createTaskBranch(taskId, parentBranch);
71
+
72
+ expect(branchName).toBe("aad-task-task-001");
73
+ expect(mockGitOps.gitExec).toHaveBeenCalledWith(
74
+ ["branch", "aad-task-task-001", parentBranch],
75
+ expect.objectContaining({ cwd: repoRoot })
76
+ );
77
+ });
78
+
79
+ test("returns existing branch if already exists", async () => {
80
+ const taskId = createTaskId("task-002");
81
+ const parentBranch = "aad-run-run-001";
82
+
83
+ mockGitOps.branchExists = mock(async () => true);
84
+
85
+ const branchName = await branchManager.createTaskBranch(taskId, parentBranch);
86
+
87
+ expect(branchName).toBe("aad-task-task-002");
88
+ expect(mockGitOps.gitExec).not.toHaveBeenCalled();
89
+ });
90
+
91
+ test("throws GitWorkspaceError on git failure", async () => {
92
+ const taskId = createTaskId("task-003");
93
+ const parentBranch = "aad-run-run-001";
94
+
95
+ mockGitOps.gitExec = mock(() => Promise.reject(new Error("git branch failed")));
96
+
97
+ await expect(
98
+ branchManager.createTaskBranch(taskId, parentBranch)
99
+ ).rejects.toThrow(GitWorkspaceError);
100
+ });
101
+ });
102
+
103
+ describe("deleteBranch", () => {
104
+ test("deletes branch with default (non-force) mode", async () => {
105
+ const branchName = "aad-task-task-001";
106
+
107
+ await branchManager.deleteBranch(branchName);
108
+
109
+ expect(mockGitOps.gitExec).toHaveBeenCalledWith(
110
+ ["branch", "-d", branchName],
111
+ expect.objectContaining({ cwd: repoRoot })
112
+ );
113
+ });
114
+
115
+ test("deletes branch with force mode", async () => {
116
+ const branchName = "aad-task-task-002";
117
+
118
+ await branchManager.deleteBranch(branchName, true);
119
+
120
+ expect(mockGitOps.gitExec).toHaveBeenCalledWith(
121
+ ["branch", "-D", branchName],
122
+ expect.objectContaining({ cwd: repoRoot })
123
+ );
124
+ });
125
+
126
+ test("throws GitWorkspaceError on failure", async () => {
127
+ const branchName = "aad-task-task-003";
128
+
129
+ mockGitOps.gitExec = mock(() => Promise.reject(new Error("branch not found")));
130
+
131
+ await expect(branchManager.deleteBranch(branchName)).rejects.toThrow(GitWorkspaceError);
132
+ });
133
+ });
134
+
135
+ describe("listBranches", () => {
136
+ test("lists branches matching pattern", async () => {
137
+ const pattern = "aad-task-*";
138
+ const output = " aad-task-task-001\n* aad-task-task-002\n aad-task-task-003\n";
139
+
140
+ mockGitOps.gitExec = mock(async () => ({ stdout: output, stderr: "", exitCode: 0 }));
141
+
142
+ const branches = await branchManager.listBranches(pattern);
143
+
144
+ expect(branches).toEqual(["aad-task-task-001", "aad-task-task-002", "aad-task-task-003"]);
145
+ expect(mockGitOps.gitExec).toHaveBeenCalledWith(
146
+ ["branch", "--list", pattern],
147
+ expect.objectContaining({ cwd: repoRoot })
148
+ );
149
+ });
150
+
151
+ test("returns empty array when no branches match", async () => {
152
+ const pattern = "nonexistent-*";
153
+
154
+ mockGitOps.gitExec = mock(async () => ({ stdout: "", stderr: "", exitCode: 0 }));
155
+
156
+ const branches = await branchManager.listBranches(pattern);
157
+
158
+ expect(branches).toEqual([]);
159
+ });
160
+
161
+ test("removes leading asterisk from current branch", async () => {
162
+ const pattern = "main";
163
+ const output = "* main\n";
164
+
165
+ mockGitOps.gitExec = mock(async () => ({ stdout: output, stderr: "", exitCode: 0 }));
166
+
167
+ const branches = await branchManager.listBranches(pattern);
168
+
169
+ expect(branches).toEqual(["main"]);
170
+ });
171
+
172
+ test("throws GitWorkspaceError on failure", async () => {
173
+ const pattern = "aad-*";
174
+
175
+ mockGitOps.gitExec = mock(() => Promise.reject(new Error("git branch failed")));
176
+
177
+ await expect(branchManager.listBranches(pattern)).rejects.toThrow(GitWorkspaceError);
178
+ });
179
+ });
180
+
181
+ describe("cleanupOrphanBranches", () => {
182
+ test("cleans up orphan branches without runId", async () => {
183
+ const branches = ["aad-task-task-001", "aad-task-task-002", "aad-run-run-001"];
184
+ let callCount = 0;
185
+ mockGitOps.gitExec = mock(async () => {
186
+ callCount++;
187
+ if (callCount === 1) {
188
+ return { stdout: branches.join("\n"), stderr: "", exitCode: 0 };
189
+ }
190
+ return { stdout: "", stderr: "", exitCode: 0 };
191
+ });
192
+
193
+ const deleted = await branchManager.cleanupOrphanBranches();
194
+
195
+ expect(deleted).toEqual(branches);
196
+ expect(mockGitOps.gitExec).toHaveBeenCalledWith(
197
+ ["branch", "--list", "aad-*"],
198
+ expect.objectContaining({ cwd: repoRoot })
199
+ );
200
+ });
201
+
202
+ test("cleans up orphan branches for specific runId", async () => {
203
+ const runId = createRunId("run-001");
204
+ const branches = ["aad-task-run-001-01", "aad-run-run-001"];
205
+
206
+ let callCount = 0;
207
+ mockGitOps.gitExec = mock(async (args: string[]) => {
208
+ callCount++;
209
+ // First pattern: aad/{runId}/* - returns branches
210
+ if (callCount === 1 && args[2] === "aad/run-001/*") {
211
+ return { stdout: branches.join("\n"), stderr: "", exitCode: 0 };
212
+ }
213
+ // All other calls (delete, second pattern list): return success
214
+ return { stdout: "", stderr: "", exitCode: 0 };
215
+ });
216
+
217
+ const deleted = await branchManager.cleanupOrphanBranches(runId);
218
+
219
+ expect(deleted).toEqual(branches);
220
+ // Should check both patterns
221
+ expect(mockGitOps.gitExec).toHaveBeenCalledWith(
222
+ ["branch", "--list", "aad/run-001/*"],
223
+ expect.objectContaining({ cwd: repoRoot })
224
+ );
225
+ expect(mockGitOps.gitExec).toHaveBeenCalledWith(
226
+ ["branch", "--list", "aad-*-run-001*"],
227
+ expect.objectContaining({ cwd: repoRoot })
228
+ );
229
+ });
230
+
231
+ test("skips branches that cannot be deleted", async () => {
232
+ const branches = ["aad-task-task-001", "aad-task-task-002"];
233
+ let callCount = 0;
234
+ mockGitOps.gitExec = mock(async (args: string[]) => {
235
+ callCount++;
236
+ // First pattern (aad/*) list: return branches
237
+ if (callCount === 1 && args[2] === "aad/*") {
238
+ return { stdout: branches.join("\n"), stderr: "", exitCode: 0 };
239
+ }
240
+ // First delete: success
241
+ if (callCount === 2 && args[0] === "branch" && args[1] === "-d") {
242
+ return { stdout: "", stderr: "", exitCode: 0 };
243
+ }
244
+ // Second delete: fail
245
+ if (callCount === 3 && args[0] === "branch" && args[1] === "-d") {
246
+ throw new Error("branch has unmerged commits");
247
+ }
248
+ // Second pattern (aad-*) list: empty
249
+ return { stdout: "", stderr: "", exitCode: 0 };
250
+ });
251
+
252
+ const deleted = await branchManager.cleanupOrphanBranches();
253
+
254
+ expect(deleted).toEqual(["aad-task-task-001"]);
255
+ });
256
+
257
+ test("returns empty array when no branches to clean up", async () => {
258
+ mockGitOps.gitExec = mock(async () => ({ stdout: "", stderr: "", exitCode: 0 }));
259
+
260
+ const deleted = await branchManager.cleanupOrphanBranches();
261
+
262
+ expect(deleted).toEqual([]);
263
+ });
264
+ });
265
+
266
+ describe("forceCleanupRun", () => {
267
+ test("force deletes all branches for a run", async () => {
268
+ const runId = createRunId("run-001");
269
+ const branches = ["aad-task-run-001-01", "aad-task-run-001-02", "aad-run-run-001"];
270
+
271
+ let callCount = 0;
272
+ mockGitOps.gitExec = mock(async (args: string[]) => {
273
+ callCount++;
274
+ // First pattern (aad/{runId}/*) list: return branches
275
+ if (callCount === 1 && args[2] === "aad/run-001/*") {
276
+ return { stdout: branches.join("\n"), stderr: "", exitCode: 0 };
277
+ }
278
+ // All other calls (delete, second pattern list): return success
279
+ return { stdout: "", stderr: "", exitCode: 0 };
280
+ });
281
+
282
+ const deleted = await branchManager.forceCleanupRun(runId);
283
+
284
+ expect(deleted).toEqual(branches);
285
+ expect(mockGitOps.gitExec).toHaveBeenCalledWith(
286
+ ["branch", "--list", "aad/run-001/*"],
287
+ expect.objectContaining({ cwd: repoRoot })
288
+ );
289
+
290
+ // Verify force delete (-D) was used
291
+ expect(mockGitOps.gitExec).toHaveBeenCalledWith(
292
+ ["branch", "-D", branches[0]],
293
+ expect.objectContaining({ cwd: repoRoot })
294
+ );
295
+ });
296
+
297
+ test("continues deleting even if some branches fail", async () => {
298
+ const runId = createRunId("run-002");
299
+ const branches = ["aad-task-run-002-01", "aad-task-run-002-02"];
300
+
301
+ let callCount = 0;
302
+ mockGitOps.gitExec = mock(async (args: string[]) => {
303
+ callCount++;
304
+ // First pattern (aad/{runId}/*) list: return branches
305
+ if (callCount === 1 && args[2] === "aad/run-002/*") {
306
+ return { stdout: branches.join("\n"), stderr: "", exitCode: 0 };
307
+ }
308
+ // First delete: success
309
+ if (callCount === 2 && args[0] === "branch" && args[1] === "-D") {
310
+ return { stdout: "", stderr: "", exitCode: 0 };
311
+ }
312
+ // Second delete: fail
313
+ if (callCount === 3 && args[0] === "branch" && args[1] === "-D") {
314
+ throw new Error("failed to delete branch");
315
+ }
316
+ // Second pattern list: empty
317
+ return { stdout: "", stderr: "", exitCode: 0 };
318
+ });
319
+
320
+ const deleted = await branchManager.forceCleanupRun(runId);
321
+
322
+ expect(deleted).toEqual(["aad-task-run-002-01"]);
323
+ });
324
+
325
+ test("returns empty array when no branches match", async () => {
326
+ const runId = createRunId("run-003");
327
+
328
+ mockGitOps.gitExec = mock(async () => ({ stdout: "", stderr: "", exitCode: 0 }));
329
+
330
+ const deleted = await branchManager.forceCleanupRun(runId);
331
+
332
+ expect(deleted).toEqual([]);
333
+ });
334
+ });
335
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2
+ import { rm, mkdir, mkdtemp } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { gitExec, isGitRepo, getCurrentBranch, branchExists } from "../git-exec";
6
+
7
+ const TEST_DIR = join(import.meta.dir, ".tmp-git-exec-test");
8
+
9
+ describe("git-exec", () => {
10
+ beforeAll(async () => {
11
+ await rm(TEST_DIR, { recursive: true, force: true });
12
+ await mkdir(TEST_DIR, { recursive: true });
13
+
14
+ // Initialize test git repo with explicit branch name
15
+ await gitExec(["init", "-b", "main"], { cwd: TEST_DIR });
16
+ await gitExec(["config", "user.name", "Test User"], { cwd: TEST_DIR });
17
+ await gitExec(["config", "user.email", "test@example.com"], { cwd: TEST_DIR });
18
+ // Disable GPG signing for test repo
19
+ await gitExec(["config", "commit.gpgsign", "false"], { cwd: TEST_DIR });
20
+
21
+ // Create initial commit
22
+ await Bun.write(join(TEST_DIR, "README.md"), "# Test Repo\n");
23
+ await gitExec(["add", "README.md"], { cwd: TEST_DIR });
24
+ await gitExec(["commit", "-m", "Initial commit"], { cwd: TEST_DIR });
25
+ }, 15_000);
26
+
27
+ afterAll(async () => {
28
+ await rm(TEST_DIR, { recursive: true, force: true });
29
+ });
30
+
31
+ describe("gitExec", () => {
32
+ test("executes git command successfully", async () => {
33
+ const result = await gitExec(["status", "--short"], { cwd: TEST_DIR });
34
+
35
+ expect(result.exitCode).toBe(0);
36
+ expect(typeof result.stdout).toBe("string");
37
+ });
38
+
39
+ test("throws GitWorkspaceError on failure", async () => {
40
+ await expect(
41
+ gitExec(["invalid-command"], { cwd: TEST_DIR })
42
+ ).rejects.toThrow("Git command failed");
43
+ });
44
+
45
+ test("returns stdout and stderr", async () => {
46
+ const result = await gitExec(["log", "--oneline", "-1"], { cwd: TEST_DIR });
47
+
48
+ expect(result.stdout).toContain("Initial commit");
49
+ expect(result.exitCode).toBe(0);
50
+ });
51
+ });
52
+
53
+ describe("isGitRepo", () => {
54
+ test("returns true for git repository", async () => {
55
+ const result = await isGitRepo(TEST_DIR);
56
+ expect(result).toBe(true);
57
+ });
58
+
59
+ test("returns false for non-git directory", async () => {
60
+ // Create a temporary directory that is guaranteed not to be a git repo
61
+ const nonGitDir = await mkdtemp(join(tmpdir(), "aad-test-"));
62
+ try {
63
+ const result = await isGitRepo(nonGitDir);
64
+ expect(result).toBe(false);
65
+ } finally {
66
+ await rm(nonGitDir, { recursive: true, force: true });
67
+ }
68
+ });
69
+ });
70
+
71
+ describe("getCurrentBranch", () => {
72
+ test("returns current branch name", async () => {
73
+ const branch = await getCurrentBranch(TEST_DIR);
74
+ // git init -b main で明示指定したので "main" であるべき
75
+ expect(branch).toBe("main");
76
+ });
77
+ });
78
+
79
+ describe("branchExists", () => {
80
+ test("returns true for existing branch", async () => {
81
+ const currentBranch = await getCurrentBranch(TEST_DIR);
82
+ const exists = await branchExists(currentBranch, TEST_DIR);
83
+ expect(exists).toBe(true);
84
+ });
85
+
86
+ test("returns false for non-existing branch", async () => {
87
+ const exists = await branchExists("non-existent-branch", TEST_DIR);
88
+ expect(exists).toBe(false);
89
+ });
90
+ });
91
+ });
@@ -0,0 +1,273 @@
1
+ import { describe, test, expect, beforeEach, mock } from "bun:test";
2
+ import { harvestMemory, type MemorySyncFsOps } from "../memory-sync";
3
+ import { pino } from "pino";
4
+
5
+ describe("harvestMemory", () => {
6
+ let mockFsOps: MemorySyncFsOps;
7
+
8
+ beforeEach(() => {
9
+ mockFsOps = {
10
+ readdir: mock(async () => []) as any,
11
+ readFile: mock(async () => "") as any,
12
+ writeFile: mock(async () => {}) as any,
13
+ };
14
+ });
15
+
16
+ test("harvests memory from agent directories", async () => {
17
+ const sourceDir = "/source/.claude/agent-memory";
18
+ const targetDir = "/target/.claude/agent-memory";
19
+
20
+ let readFileCallCount = 0;
21
+ mockFsOps.readdir = mock(async () => [
22
+ { name: "agent-1", isDirectory: () => true },
23
+ { name: "agent-2", isDirectory: () => true },
24
+ ] as any);
25
+
26
+ mockFsOps.readFile = mock(async () => {
27
+ readFileCallCount++;
28
+ if (readFileCallCount === 1) return "Source line 1\nSource line 2\n";
29
+ if (readFileCallCount === 2) throw new Error("ENOENT");
30
+ if (readFileCallCount === 3) return "Source line 3\n";
31
+ if (readFileCallCount === 4) throw new Error("ENOENT");
32
+ return "";
33
+ }) as any;
34
+
35
+ await harvestMemory({
36
+ sourceDir,
37
+ targetDir,
38
+ logger: pino({ level: "silent" }),
39
+ fsOps: mockFsOps,
40
+ });
41
+
42
+ expect(mockFsOps.readdir).toHaveBeenCalledWith(sourceDir, { withFileTypes: true });
43
+ expect(mockFsOps.writeFile).toHaveBeenCalledTimes(2);
44
+ expect(mockFsOps.writeFile).toHaveBeenCalledWith(
45
+ "/target/.claude/agent-memory/agent-1/MEMORY.md",
46
+ "Source line 1\nSource line 2\n"
47
+ );
48
+ expect(mockFsOps.writeFile).toHaveBeenCalledWith(
49
+ "/target/.claude/agent-memory/agent-2/MEMORY.md",
50
+ "Source line 3\n"
51
+ );
52
+ });
53
+
54
+ test("deduplicates lines between source and target", async () => {
55
+ const sourceDir = "/source";
56
+ const targetDir = "/target";
57
+
58
+ mockFsOps.readdir = mock(async () => [
59
+ { name: "agent-1", isDirectory: () => true },
60
+ ] as any);
61
+
62
+ let readFileCallCount = 0;
63
+ mockFsOps.readFile = mock(async () => {
64
+ readFileCallCount++;
65
+ if (readFileCallCount === 1) return "line1\nline2\nline3\n";
66
+ if (readFileCallCount === 2) return "line2\nline4\n";
67
+ return "";
68
+ }) as any;
69
+
70
+ await harvestMemory({ sourceDir, targetDir, logger: pino({ level: "silent" }), fsOps: mockFsOps });
71
+
72
+ expect(mockFsOps.writeFile).toHaveBeenCalledWith(
73
+ "/target/agent-1/MEMORY.md",
74
+ "line2\nline4\nline1\nline3\n"
75
+ );
76
+ });
77
+
78
+ test("limits output to maxLines (keeps most recent)", async () => {
79
+ const sourceDir = "/source";
80
+ const targetDir = "/target";
81
+
82
+ mockFsOps.readdir = mock(async () => [
83
+ { name: "agent-1", isDirectory: () => true },
84
+ ] as any);
85
+
86
+ let readFileCallCount = 0;
87
+ mockFsOps.readFile = mock(async () => {
88
+ readFileCallCount++;
89
+ if (readFileCallCount === 1) return "line1\nline2\nline3\n";
90
+ if (readFileCallCount === 2) return "line4\nline5\n";
91
+ return "";
92
+ }) as any;
93
+
94
+ await harvestMemory({
95
+ sourceDir,
96
+ targetDir,
97
+ maxLines: 3,
98
+ logger: pino({ level: "silent" }),
99
+ fsOps: mockFsOps,
100
+ });
101
+
102
+ expect(mockFsOps.writeFile).toHaveBeenCalledTimes(1);
103
+ const calls = (mockFsOps.writeFile as any).mock.calls as Array<[string, string]>;
104
+ if (calls.length > 0) {
105
+ const writtenContent = calls[0]![1];
106
+ const lines = writtenContent.split("\n").filter((l) => l.length > 0);
107
+ expect(lines.length).toBeLessThanOrEqual(3);
108
+ }
109
+ });
110
+
111
+ test("skips non-directory entries", async () => {
112
+ const sourceDir = "/source";
113
+ const targetDir = "/target";
114
+
115
+ mockFsOps.readdir = mock(async () => [
116
+ { name: "agent-1", isDirectory: () => true },
117
+ { name: "file.txt", isDirectory: () => false },
118
+ { name: "agent-2", isDirectory: () => true },
119
+ ] as any);
120
+
121
+ let readFileCallCount = 0;
122
+ mockFsOps.readFile = mock(async () => {
123
+ readFileCallCount++;
124
+ if (readFileCallCount === 1) return "line1\n";
125
+ if (readFileCallCount === 2) throw new Error("ENOENT");
126
+ if (readFileCallCount === 3) return "line2\n";
127
+ if (readFileCallCount === 4) throw new Error("ENOENT");
128
+ return "";
129
+ }) as any;
130
+
131
+ await harvestMemory({ sourceDir, targetDir, logger: pino({ level: "silent" }), fsOps: mockFsOps });
132
+
133
+ expect(mockFsOps.writeFile).toHaveBeenCalledTimes(2);
134
+ });
135
+
136
+ test("handles missing source memory file gracefully", async () => {
137
+ const sourceDir = "/source";
138
+ const targetDir = "/target";
139
+
140
+ mockFsOps.readdir = mock(async () => [
141
+ { name: "agent-1", isDirectory: () => true },
142
+ ] as any);
143
+
144
+ mockFsOps.readFile = mock(async () => {
145
+ throw new Error("ENOENT");
146
+ }) as any;
147
+
148
+ await harvestMemory({ sourceDir, targetDir, logger: pino({ level: "silent" }), fsOps: mockFsOps });
149
+
150
+ expect(mockFsOps.writeFile).not.toHaveBeenCalled();
151
+ });
152
+
153
+ test("handles source directory read error gracefully", async () => {
154
+ const sourceDir = "/source";
155
+ const targetDir = "/target";
156
+
157
+ mockFsOps.readdir = mock(async () => {
158
+ throw new Error("Permission denied");
159
+ }) as any;
160
+
161
+ await expect(
162
+ harvestMemory({ sourceDir, targetDir, logger: pino({ level: "silent" }), fsOps: mockFsOps })
163
+ ).resolves.toBeUndefined();
164
+
165
+ expect(mockFsOps.writeFile).not.toHaveBeenCalled();
166
+ });
167
+
168
+ test("filters out empty lines", async () => {
169
+ const sourceDir = "/source";
170
+ const targetDir = "/target";
171
+
172
+ mockFsOps.readdir = mock(async () => [
173
+ { name: "agent-1", isDirectory: () => true },
174
+ ] as any);
175
+
176
+ let readFileCallCount = 0;
177
+ mockFsOps.readFile = mock(async () => {
178
+ readFileCallCount++;
179
+ if (readFileCallCount === 1) return "line1\n\n\nline2\n \nline3\n";
180
+ if (readFileCallCount === 2) throw new Error("ENOENT");
181
+ return "";
182
+ }) as any;
183
+
184
+ await harvestMemory({ sourceDir, targetDir, logger: pino({ level: "silent" }), fsOps: mockFsOps });
185
+
186
+ expect(mockFsOps.writeFile).toHaveBeenCalledTimes(1);
187
+ const calls = (mockFsOps.writeFile as any).mock.calls as Array<[string, string]>;
188
+ if (calls.length > 0) {
189
+ const writtenContent = calls[0]![1];
190
+ const lines = writtenContent.split("\n").filter((l) => l.trim().length > 0);
191
+ expect(lines).toEqual(["line1", "line2", "line3"]);
192
+ }
193
+ });
194
+
195
+ test("preserves order with target lines first", async () => {
196
+ const sourceDir = "/source";
197
+ const targetDir = "/target";
198
+
199
+ mockFsOps.readdir = mock(async () => [
200
+ { name: "agent-1", isDirectory: () => true },
201
+ ] as any);
202
+
203
+ let readFileCallCount = 0;
204
+ mockFsOps.readFile = mock(async () => {
205
+ readFileCallCount++;
206
+ if (readFileCallCount === 1) return "source1\nsource2\n";
207
+ if (readFileCallCount === 2) return "target1\ntarget2\n";
208
+ return "";
209
+ }) as any;
210
+
211
+ await harvestMemory({ sourceDir, targetDir, logger: pino({ level: "silent" }), fsOps: mockFsOps });
212
+
213
+ expect(mockFsOps.writeFile).toHaveBeenCalledTimes(1);
214
+ const calls = (mockFsOps.writeFile as any).mock.calls as Array<[string, string]>;
215
+ if (calls.length > 0) {
216
+ const writtenContent = calls[0]![1];
217
+ expect(writtenContent).toBe("target1\ntarget2\nsource1\nsource2\n");
218
+ }
219
+ });
220
+
221
+ test("uses default maxLines of 200", async () => {
222
+ const sourceDir = "/source";
223
+ const targetDir = "/target";
224
+
225
+ mockFsOps.readdir = mock(async () => [
226
+ { name: "agent-1", isDirectory: () => true },
227
+ ] as any);
228
+
229
+ const manyLines = Array.from({ length: 250 }, (_, i) => `line${i}`).join("\n");
230
+ let readFileCallCount = 0;
231
+ mockFsOps.readFile = mock(async () => {
232
+ readFileCallCount++;
233
+ if (readFileCallCount === 1) return manyLines;
234
+ if (readFileCallCount === 2) throw new Error("ENOENT");
235
+ return "";
236
+ }) as any;
237
+
238
+ await harvestMemory({ sourceDir, targetDir, logger: pino({ level: "silent" }), fsOps: mockFsOps });
239
+
240
+ expect(mockFsOps.writeFile).toHaveBeenCalledTimes(1);
241
+ const calls = (mockFsOps.writeFile as any).mock.calls as Array<[string, string]>;
242
+ if (calls.length > 0) {
243
+ const writtenContent = calls[0]![1];
244
+ const lines = writtenContent.split("\n").filter((l) => l.length > 0);
245
+ expect(lines.length).toBe(200);
246
+ }
247
+ });
248
+
249
+ test("handles write error gracefully", async () => {
250
+ const sourceDir = "/source";
251
+ const targetDir = "/target";
252
+
253
+ mockFsOps.readdir = mock(async () => [
254
+ { name: "agent-1", isDirectory: () => true },
255
+ ] as any);
256
+
257
+ let readFileCallCount = 0;
258
+ mockFsOps.readFile = mock(async () => {
259
+ readFileCallCount++;
260
+ if (readFileCallCount === 1) return "line1\n";
261
+ if (readFileCallCount === 2) throw new Error("ENOENT");
262
+ return "";
263
+ }) as any;
264
+
265
+ mockFsOps.writeFile = mock(async () => {
266
+ throw new Error("Write failed");
267
+ }) as any;
268
+
269
+ await expect(
270
+ harvestMemory({ sourceDir, targetDir, logger: pino({ level: "silent" }), fsOps: mockFsOps })
271
+ ).resolves.toBeUndefined();
272
+ });
273
+ });