@jcjeon/integration-cli 0.2.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 (264) hide show
  1. package/.gitignore +23 -0
  2. package/.npmignore +21 -0
  3. package/.prettierignore +6 -0
  4. package/.prettierrc +26 -0
  5. package/AGENTS.md +10 -0
  6. package/CLAUDE.md +10 -0
  7. package/README.md +384 -0
  8. package/apps/server/README.md +294 -0
  9. package/apps/server/eslint.config.mjs +20 -0
  10. package/apps/server/nest-cli.json +8 -0
  11. package/apps/server/package.json +89 -0
  12. package/apps/server/scripts/postinstall.js +53 -0
  13. package/apps/server/src/__mocks__/glob.js +6 -0
  14. package/apps/server/src/__mocks__/uuid.js +5 -0
  15. package/apps/server/src/app.controller.spec.ts +24 -0
  16. package/apps/server/src/app.controller.ts +13 -0
  17. package/apps/server/src/app.module.ts +18 -0
  18. package/apps/server/src/app.service.ts +8 -0
  19. package/apps/server/src/common/ji-paths.ts +41 -0
  20. package/apps/server/src/database/database.module.ts +27 -0
  21. package/apps/server/src/database/entities/agent-changelog.entity.ts +39 -0
  22. package/apps/server/src/database/entities/agent-session.entity.ts +29 -0
  23. package/apps/server/src/database/entities/conversation.entity.ts +41 -0
  24. package/apps/server/src/database/entities/session.entity.ts +16 -0
  25. package/apps/server/src/database/entities/task-agent-run.entity.ts +40 -0
  26. package/apps/server/src/database/entities/task-agent.entity.ts +42 -0
  27. package/apps/server/src/database/entities/task-requirement.entity.ts +27 -0
  28. package/apps/server/src/database/entities/task-run.entity.ts +41 -0
  29. package/apps/server/src/database/entities/task.entity.ts +44 -0
  30. package/apps/server/src/main.ts +65 -0
  31. package/apps/server/src/modules/agents/agent-model-settings.spec.ts +80 -0
  32. package/apps/server/src/modules/agents/agents.module.ts +11 -0
  33. package/apps/server/src/modules/agents/claude/claude-auth.manager.ts +83 -0
  34. package/apps/server/src/modules/agents/claude/claude-pty.manager.ts +380 -0
  35. package/apps/server/src/modules/agents/claude/claude.controller.ts +85 -0
  36. package/apps/server/src/modules/agents/claude/claude.gateway.ts +158 -0
  37. package/apps/server/src/modules/agents/claude/claude.module.ts +18 -0
  38. package/apps/server/src/modules/agents/claude/claude.service.ts +67 -0
  39. package/apps/server/src/modules/agents/claude/dto/create-session.dto.ts +24 -0
  40. package/apps/server/src/modules/agents/claude/dto/resize-session.dto.ts +13 -0
  41. package/apps/server/src/modules/agents/claude/dto/send-input.dto.ts +9 -0
  42. package/apps/server/src/modules/agents/claude/interfaces/claude-session.interface.ts +26 -0
  43. package/apps/server/src/modules/agents/claude/interfaces/pty-event.interface.ts +10 -0
  44. package/apps/server/src/modules/agents/claude/interfaces/stream-event.interface.ts +61 -0
  45. package/apps/server/src/modules/agents/codex/codex-auth.manager.ts +107 -0
  46. package/apps/server/src/modules/agents/codex/codex-session.manager.ts +357 -0
  47. package/apps/server/src/modules/agents/codex/codex.controller.ts +64 -0
  48. package/apps/server/src/modules/agents/codex/codex.gateway.ts +97 -0
  49. package/apps/server/src/modules/agents/codex/codex.module.ts +17 -0
  50. package/apps/server/src/modules/agents/codex/dto/configure-auth.dto.ts +7 -0
  51. package/apps/server/src/modules/agents/gemini/dto/configure-auth.dto.ts +15 -0
  52. package/apps/server/src/modules/agents/gemini/dto/create-session.dto.ts +9 -0
  53. package/apps/server/src/modules/agents/gemini/dto/send-input.dto.ts +9 -0
  54. package/apps/server/src/modules/agents/gemini/gemini-auth.manager.ts +157 -0
  55. package/apps/server/src/modules/agents/gemini/gemini-session.manager.ts +287 -0
  56. package/apps/server/src/modules/agents/gemini/gemini.controller.ts +93 -0
  57. package/apps/server/src/modules/agents/gemini/gemini.gateway.ts +149 -0
  58. package/apps/server/src/modules/agents/gemini/gemini.module.ts +17 -0
  59. package/apps/server/src/modules/agents/gemini/interfaces/gemini-session.interface.ts +18 -0
  60. package/apps/server/src/modules/agents/gemini/interfaces/stream-event.interface.ts +14 -0
  61. package/apps/server/src/modules/agents/session-termination.spec.ts +103 -0
  62. package/apps/server/src/modules/changelog/changelog.controller.ts +20 -0
  63. package/apps/server/src/modules/changelog/changelog.module.ts +14 -0
  64. package/apps/server/src/modules/changelog/changelog.service.spec.ts +531 -0
  65. package/apps/server/src/modules/changelog/changelog.service.ts +690 -0
  66. package/apps/server/src/modules/conversations/conversation.controller.spec.ts +106 -0
  67. package/apps/server/src/modules/conversations/conversation.controller.ts +60 -0
  68. package/apps/server/src/modules/conversations/conversation.module.ts +14 -0
  69. package/apps/server/src/modules/conversations/conversation.service.spec.ts +176 -0
  70. package/apps/server/src/modules/conversations/conversation.service.ts +54 -0
  71. package/apps/server/src/modules/conversations/dto/create-conversation.dto.ts +37 -0
  72. package/apps/server/src/modules/conversations/enums/conversation.enum.ts +13 -0
  73. package/apps/server/src/modules/fs/fs.controller.ts +29 -0
  74. package/apps/server/src/modules/fs/fs.module.ts +8 -0
  75. package/apps/server/src/modules/harness/dto/save-harness.dto.ts +9 -0
  76. package/apps/server/src/modules/harness/harness.controller.spec.ts +95 -0
  77. package/apps/server/src/modules/harness/harness.controller.ts +35 -0
  78. package/apps/server/src/modules/harness/harness.module.ts +11 -0
  79. package/apps/server/src/modules/harness/harness.service.spec.ts +217 -0
  80. package/apps/server/src/modules/harness/harness.service.ts +112 -0
  81. package/apps/server/src/modules/sessions/session.controller.spec.ts +68 -0
  82. package/apps/server/src/modules/sessions/session.controller.ts +43 -0
  83. package/apps/server/src/modules/sessions/session.module.ts +14 -0
  84. package/apps/server/src/modules/sessions/session.service.spec.ts +106 -0
  85. package/apps/server/src/modules/sessions/session.service.ts +35 -0
  86. package/apps/server/src/modules/tasks/dto/create-task.dto.ts +54 -0
  87. package/apps/server/src/modules/tasks/dto/execute-task.dto.ts +22 -0
  88. package/apps/server/src/modules/tasks/dto/merge-file.dto.ts +7 -0
  89. package/apps/server/src/modules/tasks/dto/rerun-task.dto.ts +14 -0
  90. package/apps/server/src/modules/tasks/dto/update-task.dto.ts +55 -0
  91. package/apps/server/src/modules/tasks/task-execution.service.ts +978 -0
  92. package/apps/server/src/modules/tasks/task.gateway.ts +140 -0
  93. package/apps/server/src/modules/tasks/tasks.controller.spec.ts +210 -0
  94. package/apps/server/src/modules/tasks/tasks.controller.ts +139 -0
  95. package/apps/server/src/modules/tasks/tasks.module.ts +30 -0
  96. package/apps/server/src/modules/tasks/tasks.service.spec.ts +552 -0
  97. package/apps/server/src/modules/tasks/tasks.service.ts +333 -0
  98. package/apps/server/test/app.e2e-spec.ts +28 -0
  99. package/apps/server/test/jest-e2e.json +9 -0
  100. package/apps/server/tsconfig.build.json +4 -0
  101. package/apps/server/tsconfig.json +13 -0
  102. package/apps/web/AGENTS.md +7 -0
  103. package/apps/web/CLAUDE.md +1 -0
  104. package/apps/web/README.md +36 -0
  105. package/apps/web/eslint.config.mjs +21 -0
  106. package/apps/web/next-env.d.ts +6 -0
  107. package/apps/web/next.config.ts +7 -0
  108. package/apps/web/package.json +49 -0
  109. package/apps/web/postcss.config.mjs +7 -0
  110. package/apps/web/public/file.svg +1 -0
  111. package/apps/web/public/globe.svg +1 -0
  112. package/apps/web/public/next.svg +1 -0
  113. package/apps/web/public/vercel.svg +1 -0
  114. package/apps/web/public/window.svg +1 -0
  115. package/apps/web/src/app/claude/page.tsx +5 -0
  116. package/apps/web/src/app/codex/page.tsx +126 -0
  117. package/apps/web/src/app/favicon.ico +0 -0
  118. package/apps/web/src/app/gemini/page.tsx +130 -0
  119. package/apps/web/src/app/globals.css +149 -0
  120. package/apps/web/src/app/layout.tsx +40 -0
  121. package/apps/web/src/app/login/page.tsx +67 -0
  122. package/apps/web/src/app/page.tsx +497 -0
  123. package/apps/web/src/app/task/[id]/page.tsx +11 -0
  124. package/apps/web/src/app/test/page.tsx +298 -0
  125. package/apps/web/src/components/ui/Modal.tsx +78 -0
  126. package/apps/web/src/components/ui/WorkingDirPicker.tsx +195 -0
  127. package/apps/web/src/components/ui/__tests__/Modal.test.tsx +68 -0
  128. package/apps/web/src/features/auth/api/__tests__/auth.api.test.ts +83 -0
  129. package/apps/web/src/features/auth/api/auth.api.ts +81 -0
  130. package/apps/web/src/features/auth/hooks/__tests__/useClaudeAuth.test.ts +166 -0
  131. package/apps/web/src/features/auth/hooks/__tests__/useCodexAuth.test.ts +127 -0
  132. package/apps/web/src/features/auth/hooks/__tests__/useGeminiAuth.test.ts +120 -0
  133. package/apps/web/src/features/auth/hooks/useClaudeAuth.ts +88 -0
  134. package/apps/web/src/features/auth/hooks/useCodexAuth.ts +149 -0
  135. package/apps/web/src/features/auth/hooks/useGeminiAuth.ts +125 -0
  136. package/apps/web/src/features/auth/ui/CodexLoginPanel.tsx +302 -0
  137. package/apps/web/src/features/auth/ui/GeminiLoginPanel.tsx +316 -0
  138. package/apps/web/src/features/auth/ui/LoginForm.tsx +190 -0
  139. package/apps/web/src/features/auth/ui/LoginPanel.tsx +114 -0
  140. package/apps/web/src/features/auth/ui/__tests__/LoginPanel.test.tsx +105 -0
  141. package/apps/web/src/features/chat/api/__tests__/sessions.api.test.ts +187 -0
  142. package/apps/web/src/features/chat/api/sessions.api.ts +161 -0
  143. package/apps/web/src/features/chat/container/ClaudePageContainer.tsx +152 -0
  144. package/apps/web/src/features/chat/hooks/__tests__/useCodexSessions.test.ts +131 -0
  145. package/apps/web/src/features/chat/hooks/__tests__/useGeminiSessions.test.ts +130 -0
  146. package/apps/web/src/features/chat/hooks/useAgentModelSettings.ts +54 -0
  147. package/apps/web/src/features/chat/hooks/useClaudeSessions.ts +323 -0
  148. package/apps/web/src/features/chat/hooks/useCodexSessions.ts +275 -0
  149. package/apps/web/src/features/chat/hooks/useGeminiSessions.ts +255 -0
  150. package/apps/web/src/features/chat/hooks/useSessionCommand.ts +66 -0
  151. package/apps/web/src/features/chat/hooks/useSessionRename.ts +61 -0
  152. package/apps/web/src/features/chat/hooks/useSessionWorkingDirectories.ts +34 -0
  153. package/apps/web/src/features/chat/hooks/useUnifiedSessions.ts +156 -0
  154. package/apps/web/src/features/chat/lib/agentModelOptions.ts +72 -0
  155. package/apps/web/src/features/chat/ui/AgentModelPicker.tsx +134 -0
  156. package/apps/web/src/features/chat/ui/AgentSelectModal.tsx +236 -0
  157. package/apps/web/src/features/chat/ui/ChatInput.tsx +162 -0
  158. package/apps/web/src/features/chat/ui/ChatMessage.tsx +204 -0
  159. package/apps/web/src/features/chat/ui/ChatWorkspace.tsx +207 -0
  160. package/apps/web/src/features/chat/ui/CheckingSkeleton.tsx +44 -0
  161. package/apps/web/src/features/chat/ui/ClaudeLoginView.tsx +44 -0
  162. package/apps/web/src/features/chat/ui/PermissionCard.tsx +37 -0
  163. package/apps/web/src/features/chat/ui/SessionSidebar.tsx +280 -0
  164. package/apps/web/src/features/chat/ui/__tests__/AgentSelectModal.test.tsx +58 -0
  165. package/apps/web/src/features/chat/ui/__tests__/ChatInput.test.tsx +134 -0
  166. package/apps/web/src/features/chat/ui/__tests__/ChatMessage.test.tsx +106 -0
  167. package/apps/web/src/features/chat/ui/__tests__/ChatWorkspace.test.tsx +66 -0
  168. package/apps/web/src/features/diff/ui/DiffFileRow.tsx +73 -0
  169. package/apps/web/src/features/diff/ui/DiffHunk.tsx +61 -0
  170. package/apps/web/src/features/diff/ui/FileChangeBadge.tsx +23 -0
  171. package/apps/web/src/features/diff/ui/__tests__/DiffFileRow.test.tsx +40 -0
  172. package/apps/web/src/features/diff/ui/__tests__/DiffHunk.test.tsx +24 -0
  173. package/apps/web/src/features/diff/ui/__tests__/FileChangeBadge.test.tsx +16 -0
  174. package/apps/web/src/features/fs/api/fs.api.ts +14 -0
  175. package/apps/web/src/features/fs/hooks/useDirBrowser.ts +50 -0
  176. package/apps/web/src/features/harness/api/__tests__/harness.api.test.ts +73 -0
  177. package/apps/web/src/features/harness/api/harness.api.ts +46 -0
  178. package/apps/web/src/features/harness/hooks/__tests__/useHarness.test.ts +65 -0
  179. package/apps/web/src/features/harness/hooks/useHarness.ts +66 -0
  180. package/apps/web/src/features/harness/ui/HarnessModal.tsx +171 -0
  181. package/apps/web/src/features/harness/ui/__tests__/HarnessModal.test.tsx +46 -0
  182. package/apps/web/src/features/status/ui/AgentStatusModal.tsx +267 -0
  183. package/apps/web/src/features/status/ui/__tests__/AgentStatusModal.test.tsx +71 -0
  184. package/apps/web/src/features/tasks/api/__tests__/changelog.api.test.ts +89 -0
  185. package/apps/web/src/features/tasks/api/__tests__/tasks.api.test.ts +282 -0
  186. package/apps/web/src/features/tasks/api/changelog.api.ts +52 -0
  187. package/apps/web/src/features/tasks/api/tasks.api.ts +175 -0
  188. package/apps/web/src/features/tasks/container/TaskDetailPageContainer.tsx +69 -0
  189. package/apps/web/src/features/tasks/hooks/__tests__/useChangelogCodeCopy.test.ts +48 -0
  190. package/apps/web/src/features/tasks/hooks/__tests__/useTaskChangelog.test.ts +48 -0
  191. package/apps/web/src/features/tasks/hooks/__tests__/useTaskCreate.test.ts +217 -0
  192. package/apps/web/src/features/tasks/hooks/__tests__/useTaskEdit.test.ts +152 -0
  193. package/apps/web/src/features/tasks/hooks/__tests__/useTaskExecution.test.ts +143 -0
  194. package/apps/web/src/features/tasks/hooks/__tests__/useTaskList.test.ts +168 -0
  195. package/apps/web/src/features/tasks/hooks/__tests__/useTaskNotification.test.ts +125 -0
  196. package/apps/web/src/features/tasks/hooks/__tests__/useTaskRuns.test.ts +51 -0
  197. package/apps/web/src/features/tasks/hooks/useChangelogCodeCopy.ts +52 -0
  198. package/apps/web/src/features/tasks/hooks/useCopyToClipboard.ts +47 -0
  199. package/apps/web/src/features/tasks/hooks/useTaskChangelog.ts +32 -0
  200. package/apps/web/src/features/tasks/hooks/useTaskCreate.ts +137 -0
  201. package/apps/web/src/features/tasks/hooks/useTaskDetail.ts +217 -0
  202. package/apps/web/src/features/tasks/hooks/useTaskEdit.ts +130 -0
  203. package/apps/web/src/features/tasks/hooks/useTaskExecution.ts +137 -0
  204. package/apps/web/src/features/tasks/hooks/useTaskList.ts +159 -0
  205. package/apps/web/src/features/tasks/hooks/useTaskNotification.ts +80 -0
  206. package/apps/web/src/features/tasks/hooks/useTaskRuns.ts +32 -0
  207. package/apps/web/src/features/tasks/ui/AgentOutputPanel.tsx +203 -0
  208. package/apps/web/src/features/tasks/ui/AgentRoleSelect.tsx +97 -0
  209. package/apps/web/src/features/tasks/ui/ChangelogPanel.tsx +321 -0
  210. package/apps/web/src/features/tasks/ui/RunHistoryPanel.tsx +193 -0
  211. package/apps/web/src/features/tasks/ui/TaskCreateModal.tsx +205 -0
  212. package/apps/web/src/features/tasks/ui/TaskDetailView.tsx +413 -0
  213. package/apps/web/src/features/tasks/ui/TaskEditModal.tsx +165 -0
  214. package/apps/web/src/features/tasks/ui/TaskListModal.tsx +591 -0
  215. package/apps/web/src/features/tasks/ui/__tests__/AgentRoleSelect.test.tsx +91 -0
  216. package/apps/web/src/features/tasks/ui/__tests__/ChangelogPanel.test.tsx +94 -0
  217. package/apps/web/src/features/tasks/ui/__tests__/RunHistoryPanel.test.tsx +71 -0
  218. package/apps/web/src/features/tasks/ui/__tests__/TaskCreateModal.test.tsx +153 -0
  219. package/apps/web/src/features/tasks/ui/__tests__/TaskEditModal.test.tsx +75 -0
  220. package/apps/web/src/features/tasks/ui/__tests__/TaskListModal.test.tsx +243 -0
  221. package/apps/web/src/hooks/useWorkingDir.ts +28 -0
  222. package/apps/web/src/lib/__tests__/ansi.test.ts +88 -0
  223. package/apps/web/src/lib/ansi.ts +105 -0
  224. package/apps/web/src/lib/constants.ts +4 -0
  225. package/apps/web/src/lib/quota.ts +22 -0
  226. package/apps/web/src/lib/theme.tsx +78 -0
  227. package/apps/web/src/lib/toast.tsx +175 -0
  228. package/apps/web/src/store/agentStatusStore.ts +38 -0
  229. package/apps/web/tsconfig.json +18 -0
  230. package/apps/web/vitest.config.ts +25 -0
  231. package/apps/web/vitest.setup.ts +10 -0
  232. package/package.json +85 -0
  233. package/packages/cli/dist/commands/check.d.ts +1 -0
  234. package/packages/cli/dist/commands/check.js +89 -0
  235. package/packages/cli/dist/commands/init.d.ts +5 -0
  236. package/packages/cli/dist/commands/init.js +183 -0
  237. package/packages/cli/dist/commands/start.d.ts +4 -0
  238. package/packages/cli/dist/commands/start.js +188 -0
  239. package/packages/cli/dist/index.d.ts +2 -0
  240. package/packages/cli/dist/index.js +71 -0
  241. package/packages/cli/dist/utils/agent-tools.d.ts +28 -0
  242. package/packages/cli/dist/utils/agent-tools.js +193 -0
  243. package/packages/cli/dist/utils/project-init.d.ts +12 -0
  244. package/packages/cli/dist/utils/project-init.js +258 -0
  245. package/packages/cli/dist/utils/proxy.d.ts +8 -0
  246. package/packages/cli/dist/utils/proxy.js +138 -0
  247. package/packages/cli/package.json +30 -0
  248. package/packages/cli/src/commands/check.ts +77 -0
  249. package/packages/cli/src/commands/init.ts +209 -0
  250. package/packages/cli/src/commands/start.ts +183 -0
  251. package/packages/cli/src/index.ts +91 -0
  252. package/packages/cli/src/utils/agent-tools.ts +201 -0
  253. package/packages/cli/src/utils/project-init.ts +252 -0
  254. package/packages/cli/src/utils/proxy.ts +123 -0
  255. package/packages/cli/tsconfig.json +14 -0
  256. package/packages/eslint-config/base.mjs +31 -0
  257. package/packages/eslint-config/nest.mjs +55 -0
  258. package/packages/eslint-config/next.mjs +23 -0
  259. package/packages/eslint-config/package.json +20 -0
  260. package/packages/typescript-config/base.json +16 -0
  261. package/packages/typescript-config/nestjs.json +17 -0
  262. package/packages/typescript-config/nextjs.json +15 -0
  263. package/packages/typescript-config/package.json +11 -0
  264. package/turbo.json +28 -0
