byterover-cli 2.6.0 → 3.0.1

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 (316) hide show
  1. package/.env.production +1 -0
  2. package/README.md +240 -14
  3. package/dist/agent/core/domain/knowledge/conflict-detector.d.ts +38 -0
  4. package/dist/agent/core/domain/knowledge/conflict-detector.js +71 -0
  5. package/dist/agent/core/domain/knowledge/conflict-resolver.d.ts +17 -0
  6. package/dist/agent/core/domain/knowledge/conflict-resolver.js +118 -0
  7. package/dist/agent/core/domain/knowledge/utils.d.ts +4 -0
  8. package/dist/agent/core/domain/knowledge/utils.js +6 -0
  9. package/dist/agent/core/interfaces/i-curate-service.d.ts +6 -0
  10. package/dist/agent/infra/tools/implementations/curate-tool.d.ts +67 -34
  11. package/dist/agent/infra/tools/implementations/curate-tool.js +294 -47
  12. package/dist/agent/resources/prompts/system-prompt.yml +15 -8
  13. package/dist/agent/resources/tools/code_exec.txt +3 -0
  14. package/dist/agent/resources/tools/curate.txt +12 -3
  15. package/dist/oclif/commands/connectors/install.d.ts +2 -1
  16. package/dist/oclif/commands/connectors/install.js +38 -3
  17. package/dist/oclif/commands/curate/index.d.ts +18 -0
  18. package/dist/oclif/commands/curate/index.js +78 -1
  19. package/dist/oclif/commands/init.d.ts +12 -0
  20. package/dist/oclif/commands/init.js +75 -0
  21. package/dist/oclif/commands/locations.js +1 -1
  22. package/dist/oclif/commands/providers/connect.d.ts +31 -1
  23. package/dist/oclif/commands/providers/connect.js +307 -27
  24. package/dist/oclif/commands/pull.d.ts +1 -0
  25. package/dist/oclif/commands/pull.js +7 -0
  26. package/dist/oclif/commands/push.d.ts +1 -0
  27. package/dist/oclif/commands/push.js +8 -0
  28. package/dist/oclif/commands/review/approve.d.ts +17 -0
  29. package/dist/oclif/commands/review/approve.js +37 -0
  30. package/dist/oclif/commands/review/base-review-decision.d.ts +18 -0
  31. package/dist/oclif/commands/review/base-review-decision.js +71 -0
  32. package/dist/oclif/commands/review/pending.d.ts +13 -0
  33. package/dist/oclif/commands/review/pending.js +94 -0
  34. package/dist/oclif/commands/review/reject.d.ts +17 -0
  35. package/dist/oclif/commands/review/reject.js +38 -0
  36. package/dist/oclif/commands/space/list.d.ts +2 -2
  37. package/dist/oclif/commands/space/list.js +13 -35
  38. package/dist/oclif/commands/space/switch.d.ts +2 -7
  39. package/dist/oclif/commands/space/switch.js +13 -56
  40. package/dist/oclif/commands/status.d.ts +1 -0
  41. package/dist/oclif/commands/status.js +11 -1
  42. package/dist/oclif/commands/vc/add.d.ts +7 -0
  43. package/dist/oclif/commands/vc/add.js +29 -0
  44. package/dist/oclif/commands/vc/branch.d.ts +15 -0
  45. package/dist/oclif/commands/vc/branch.js +70 -0
  46. package/dist/oclif/commands/vc/checkout.d.ts +14 -0
  47. package/dist/oclif/commands/vc/checkout.js +47 -0
  48. package/dist/oclif/commands/vc/clone.d.ts +9 -0
  49. package/dist/oclif/commands/vc/clone.js +61 -0
  50. package/dist/oclif/commands/vc/commit.d.ts +10 -0
  51. package/dist/oclif/commands/vc/commit.js +32 -0
  52. package/dist/oclif/commands/vc/config.d.ts +10 -0
  53. package/dist/oclif/commands/vc/config.js +30 -0
  54. package/dist/oclif/commands/vc/fetch.d.ts +10 -0
  55. package/dist/oclif/commands/vc/fetch.js +42 -0
  56. package/dist/oclif/commands/vc/index.d.ts +6 -0
  57. package/dist/oclif/commands/vc/index.js +8 -0
  58. package/dist/oclif/commands/vc/init.d.ts +6 -0
  59. package/dist/oclif/commands/vc/init.js +25 -0
  60. package/dist/oclif/commands/vc/log.d.ts +13 -0
  61. package/dist/oclif/commands/vc/log.js +48 -0
  62. package/dist/oclif/commands/vc/merge.d.ts +19 -0
  63. package/dist/oclif/commands/vc/merge.js +130 -0
  64. package/dist/oclif/commands/vc/pull.d.ts +13 -0
  65. package/dist/oclif/commands/vc/pull.js +60 -0
  66. package/dist/oclif/commands/vc/push.d.ts +13 -0
  67. package/dist/oclif/commands/vc/push.js +60 -0
  68. package/dist/oclif/commands/vc/remote/add.d.ts +10 -0
  69. package/dist/oclif/commands/vc/remote/add.js +30 -0
  70. package/dist/oclif/commands/vc/remote/index.d.ts +6 -0
  71. package/dist/oclif/commands/vc/remote/index.js +16 -0
  72. package/dist/oclif/commands/vc/remote/set-url.d.ts +10 -0
  73. package/dist/oclif/commands/vc/remote/set-url.js +30 -0
  74. package/dist/oclif/commands/vc/reset.d.ts +13 -0
  75. package/dist/oclif/commands/vc/reset.js +62 -0
  76. package/dist/oclif/commands/vc/status.d.ts +8 -0
  77. package/dist/oclif/commands/vc/status.js +106 -0
  78. package/dist/oclif/hooks/init/validate-brv-config.d.ts +26 -0
  79. package/dist/oclif/hooks/init/validate-brv-config.js +62 -0
  80. package/dist/oclif/lib/daemon-client.d.ts +2 -0
  81. package/dist/oclif/lib/daemon-client.js +36 -10
  82. package/dist/oclif/lib/prompt-utils.d.ts +43 -0
  83. package/dist/oclif/lib/prompt-utils.js +84 -0
  84. package/dist/oclif/lib/spinner.d.ts +8 -0
  85. package/dist/oclif/lib/spinner.js +23 -0
  86. package/dist/oclif/lib/task-client.d.ts +5 -0
  87. package/dist/oclif/lib/task-client.js +15 -2
  88. package/dist/server/config/environment.d.ts +2 -0
  89. package/dist/server/config/environment.js +2 -0
  90. package/dist/server/constants.d.ts +3 -0
  91. package/dist/server/constants.js +9 -0
  92. package/dist/server/core/domain/entities/auth-token.d.ts +2 -0
  93. package/dist/server/core/domain/entities/auth-token.js +7 -1
  94. package/dist/server/core/domain/entities/curate-log-entry.d.ts +11 -0
  95. package/dist/server/core/domain/entities/space.d.ts +4 -0
  96. package/dist/server/core/domain/entities/space.js +8 -0
  97. package/dist/server/core/domain/entities/team.d.ts +2 -0
  98. package/dist/server/core/domain/entities/team.js +4 -0
  99. package/dist/server/core/domain/errors/git-error.d.ts +6 -0
  100. package/dist/server/core/domain/errors/git-error.js +12 -0
  101. package/dist/server/core/domain/errors/task-error.d.ts +4 -0
  102. package/dist/server/core/domain/errors/task-error.js +8 -0
  103. package/dist/server/core/domain/errors/vc-error.d.ts +5 -0
  104. package/dist/server/core/domain/errors/vc-error.js +8 -0
  105. package/dist/server/core/domain/knowledge/markdown-writer.d.ts +4 -1
  106. package/dist/server/core/domain/knowledge/markdown-writer.js +37 -7
  107. package/dist/server/core/domain/transport/schemas.d.ts +6 -6
  108. package/dist/server/core/interfaces/context-tree/i-context-tree-service.d.ts +11 -0
  109. package/dist/server/core/interfaces/process/i-task-lifecycle-hook.d.ts +6 -0
  110. package/dist/server/core/interfaces/services/i-git-service.d.ts +234 -0
  111. package/dist/server/core/interfaces/services/i-git-service.js +1 -0
  112. package/dist/server/core/interfaces/storage/i-curate-log-store.d.ts +5 -0
  113. package/dist/server/core/interfaces/storage/i-review-backup-store.d.ts +19 -0
  114. package/dist/server/core/interfaces/storage/i-review-backup-store.js +1 -0
  115. package/dist/server/core/interfaces/vc/i-vc-git-config-store.d.ts +8 -0
  116. package/dist/server/core/interfaces/vc/i-vc-git-config-store.js +1 -0
  117. package/dist/server/infra/config/auto-init.d.ts +0 -2
  118. package/dist/server/infra/config/auto-init.js +0 -1
  119. package/dist/server/infra/context-tree/file-context-tree-service.d.ts +2 -0
  120. package/dist/server/infra/context-tree/file-context-tree-service.js +13 -0
  121. package/dist/server/infra/daemon/brv-server.js +23 -3
  122. package/dist/server/infra/git/cogit-url.d.ts +17 -0
  123. package/dist/server/infra/git/cogit-url.js +39 -0
  124. package/dist/server/infra/git/git-http-wrapper.d.ts +20 -0
  125. package/dist/server/infra/git/git-http-wrapper.js +334 -0
  126. package/dist/server/infra/git/isomorphic-git-service.d.ts +78 -0
  127. package/dist/server/infra/git/isomorphic-git-service.js +983 -0
  128. package/dist/server/infra/http/review-api-handler.d.ts +13 -0
  129. package/dist/server/infra/http/review-api-handler.js +286 -0
  130. package/dist/server/infra/http/review-ui.d.ts +7 -0
  131. package/dist/server/infra/http/review-ui.js +606 -0
  132. package/dist/server/infra/mcp/tools/brv-curate-tool.d.ts +2 -2
  133. package/dist/server/infra/process/curate-log-handler.d.ts +18 -2
  134. package/dist/server/infra/process/curate-log-handler.js +50 -13
  135. package/dist/server/infra/process/feature-handlers.js +41 -1
  136. package/dist/server/infra/process/task-router.js +16 -0
  137. package/dist/server/infra/space/http-space-service.js +2 -0
  138. package/dist/server/infra/storage/file-curate-log-store.d.ts +10 -0
  139. package/dist/server/infra/storage/file-curate-log-store.js +35 -0
  140. package/dist/server/infra/storage/file-review-backup-store.d.ts +29 -0
  141. package/dist/server/infra/storage/file-review-backup-store.js +121 -0
  142. package/dist/server/infra/transport/handlers/auth-handler.js +9 -5
  143. package/dist/server/infra/transport/handlers/handler-types.d.ts +9 -0
  144. package/dist/server/infra/transport/handlers/handler-types.js +11 -0
  145. package/dist/server/infra/transport/handlers/index.d.ts +4 -0
  146. package/dist/server/infra/transport/handlers/index.js +2 -0
  147. package/dist/server/infra/transport/handlers/init-handler.d.ts +1 -0
  148. package/dist/server/infra/transport/handlers/init-handler.js +13 -1
  149. package/dist/server/infra/transport/handlers/pull-handler.d.ts +3 -0
  150. package/dist/server/infra/transport/handlers/pull-handler.js +5 -1
  151. package/dist/server/infra/transport/handlers/push-handler.d.ts +20 -0
  152. package/dist/server/infra/transport/handlers/push-handler.js +116 -14
  153. package/dist/server/infra/transport/handlers/reset-handler.d.ts +11 -0
  154. package/dist/server/infra/transport/handlers/reset-handler.js +37 -1
  155. package/dist/server/infra/transport/handlers/review-handler.d.ts +35 -0
  156. package/dist/server/infra/transport/handlers/review-handler.js +162 -0
  157. package/dist/server/infra/transport/handlers/space-handler.d.ts +3 -0
  158. package/dist/server/infra/transport/handlers/space-handler.js +4 -1
  159. package/dist/server/infra/transport/handlers/status-handler.d.ts +5 -0
  160. package/dist/server/infra/transport/handlers/status-handler.js +51 -16
  161. package/dist/server/infra/transport/handlers/vc-handler.d.ts +100 -0
  162. package/dist/server/infra/transport/handlers/vc-handler.js +1050 -0
  163. package/dist/server/infra/transport/socket-io-transport-server.d.ts +7 -0
  164. package/dist/server/infra/transport/socket-io-transport-server.js +12 -1
  165. package/dist/server/infra/transport/transport-connector.d.ts +1 -1
  166. package/dist/server/infra/transport/transport-connector.js +2 -1
  167. package/dist/server/infra/vc/file-vc-git-config-store.d.ts +11 -0
  168. package/dist/server/infra/vc/file-vc-git-config-store.js +43 -0
  169. package/dist/server/templates/skill/SKILL.md +167 -33
  170. package/dist/server/utils/curate-result-parser.d.ts +64 -0
  171. package/dist/server/utils/curate-result-parser.js +8 -0
  172. package/dist/server/utils/gitignore.d.ts +9 -0
  173. package/dist/server/utils/gitignore.js +47 -0
  174. package/dist/shared/transport/events/index.d.ts +6 -0
  175. package/dist/shared/transport/events/index.js +3 -0
  176. package/dist/shared/transport/events/init-events.d.ts +8 -0
  177. package/dist/shared/transport/events/init-events.js +1 -0
  178. package/dist/shared/transport/events/push-events.d.ts +6 -0
  179. package/dist/shared/transport/events/review-events.d.ts +41 -0
  180. package/dist/shared/transport/events/review-events.js +5 -0
  181. package/dist/shared/transport/events/vc-events.d.ts +257 -0
  182. package/dist/shared/transport/events/vc-events.js +67 -0
  183. package/dist/shared/transport/types/dto.d.ts +6 -1
  184. package/dist/tui/app/pages/init-project-page.d.ts +9 -0
  185. package/dist/tui/app/pages/init-project-page.js +54 -0
  186. package/dist/tui/app/pages/protected-routes.js +14 -6
  187. package/dist/tui/components/index.d.ts +0 -2
  188. package/dist/tui/components/index.js +0 -1
  189. package/dist/tui/features/activity/hooks/use-activity-logs.js +7 -1
  190. package/dist/tui/features/commands/definitions/index.js +3 -0
  191. package/dist/tui/features/commands/definitions/space-list.js +9 -18
  192. package/dist/tui/features/commands/definitions/space-switch.js +10 -6
  193. package/dist/tui/features/commands/definitions/vc-add.d.ts +2 -0
  194. package/dist/tui/features/commands/definitions/vc-add.js +15 -0
  195. package/dist/tui/features/commands/definitions/vc-branch.d.ts +2 -0
  196. package/dist/tui/features/commands/definitions/vc-branch.js +33 -0
  197. package/dist/tui/features/commands/definitions/vc-checkout.d.ts +2 -0
  198. package/dist/tui/features/commands/definitions/vc-checkout.js +32 -0
  199. package/dist/tui/features/commands/definitions/vc-clone.d.ts +2 -0
  200. package/dist/tui/features/commands/definitions/vc-clone.js +18 -0
  201. package/dist/tui/features/commands/definitions/vc-commit.d.ts +2 -0
  202. package/dist/tui/features/commands/definitions/vc-commit.js +32 -0
  203. package/dist/tui/features/commands/definitions/vc-config.d.ts +2 -0
  204. package/dist/tui/features/commands/definitions/vc-config.js +40 -0
  205. package/dist/tui/features/commands/definitions/vc-fetch.d.ts +2 -0
  206. package/dist/tui/features/commands/definitions/vc-fetch.js +37 -0
  207. package/dist/tui/features/commands/definitions/vc-init.d.ts +2 -0
  208. package/dist/tui/features/commands/definitions/vc-init.js +11 -0
  209. package/dist/tui/features/commands/definitions/vc-log.d.ts +2 -0
  210. package/dist/tui/features/commands/definitions/vc-log.js +25 -0
  211. package/dist/tui/features/commands/definitions/vc-merge.d.ts +2 -0
  212. package/dist/tui/features/commands/definitions/vc-merge.js +48 -0
  213. package/dist/tui/features/commands/definitions/vc-pull.d.ts +2 -0
  214. package/dist/tui/features/commands/definitions/vc-pull.js +42 -0
  215. package/dist/tui/features/commands/definitions/vc-push.d.ts +2 -0
  216. package/dist/tui/features/commands/definitions/vc-push.js +38 -0
  217. package/dist/tui/features/commands/definitions/vc-remote.d.ts +2 -0
  218. package/dist/tui/features/commands/definitions/vc-remote.js +57 -0
  219. package/dist/tui/features/commands/definitions/vc-reset.d.ts +2 -0
  220. package/dist/tui/features/commands/definitions/vc-reset.js +35 -0
  221. package/dist/tui/features/commands/definitions/vc-status.d.ts +2 -0
  222. package/dist/tui/features/commands/definitions/vc-status.js +11 -0
  223. package/dist/tui/features/commands/definitions/vc.d.ts +2 -0
  224. package/dist/tui/features/commands/definitions/vc.js +36 -0
  225. package/dist/tui/features/commands/hooks/use-slash-command-processor.js +5 -5
  226. package/dist/tui/features/log/api/execute-log.d.ts +8 -0
  227. package/dist/tui/features/log/api/execute-log.js +13 -0
  228. package/dist/tui/features/log/components/log-flow.d.ts +14 -0
  229. package/dist/tui/features/log/components/log-flow.js +29 -0
  230. package/dist/tui/features/log/utils/format-log.d.ts +3 -0
  231. package/dist/tui/features/log/utils/format-log.js +42 -0
  232. package/dist/tui/features/onboarding/hooks/use-app-view-mode.d.ts +9 -5
  233. package/dist/tui/features/onboarding/hooks/use-app-view-mode.js +12 -5
  234. package/dist/tui/features/push/components/push-flow.js +9 -2
  235. package/dist/tui/features/reset/components/reset-flow.js +2 -1
  236. package/dist/tui/features/status/components/status-view.js +2 -1
  237. package/dist/tui/features/status/utils/format-status.js +9 -0
  238. package/dist/tui/features/tasks/hooks/use-task-subscriptions.js +11 -0
  239. package/dist/tui/features/tasks/stores/tasks-store.d.ts +10 -0
  240. package/dist/tui/features/tasks/stores/tasks-store.js +16 -0
  241. package/dist/tui/features/vc/add/api/execute-vc-add.d.ts +8 -0
  242. package/dist/tui/features/vc/add/api/execute-vc-add.js +13 -0
  243. package/dist/tui/features/vc/add/components/vc-add-flow.d.ts +7 -0
  244. package/dist/tui/features/vc/add/components/vc-add-flow.js +35 -0
  245. package/dist/tui/features/vc/branch/api/execute-vc-branch.d.ts +8 -0
  246. package/dist/tui/features/vc/branch/api/execute-vc-branch.js +13 -0
  247. package/dist/tui/features/vc/branch/components/vc-branch-flow.d.ts +8 -0
  248. package/dist/tui/features/vc/branch/components/vc-branch-flow.js +53 -0
  249. package/dist/tui/features/vc/branch/utils/format-branch.d.ts +4 -0
  250. package/dist/tui/features/vc/branch/utils/format-branch.js +12 -0
  251. package/dist/tui/features/vc/checkout/api/execute-vc-checkout.d.ts +8 -0
  252. package/dist/tui/features/vc/checkout/api/execute-vc-checkout.js +13 -0
  253. package/dist/tui/features/vc/checkout/components/vc-checkout-flow.d.ts +8 -0
  254. package/dist/tui/features/vc/checkout/components/vc-checkout-flow.js +33 -0
  255. package/dist/tui/features/vc/clone/api/execute-vc-clone.d.ts +8 -0
  256. package/dist/tui/features/vc/clone/api/execute-vc-clone.js +13 -0
  257. package/dist/tui/features/vc/clone/components/vc-clone-flow.d.ts +7 -0
  258. package/dist/tui/features/vc/clone/components/vc-clone-flow.js +79 -0
  259. package/dist/tui/features/vc/commit/api/execute-vc-commit.d.ts +8 -0
  260. package/dist/tui/features/vc/commit/api/execute-vc-commit.js +13 -0
  261. package/dist/tui/features/vc/commit/components/vc-commit-flow.d.ts +7 -0
  262. package/dist/tui/features/vc/commit/components/vc-commit-flow.js +29 -0
  263. package/dist/tui/features/vc/config/api/execute-vc-config.d.ts +8 -0
  264. package/dist/tui/features/vc/config/api/execute-vc-config.js +13 -0
  265. package/dist/tui/features/vc/config/components/vc-config-flow.d.ts +9 -0
  266. package/dist/tui/features/vc/config/components/vc-config-flow.js +30 -0
  267. package/dist/tui/features/vc/fetch/api/execute-vc-fetch.d.ts +8 -0
  268. package/dist/tui/features/vc/fetch/api/execute-vc-fetch.js +13 -0
  269. package/dist/tui/features/vc/fetch/components/vc-fetch-flow.d.ts +8 -0
  270. package/dist/tui/features/vc/fetch/components/vc-fetch-flow.js +75 -0
  271. package/dist/tui/features/vc/init/api/execute-vc-init.d.ts +8 -0
  272. package/dist/tui/features/vc/init/api/execute-vc-init.js +13 -0
  273. package/dist/tui/features/vc/init/components/vc-init-flow.d.ts +10 -0
  274. package/dist/tui/features/vc/init/components/vc-init-flow.js +37 -0
  275. package/dist/tui/features/vc/merge/api/execute-vc-merge.d.ts +8 -0
  276. package/dist/tui/features/vc/merge/api/execute-vc-merge.js +13 -0
  277. package/dist/tui/features/vc/merge/components/vc-merge-flow.d.ts +11 -0
  278. package/dist/tui/features/vc/merge/components/vc-merge-flow.js +72 -0
  279. package/dist/tui/features/vc/pull/api/execute-vc-pull.d.ts +8 -0
  280. package/dist/tui/features/vc/pull/api/execute-vc-pull.js +13 -0
  281. package/dist/tui/features/vc/pull/components/vc-pull-flow.d.ts +9 -0
  282. package/dist/tui/features/vc/pull/components/vc-pull-flow.js +83 -0
  283. package/dist/tui/features/vc/push/api/execute-vc-push.d.ts +8 -0
  284. package/dist/tui/features/vc/push/api/execute-vc-push.js +13 -0
  285. package/dist/tui/features/vc/push/components/vc-push-flow.d.ts +8 -0
  286. package/dist/tui/features/vc/push/components/vc-push-flow.js +83 -0
  287. package/dist/tui/features/vc/remote/api/execute-vc-remote.d.ts +8 -0
  288. package/dist/tui/features/vc/remote/api/execute-vc-remote.js +13 -0
  289. package/dist/tui/features/vc/remote/components/vc-remote-flow.d.ts +9 -0
  290. package/dist/tui/features/vc/remote/components/vc-remote-flow.js +42 -0
  291. package/dist/tui/features/vc/reset/api/execute-vc-reset.d.ts +8 -0
  292. package/dist/tui/features/vc/reset/api/execute-vc-reset.js +13 -0
  293. package/dist/tui/features/vc/reset/components/vc-reset-flow.d.ts +10 -0
  294. package/dist/tui/features/vc/reset/components/vc-reset-flow.js +63 -0
  295. package/dist/tui/features/vc/status/api/execute-vc-status.d.ts +8 -0
  296. package/dist/tui/features/vc/status/api/execute-vc-status.js +13 -0
  297. package/dist/tui/features/vc/status/components/vc-status-flow.d.ts +10 -0
  298. package/dist/tui/features/vc/status/components/vc-status-flow.js +133 -0
  299. package/dist/tui/lib/environment.d.ts +8 -0
  300. package/dist/tui/lib/environment.js +8 -0
  301. package/dist/tui/utils/error-messages.d.ts +5 -1
  302. package/dist/tui/utils/error-messages.js +32 -3
  303. package/oclif.manifest.json +1018 -98
  304. package/package.json +9 -3
  305. package/dist/oclif/hooks/prerun/validate-brv-config-version.d.ts +0 -33
  306. package/dist/oclif/hooks/prerun/validate-brv-config-version.js +0 -86
  307. package/dist/tui/components/init.d.ts +0 -33
  308. package/dist/tui/components/init.js +0 -234
  309. package/dist/tui/features/space/api/get-spaces.d.ts +0 -16
  310. package/dist/tui/features/space/api/get-spaces.js +0 -17
  311. package/dist/tui/features/space/api/switch-space.d.ts +0 -11
  312. package/dist/tui/features/space/api/switch-space.js +0 -24
  313. package/dist/tui/features/space/components/space-list-view.d.ts +0 -12
  314. package/dist/tui/features/space/components/space-list-view.js +0 -56
  315. package/dist/tui/features/space/components/space-switch-flow.d.ts +0 -13
  316. package/dist/tui/features/space/components/space-switch-flow.js +0 -97
