@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,690 @@
1
+ import { execFileSync, execSync } from 'child_process';
2
+ import { createHash } from 'crypto';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+
6
+ import { Injectable, Logger } from '@nestjs/common';
7
+ import { InjectRepository } from '@nestjs/typeorm';
8
+ import { Repository } from 'typeorm';
9
+
10
+ import { JI_PATHS } from '../../common/ji-paths';
11
+ import { AgentChangelogEntity, ChangeType } from '../../database/entities/agent-changelog.entity';
12
+
13
+ interface ParsedFile {
14
+ filePath: string;
15
+ changeType: ChangeType;
16
+ patch: string;
17
+ fullPatch: string;
18
+ additions: number;
19
+ deletions: number;
20
+ }
21
+
22
+ export interface DirectorySnapshotEntry {
23
+ hash: string;
24
+ content: string | null;
25
+ isBinary: boolean;
26
+ }
27
+
28
+ export type DirectorySnapshot = Map<string, DirectorySnapshotEntry>;
29
+
30
+ export interface AgentChangelog {
31
+ agentId: number;
32
+ files: Array<{
33
+ id: number;
34
+ filePath: string;
35
+ changeType: ChangeType;
36
+ additions: number;
37
+ deletions: number;
38
+ patch: string | null;
39
+ patchPath: string | null;
40
+ }>;
41
+ }
42
+
43
+ interface MergeResult {
44
+ success: boolean;
45
+ message: string;
46
+ }
47
+
48
+ const IGNORED_DIRS = new Set([
49
+ '.git',
50
+ '.ji',
51
+ '.next',
52
+ '.turbo',
53
+ 'build',
54
+ 'coverage',
55
+ 'dist',
56
+ 'node_modules',
57
+ ]);
58
+ const MAX_TEXT_SNAPSHOT_BYTES = 1024 * 1024;
59
+
60
+ @Injectable()
61
+ export class GitChangelogService {
62
+ private readonly logger = new Logger(GitChangelogService.name);
63
+
64
+ constructor(
65
+ @InjectRepository(AgentChangelogEntity)
66
+ private readonly changelogRepo: Repository<AgentChangelogEntity>,
67
+ ) {}
68
+
69
+ isGitRepo(dir: string): boolean {
70
+ try {
71
+ execSync('git rev-parse --git-dir', { cwd: dir, stdio: 'ignore', timeout: 3000 });
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ getCurrentHead(repoDir: string): string {
79
+ return execSync('git rev-parse HEAD', { cwd: repoDir, encoding: 'utf8', timeout: 3000 }).trim();
80
+ }
81
+
82
+ getCurrentBranch(repoDir: string): string {
83
+ try {
84
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoDir, encoding: 'utf8', timeout: 3000 }).trim();
85
+ return branch.replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 40);
86
+ } catch {
87
+ return 'unknown';
88
+ }
89
+ }
90
+
91
+ getRepoRoot(dir: string): string {
92
+ return execSync('git rev-parse --show-toplevel', { cwd: dir, encoding: 'utf8', timeout: 3000 }).trim();
93
+ }
94
+
95
+ createWorktree(workingDir: string, agentType: string): { worktreePath: string; branchName: string; agentWorkDir: string } {
96
+ const repoRoot = this.getRepoRoot(workingDir);
97
+ const ts = Date.now();
98
+ const currentBranch = this.getCurrentBranch(workingDir);
99
+ const branchName = `${agentType}-${currentBranch}-${ts}`;
100
+ const worktreePath = path.join(JI_PATHS.worktrees, branchName);
101
+ fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
102
+
103
+ execFileSync('git', ['worktree', 'add', worktreePath, '-b', branchName, 'HEAD'], {
104
+ cwd: repoRoot,
105
+ stdio: 'ignore',
106
+ timeout: 10000,
107
+ });
108
+
109
+ const relativeSubDir = path.relative(repoRoot, workingDir);
110
+ const agentWorkDir = relativeSubDir ? path.join(worktreePath, relativeSubDir) : worktreePath;
111
+
112
+ this.logger.log(`Worktree created: ${worktreePath} (branch: ${branchName}, agentDir: ${agentWorkDir})`);
113
+ return { worktreePath, branchName, agentWorkDir };
114
+ }
115
+
116
+ removeWorktree(repoDir: string, worktreePath: string, branchName: string): void {
117
+ try {
118
+ const repoRoot = this.getRepoRoot(repoDir);
119
+ execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
120
+ cwd: repoRoot,
121
+ stdio: 'ignore',
122
+ timeout: 10000,
123
+ });
124
+ execFileSync('git', ['branch', '-D', branchName], {
125
+ cwd: repoRoot,
126
+ stdio: 'ignore',
127
+ timeout: 5000,
128
+ });
129
+ this.logger.log(`Worktree removed: ${worktreePath} (branch: ${branchName})`);
130
+ } catch (err) {
131
+ this.logger.warn(`Failed to remove worktree (ignored): ${err instanceof Error ? err.message : String(err)}`);
132
+ }
133
+ }
134
+
135
+ async captureAndSave(
136
+ taskId: string,
137
+ agentId: number,
138
+ worktreePath: string,
139
+ startCommitHash: string,
140
+ commitMessage: string,
141
+ runId?: number,
142
+ ): Promise<string | null> {
143
+ try {
144
+ execSync('git add -A', { cwd: worktreePath, stdio: 'ignore', timeout: 10000 });
145
+
146
+ const safeMsg = commitMessage.replace(/'/g, "'\\''");
147
+ execSync(
148
+ `git -c core.hooksPath=/dev/null commit --allow-empty -m '${safeMsg}'`,
149
+ { cwd: worktreePath, stdio: 'ignore', timeout: 10000 },
150
+ );
151
+
152
+ const snapshotSha = execSync('git rev-parse HEAD', {
153
+ cwd: worktreePath,
154
+ encoding: 'utf8',
155
+ timeout: 5000,
156
+ }).trim();
157
+
158
+ const diffOutput = execSync(`git diff "${startCommitHash}"..HEAD`, {
159
+ cwd: worktreePath,
160
+ encoding: 'utf8',
161
+ maxBuffer: 50 * 1024 * 1024,
162
+ timeout: 30000,
163
+ });
164
+
165
+ if (!diffOutput.trim()) {
166
+ this.logger.log(`Agent ${agentId}: no changes`);
167
+ return snapshotSha;
168
+ }
169
+
170
+ const files = this.parseDiff(diffOutput);
171
+ if (files.length) {
172
+ await this.saveParsedFiles(taskId, agentId, runId, files);
173
+ this.logger.log(`Agent ${agentId}: saved ${files.length} changelog file(s)`);
174
+ }
175
+
176
+ return snapshotSha;
177
+ } catch (err) {
178
+ this.logger.warn(`Agent ${agentId} changelog capture failed: ${err instanceof Error ? err.message : String(err)}`);
179
+ return null;
180
+ }
181
+ }
182
+
183
+ createDirectorySnapshot(rootDir: string): DirectorySnapshot {
184
+ const snapshot: DirectorySnapshot = new Map();
185
+
186
+ try {
187
+ this.walkDirectory(rootDir, rootDir, snapshot);
188
+ } catch (err) {
189
+ this.logger.warn(`Directory snapshot failed (${rootDir}): ${err instanceof Error ? err.message : String(err)}`);
190
+ }
191
+
192
+ return snapshot;
193
+ }
194
+
195
+ async captureDirectoryAndSave(
196
+ taskId: string,
197
+ agentId: number,
198
+ rootDir: string,
199
+ before: DirectorySnapshot,
200
+ runId?: number,
201
+ ): Promise<number> {
202
+ try {
203
+ const after = this.createDirectorySnapshot(rootDir);
204
+ const files = this.diffDirectorySnapshots(before, after);
205
+
206
+ if (!files.length) {
207
+ this.logger.log(`Agent ${agentId}: no directory changes`);
208
+ return 0;
209
+ }
210
+
211
+ await this.saveParsedFiles(taskId, agentId, runId, files);
212
+ this.logger.log(`Agent ${agentId}: saved ${files.length} directory changelog file(s)`);
213
+ return files.length;
214
+ } catch (err) {
215
+ this.logger.warn(`Agent ${agentId} directory changelog capture failed: ${err instanceof Error ? err.message : String(err)}`);
216
+ return 0;
217
+ }
218
+ }
219
+
220
+ mergeToMain(mainRepoDir: string, snapshotSha: string, agentId: number): void {
221
+ try {
222
+ execSync(
223
+ `git -c core.hooksPath=/dev/null merge --no-ff "${snapshotSha}" -m "chore: apply agent-${agentId} changes"`,
224
+ { cwd: mainRepoDir, stdio: 'ignore', timeout: 30000 },
225
+ );
226
+ this.logger.log(`Agent ${agentId}: merged snapshot ${snapshotSha.slice(0, 7)}`);
227
+ } catch (err) {
228
+ this.logger.warn(`Agent ${agentId}: merge conflict; changelog remains available. ${err instanceof Error ? err.message : String(err)}`);
229
+ try {
230
+ execSync('git merge --abort', { cwd: mainRepoDir, stdio: 'ignore', timeout: 5000 });
231
+ } catch {}
232
+ }
233
+ }
234
+
235
+ mergeAll(worktreePath: string, workingDir: string): MergeResult {
236
+ if (!fs.existsSync(worktreePath)) {
237
+ return { success: false, message: `worktree 경로가 존재하지 않습니다: ${worktreePath}` };
238
+ }
239
+
240
+ try {
241
+ const repoRoot = this.getRepoRoot(workingDir);
242
+ const branchName = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
243
+ cwd: worktreePath,
244
+ encoding: 'utf8',
245
+ timeout: 3000,
246
+ }).trim();
247
+
248
+ execFileSync(
249
+ 'git',
250
+ ['-c', 'core.hooksPath=/dev/null', 'merge', '--no-ff', branchName],
251
+ { cwd: repoRoot, timeout: 30000 },
252
+ );
253
+ this.logger.log(`Merged branch ${branchName} into ${repoRoot}`);
254
+ return { success: true, message: '전체 병합이 완료되었습니다.' };
255
+ } catch (err) {
256
+ try {
257
+ const repoRoot = this.getRepoRoot(workingDir);
258
+ execFileSync('git', ['merge', '--abort'], { cwd: repoRoot });
259
+ } catch {}
260
+ const msg = err instanceof Error ? err.message : String(err);
261
+ this.logger.warn(`Merge failed: ${msg}`);
262
+ return { success: false, message: msg };
263
+ }
264
+ }
265
+
266
+ mergeFile(worktreePath: string, workingDir: string, filePath: string): MergeResult {
267
+ if (!fs.existsSync(worktreePath)) {
268
+ return { success: false, message: `worktree 경로가 존재하지 않습니다: ${worktreePath}` };
269
+ }
270
+
271
+ try {
272
+ const safeFilePath = this.normalizeGitPathspec(filePath);
273
+ const repoRoot = this.getRepoRoot(workingDir);
274
+ const branchName = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
275
+ cwd: worktreePath,
276
+ encoding: 'utf8',
277
+ timeout: 3000,
278
+ }).trim();
279
+
280
+ execFileSync('git', ['checkout', branchName, '--', safeFilePath], {
281
+ cwd: repoRoot,
282
+ timeout: 10000,
283
+ });
284
+ this.logger.log(`Merged file from worktree: ${safeFilePath}`);
285
+ return { success: true, message: `${safeFilePath} 병합이 완료되었습니다.` };
286
+ } catch (err) {
287
+ const msg = err instanceof Error ? err.message : String(err);
288
+ this.logger.warn(`File merge failed (${filePath}): ${msg}`);
289
+ return { success: false, message: msg };
290
+ }
291
+ }
292
+
293
+ async mergeAllFromChangelog(taskId: string, agentId: number, workingDir: string): Promise<MergeResult> {
294
+ try {
295
+ const rows = await this.findLatestAgentRows(taskId, agentId);
296
+ if (!rows.length) {
297
+ return { success: false, message: '저장된 changelog patch가 없습니다.' };
298
+ }
299
+
300
+ const patchText = this.buildPatchText(rows);
301
+ if (!patchText) {
302
+ return { success: false, message: '저장된 changelog patch가 없거나 잘려 있습니다.' };
303
+ }
304
+
305
+ const repoRoot = this.getRepoRoot(workingDir);
306
+ this.applyPatchText(repoRoot, patchText);
307
+ return { success: true, message: '저장된 changelog patch를 적용했습니다.' };
308
+ } catch (err) {
309
+ const msg = err instanceof Error ? err.message : String(err);
310
+ this.logger.warn(`Stored changelog merge failed: ${msg}`);
311
+ return { success: false, message: msg };
312
+ }
313
+ }
314
+
315
+ async mergeFileFromChangelog(taskId: string, agentId: number, workingDir: string, filePath: string): Promise<MergeResult> {
316
+ try {
317
+ const safeFilePath = this.normalizeGitPathspec(filePath);
318
+ const rows = await this.findLatestAgentRows(taskId, agentId);
319
+ const row = rows.find((entry) => entry.filePath === safeFilePath);
320
+
321
+ if (!row) {
322
+ return { success: false, message: '이 파일의 저장된 changelog patch가 없습니다.' };
323
+ }
324
+
325
+ const patchText = this.readStoredPatch(row);
326
+ if (!patchText) {
327
+ return { success: false, message: '저장된 changelog patch가 없거나 잘려 있습니다.' };
328
+ }
329
+
330
+ const repoRoot = this.getRepoRoot(workingDir);
331
+ this.applyPatchText(repoRoot, patchText);
332
+ return { success: true, message: `${safeFilePath} 파일에 저장된 changelog patch를 적용했습니다.` };
333
+ } catch (err) {
334
+ const msg = err instanceof Error ? err.message : String(err);
335
+ this.logger.warn(`Stored changelog file merge failed (${filePath}): ${msg}`);
336
+ return { success: false, message: msg };
337
+ }
338
+ }
339
+
340
+ async getLatestRunId(taskId: string): Promise<number | null> {
341
+ const row = await this.changelogRepo.findOne({
342
+ where: { taskId },
343
+ order: { runId: 'DESC' },
344
+ select: ['runId'],
345
+ });
346
+ return row?.runId ?? null;
347
+ }
348
+
349
+ async getByTask(taskId: string, runId?: number): Promise<AgentChangelog[]> {
350
+ const targetRunId = runId ?? (await this.getLatestRunId(taskId));
351
+
352
+ const rows = await this.changelogRepo.find({
353
+ where: targetRunId != null ? { taskId, runId: targetRunId } : { taskId },
354
+ order: { agentId: 'ASC', id: 'ASC' },
355
+ });
356
+
357
+ const byAgent = new Map<number, AgentChangelog>();
358
+ for (const row of rows) {
359
+ if (!byAgent.has(row.agentId)) {
360
+ byAgent.set(row.agentId, { agentId: row.agentId, files: [] });
361
+ }
362
+ byAgent.get(row.agentId)!.files.push({
363
+ id: row.id,
364
+ filePath: row.filePath,
365
+ changeType: row.changeType,
366
+ additions: row.additions,
367
+ deletions: row.deletions,
368
+ patch: row.patch,
369
+ patchPath: row.patchPath,
370
+ });
371
+ }
372
+
373
+ return Array.from(byAgent.values());
374
+ }
375
+
376
+ private async findLatestAgentRows(taskId: string, agentId: number): Promise<AgentChangelogEntity[]> {
377
+ const targetRunId = await this.getLatestRunId(taskId);
378
+ return this.changelogRepo.find({
379
+ where: targetRunId != null ? { taskId, agentId, runId: targetRunId } : { taskId, agentId },
380
+ order: { id: 'ASC' },
381
+ });
382
+ }
383
+
384
+ private buildPatchText(rows: AgentChangelogEntity[]): string | null {
385
+ const patches: string[] = [];
386
+ for (const row of rows) {
387
+ const patchText = this.readStoredPatch(row);
388
+ if (!patchText) return null;
389
+ patches.push(patchText.trimEnd());
390
+ }
391
+ return patches.length ? `${patches.join('\n')}\n` : null;
392
+ }
393
+
394
+ private readStoredPatch(row: AgentChangelogEntity): string | null {
395
+ try {
396
+ if (row.patchPath && fs.existsSync(row.patchPath)) {
397
+ return fs.readFileSync(row.patchPath, 'utf8');
398
+ }
399
+
400
+ if (row.patch && !row.patch.includes('\n... (truncated)')) {
401
+ return row.patch;
402
+ }
403
+
404
+ return null;
405
+ } catch (err) {
406
+ this.logger.warn(`Failed to read stored patch ${row.patchPath ?? row.id}: ${err instanceof Error ? err.message : String(err)}`);
407
+ return null;
408
+ }
409
+ }
410
+
411
+ private applyPatchText(repoRoot: string, patchText: string): void {
412
+ const tempPath = path.join(JI_PATHS.patches, `.apply-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
413
+ try {
414
+ fs.mkdirSync(JI_PATHS.patches, { recursive: true });
415
+ fs.writeFileSync(tempPath, patchText, 'utf8');
416
+ execFileSync('git', ['apply', '--index', '--whitespace=nowarn', tempPath], {
417
+ cwd: repoRoot,
418
+ timeout: 30000,
419
+ });
420
+ } finally {
421
+ try {
422
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
423
+ } catch {}
424
+ }
425
+ }
426
+
427
+ private async saveParsedFiles(
428
+ taskId: string,
429
+ agentId: number,
430
+ runId: number | undefined,
431
+ files: ParsedFile[],
432
+ ): Promise<void> {
433
+ await this.changelogRepo.save(
434
+ files.map((f, index) => {
435
+ const patchPath = this.writePatchFile(taskId, agentId, runId, f.filePath, f.fullPatch, index);
436
+ return this.changelogRepo.create({
437
+ taskId,
438
+ agentId,
439
+ runId: runId ?? null,
440
+ filePath: f.filePath,
441
+ changeType: f.changeType,
442
+ patch: f.patch,
443
+ patchPath,
444
+ additions: f.additions,
445
+ deletions: f.deletions,
446
+ });
447
+ }),
448
+ );
449
+ }
450
+
451
+ private walkDirectory(rootDir: string, currentDir: string, snapshot: DirectorySnapshot): void {
452
+ let entries: fs.Dirent[];
453
+
454
+ try {
455
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
456
+ } catch (err) {
457
+ this.logger.warn(`Directory read skipped (${currentDir}): ${err instanceof Error ? err.message : String(err)}`);
458
+ return;
459
+ }
460
+
461
+ for (const entry of entries) {
462
+ const fullPath = path.join(currentDir, entry.name);
463
+
464
+ try {
465
+ if (entry.isDirectory()) {
466
+ if (!IGNORED_DIRS.has(entry.name)) {
467
+ this.walkDirectory(rootDir, fullPath, snapshot);
468
+ }
469
+ continue;
470
+ }
471
+
472
+ if (!entry.isFile()) continue;
473
+
474
+ const relativePath = this.toRelativeGitPath(rootDir, fullPath);
475
+ if (!relativePath) continue;
476
+
477
+ const snapshotEntry = this.readSnapshotEntry(fullPath);
478
+ if (snapshotEntry) {
479
+ snapshot.set(relativePath, snapshotEntry);
480
+ }
481
+ } catch (err) {
482
+ this.logger.warn(`Directory entry skipped (${fullPath}): ${err instanceof Error ? err.message : String(err)}`);
483
+ }
484
+ }
485
+ }
486
+
487
+ private readSnapshotEntry(filePath: string): DirectorySnapshotEntry | null {
488
+ try {
489
+ const data = fs.readFileSync(filePath);
490
+ const hash = createHash('sha256').update(data).digest('hex');
491
+ const isBinary = this.isBinaryBuffer(data);
492
+ const content = !isBinary && data.byteLength <= MAX_TEXT_SNAPSHOT_BYTES
493
+ ? data.toString('utf8')
494
+ : null;
495
+
496
+ return { hash, content, isBinary };
497
+ } catch (err) {
498
+ this.logger.warn(`File snapshot skipped (${filePath}): ${err instanceof Error ? err.message : String(err)}`);
499
+ return null;
500
+ }
501
+ }
502
+
503
+ private diffDirectorySnapshots(before: DirectorySnapshot, after: DirectorySnapshot): ParsedFile[] {
504
+ const paths = Array.from(new Set([...before.keys(), ...after.keys()])).sort();
505
+ const files: ParsedFile[] = [];
506
+
507
+ for (const filePath of paths) {
508
+ const beforeEntry = before.get(filePath);
509
+ const afterEntry = after.get(filePath);
510
+
511
+ if (beforeEntry && afterEntry && beforeEntry.hash === afterEntry.hash) continue;
512
+
513
+ const changeType: ChangeType = beforeEntry && afterEntry
514
+ ? 'modified'
515
+ : beforeEntry
516
+ ? 'deleted'
517
+ : 'added';
518
+
519
+ const beforeContent = beforeEntry?.content ?? null;
520
+ const afterContent = afterEntry?.content ?? null;
521
+ const fullPatch = this.buildDirectoryPatch(filePath, changeType, beforeContent, afterContent);
522
+ const additions = afterContent == null ? 0 : this.countContentLines(afterContent);
523
+ const deletions = beforeContent == null ? 0 : this.countContentLines(beforeContent);
524
+
525
+ files.push({
526
+ filePath,
527
+ changeType,
528
+ patch: fullPatch.length > 200_000 ? `${fullPatch.slice(0, 200_000)}\n... (truncated)` : fullPatch,
529
+ fullPatch,
530
+ additions,
531
+ deletions,
532
+ });
533
+ }
534
+
535
+ return files;
536
+ }
537
+
538
+ private buildDirectoryPatch(
539
+ filePath: string,
540
+ changeType: ChangeType,
541
+ beforeContent: string | null,
542
+ afterContent: string | null,
543
+ ): string {
544
+ const header = [`diff --git a/${filePath} b/${filePath}`];
545
+
546
+ if (changeType === 'added') header.push('new file mode 100644');
547
+ if (changeType === 'deleted') header.push('deleted file mode 100644');
548
+
549
+ const oldContent = changeType === 'added' ? '' : beforeContent;
550
+ const newContent = changeType === 'deleted' ? '' : afterContent;
551
+
552
+ if (oldContent == null || newContent == null) {
553
+ header.push('Binary files differ or file is too large to inline');
554
+ return `${header.join('\n')}\n`;
555
+ }
556
+
557
+ const beforeLines = this.countContentLines(oldContent);
558
+ const afterLines = this.countContentLines(newContent);
559
+ const oldPath = changeType === 'added' ? '/dev/null' : `a/${filePath}`;
560
+ const newPath = changeType === 'deleted' ? '/dev/null' : `b/${filePath}`;
561
+
562
+ header.push(`--- ${oldPath}`);
563
+ header.push(`+++ ${newPath}`);
564
+ header.push(`@@ -${this.formatHunkRange(beforeLines)} +${this.formatHunkRange(afterLines)} @@`);
565
+ header.push(this.prefixContentLines('-', oldContent).trimEnd());
566
+ header.push(this.prefixContentLines('+', newContent).trimEnd());
567
+
568
+ return `${header.filter((line) => line.length > 0).join('\n')}\n`;
569
+ }
570
+
571
+ private countContentLines(content: string): number {
572
+ if (!content) return 0;
573
+ const withoutTrailingNewline = content.endsWith('\n') ? content.slice(0, -1) : content;
574
+ return withoutTrailingNewline ? withoutTrailingNewline.split('\n').length : 0;
575
+ }
576
+
577
+ private formatHunkRange(lineCount: number): string {
578
+ return lineCount === 0 ? '0,0' : `1,${lineCount}`;
579
+ }
580
+
581
+ private prefixContentLines(prefix: string, content: string): string {
582
+ if (!content) return '';
583
+ const withoutTrailingNewline = content.endsWith('\n') ? content.slice(0, -1) : content;
584
+ if (!withoutTrailingNewline) return '';
585
+ return withoutTrailingNewline.split('\n').map((line) => `${prefix}${line}`).join('\n');
586
+ }
587
+
588
+ private isBinaryBuffer(data: Buffer): boolean {
589
+ return data.includes(0);
590
+ }
591
+
592
+ private toRelativeGitPath(rootDir: string, fullPath: string): string {
593
+ const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, '/');
594
+ const normalized = path.posix.normalize(relativePath);
595
+
596
+ if (
597
+ !normalized ||
598
+ normalized === '.' ||
599
+ normalized === '..' ||
600
+ normalized.startsWith('../') ||
601
+ path.posix.isAbsolute(normalized)
602
+ ) {
603
+ return '';
604
+ }
605
+
606
+ return normalized;
607
+ }
608
+
609
+ private writePatchFile(
610
+ taskId: string,
611
+ agentId: number,
612
+ runId: number | undefined,
613
+ filePath: string,
614
+ patchText: string,
615
+ index: number,
616
+ ): string {
617
+ const runSegment = runId != null ? `run-${runId}` : 'run-unversioned';
618
+ const dir = path.join(
619
+ JI_PATHS.patches,
620
+ this.toPathSegment(taskId),
621
+ runSegment,
622
+ `agent-${agentId}`,
623
+ );
624
+ const filename = `${String(index + 1).padStart(3, '0')}-${this.toPathSegment(filePath).slice(0, 96)}.patch`;
625
+ const patchPath = path.join(dir, filename);
626
+
627
+ fs.mkdirSync(dir, { recursive: true });
628
+ fs.writeFileSync(patchPath, patchText, 'utf8');
629
+ return patchPath;
630
+ }
631
+
632
+ private toPathSegment(value: string): string {
633
+ const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
634
+ return sanitized || 'item';
635
+ }
636
+
637
+ private normalizeGitPathspec(filePath: string): string {
638
+ const normalized = path.posix.normalize(filePath.replace(/\\/g, '/'));
639
+
640
+ if (
641
+ !filePath.trim() ||
642
+ normalized === '.' ||
643
+ normalized === '..' ||
644
+ normalized.startsWith('../') ||
645
+ path.posix.isAbsolute(normalized)
646
+ ) {
647
+ throw new Error(`유효하지 않은 파일 경로입니다: ${filePath}`);
648
+ }
649
+
650
+ return normalized;
651
+ }
652
+
653
+ private parseDiff(diff: string): ParsedFile[] {
654
+ const files: ParsedFile[] = [];
655
+ const sections = diff.split(/^diff --git /m).filter(Boolean);
656
+
657
+ for (const section of sections) {
658
+ const full = `diff --git ${section}`;
659
+ const lines = section.split('\n');
660
+ const header = lines[0] ?? '';
661
+ const match = header.match(/^a\/(.+?) b\/(.+)$/);
662
+ if (!match) continue;
663
+
664
+ const filePath = match[2];
665
+
666
+ let changeType: ChangeType = 'modified';
667
+ if (full.includes('\nnew file mode')) changeType = 'added';
668
+ else if (full.includes('\ndeleted file mode')) changeType = 'deleted';
669
+ else if (full.includes('\nrename from ')) changeType = 'renamed';
670
+
671
+ let additions = 0;
672
+ let deletions = 0;
673
+ for (const line of lines) {
674
+ if (line.startsWith('+') && !line.startsWith('+++')) additions++;
675
+ else if (line.startsWith('-') && !line.startsWith('---')) deletions++;
676
+ }
677
+
678
+ files.push({
679
+ filePath,
680
+ changeType,
681
+ patch: full.length > 200_000 ? `${full.slice(0, 200_000)}\n... (truncated)` : full,
682
+ fullPatch: full,
683
+ additions,
684
+ deletions,
685
+ });
686
+ }
687
+
688
+ return files;
689
+ }
690
+ }