@@ -0,0 +1,531 @@
1
+ import * as childProcess from 'child_process';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+
5
+ import { Test, TestingModule } from '@nestjs/testing';
6
+ import { getRepositoryToken } from '@nestjs/typeorm';
7
+
8
+ import { AgentChangelogEntity } from '../../database/entities/agent-changelog.entity';
9
+ import { GitChangelogService } from './changelog.service';
10
+
11
+ jest.mock('fs');
12
+ jest.mock('child_process', () => ({
13
+ execSync: jest.fn(),
14
+ execFileSync: jest.fn(),
15
+ }));
16
+
17
+ const mockRepo = () => ({
18
+ create: jest.fn((dto: any) => ({ ...dto })),
19
+ save: jest.fn((e: any) => Promise.resolve(Array.isArray(e) ? e : [e])),
20
+ findOne: jest.fn(),
21
+ find: jest.fn(),
22
+ });
23
+
24
+ describe('GitChangelogService', () => {
25
+ let service: GitChangelogService;
26
+ let repo: ReturnType<typeof mockRepo>;
27
+
28
+ const execSync = childProcess.execSync as jest.Mock;
29
+ const execFileSync = childProcess.execFileSync as jest.Mock;
30
+
31
+ beforeEach(async () => {
32
+ const module: TestingModule = await Test.createTestingModule({
33
+ providers: [
34
+ GitChangelogService,
35
+ { provide: getRepositoryToken(AgentChangelogEntity), useFactory: mockRepo },
36
+ ],
37
+ }).compile();
38
+
39
+ service = module.get(GitChangelogService);
40
+ repo = module.get(getRepositoryToken(AgentChangelogEntity));
41
+ jest.clearAllMocks();
42
+ });
43
+
44
+ // ─── isGitRepo ───────────────────────────────────────────────────────────
45
+
46
+ describe('isGitRepo', () => {
47
+ it('git repo이면 true를 반환한다', () => {
48
+ execSync.mockReturnValue('');
49
+
50
+ expect(service.isGitRepo('/some/dir')).toBe(true);
51
+ expect(execSync).toHaveBeenCalledWith('git rev-parse --git-dir', expect.objectContaining({ cwd: '/some/dir' }));
52
+ });
53
+
54
+ it('git repo가 아니면 false를 반환한다', () => {
55
+ execSync.mockImplementation(() => { throw new Error('not a git repo'); });
56
+
57
+ expect(service.isGitRepo('/not/a/repo')).toBe(false);
58
+ });
59
+ });
60
+
61
+ // ─── getCurrentHead ──────────────────────────────────────────────────────
62
+
63
+ describe('getCurrentHead', () => {
64
+ it('현재 HEAD 커밋 SHA를 반환한다', () => {
65
+ execSync.mockReturnValue('abc123def456\n');
66
+
67
+ const result = service.getCurrentHead('/repo');
68
+
69
+ expect(result).toBe('abc123def456');
70
+ });
71
+ });
72
+
73
+ // ─── getCurrentBranch ────────────────────────────────────────────────────
74
+
75
+ describe('getCurrentBranch', () => {
76
+ it('현재 브랜치명을 반환한다', () => {
77
+ execSync.mockReturnValue('main\n');
78
+
79
+ const result = service.getCurrentBranch('/repo');
80
+
81
+ expect(result).toBe('main');
82
+ });
83
+
84
+ it('특수문자를 대시로 치환하고 40자로 자른다', () => {
85
+ const longBranch = 'feature/some-branch-name-that-is-very-very-long-and-has/slashes';
86
+ execSync.mockReturnValue(longBranch + '\n');
87
+
88
+ const result = service.getCurrentBranch('/repo');
89
+
90
+ expect(result).not.toContain('/');
91
+ expect(result.length).toBeLessThanOrEqual(40);
92
+ });
93
+
94
+ it('에러 발생 시 "unknown"을 반환한다', () => {
95
+ execSync.mockImplementation(() => { throw new Error('git error'); });
96
+
97
+ const result = service.getCurrentBranch('/repo');
98
+
99
+ expect(result).toBe('unknown');
100
+ });
101
+ });
102
+
103
+ // ─── getRepoRoot ─────────────────────────────────────────────────────────
104
+
105
+ describe('getRepoRoot', () => {
106
+ it('git repo의 루트 디렉토리를 반환한다', () => {
107
+ execSync.mockReturnValue('/path/to/repo\n');
108
+
109
+ const result = service.getRepoRoot('/path/to/repo/subdir');
110
+
111
+ expect(result).toBe('/path/to/repo');
112
+ });
113
+ });
114
+
115
+ // ─── mergeAll ────────────────────────────────────────────────────────────
116
+
117
+ describe('mergeAll', () => {
118
+ it('전체 변경사항을 병합하고 성공 결과를 반환한다', () => {
119
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
120
+ execSync.mockReturnValue('/repo\n'); // getRepoRoot uses execSync
121
+ execFileSync
122
+ .mockReturnValueOnce('agent-branch\n') // getCurrentBranch in worktree
123
+ .mockReturnValueOnce(''); // merge
124
+
125
+ const result = service.mergeAll('/tmp/worktrees/agent-1', '/repo');
126
+
127
+ expect(result.success).toBe(true);
128
+ expect(result.message).toBe('전체 병합이 완료되었습니다.');
129
+ });
130
+
131
+ it('worktree 경로가 없으면 실패 메시지를 반환한다', () => {
132
+ (fs.existsSync as jest.Mock).mockReturnValue(false);
133
+
134
+ const result = service.mergeAll('/not/exist', '/repo');
135
+
136
+ expect(result.success).toBe(false);
137
+ expect(result.message).toContain('worktree 경로가 존재하지 않습니다');
138
+ });
139
+
140
+ it('merge 실패 시 false를 반환하고 abort를 시도한다', () => {
141
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
142
+ execSync.mockReturnValue('/repo\n'); // getRepoRoot (main flow + catch block)
143
+ execFileSync
144
+ .mockReturnValueOnce('agent-branch\n') // getCurrentBranch in worktree
145
+ .mockImplementationOnce(() => { throw new Error('merge conflict'); }) // merge fails
146
+ .mockReturnValueOnce(''); // abort in catch
147
+
148
+ const result = service.mergeAll('/tmp/worktrees/agent-1', '/repo');
149
+
150
+ expect(result.success).toBe(false);
151
+ });
152
+ });
153
+
154
+ // ─── mergeFile ───────────────────────────────────────────────────────────
155
+
156
+ describe('mergeFile', () => {
157
+ it('단일 파일을 병합하고 성공 결과를 반환한다', () => {
158
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
159
+ execSync.mockReturnValue('/repo\n'); // getRepoRoot
160
+ execFileSync
161
+ .mockReturnValueOnce('agent-branch\n') // getCurrentBranch in worktree
162
+ .mockReturnValueOnce(''); // checkout
163
+
164
+ const result = service.mergeFile('/tmp/worktrees/agent-1', '/repo', 'src/app.ts');
165
+
166
+ expect(result.success).toBe(true);
167
+ expect(result.message).toContain('src/app.ts');
168
+ });
169
+
170
+ it('worktree 경로가 없으면 실패 메시지를 반환한다', () => {
171
+ (fs.existsSync as jest.Mock).mockReturnValue(false);
172
+
173
+ const result = service.mergeFile('/not/exist', '/repo', 'file.ts');
174
+
175
+ expect(result.success).toBe(false);
176
+ expect(result.message).toContain('worktree 경로가 존재하지 않습니다');
177
+ });
178
+
179
+ it('checkout 실패 시 에러 메시지를 반환한다', () => {
180
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
181
+ execSync.mockReturnValue('/repo\n'); // getRepoRoot
182
+ execFileSync
183
+ .mockReturnValueOnce('agent-branch\n') // getCurrentBranch
184
+ .mockImplementationOnce(() => { throw new Error('file not found'); }); // checkout fails
185
+
186
+ const result = service.mergeFile('/tmp/worktrees/agent-1', '/repo', 'missing.ts');
187
+
188
+ expect(result.success).toBe(false);
189
+ expect(result.message).toContain('file not found');
190
+ });
191
+
192
+ it('상위 디렉토리 pathspec은 거부한다', () => {
193
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
194
+
195
+ const result = service.mergeFile('/tmp/worktrees/agent-1', '/repo', '../secret.ts');
196
+
197
+ expect(result.success).toBe(false);
198
+ expect(result.message).toContain('유효하지 않은 파일 경로입니다');
199
+ expect(execSync).not.toHaveBeenCalled();
200
+ expect(execFileSync).not.toHaveBeenCalled();
201
+ });
202
+ });
203
+
204
+ // ─── getLatestRunId ──────────────────────────────────────────────────────
205
+
206
+ describe('getLatestRunId', () => {
207
+ it('가장 최근 runId를 반환한다', async () => {
208
+ repo.findOne.mockResolvedValue({ runId: 5 });
209
+
210
+ const result = await service.getLatestRunId('task-1');
211
+
212
+ expect(result).toBe(5);
213
+ expect(repo.findOne).toHaveBeenCalledWith({
214
+ where: { taskId: 'task-1' },
215
+ order: { runId: 'DESC' },
216
+ select: ['runId'],
217
+ });
218
+ });
219
+
220
+ it('기록이 없으면 null을 반환한다', async () => {
221
+ repo.findOne.mockResolvedValue(null);
222
+
223
+ const result = await service.getLatestRunId('empty-task');
224
+
225
+ expect(result).toBeNull();
226
+ });
227
+ });
228
+
229
+ // ─── getByTask ───────────────────────────────────────────────────────────
230
+
231
+ describe('getByTask', () => {
232
+ it('task의 changelog를 에이전트별로 그룹핑해서 반환한다', async () => {
233
+ repo.findOne.mockResolvedValue({ runId: 3 });
234
+ repo.find.mockResolvedValue([
235
+ { id: 1, taskId: 'task-1', agentId: 1, runId: 3, filePath: 'src/a.ts', changeType: 'modified', additions: 5, deletions: 2, patch: 'diff...' },
236
+ { id: 2, taskId: 'task-1', agentId: 1, runId: 3, filePath: 'src/b.ts', changeType: 'added', additions: 10, deletions: 0, patch: 'diff...' },
237
+ { id: 3, taskId: 'task-1', agentId: 2, runId: 3, filePath: 'src/c.ts', changeType: 'deleted', additions: 0, deletions: 3, patch: 'diff...' },
238
+ ]);
239
+
240
+ const result = await service.getByTask('task-1');
241
+
242
+ expect(result).toHaveLength(2);
243
+ expect(result[0].agentId).toBe(1);
244
+ expect(result[0].files).toHaveLength(2);
245
+ expect(result[1].agentId).toBe(2);
246
+ expect(result[1].files).toHaveLength(1);
247
+ });
248
+
249
+ it('runId를 직접 지정하면 해당 run의 결과만 반환한다', async () => {
250
+ repo.find.mockResolvedValue([
251
+ { id: 1, taskId: 'task-1', agentId: 1, runId: 2, filePath: 'src/x.ts', changeType: 'modified', additions: 1, deletions: 1, patch: null },
252
+ ]);
253
+
254
+ const result = await service.getByTask('task-1', 2);
255
+
256
+ expect(repo.find).toHaveBeenCalledWith(expect.objectContaining({
257
+ where: { taskId: 'task-1', runId: 2 },
258
+ }));
259
+ expect(result).toHaveLength(1);
260
+ });
261
+
262
+ it('changelog가 없으면 빈 배열을 반환한다', async () => {
263
+ repo.findOne.mockResolvedValue(null);
264
+ repo.find.mockResolvedValue([]);
265
+
266
+ const result = await service.getByTask('task-1');
267
+
268
+ expect(result).toEqual([]);
269
+ });
270
+ });
271
+
272
+ // ─── directory snapshot changelog ────────────────────────────────────────
273
+
274
+ describe('directory snapshot changelog', () => {
275
+ const dirent = (name: string, type: 'file' | 'dir') => ({
276
+ name,
277
+ isDirectory: () => type === 'dir',
278
+ isFile: () => type === 'file',
279
+ }) as fs.Dirent;
280
+
281
+ it('일반 디렉토리 스냅샷을 생성하고 무거운 폴더를 건너뛴다', () => {
282
+ (fs.readdirSync as jest.Mock).mockImplementation((dir: string) => {
283
+ if (dir === '/work') return [dirent('src', 'dir'), dirent('root.txt', 'file'), dirent('node_modules', 'dir')];
284
+ if (dir === path.join('/work', 'src')) return [dirent('app.ts', 'file')];
285
+ return [];
286
+ });
287
+ (fs.readFileSync as jest.Mock).mockImplementation((file: string) => Buffer.from(`content:${file}`));
288
+
289
+ const snapshot = service.createDirectorySnapshot('/work');
290
+
291
+ expect(snapshot.has('root.txt')).toBe(true);
292
+ expect(snapshot.has('src/app.ts')).toBe(true);
293
+ expect(snapshot.has('node_modules/pkg/index.js')).toBe(false);
294
+ });
295
+
296
+ it('일반 디렉토리 변경사항을 changelog로 저장한다', async () => {
297
+ const before = new Map([
298
+ ['src/app.ts', { hash: 'old-hash', content: 'old\n', isBinary: false }],
299
+ ['src/remove.ts', { hash: 'remove-hash', content: 'remove\n', isBinary: false }],
300
+ ]);
301
+ const after = new Map([
302
+ ['src/app.ts', { hash: 'new-hash', content: 'new\n', isBinary: false }],
303
+ ['src/add.ts', { hash: 'add-hash', content: 'add\n', isBinary: false }],
304
+ ]);
305
+
306
+ jest.spyOn(service, 'createDirectorySnapshot').mockReturnValue(after);
307
+ (fs.mkdirSync as jest.Mock).mockReturnValue(undefined);
308
+ (fs.writeFileSync as jest.Mock).mockReturnValue(undefined);
309
+
310
+ const result = await service.captureDirectoryAndSave('task-1', 1, '/work', before, 3);
311
+
312
+ expect(result).toBe(3);
313
+ expect(repo.save).toHaveBeenCalledWith(expect.arrayContaining([
314
+ expect.objectContaining({ filePath: 'src/add.ts', changeType: 'added', runId: 3 }),
315
+ expect.objectContaining({ filePath: 'src/app.ts', changeType: 'modified', runId: 3 }),
316
+ expect.objectContaining({ filePath: 'src/remove.ts', changeType: 'deleted', runId: 3 }),
317
+ ]));
318
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
319
+ expect.stringContaining('agent-1'),
320
+ expect.stringContaining('diff --git'),
321
+ 'utf8',
322
+ );
323
+ });
324
+
325
+ it('일반 디렉토리 변경사항이 없으면 DB에 저장하지 않는다', async () => {
326
+ const before = new Map([
327
+ ['src/app.ts', { hash: 'same-hash', content: 'same\n', isBinary: false }],
328
+ ]);
329
+
330
+ jest.spyOn(service, 'createDirectorySnapshot').mockReturnValue(before);
331
+
332
+ const result = await service.captureDirectoryAndSave('task-1', 1, '/work', before, 3);
333
+
334
+ expect(result).toBe(0);
335
+ expect(repo.save).not.toHaveBeenCalled();
336
+ });
337
+ });
338
+
339
+ // ─── parseDiff (private, via any 접근) ──────────────────────────────────
340
+
341
+ describe('parseDiff', () => {
342
+ const parseDiff = (diffText: string) => (service as any).parseDiff(diffText);
343
+
344
+ it('modified 파일을 파싱한다', () => {
345
+ const diff = `diff --git a/src/app.ts b/src/app.ts
346
+ index abc..def 100644
347
+ --- a/src/app.ts
348
+ +++ b/src/app.ts
349
+ @@ -1,3 +1,4 @@
350
+ const x = 1;
351
+ -const y = 2;
352
+ +const y = 3;
353
+ +const z = 4;
354
+ `;
355
+ const result = parseDiff(diff);
356
+
357
+ expect(result).toHaveLength(1);
358
+ expect(result[0].filePath).toBe('src/app.ts');
359
+ expect(result[0].changeType).toBe('modified');
360
+ expect(result[0].additions).toBe(2);
361
+ expect(result[0].deletions).toBe(1);
362
+ });
363
+
364
+ it('새로 추가된 파일을 파싱한다', () => {
365
+ const diff = `diff --git a/src/new.ts b/src/new.ts
366
+ new file mode 100644
367
+ index 000..abc
368
+ --- /dev/null
369
+ +++ b/src/new.ts
370
+ @@ -0,0 +1,2 @@
371
+ +export const x = 1;
372
+ +export const y = 2;
373
+ `;
374
+ const result = parseDiff(diff);
375
+
376
+ expect(result[0].changeType).toBe('added');
377
+ expect(result[0].additions).toBe(2);
378
+ expect(result[0].deletions).toBe(0);
379
+ });
380
+
381
+ it('삭제된 파일을 파싱한다', () => {
382
+ const diff = `diff --git a/src/old.ts b/src/old.ts
383
+ deleted file mode 100644
384
+ index abc..000
385
+ --- a/src/old.ts
386
+ +++ /dev/null
387
+ @@ -1,2 +0,0 @@
388
+ -export const x = 1;
389
+ -export const y = 2;
390
+ `;
391
+ const result = parseDiff(diff);
392
+
393
+ expect(result[0].changeType).toBe('deleted');
394
+ expect(result[0].deletions).toBe(2);
395
+ });
396
+
397
+ it('rename된 파일을 파싱한다', () => {
398
+ const diff = `diff --git a/src/old.ts b/src/renamed.ts
399
+ rename from src/old.ts
400
+ rename to src/renamed.ts
401
+ similarity index 100%
402
+ `;
403
+ const result = parseDiff(diff);
404
+
405
+ expect(result[0].changeType).toBe('renamed');
406
+ expect(result[0].filePath).toBe('src/renamed.ts');
407
+ });
408
+
409
+ it('여러 파일을 포함한 diff를 파싱한다', () => {
410
+ const diff = `diff --git a/src/a.ts b/src/a.ts
411
+ index abc..def 100644
412
+ --- a/src/a.ts
413
+ +++ b/src/a.ts
414
+ @@ -1 +1,2 @@
415
+ const a = 1;
416
+ +const b = 2;
417
+ diff --git a/src/b.ts b/src/b.ts
418
+ new file mode 100644
419
+ --- /dev/null
420
+ +++ b/src/b.ts
421
+ @@ -0,0 +1 @@
422
+ +export default {};
423
+ `;
424
+ const result = parseDiff(diff);
425
+
426
+ expect(result).toHaveLength(2);
427
+ expect(result[0].filePath).toBe('src/a.ts');
428
+ expect(result[1].filePath).toBe('src/b.ts');
429
+ });
430
+
431
+ it('빈 diff는 빈 배열을 반환한다', () => {
432
+ expect(parseDiff('')).toEqual([]);
433
+ });
434
+
435
+ it('200KB 초과 patch는 truncated 처리한다', () => {
436
+ const longContent = '+' + 'x'.repeat(210_000);
437
+ const diff = `diff --git a/big.ts b/big.ts
438
+ index abc..def 100644
439
+ --- a/big.ts
440
+ +++ b/big.ts
441
+ @@ -1 +1 @@
442
+ ${longContent}
443
+ `;
444
+ const result = parseDiff(diff);
445
+
446
+ expect(result[0].patch).toContain('(truncated)');
447
+ });
448
+
449
+ it('+++ / --- 헤더 라인은 추가/삭제 카운트에 포함하지 않는다', () => {
450
+ const diff = `diff --git a/src/x.ts b/src/x.ts
451
+ index abc..def 100644
452
+ --- a/src/x.ts
453
+ +++ b/src/x.ts
454
+ @@ -1,2 +1,2 @@
455
+ -old line
456
+ +new line
457
+ `;
458
+ const result = parseDiff(diff);
459
+
460
+ expect(result[0].additions).toBe(1);
461
+ expect(result[0].deletions).toBe(1);
462
+ });
463
+ });
464
+
465
+ // ─── captureAndSave ──────────────────────────────────────────────────────
466
+
467
+ describe('captureAndSave', () => {
468
+ it('변경사항을 커밋하고 DB에 저장한 뒤 snapshotSha를 반환한다', async () => {
469
+ execSync
470
+ .mockReturnValueOnce('') // git add -A
471
+ .mockReturnValueOnce('') // git commit
472
+ .mockReturnValueOnce('newsha123\n') // git rev-parse HEAD
473
+ .mockReturnValueOnce(`diff --git a/src/x.ts b/src/x.ts
474
+ index abc..def 100644
475
+ --- a/src/x.ts
476
+ +++ b/src/x.ts
477
+ @@ -1 +1 @@
478
+ -old
479
+ +new
480
+ `); // git diff
481
+
482
+ const result = await service.captureAndSave('task-1', 1, '/worktree', 'startsha', 'task commit', 2);
483
+
484
+ expect(result).toBe('newsha123');
485
+ expect(repo.save).toHaveBeenCalled();
486
+ });
487
+
488
+ it('변경사항이 없으면 DB 저장 없이 snapshotSha를 반환한다', async () => {
489
+ execSync
490
+ .mockReturnValueOnce('')
491
+ .mockReturnValueOnce('')
492
+ .mockReturnValueOnce('newsha123\n')
493
+ .mockReturnValueOnce(' '); // empty diff
494
+
495
+ const result = await service.captureAndSave('task-1', 1, '/worktree', 'startsha', 'task commit');
496
+
497
+ expect(result).toBe('newsha123');
498
+ expect(repo.save).not.toHaveBeenCalled();
499
+ });
500
+
501
+ it('에러 발생 시 null을 반환한다', async () => {
502
+ execSync.mockImplementation(() => { throw new Error('git error'); });
503
+
504
+ const result = await service.captureAndSave('task-1', 1, '/worktree', 'startsha', 'task commit');
505
+
506
+ expect(result).toBeNull();
507
+ });
508
+ });
509
+
510
+ // ─── mergeToMain ─────────────────────────────────────────────────────────
511
+
512
+ describe('mergeToMain', () => {
513
+ it('스냅샷 커밋을 main repo에 merge한다', () => {
514
+ execSync.mockReturnValue('');
515
+
516
+ expect(() => service.mergeToMain('/repo', 'snapshotsha', 1)).not.toThrow();
517
+ expect(execSync).toHaveBeenCalledWith(
518
+ expect.stringContaining('merge --no-ff'),
519
+ expect.anything(),
520
+ );
521
+ });
522
+
523
+ it('merge 충돌 시 abort를 시도하고 경고만 남긴다', () => {
524
+ execSync
525
+ .mockImplementationOnce(() => { throw new Error('conflict'); })
526
+ .mockReturnValueOnce(''); // abort
527
+
528
+ expect(() => service.mergeToMain('/repo', 'sha', 1)).not.toThrow();
529
+ });
530
+ });
531
+ });