@@ -0,0 +1,983 @@
1
+ import * as git from 'isomorphic-git';
2
+ import fs from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { GitAuthError, GitError } from '../../core/domain/errors/git-error.js';
5
+ import { gitHttpWrapper as http } from './git-http-wrapper.js';
6
+ /** Max commit depth for ahead/behind calculation. Counts beyond this are truncated. */
7
+ const MAX_AHEAD_BEHIND_DEPTH = 500;
8
+ export class IsomorphicGitService {
9
+ authStateStore;
10
+ constructor(authStateStore) {
11
+ this.authStateStore = authStateStore;
12
+ }
13
+ static isConflictError(error) {
14
+ return error.name === 'MergeConflictError' && 'data' in error;
15
+ }
16
+ static isMergeConflictData(data) {
17
+ if (typeof data !== 'object' || data === null)
18
+ return false;
19
+ return 'filepaths' in data && Array.isArray(data.filepaths);
20
+ }
21
+ async abortMerge(params) {
22
+ const dir = this.requireDirectory(params);
23
+ const mergeHeadPath = join(dir, '.git', 'MERGE_HEAD');
24
+ const mergeMsgPath = join(dir, '.git', 'MERGE_MSG');
25
+ // Identify files introduced by the merge source (in MERGE_HEAD tree but not in HEAD tree).
26
+ // These files appeared on disk during merge and must be removed on abort.
27
+ // We compare tree contents (not statusMatrix) because merge-introduced files and
28
+ // pre-existing untracked files both show as [0,2,0] in the status matrix.
29
+ // Get current branch BEFORE checkout to avoid detached HEAD
30
+ const branch = await this.getCurrentBranch(params);
31
+ let mergeIntroducedFiles = [];
32
+ const mergeHeadOid = await fs.promises
33
+ .readFile(mergeHeadPath, 'utf8')
34
+ .then((s) => s.trim())
35
+ .catch(() => null);
36
+ if (mergeHeadOid) {
37
+ const headFiles = new Set(await git.listFiles({ dir, fs, ref: branch ?? 'HEAD' }));
38
+ const mergeFiles = await git.listFiles({ dir, fs, ref: mergeHeadOid });
39
+ mergeIntroducedFiles = mergeFiles.filter((f) => !headFiles.has(f));
40
+ }
41
+ await git.checkout({ dir, force: true, fs, ref: branch ?? 'HEAD' });
42
+ // Clean up files that the merge brought in (exist in MERGE_HEAD but not in HEAD)
43
+ for (const filepath of mergeIntroducedFiles) {
44
+ // eslint-disable-next-line no-await-in-loop
45
+ await fs.promises.unlink(join(dir, filepath)).catch(() => { });
46
+ }
47
+ await fs.promises.unlink(mergeHeadPath).catch(() => { });
48
+ await fs.promises.unlink(mergeMsgPath).catch(() => { });
49
+ }
50
+ async add(params) {
51
+ const dir = this.requireDirectory(params);
52
+ // Identify deleted files (exist in HEAD + index but not on disk: [1,0,1])
53
+ // git.add() silently ignores missing files; git.remove() is required to stage deletions
54
+ const matrix = await git.statusMatrix({ dir, fs });
55
+ // Covers [1,0,1] (unstaged deletion) and [1,0,2] (post-merge-conflict absent file).
56
+ // stage !== 0 excludes [1,0,0] which is already staged as deletion — no git.remove() needed.
57
+ const deletedInIndex = new Set(matrix
58
+ .filter(([, head, workdir, stage]) => head === 1 && workdir === 0 && stage !== 0)
59
+ .map(([filepath]) => String(filepath)));
60
+ // Files not present on disk at all (workdir=0): covers both [1,0,1] (unstaged deletion)
61
+ // and [1,0,0] (already staged deletion). git.add() on these exact paths throws; skip toAdd.
62
+ const notOnDisk = new Set(matrix.filter((row) => row[2] === 0).map((row) => String(row[0])));
63
+ const toRemove = [];
64
+ const toAdd = [];
65
+ for (const rp of params.filePaths) {
66
+ const matchesDelete = (filepath) => {
67
+ if (rp === '.')
68
+ return true;
69
+ if (filepath === rp)
70
+ return true;
71
+ const prefix = rp.endsWith('/') ? rp : `${rp}/`;
72
+ return filepath.startsWith(prefix);
73
+ };
74
+ for (const deleted of deletedInIndex) {
75
+ if (matchesDelete(deleted))
76
+ toRemove.push(deleted);
77
+ }
78
+ // Don't call git.add() for exact paths not on disk — git.remove() handles [1,0,1] above,
79
+ // and [1,0,0] (already staged deletion) needs no further action.
80
+ // git.add('.') and directory patterns are fine (silently skip missing files).
81
+ if (!notOnDisk.has(rp)) {
82
+ toAdd.push(rp);
83
+ }
84
+ }
85
+ const results = await Promise.allSettled([
86
+ ...toRemove.map((filepath) => git.remove({ dir, filepath, fs })),
87
+ ...toAdd.map((filepath) => git.add({ dir, filepath, fs })),
88
+ ]);
89
+ const allPaths = [...toRemove, ...toAdd];
90
+ const failed = results
91
+ .map((r, i) => ({ path: allPaths[i], result: r }))
92
+ .filter((x) => x.result.status === 'rejected');
93
+ if (failed.length > 0) {
94
+ const paths = failed.map((f) => f.path).join(', ');
95
+ throw new GitError(`Failed to stage: ${paths}`);
96
+ }
97
+ }
98
+ async addRemote(params) {
99
+ const dir = this.requireDirectory(params);
100
+ await git.addRemote({ dir, fs, remote: params.remote, url: params.url });
101
+ }
102
+ async checkout(params) {
103
+ const dir = this.requireDirectory(params);
104
+ // Snapshot tracked files in current branch BEFORE checkout.
105
+ // isomorphic-git's checkout only restores target files but does NOT remove
106
+ // files that are tracked in the source branch but absent in the target.
107
+ // Native git removes them, so we do it manually after checkout.
108
+ const sourceBranch = await this.getCurrentBranch(params);
109
+ const sourceFiles = sourceBranch ? new Set(await git.listFiles({ dir, fs, ref: sourceBranch })) : new Set();
110
+ // isomorphic-git's checkout detects unstaged conflicts (CheckoutConflictError)
111
+ // but silently overwrites staged changes — a data-loss bug. Guard staged
112
+ // conflicts here to match native git behavior.
113
+ if (!params.force && sourceBranch) {
114
+ await this.guardStagedConflicts(dir, sourceBranch, params.ref);
115
+ }
116
+ try {
117
+ await git.checkout({ dir, force: params.force, fs, ref: params.ref });
118
+ }
119
+ catch (error) {
120
+ if (error instanceof git.Errors.CheckoutConflictError) {
121
+ throw new GitError('Your local changes to the following files would be overwritten by checkout. ' +
122
+ 'Commit your changes or stash them before you switch branches.');
123
+ }
124
+ throw error;
125
+ }
126
+ // Remove files tracked in source but not in target (matches native git behavior).
127
+ // Untracked files are not in either set, so they are preserved.
128
+ if (sourceFiles.size > 0) {
129
+ const targetFiles = new Set(await git.listFiles({ dir, fs, ref: params.ref }));
130
+ for (const filepath of sourceFiles) {
131
+ if (!targetFiles.has(filepath)) {
132
+ // eslint-disable-next-line no-await-in-loop
133
+ await fs.promises.unlink(join(dir, filepath)).catch(() => { });
134
+ }
135
+ }
136
+ }
137
+ }
138
+ async clone(params) {
139
+ const dir = this.requireDirectory(params);
140
+ const token = this.requireToken();
141
+ await git.clone({
142
+ dir,
143
+ fs,
144
+ headers: this.buildBasicAuthHeaders(token.userId, token.sessionKey),
145
+ http,
146
+ onAuth: this.getOnAuth(),
147
+ onAuthFailure: this.getOnAuthFailure(),
148
+ onProgress: params.onProgress,
149
+ url: params.url,
150
+ });
151
+ }
152
+ async commit(params) {
153
+ const dir = this.requireDirectory(params);
154
+ const author = params.author ?? this.getAuthor();
155
+ // If MERGE_HEAD exists, create a proper merge commit with two parents
156
+ const mergeHeadPath = join(dir, '.git', 'MERGE_HEAD');
157
+ const mergeMsgPath = join(dir, '.git', 'MERGE_MSG');
158
+ const mergeHeadContent = await fs.promises.readFile(mergeHeadPath, 'utf8').catch(() => null);
159
+ const mergeHead = mergeHeadContent?.trim() ?? null;
160
+ let parent;
161
+ if (mergeHead) {
162
+ const headSha = await git.resolveRef({ dir, fs, ref: 'HEAD' });
163
+ parent = [headSha, mergeHead];
164
+ }
165
+ let sha;
166
+ try {
167
+ sha = await git.commit({ author, dir, fs, message: params.message, ...(parent ? { parent } : {}) });
168
+ }
169
+ catch (error) {
170
+ if (error instanceof git.Errors.UnmergedPathsError) {
171
+ const paths = error.data.filepaths.join(', ');
172
+ throw new GitError(`Unmerged files must be resolved before committing: ${paths}`);
173
+ }
174
+ throw error;
175
+ }
176
+ const { commit: commitObj } = await git.readCommit({ dir, fs, oid: sha });
177
+ // Clean up MERGE_HEAD and MERGE_MSG (isomorphic-git does not remove them automatically)
178
+ await fs.promises.unlink(mergeHeadPath).catch(() => { });
179
+ await fs.promises.unlink(mergeMsgPath).catch(() => { });
180
+ return {
181
+ author,
182
+ message: params.message,
183
+ sha,
184
+ timestamp: new Date(commitObj.author.timestamp * 1000),
185
+ };
186
+ }
187
+ async createBranch(params) {
188
+ const dir = this.requireDirectory(params);
189
+ await git.branch({ checkout: params.checkout, dir, fs, ref: params.branch });
190
+ }
191
+ async deleteBranch(params) {
192
+ const dir = this.requireDirectory(params);
193
+ await git.deleteBranch({ dir, fs, ref: params.branch });
194
+ }
195
+ async fetch(params) {
196
+ const dir = this.requireDirectory(params);
197
+ const token = this.requireToken();
198
+ await git.fetch({
199
+ dir,
200
+ fs,
201
+ headers: this.buildBasicAuthHeaders(token.userId, token.sessionKey),
202
+ http,
203
+ onAuth: this.getOnAuth(),
204
+ onAuthFailure: this.getOnAuthFailure(),
205
+ ref: params.ref,
206
+ remote: params.remote ?? 'origin',
207
+ });
208
+ }
209
+ async getAheadBehind(params) {
210
+ const dir = this.requireDirectory(params);
211
+ const localSha = await git.resolveRef({ dir, fs, ref: params.localRef }).catch(() => null);
212
+ const remoteSha = await git.resolveRef({ dir, fs, ref: params.remoteRef }).catch(() => null);
213
+ if (!localSha || !remoteSha)
214
+ return { ahead: 0, behind: 0 };
215
+ if (localSha === remoteSha)
216
+ return { ahead: 0, behind: 0 };
217
+ const [localLog, remoteLog] = await Promise.all([
218
+ git.log({ depth: MAX_AHEAD_BEHIND_DEPTH, dir, fs, ref: params.localRef }).catch(() => []),
219
+ git.log({ depth: MAX_AHEAD_BEHIND_DEPTH, dir, fs, ref: params.remoteRef }).catch(() => []),
220
+ ]);
221
+ const localShas = new Set(localLog.map((c) => c.oid));
222
+ const remoteShas = new Set(remoteLog.map((c) => c.oid));
223
+ const ahead = localLog.filter((c) => !remoteShas.has(c.oid)).length;
224
+ const behind = remoteLog.filter((c) => !localShas.has(c.oid)).length;
225
+ return { ahead, behind };
226
+ }
227
+ async getConflicts(params) {
228
+ const dir = this.requireDirectory(params);
229
+ // Only report conflicts when a merge is actually in progress
230
+ const mergeInProgress = await fs.promises
231
+ .access(join(dir, '.git', 'MERGE_HEAD'))
232
+ .then(() => true)
233
+ .catch(() => false);
234
+ if (!mergeInProgress)
235
+ return [];
236
+ const matrix = await git.statusMatrix({ dir, fs });
237
+ const conflicts = [];
238
+ await Promise.all(matrix.map(async ([filepath, head, workdir, stage]) => {
239
+ const path = String(filepath);
240
+ // deleted_modified: file was in HEAD but gone from workdir
241
+ if (head === 1 && workdir === 0) {
242
+ conflicts.push({ path, type: 'deleted_modified' });
243
+ return;
244
+ }
245
+ // deleted_modified (isomorphic-git variant): file in HEAD, on disk unchanged,
246
+ // but index differs from both (stage=3). This happens when the other branch
247
+ // deletes a file that we modified — isomorphic-git keeps our version on disk.
248
+ if (head === 1 && workdir === 1 && stage === 3) {
249
+ conflicts.push({ path, type: 'deleted_modified' });
250
+ return;
251
+ }
252
+ // both_added or both_modified: look for conflict markers in file content
253
+ if (workdir === 2) {
254
+ try {
255
+ const content = await fs.promises.readFile(join(dir, path), 'utf8');
256
+ if (content.includes('<<<<<<<')) {
257
+ const type = head === 0 ? 'both_added' : 'both_modified';
258
+ conflicts.push({ path, type });
259
+ }
260
+ }
261
+ catch {
262
+ // skip binary or unreadable files
263
+ }
264
+ }
265
+ }));
266
+ return conflicts.sort((a, b) => a.path.localeCompare(b.path));
267
+ }
268
+ async getCurrentBranch(params) {
269
+ const dir = this.requireDirectory(params);
270
+ const branch = await git.currentBranch({ dir, fs });
271
+ return branch ?? undefined;
272
+ }
273
+ async getFilesWithConflictMarkers(params) {
274
+ const dir = this.requireDirectory(params);
275
+ const matrix = await git.statusMatrix({ dir, fs });
276
+ const conflicted = [];
277
+ await Promise.all(matrix.map(async ([filepath, , workdir]) => {
278
+ const path = String(filepath);
279
+ // Only check files that exist in the working directory
280
+ if (workdir === 0)
281
+ return;
282
+ try {
283
+ const content = await fs.promises.readFile(join(dir, path), 'utf8');
284
+ if (content.includes('<<<<<<<') && content.includes('=======') && content.includes('>>>>>>>')) {
285
+ conflicted.push(path);
286
+ }
287
+ }
288
+ catch {
289
+ // skip binary or unreadable files
290
+ }
291
+ }));
292
+ return conflicted.sort();
293
+ }
294
+ async getRemoteUrl(params) {
295
+ const dir = this.requireDirectory(params);
296
+ const result = await git.getConfig({ dir, fs, path: `remote.${params.remote}.url` });
297
+ return result === undefined || result === null ? undefined : String(result);
298
+ }
299
+ async getTrackingBranch(params) {
300
+ const dir = this.requireDirectory(params);
301
+ const remote = await git.getConfig({ dir, fs, path: `branch.${params.branch}.remote` });
302
+ if (remote === undefined || remote === null)
303
+ return undefined;
304
+ const merge = await git.getConfig({ dir, fs, path: `branch.${params.branch}.merge` });
305
+ if (merge === undefined || merge === null)
306
+ return undefined;
307
+ // merge is stored as refs/heads/<branch> — extract the branch name
308
+ const mergeStr = String(merge);
309
+ const remoteBranch = mergeStr.startsWith('refs/heads/') ? mergeStr.slice('refs/heads/'.length) : mergeStr;
310
+ return { remote: String(remote), remoteBranch };
311
+ }
312
+ async init(params) {
313
+ const dir = this.requireDirectory(params);
314
+ await git.init({ defaultBranch: params.defaultBranch ?? 'main', dir, fs });
315
+ }
316
+ async isAncestor(params) {
317
+ const dir = this.requireDirectory(params);
318
+ const commitOid = await git.resolveRef({ dir, fs, ref: params.commit });
319
+ const ancestorOid = await git.resolveRef({ dir, fs, ref: params.ancestor });
320
+ if (commitOid === ancestorOid)
321
+ return true;
322
+ return git.isDescendent({ ancestor: ancestorOid, depth: -1, dir, fs, oid: commitOid });
323
+ }
324
+ async isEmptyRepository(params) {
325
+ const dir = this.requireDirectory(params);
326
+ const commits = await this.log({ depth: 1, directory: dir });
327
+ if (commits.length > 0)
328
+ return false;
329
+ const remotes = await this.listRemotes({ directory: dir });
330
+ if (remotes.length > 0)
331
+ return false;
332
+ const branches = await git.listBranches({ dir, fs });
333
+ if (branches.length > 0)
334
+ return false;
335
+ const tags = await git.listTags({ dir, fs });
336
+ if (tags.length > 0)
337
+ return false;
338
+ const { isClean } = await this.status({ directory: dir });
339
+ return isClean;
340
+ }
341
+ async isInitialized(params) {
342
+ const dir = this.requireDirectory(params);
343
+ return fs.promises
344
+ .access(join(dir, '.git'))
345
+ .then(() => true)
346
+ .catch(() => false);
347
+ }
348
+ async listBranches(params) {
349
+ const dir = this.requireDirectory(params);
350
+ const current = await this.getCurrentBranch(params);
351
+ const branches = await git.listBranches({ dir, fs });
352
+ const result = branches.map((name) => ({ isCurrent: name === current, isRemote: false, name }));
353
+ if (params.remote) {
354
+ try {
355
+ const remoteBranches = await git.listBranches({ dir, fs, remote: params.remote });
356
+ for (const name of remoteBranches) {
357
+ if (name === 'HEAD')
358
+ continue;
359
+ result.push({ isCurrent: false, isRemote: true, name: `${params.remote}/${name}` });
360
+ }
361
+ }
362
+ catch {
363
+ // No remote configured or no refs fetched yet — return local-only.
364
+ // Mirrors `git branch -a`: never auto-fetches, reads cached refs only.
365
+ }
366
+ }
367
+ return result;
368
+ }
369
+ async listRemotes(params) {
370
+ const dir = this.requireDirectory(params);
371
+ const remotes = await git.listRemotes({ dir, fs });
372
+ return remotes.map((r) => ({ remote: r.remote, url: r.url }));
373
+ }
374
+ async log(params) {
375
+ const dir = this.requireDirectory(params);
376
+ try {
377
+ const commits = await git.log({ depth: params.depth, dir, fs, ref: params.ref });
378
+ return commits.map((c) => ({
379
+ author: { email: c.commit.author.email, name: c.commit.author.name },
380
+ message: c.commit.message.trim(),
381
+ sha: c.oid,
382
+ timestamp: new Date(c.commit.author.timestamp * 1000),
383
+ }));
384
+ }
385
+ catch (error) {
386
+ // No commits yet — HEAD ref does not exist
387
+ if (error instanceof git.Errors.NotFoundError)
388
+ return [];
389
+ throw error;
390
+ }
391
+ }
392
+ async merge(params) {
393
+ const dir = this.requireDirectory(params);
394
+ const mergeHeadPath = join(dir, '.git', 'MERGE_HEAD');
395
+ const mergeMsgPath = join(dir, '.git', 'MERGE_MSG');
396
+ const author = params.author ?? this.getAuthor();
397
+ const message = params.message ?? `Merge branch '${params.branch}'`;
398
+ const currentBranch = await git.currentBranch({ dir, fs });
399
+ const localSha = currentBranch
400
+ ? await git.resolveRef({ dir, fs, ref: `refs/heads/${currentBranch}` }).catch(() => null)
401
+ : null;
402
+ try {
403
+ const mergeResult = await git.merge({
404
+ abortOnConflict: false,
405
+ author,
406
+ committer: author,
407
+ dir,
408
+ fs,
409
+ message,
410
+ theirs: params.branch,
411
+ });
412
+ if (mergeResult.alreadyMerged) {
413
+ return { alreadyUpToDate: true, success: true };
414
+ }
415
+ // isomorphic-git merge only updates refs — checkout to apply changes to working tree
416
+ if (currentBranch) {
417
+ await git.checkout({ dir, fs, ref: currentBranch });
418
+ }
419
+ return { success: true };
420
+ }
421
+ catch (error) {
422
+ if (error instanceof git.Errors.CheckoutConflictError) {
423
+ // Undo the merge commit — restore HEAD to pre-merge state so repo is left clean
424
+ if (localSha && currentBranch) {
425
+ await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${currentBranch}`, value: localSha });
426
+ }
427
+ throw new GitError('Local changes would be overwritten by merge. Commit or discard your changes first.');
428
+ }
429
+ if (error instanceof git.Errors.MergeConflictError) {
430
+ // isomorphic-git does not write MERGE_HEAD/MERGE_MSG — write them so
431
+ // getConflicts() and --continue work post-restart
432
+ const theirsOid = await git.resolveRef({ dir, fs, ref: params.branch });
433
+ await fs.promises.writeFile(mergeHeadPath, `${theirsOid}\n`);
434
+ await fs.promises.writeFile(mergeMsgPath, `${message}\n`);
435
+ // isomorphic-git uses the branch name as marker label (<<<<<<< main);
436
+ // native git uses HEAD — rewrite markers to match git convention
437
+ if (currentBranch) {
438
+ await this.rewriteConflictMarkers(dir, currentBranch, this.conflictsFromError(error));
439
+ }
440
+ return { conflicts: this.conflictsFromError(error), success: false };
441
+ }
442
+ if (error instanceof git.Errors.MergeNotSupportedError) {
443
+ if (!params.allowUnrelatedHistories) {
444
+ throw new GitError('Refusing to merge unrelated histories. Use --allow-unrelated-histories to force.');
445
+ }
446
+ const oursRef = (await git.currentBranch({ dir, fs })) ?? 'HEAD';
447
+ return this.mergeUnrelatedHistories({ author, dir, message, oursRef, theirsRef: params.branch });
448
+ }
449
+ throw error;
450
+ }
451
+ }
452
+ async pull(params) {
453
+ const dir = this.requireDirectory(params);
454
+ const token = this.requireToken();
455
+ const remote = params.remote ?? 'origin';
456
+ // Guard: if MERGE_HEAD exists, a previous merge is unresolved — refuse to pull
457
+ const hasPendingMerge = await fs.promises
458
+ .readFile(join(dir, '.git', 'MERGE_HEAD'), 'utf8')
459
+ .then(() => true)
460
+ .catch(() => false);
461
+ if (hasPendingMerge) {
462
+ throw new GitError('You have unresolved merge conflicts. Resolve them, stage the files, and commit before pulling again.');
463
+ }
464
+ // Fetch from remote
465
+ await git.fetch({
466
+ dir,
467
+ fs,
468
+ headers: this.buildBasicAuthHeaders(token.userId, token.sessionKey),
469
+ http,
470
+ onAuth: this.getOnAuth(),
471
+ onAuthFailure: this.getOnAuthFailure(),
472
+ remote,
473
+ ...(params.branch ? { remoteRef: params.branch } : {}),
474
+ });
475
+ // Determine which remote-tracking branch to merge
476
+ const localBranch = params.branch ?? (await this.getCurrentBranch(params));
477
+ if (!localBranch)
478
+ throw new GitError('Cannot determine branch for pull');
479
+ // After fetch, check if already up to date
480
+ const localSha = await git.resolveRef({ dir, fs, ref: `refs/heads/${localBranch}` }).catch(() => null);
481
+ const remoteSha = await git.resolveRef({ dir, fs, ref: `refs/remotes/${remote}/${localBranch}` }).catch(() => null);
482
+ if (localSha && remoteSha && localSha === remoteSha) {
483
+ return { alreadyUpToDate: true, success: true };
484
+ }
485
+ // Empty local repo — fast-forward HEAD to remote tip (like native git pull on empty repo)
486
+ if (!localSha && remoteSha) {
487
+ await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${localBranch}`, value: remoteSha });
488
+ await git.checkout({ dir, fs, ref: localBranch });
489
+ return { alreadyUpToDate: false, success: true };
490
+ }
491
+ // Step 2: working tree safety check (isomorphic-git does not do this automatically)
492
+ // Abort if any dirty local file would be overwritten by the incoming changes
493
+ if (localSha && remoteSha) {
494
+ const matrix = await git.statusMatrix({ dir, fs });
495
+ const dirtyFiles = matrix.filter((row) => row[2] !== 1 || row[3] !== 1).map((row) => String(row[0]));
496
+ const localRef = localSha;
497
+ const remoteRef = remoteSha;
498
+ const wouldBeOverwritten = await Promise.all(dirtyFiles.map(async (filepath) => {
499
+ const [localFileOid, remoteFileOid] = await Promise.all([
500
+ git
501
+ .readBlob({ dir, filepath, fs, oid: localRef })
502
+ .then((r) => r.oid)
503
+ .catch(() => null),
504
+ git
505
+ .readBlob({ dir, filepath, fs, oid: remoteRef })
506
+ .then((r) => r.oid)
507
+ .catch(() => null),
508
+ ]);
509
+ return localFileOid !== remoteFileOid;
510
+ }));
511
+ if (wouldBeOverwritten.some(Boolean)) {
512
+ throw new GitError('Local changes would be overwritten by pull. Commit or discard your changes first.');
513
+ }
514
+ }
515
+ const author = params.author ?? this.getAuthor();
516
+ try {
517
+ const mergeResult = await git.merge({
518
+ abortOnConflict: false,
519
+ author,
520
+ committer: author,
521
+ dir,
522
+ fs,
523
+ theirs: `${remote}/${localBranch}`,
524
+ });
525
+ // isomorphic-git merge only updates refs/commits — checkout to apply file changes to workdir.
526
+ await git.checkout({ dir, fs, ref: localBranch });
527
+ return { alreadyUpToDate: mergeResult.alreadyMerged, success: true };
528
+ }
529
+ catch (error) {
530
+ if (error instanceof git.Errors.MergeConflictError) {
531
+ // isomorphic-git does not write MERGE_HEAD/MERGE_MSG — write them so
532
+ // getConflicts() and --continue work post-restart
533
+ const theirsOid = await git.resolveRef({ dir, fs, ref: `refs/remotes/${remote}/${localBranch}` });
534
+ await fs.promises.writeFile(join(dir, '.git', 'MERGE_HEAD'), `${theirsOid}\n`);
535
+ await fs.promises.writeFile(join(dir, '.git', 'MERGE_MSG'), `Merge remote-tracking branch '${remote}/${localBranch}'\n`);
536
+ // Rewrite conflict markers: isomorphic-git uses branch name, git uses HEAD
537
+ await this.rewriteConflictMarkers(dir, localBranch, this.conflictsFromError(error));
538
+ return { conflicts: this.conflictsFromError(error), success: false };
539
+ }
540
+ if (error instanceof git.Errors.MergeNotSupportedError) {
541
+ if (!params.allowUnrelatedHistories) {
542
+ throw new GitError('Refusing to merge unrelated histories. Use --allow-unrelated-histories to force.');
543
+ }
544
+ const result = await this.mergeUnrelatedHistories({
545
+ author,
546
+ dir,
547
+ message: `Merge remote-tracking branch '${remote}/${localBranch}'`,
548
+ oursRef: localBranch,
549
+ theirsRef: `${remote}/${localBranch}`,
550
+ });
551
+ if (result.success) {
552
+ await git.checkout({ dir, fs, ref: localBranch });
553
+ }
554
+ return result;
555
+ }
556
+ if (error instanceof git.Errors.CheckoutConflictError) {
557
+ // Undo the merge commit — restore HEAD to pre-merge state so repo is left clean
558
+ if (localSha) {
559
+ await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${localBranch}`, value: localSha });
560
+ }
561
+ throw new GitError('Local changes would be overwritten by pull. Commit or discard your changes first.');
562
+ }
563
+ throw error;
564
+ }
565
+ }
566
+ async push(params) {
567
+ const dir = this.requireDirectory(params);
568
+ const token = this.requireToken();
569
+ try {
570
+ const branch = params.branch ?? (await git.currentBranch({ dir, fs })) ?? 'main';
571
+ const remote = params.remote ?? 'origin';
572
+ const localSha = await git.resolveRef({ dir, fs, ref: `refs/heads/${branch}` }).catch(() => null);
573
+ const remoteSha = await git.resolveRef({ dir, fs, ref: `refs/remotes/${remote}/${branch}` }).catch(() => null);
574
+ if (localSha && remoteSha && localSha === remoteSha) {
575
+ return { alreadyUpToDate: true, success: true };
576
+ }
577
+ await git.push({
578
+ dir,
579
+ fs,
580
+ headers: this.buildBasicAuthHeaders(token.userId, token.sessionKey),
581
+ http,
582
+ onAuth: this.getOnAuth(),
583
+ onAuthFailure: this.getOnAuthFailure(),
584
+ ref: branch,
585
+ remote,
586
+ });
587
+ return { alreadyUpToDate: false, success: true };
588
+ }
589
+ catch (error) {
590
+ if (error instanceof git.Errors.PushRejectedError) {
591
+ return { message: error.message, reason: 'non_fast_forward', success: false };
592
+ }
593
+ throw error;
594
+ }
595
+ }
596
+ async removeRemote(params) {
597
+ const dir = this.requireDirectory(params);
598
+ await git.deleteRemote({ dir, fs, remote: params.remote });
599
+ }
600
+ async reset(params) {
601
+ const dir = this.requireDirectory(params);
602
+ const mode = params.mode ?? 'mixed';
603
+ const ref = params.ref ?? 'HEAD';
604
+ // Case 1: Path-scoped unstage — always mixed, ignores mode/ref
605
+ if (params.filePaths && params.filePaths.length > 0) {
606
+ return this.resetUnstage(dir, params.filePaths);
607
+ }
608
+ // Case 2: Whole-tree unstage (mixed mode, ref=HEAD, no filePaths)
609
+ if (mode === 'mixed' && ref === 'HEAD') {
610
+ return this.resetUnstage(dir);
611
+ }
612
+ // Cases 3-5: Reset to a specific ref (soft/mixed/hard)
613
+ // Empty repo (no commits) — HEAD doesn't exist. Git treats this as a silent no-op.
614
+ const headExists = await git.resolveRef({ dir, fs, ref: 'HEAD' }).then(() => true, () => false);
615
+ if (!headExists) {
616
+ return { filesChanged: 0, headSha: '' };
617
+ }
618
+ const targetSha = await this.resolveRefExpression(dir, ref);
619
+ const branch = await this.getCurrentBranch(params);
620
+ if (!branch) {
621
+ throw new GitError('Cannot reset in detached HEAD state.');
622
+ }
623
+ const previousSha = await git.resolveRef({ dir, fs, ref: 'HEAD' });
624
+ if (mode === 'soft') {
625
+ await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${branch}`, value: targetSha });
626
+ return { filesChanged: 0, headSha: targetSha };
627
+ }
628
+ if (mode === 'hard') {
629
+ // Snapshot files in current HEAD to detect orphans after reset
630
+ const currentFiles = new Set(await git.listFiles({ dir, fs, ref: previousSha }));
631
+ const targetFiles = new Set(await git.listFiles({ dir, fs, ref: targetSha }));
632
+ // Move branch pointer
633
+ await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${branch}`, value: targetSha });
634
+ // Restore working tree + index
635
+ await git.checkout({ dir, force: true, fs, ref: branch });
636
+ // Delete orphaned files (tracked in old HEAD but not in target)
637
+ for (const filepath of currentFiles) {
638
+ if (!targetFiles.has(filepath)) {
639
+ // eslint-disable-next-line no-await-in-loop
640
+ await fs.promises.unlink(join(dir, filepath)).catch(() => { });
641
+ }
642
+ }
643
+ // Clean up merge state if present
644
+ await fs.promises.unlink(join(dir, '.git', 'MERGE_HEAD')).catch(() => { });
645
+ await fs.promises.unlink(join(dir, '.git', 'MERGE_MSG')).catch(() => { });
646
+ const filesChanged = [...currentFiles].filter((f) => !targetFiles.has(f)).length +
647
+ [...targetFiles].filter((f) => !currentFiles.has(f)).length;
648
+ return { filesChanged, headSha: targetSha };
649
+ }
650
+ // mode === 'mixed' with ref !== HEAD
651
+ // Move branch pointer, then reset index to match new HEAD (working tree untouched)
652
+ await git.writeRef({ dir, force: true, fs, ref: `refs/heads/${branch}`, value: targetSha });
653
+ // Reset every file in the index to match the target
654
+ const targetFiles = await git.listFiles({ dir, fs, ref: targetSha });
655
+ const matrix = await git.statusMatrix({ dir, fs });
656
+ const allPaths = new Set([...matrix.map((row) => String(row[0])), ...targetFiles]);
657
+ await Promise.all([...allPaths].map((filepath) => git.resetIndex({ dir, filepath, fs, ref: targetSha })));
658
+ return { filesChanged: allPaths.size, headSha: targetSha };
659
+ }
660
+ async setTrackingBranch(params) {
661
+ const dir = this.requireDirectory(params);
662
+ await git.setConfig({ dir, fs, path: `branch.${params.branch}.remote`, value: params.remote });
663
+ await git.setConfig({ dir, fs, path: `branch.${params.branch}.merge`, value: `refs/heads/${params.remoteBranch}` });
664
+ }
665
+ async status(params) {
666
+ const dir = this.requireDirectory(params);
667
+ const matrix = await git.statusMatrix({ dir, fs });
668
+ const files = this.parseMatrix(matrix);
669
+ return { files, isClean: files.length === 0 };
670
+ }
671
+ buildBasicAuthHeaders(userId, sessionKey) {
672
+ const credentials = Buffer.from(`${userId}:${sessionKey}`).toString('base64');
673
+ return { Authorization: `Basic ${credentials}` };
674
+ }
675
+ conflictsFromError(error) {
676
+ if (!IsomorphicGitService.isConflictError(error))
677
+ return [];
678
+ const conflictData = error.data;
679
+ if (!IsomorphicGitService.isMergeConflictData(conflictData))
680
+ return [];
681
+ const deletedPaths = new Set([...(conflictData.deleteByTheirs ?? []), ...(conflictData.deleteByUs ?? [])]);
682
+ const bothModifiedPaths = new Set(conflictData.bothModified ?? []);
683
+ return conflictData.filepaths
684
+ .map((path) => ({
685
+ path,
686
+ type: deletedPaths.has(path)
687
+ ? 'deleted_modified'
688
+ : bothModifiedPaths.has(path)
689
+ ? 'both_modified'
690
+ : 'both_added',
691
+ }))
692
+ .sort((a, b) => a.path.localeCompare(b.path));
693
+ }
694
+ getAuthor() {
695
+ const token = this.authStateStore.getToken();
696
+ if (!token)
697
+ throw new GitAuthError();
698
+ return {
699
+ email: token.userEmail,
700
+ name: token.userName ?? token.userEmail,
701
+ };
702
+ }
703
+ getOnAuth() {
704
+ return () => {
705
+ const token = this.authStateStore.getToken();
706
+ if (!token)
707
+ throw new GitAuthError();
708
+ return {
709
+ password: token.sessionKey,
710
+ username: token.userId,
711
+ };
712
+ };
713
+ }
714
+ getOnAuthFailure() {
715
+ return () => {
716
+ throw new GitAuthError('Authentication failed. Try /login again.');
717
+ };
718
+ }
719
+ /**
720
+ * Guard against staged changes that would be overwritten by checkout.
721
+ * isomorphic-git's checkout only detects unstaged conflicts — it silently
722
+ * overwrites staged changes, causing data loss. This method fills that gap
723
+ * to match native git behavior.
724
+ */
725
+ async guardStagedConflicts(dir, sourceBranch, targetRef) {
726
+ const matrix = await git.statusMatrix({ dir, fs });
727
+ // Staged files: index (col 3) differs from HEAD (col 1)
728
+ // [1,_,2] modified+staged, [1,_,0] deleted+staged, [0,_,2] new+staged
729
+ const stagedFiles = matrix.filter(([, head, , stage]) => stage !== head).map(([filepath]) => String(filepath));
730
+ if (stagedFiles.length === 0)
731
+ return;
732
+ const sourceOid = await git.resolveRef({ dir, fs, ref: sourceBranch });
733
+ const targetOid = await git.resolveRef({ dir, fs, ref: targetRef });
734
+ const conflicting = [];
735
+ /* eslint-disable no-await-in-loop -- sequential file I/O is intentional here */
736
+ for (const filepath of stagedFiles) {
737
+ const sourceBlobOid = await this.readBlobOid(dir, sourceOid, filepath);
738
+ const targetBlobOid = await this.readBlobOid(dir, targetOid, filepath);
739
+ if (sourceBlobOid !== targetBlobOid) {
740
+ conflicting.push(filepath);
741
+ }
742
+ }
743
+ /* eslint-enable no-await-in-loop */
744
+ if (conflicting.length > 0) {
745
+ throw new GitError('Your local changes to the following files would be overwritten by checkout:\n' +
746
+ conflicting.map((f) => `\t${f}`).join('\n') +
747
+ '\nPlease commit your changes or stash them before you switch branches.');
748
+ }
749
+ }
750
+ /**
751
+ * Manual merge for unrelated histories (no common ancestor).
752
+ * isomorphic-git throws MergeNotSupportedError because it can't handle
753
+ * base=null at the root tree level. We bypass by combining both trees directly.
754
+ */
755
+ async mergeUnrelatedHistories(params) {
756
+ const { author, dir, message, oursRef, theirsRef } = params;
757
+ const mergeHeadPath = join(dir, '.git', 'MERGE_HEAD');
758
+ const mergeMsgPath = join(dir, '.git', 'MERGE_MSG');
759
+ const oursSha = await git.resolveRef({ dir, fs, ref: oursRef });
760
+ const theirsSha = await git.resolveRef({ dir, fs, ref: theirsRef });
761
+ // List all files from both sides
762
+ const oursFiles = await git.listFiles({ dir, fs, ref: oursSha });
763
+ const theirsFiles = await git.listFiles({ dir, fs, ref: theirsSha });
764
+ const theirsSet = new Set(theirsFiles);
765
+ // Detect conflicts: same filepath on both sides with different content
766
+ const conflicts = [];
767
+ /* eslint-disable no-await-in-loop -- sequential file I/O is intentional here */
768
+ for (const filepath of oursFiles) {
769
+ if (!theirsSet.has(filepath))
770
+ continue;
771
+ const oursBlob = await git.readBlob({ dir, filepath, fs, oid: oursSha });
772
+ const theirsBlob = await git.readBlob({ dir, filepath, fs, oid: theirsSha });
773
+ if (oursBlob.oid !== theirsBlob.oid) {
774
+ conflicts.push({ path: filepath, type: 'both_added' });
775
+ }
776
+ }
777
+ /* eslint-enable no-await-in-loop */
778
+ if (conflicts.length > 0) {
779
+ // Write MERGE_HEAD/MERGE_MSG so --continue works
780
+ await fs.promises.writeFile(mergeHeadPath, `${theirsSha}\n`);
781
+ await fs.promises.writeFile(mergeMsgPath, `${message}\n`);
782
+ // Write conflict markers to working tree for each conflicted file
783
+ /* eslint-disable no-await-in-loop -- sequential per-file conflict marker writes */
784
+ for (const conflict of conflicts) {
785
+ const oursBlob = await git.readBlob({ dir, filepath: conflict.path, fs, oid: oursSha });
786
+ const theirsBlob = await git.readBlob({ dir, filepath: conflict.path, fs, oid: theirsSha });
787
+ const oursContent = Buffer.from(oursBlob.blob).toString('utf8');
788
+ const theirsContent = Buffer.from(theirsBlob.blob).toString('utf8');
789
+ const conflictContent = `<<<<<<< HEAD\n` +
790
+ oursContent +
791
+ (oursContent.endsWith('\n') ? '' : '\n') +
792
+ `=======\n` +
793
+ theirsContent +
794
+ (theirsContent.endsWith('\n') ? '' : '\n') +
795
+ `>>>>>>> ${theirsRef}\n`;
796
+ await fs.promises.writeFile(join(dir, conflict.path), conflictContent);
797
+ }
798
+ /* eslint-enable no-await-in-loop */
799
+ return { conflicts, success: false };
800
+ }
801
+ // No conflicts — write all remote files to working tree and stage them
802
+ const oursSet = new Set(oursFiles);
803
+ /* eslint-disable no-await-in-loop -- sequential file writes + git add */
804
+ for (const filepath of theirsFiles) {
805
+ if (oursSet.has(filepath))
806
+ continue; // same content, already present
807
+ const blob = await git.readBlob({ dir, filepath, fs, oid: theirsSha });
808
+ const filePath = join(dir, filepath);
809
+ const fileDir = join(filePath, '..');
810
+ await fs.promises.mkdir(fileDir, { recursive: true });
811
+ await fs.promises.writeFile(filePath, Buffer.from(blob.blob));
812
+ await git.add({ dir, filepath, fs });
813
+ }
814
+ /* eslint-enable no-await-in-loop */
815
+ // Create merge commit with both parents
816
+ await git.commit({
817
+ author,
818
+ committer: author,
819
+ dir,
820
+ fs,
821
+ message,
822
+ parent: [oursSha, theirsSha],
823
+ });
824
+ return { success: true };
825
+ }
826
+ // eslint-disable-next-line complexity
827
+ parseMatrix(matrix) {
828
+ const files = [];
829
+ for (const [filepath, head, workdir, stage] of matrix) {
830
+ const path = String(filepath);
831
+ if (head === 1 && workdir === 0 && stage === 0) {
832
+ files.push({ path, staged: true, status: 'deleted' }); // [1,0,0] staged deletion (git rm)
833
+ }
834
+ else if (head === 1 && workdir === 0 && stage === 1) {
835
+ files.push({ path, staged: false, status: 'deleted' }); // [1,0,1] unstaged deletion (rm without git rm)
836
+ }
837
+ else if (head === 1 && workdir === 0 && stage === 2) {
838
+ files.push({ path, staged: false, status: 'deleted' }); // [1,0,2] absent from disk, index differs from HEAD (e.g. post-merge-conflict)
839
+ }
840
+ else if (head === 1 && workdir === 0 && stage === 3) {
841
+ // [1,0,3] staged modification then deleted from disk → show both staged mod and unstaged deletion
842
+ files.push({ path, staged: true, status: 'modified' }, { path, staged: false, status: 'deleted' });
843
+ }
844
+ else if (head === 1 && workdir === 1 && stage === 0) {
845
+ files.push({ path, staged: true, status: 'deleted' }, { path, staged: false, status: 'untracked' }); // [1,1,0] git rm --cached: staged deletion + file still in workdir → untracked
846
+ }
847
+ else if (head === 1 && workdir === 2 && stage === 1) {
848
+ files.push({ path, staged: false, status: 'modified' }); // [1,2,1] unstaged modification
849
+ }
850
+ else if (head === 1 && workdir === 2 && stage === 2) {
851
+ files.push({ path, staged: true, status: 'modified' }); // [1,2,2] staged modification
852
+ }
853
+ else if (head === 1 && workdir === 2 && stage === 3) {
854
+ files.push({ path, staged: true, status: 'modified' }, { path, staged: false, status: 'modified' }); // [1,2,3] partially staged modification
855
+ }
856
+ else if (head === 0 && workdir === 2 && stage === 0) {
857
+ files.push({ path, staged: false, status: 'untracked' }); // [0,2,0] untracked new file
858
+ }
859
+ else if (head === 0 && workdir === 2 && stage === 2) {
860
+ files.push({ path, staged: true, status: 'added' }); // [0,2,2] staged new file
861
+ }
862
+ else if (head === 0 && workdir === 2 && stage === 3) {
863
+ files.push({ path, staged: true, status: 'added' }, { path, staged: false, status: 'modified' }); // [0,2,3] partially staged new file
864
+ }
865
+ // [1,1,1] unmodified → skip
866
+ }
867
+ return files;
868
+ }
869
+ async readBlobOid(dir, commitOid, filepath) {
870
+ try {
871
+ const result = await git.readBlob({ dir, filepath, fs, oid: commitOid });
872
+ return result.oid;
873
+ }
874
+ catch {
875
+ return null;
876
+ }
877
+ }
878
+ requireDirectory(params) {
879
+ // Guard against empty string — undefined/null are caught by TypeScript at compile time
880
+ if (!params.directory)
881
+ throw new GitError('directory is required for git operations');
882
+ return params.directory;
883
+ }
884
+ requireToken() {
885
+ const token = this.authStateStore.getToken();
886
+ if (!token)
887
+ throw new GitAuthError();
888
+ return token;
889
+ }
890
+ /**
891
+ * Unstage files by resetting their index entries to match HEAD.
892
+ * When filePaths is omitted, unstages all staged files.
893
+ */
894
+ async resetUnstage(dir, filePaths) {
895
+ const headSha = await git.resolveRef({ dir, fs, ref: 'HEAD' }).catch(() => null);
896
+ const matrix = await git.statusMatrix({ dir, fs });
897
+ // Identify staged rows — any file where the index differs from HEAD
898
+ const stagedRows = matrix.filter(([, head, , stage]) => {
899
+ if (head === 1 && stage === 0)
900
+ return true; // staged deletion or git rm --cached ([1,0,0] and [1,1,0])
901
+ if (head === 0 && stage === 2)
902
+ return true; // staged new file ([0,2,2])
903
+ if (head === 1 && stage === 2)
904
+ return true; // staged modification ([1,2,2])
905
+ if (stage === 3)
906
+ return true; // partially staged ([*,*,3])
907
+ return false;
908
+ });
909
+ const toUnstage = filePaths && filePaths.length > 0
910
+ ? stagedRows
911
+ .filter(([filepath]) => {
912
+ const path = String(filepath);
913
+ return filePaths.some((fp) => {
914
+ if (path === fp)
915
+ return true;
916
+ const prefix = fp.endsWith('/') ? fp : `${fp}/`;
917
+ return path.startsWith(prefix);
918
+ });
919
+ })
920
+ .map(([filepath]) => String(filepath))
921
+ : stagedRows.map(([filepath]) => String(filepath));
922
+ // Validate that every requested path is known to the repository
923
+ if (filePaths && filePaths.length > 0) {
924
+ const allKnownPaths = new Set(matrix.map(([filepath]) => String(filepath)));
925
+ for (const fp of filePaths) {
926
+ const isKnown = fp.endsWith('/') ? [...allKnownPaths].some((p) => p.startsWith(fp)) : allKnownPaths.has(fp);
927
+ if (!isKnown) {
928
+ throw new GitError(`pathspec '${fp}' did not match any file(s) known to git`);
929
+ }
930
+ }
931
+ }
932
+ await Promise.all(headSha
933
+ ? toUnstage.map((filepath) => git.resetIndex({ dir, filepath, fs, ref: 'HEAD' }))
934
+ : toUnstage.map((filepath) => git.remove({ dir, filepath, fs })));
935
+ return { filesChanged: toUnstage.length, headSha: headSha ?? '' };
936
+ }
937
+ /**
938
+ * Resolves a ref expression that may include ~N ancestry syntax (e.g. HEAD~2).
939
+ * Falls back to git.resolveRef for plain refs.
940
+ */
941
+ async resolveRefExpression(dir, ref) {
942
+ const tildeMatch = /^(.+)~(\d+)$/.exec(ref);
943
+ if (!tildeMatch) {
944
+ return git.resolveRef({ dir, fs, ref });
945
+ }
946
+ const baseRef = tildeMatch[1];
947
+ const count = Number.parseInt(tildeMatch[2], 10);
948
+ if (count === 0) {
949
+ return git.resolveRef({ dir, fs, ref: baseRef });
950
+ }
951
+ let oid = await git.resolveRef({ dir, fs, ref: baseRef });
952
+ for (let i = 0; i < count; i++) {
953
+ // eslint-disable-next-line no-await-in-loop
954
+ const commit = await git.readCommit({ dir, fs, oid });
955
+ if (commit.commit.parent.length === 0) {
956
+ throw new GitError(`Cannot resolve '${ref}': not enough ancestors.`);
957
+ }
958
+ oid = commit.commit.parent[0];
959
+ }
960
+ return oid;
961
+ }
962
+ /**
963
+ * Fixes conflict markers written by isomorphic-git to match native git:
964
+ * 1. Replaces `<<<<<<< <branchName>` with `<<<<<<< HEAD`
965
+ * 2. Ensures `\n` before `=======` and `>>>>>>>` (isomorphic-git omits it when content has no trailing newline)
966
+ */
967
+ async rewriteConflictMarkers(dir, branchName, conflicts) {
968
+ const marker = `<<<<<<< ${branchName}`;
969
+ await Promise.all(conflicts
970
+ .filter((c) => c.type !== 'deleted_modified')
971
+ .map(async (c) => {
972
+ const filePath = join(dir, c.path);
973
+ let content = await fs.promises.readFile(filePath, 'utf8').catch(() => null);
974
+ if (!content)
975
+ return;
976
+ content = content.replaceAll(marker, '<<<<<<< HEAD');
977
+ // isomorphic-git omits \n before ======= and >>>>>>> when file content has no trailing newline
978
+ content = content.replaceAll(/([^\n])=======/g, '$1\n=======');
979
+ content = content.replaceAll(/([^\n])>>>>>>>/g, '$1\n>>>>>>>');
980
+ await fs.promises.writeFile(filePath, content);
981
+ }));
982
+ }
983
+ }