trident-git 0.2.0 → 0.2.2

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 (311) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-path-routes-manifest.json +23 -0
  3. package/.next/build-manifest.json +20 -0
  4. package/.next/export-marker.json +6 -0
  5. package/.next/images-manifest.json +73 -0
  6. package/.next/next-minimal-server.js.nft.json +1 -0
  7. package/.next/next-server.js.nft.json +1 -0
  8. package/.next/package.json +1 -0
  9. package/.next/prerender-manifest.json +282 -0
  10. package/.next/react-loadable-manifest.json +1 -0
  11. package/.next/required-server-files.js +330 -0
  12. package/.next/required-server-files.json +330 -0
  13. package/.next/routes-manifest.json +170 -0
  14. package/.next/server/app/_global-error/page.js +3 -0
  15. package/.next/server/app/_global-error/page.js.nft.json +1 -0
  16. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_global-error.html +2 -0
  18. package/.next/server/app/_global-error.meta +16 -0
  19. package/.next/server/app/_global-error.rsc +12 -0
  20. package/.next/server/app/_global-error.segments/_full.segment.rsc +12 -0
  21. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +5 -0
  22. package/.next/server/app/_global-error.segments/_global-error.segment.rsc +4 -0
  23. package/.next/server/app/_global-error.segments/_head.segment.rsc +5 -0
  24. package/.next/server/app/_global-error.segments/_index.segment.rsc +4 -0
  25. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -0
  26. package/.next/server/app/_not-found/page.js +2 -0
  27. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  28. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  29. package/.next/server/app/_not-found.html +1 -0
  30. package/.next/server/app/_not-found.meta +16 -0
  31. package/.next/server/app/_not-found.rsc +19 -0
  32. package/.next/server/app/_not-found.segments/_full.segment.rsc +19 -0
  33. package/.next/server/app/_not-found.segments/_head.segment.rsc +6 -0
  34. package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -0
  35. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +5 -0
  36. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +4 -0
  37. package/.next/server/app/_not-found.segments/_tree.segment.rsc +6 -0
  38. package/.next/server/app/api/credentials/route.js +1 -0
  39. package/.next/server/app/api/credentials/route.js.nft.json +1 -0
  40. package/.next/server/app/api/credentials/route_client-reference-manifest.js +1 -0
  41. package/.next/server/app/api/custom-scripts/route.js +3 -0
  42. package/.next/server/app/api/custom-scripts/route.js.nft.json +1 -0
  43. package/.next/server/app/api/custom-scripts/route_client-reference-manifest.js +1 -0
  44. package/.next/server/app/api/fs/route.js +1 -0
  45. package/.next/server/app/api/fs/route.js.nft.json +1 -0
  46. package/.next/server/app/api/fs/route_client-reference-manifest.js +1 -0
  47. package/.next/server/app/api/git/action/route.js +1 -0
  48. package/.next/server/app/api/git/action/route.js.nft.json +1 -0
  49. package/.next/server/app/api/git/action/route_client-reference-manifest.js +1 -0
  50. package/.next/server/app/api/git/branches/route.js +1 -0
  51. package/.next/server/app/api/git/branches/route.js.nft.json +1 -0
  52. package/.next/server/app/api/git/branches/route_client-reference-manifest.js +1 -0
  53. package/.next/server/app/api/git/diff/route.js +1 -0
  54. package/.next/server/app/api/git/diff/route.js.nft.json +1 -0
  55. package/.next/server/app/api/git/diff/route_client-reference-manifest.js +1 -0
  56. package/.next/server/app/api/git/log/route.js +1 -0
  57. package/.next/server/app/api/git/log/route.js.nft.json +1 -0
  58. package/.next/server/app/api/git/log/route_client-reference-manifest.js +1 -0
  59. package/.next/server/app/api/git/status/route.js +1 -0
  60. package/.next/server/app/api/git/status/route.js.nft.json +1 -0
  61. package/.next/server/app/api/git/status/route_client-reference-manifest.js +1 -0
  62. package/.next/server/app/api/repos/route.js +1 -0
  63. package/.next/server/app/api/repos/route.js.nft.json +1 -0
  64. package/.next/server/app/api/repos/route_client-reference-manifest.js +1 -0
  65. package/.next/server/app/api/settings/route.js +1 -0
  66. package/.next/server/app/api/settings/route.js.nft.json +1 -0
  67. package/.next/server/app/api/settings/route_client-reference-manifest.js +1 -0
  68. package/.next/server/app/credentials/page.js +4 -0
  69. package/.next/server/app/credentials/page.js.nft.json +1 -0
  70. package/.next/server/app/credentials/page_client-reference-manifest.js +1 -0
  71. package/.next/server/app/credentials.html +1 -0
  72. package/.next/server/app/credentials.meta +15 -0
  73. package/.next/server/app/credentials.rsc +23 -0
  74. package/.next/server/app/credentials.segments/_full.segment.rsc +23 -0
  75. package/.next/server/app/credentials.segments/_head.segment.rsc +6 -0
  76. package/.next/server/app/credentials.segments/_index.segment.rsc +8 -0
  77. package/.next/server/app/credentials.segments/_tree.segment.rsc +6 -0
  78. package/.next/server/app/credentials.segments/credentials/__PAGE__.segment.rsc +9 -0
  79. package/.next/server/app/credentials.segments/credentials.segment.rsc +4 -0
  80. package/.next/server/app/icon.png/route.js +1 -0
  81. package/.next/server/app/icon.png/route.js.nft.json +1 -0
  82. package/.next/server/app/icon.png.meta +1 -0
  83. package/.next/server/app/index.html +1 -0
  84. package/.next/server/app/index.meta +14 -0
  85. package/.next/server/app/index.rsc +20 -0
  86. package/.next/server/app/index.segments/__PAGE__.segment.rsc +6 -0
  87. package/.next/server/app/index.segments/_full.segment.rsc +20 -0
  88. package/.next/server/app/index.segments/_head.segment.rsc +6 -0
  89. package/.next/server/app/index.segments/_index.segment.rsc +8 -0
  90. package/.next/server/app/index.segments/_tree.segment.rsc +6 -0
  91. package/.next/server/app/page.js +2 -0
  92. package/.next/server/app/page.js.nft.json +1 -0
  93. package/.next/server/app/page_client-reference-manifest.js +1 -0
  94. package/.next/server/app/workspace/changes/page.js +4 -0
  95. package/.next/server/app/workspace/changes/page.js.nft.json +1 -0
  96. package/.next/server/app/workspace/changes/page_client-reference-manifest.js +1 -0
  97. package/.next/server/app/workspace/changes.html +1 -0
  98. package/.next/server/app/workspace/changes.meta +16 -0
  99. package/.next/server/app/workspace/changes.rsc +26 -0
  100. package/.next/server/app/workspace/changes.segments/_full.segment.rsc +26 -0
  101. package/.next/server/app/workspace/changes.segments/_head.segment.rsc +6 -0
  102. package/.next/server/app/workspace/changes.segments/_index.segment.rsc +8 -0
  103. package/.next/server/app/workspace/changes.segments/_tree.segment.rsc +7 -0
  104. package/.next/server/app/workspace/changes.segments/workspace/changes/__PAGE__.segment.rsc +10 -0
  105. package/.next/server/app/workspace/changes.segments/workspace/changes.segment.rsc +4 -0
  106. package/.next/server/app/workspace/changes.segments/workspace.segment.rsc +7 -0
  107. package/.next/server/app/workspace/custom-scripts/page.js +2 -0
  108. package/.next/server/app/workspace/custom-scripts/page.js.nft.json +1 -0
  109. package/.next/server/app/workspace/custom-scripts/page_client-reference-manifest.js +1 -0
  110. package/.next/server/app/workspace/custom-scripts.html +1 -0
  111. package/.next/server/app/workspace/custom-scripts.meta +16 -0
  112. package/.next/server/app/workspace/custom-scripts.rsc +25 -0
  113. package/.next/server/app/workspace/custom-scripts.segments/_full.segment.rsc +25 -0
  114. package/.next/server/app/workspace/custom-scripts.segments/_head.segment.rsc +6 -0
  115. package/.next/server/app/workspace/custom-scripts.segments/_index.segment.rsc +8 -0
  116. package/.next/server/app/workspace/custom-scripts.segments/_tree.segment.rsc +6 -0
  117. package/.next/server/app/workspace/custom-scripts.segments/workspace/custom-scripts/__PAGE__.segment.rsc +9 -0
  118. package/.next/server/app/workspace/custom-scripts.segments/workspace/custom-scripts.segment.rsc +4 -0
  119. package/.next/server/app/workspace/custom-scripts.segments/workspace.segment.rsc +7 -0
  120. package/.next/server/app/workspace/history/page.js +2 -0
  121. package/.next/server/app/workspace/history/page.js.nft.json +1 -0
  122. package/.next/server/app/workspace/history/page_client-reference-manifest.js +1 -0
  123. package/.next/server/app/workspace/history.html +1 -0
  124. package/.next/server/app/workspace/history.meta +16 -0
  125. package/.next/server/app/workspace/history.rsc +26 -0
  126. package/.next/server/app/workspace/history.segments/_full.segment.rsc +26 -0
  127. package/.next/server/app/workspace/history.segments/_head.segment.rsc +6 -0
  128. package/.next/server/app/workspace/history.segments/_index.segment.rsc +8 -0
  129. package/.next/server/app/workspace/history.segments/_tree.segment.rsc +7 -0
  130. package/.next/server/app/workspace/history.segments/workspace/history/__PAGE__.segment.rsc +10 -0
  131. package/.next/server/app/workspace/history.segments/workspace/history.segment.rsc +4 -0
  132. package/.next/server/app/workspace/history.segments/workspace.segment.rsc +7 -0
  133. package/.next/server/app/workspace/page.js +2 -0
  134. package/.next/server/app/workspace/page.js.nft.json +1 -0
  135. package/.next/server/app/workspace/page_client-reference-manifest.js +1 -0
  136. package/.next/server/app/workspace/settings/page.js +2 -0
  137. package/.next/server/app/workspace/settings/page.js.nft.json +1 -0
  138. package/.next/server/app/workspace/settings/page_client-reference-manifest.js +1 -0
  139. package/.next/server/app/workspace/settings.html +1 -0
  140. package/.next/server/app/workspace/settings.meta +16 -0
  141. package/.next/server/app/workspace/settings.rsc +25 -0
  142. package/.next/server/app/workspace/settings.segments/_full.segment.rsc +25 -0
  143. package/.next/server/app/workspace/settings.segments/_head.segment.rsc +6 -0
  144. package/.next/server/app/workspace/settings.segments/_index.segment.rsc +8 -0
  145. package/.next/server/app/workspace/settings.segments/_tree.segment.rsc +6 -0
  146. package/.next/server/app/workspace/settings.segments/workspace/settings/__PAGE__.segment.rsc +9 -0
  147. package/.next/server/app/workspace/settings.segments/workspace/settings.segment.rsc +4 -0
  148. package/.next/server/app/workspace/settings.segments/workspace.segment.rsc +7 -0
  149. package/.next/server/app/workspace/stashes/page.js +2 -0
  150. package/.next/server/app/workspace/stashes/page.js.nft.json +1 -0
  151. package/.next/server/app/workspace/stashes/page_client-reference-manifest.js +1 -0
  152. package/.next/server/app/workspace/stashes.html +1 -0
  153. package/.next/server/app/workspace/stashes.meta +16 -0
  154. package/.next/server/app/workspace/stashes.rsc +26 -0
  155. package/.next/server/app/workspace/stashes.segments/_full.segment.rsc +26 -0
  156. package/.next/server/app/workspace/stashes.segments/_head.segment.rsc +6 -0
  157. package/.next/server/app/workspace/stashes.segments/_index.segment.rsc +8 -0
  158. package/.next/server/app/workspace/stashes.segments/_tree.segment.rsc +7 -0
  159. package/.next/server/app/workspace/stashes.segments/workspace/stashes/__PAGE__.segment.rsc +10 -0
  160. package/.next/server/app/workspace/stashes.segments/workspace/stashes.segment.rsc +4 -0
  161. package/.next/server/app/workspace/stashes.segments/workspace.segment.rsc +7 -0
  162. package/.next/server/app/workspace.html +1 -0
  163. package/.next/server/app/workspace.meta +15 -0
  164. package/.next/server/app/workspace.rsc +26 -0
  165. package/.next/server/app/workspace.segments/_full.segment.rsc +26 -0
  166. package/.next/server/app/workspace.segments/_head.segment.rsc +6 -0
  167. package/.next/server/app/workspace.segments/_index.segment.rsc +8 -0
  168. package/.next/server/app/workspace.segments/_tree.segment.rsc +7 -0
  169. package/.next/server/app/workspace.segments/workspace/__PAGE__.segment.rsc +10 -0
  170. package/.next/server/app/workspace.segments/workspace.segment.rsc +7 -0
  171. package/.next/server/app-paths-manifest.json +23 -0
  172. package/.next/server/chunks/116.js +1 -0
  173. package/.next/server/chunks/170.js +1 -0
  174. package/.next/server/chunks/309.js +6 -0
  175. package/.next/server/chunks/356.js +1 -0
  176. package/.next/server/chunks/392.js +1 -0
  177. package/.next/server/chunks/443.js +37 -0
  178. package/.next/server/chunks/445.js +22 -0
  179. package/.next/server/chunks/471.js +13 -0
  180. package/.next/server/chunks/557.js +5 -0
  181. package/.next/server/chunks/680.js +1 -0
  182. package/.next/server/chunks/739.js +5 -0
  183. package/.next/server/chunks/781.js +1 -0
  184. package/.next/server/chunks/813.js +1 -0
  185. package/.next/server/chunks/816.js +13 -0
  186. package/.next/server/functions-config-manifest.json +6 -0
  187. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  188. package/.next/server/middleware-build-manifest.js +1 -0
  189. package/.next/server/middleware-manifest.json +6 -0
  190. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  191. package/.next/server/next-font-manifest.js +1 -0
  192. package/.next/server/next-font-manifest.json +1 -0
  193. package/.next/server/pages/404.html +1 -0
  194. package/.next/server/pages/500.html +2 -0
  195. package/.next/server/pages-manifest.json +4 -0
  196. package/.next/server/server-reference-manifest.js +1 -0
  197. package/.next/server/server-reference-manifest.json +1 -0
  198. package/.next/server/webpack-runtime.js +1 -0
  199. package/.next/static/VTWJwhFJd5VcC6GP5bbli/_buildManifest.js +1 -0
  200. package/.next/static/VTWJwhFJd5VcC6GP5bbli/_ssgManifest.js +1 -0
  201. package/.next/static/chunks/131-6237c77d708182b1.js +1 -0
  202. package/.next/static/chunks/147-d4cb3e03218d75df.js +6 -0
  203. package/.next/static/chunks/176-b068120641913226.js +1 -0
  204. package/.next/static/chunks/200-3664e65687e716a2.js +1 -0
  205. package/.next/static/chunks/26-d93e82c9c09ff972.js +1 -0
  206. package/.next/static/chunks/4bd1b696-096d35a2bd1da3af.js +1 -0
  207. package/.next/static/chunks/500-5d45efbf86b8d36e.js +1 -0
  208. package/.next/static/chunks/772-b39107b40c31b6b6.js +3 -0
  209. package/.next/static/chunks/785-e6d755147ff87934.js +1 -0
  210. package/.next/static/chunks/790-3e8fa6e595d97940.js +6 -0
  211. package/.next/static/chunks/794-37dad9bbc14b04b8.js +2 -0
  212. package/.next/static/chunks/841-4d865378216d36eb.js +13 -0
  213. package/.next/static/chunks/app/_global-error/page-9eb8a19109bbd711.js +1 -0
  214. package/.next/static/chunks/app/_not-found/page-5bea07380837254f.js +1 -0
  215. package/.next/static/chunks/app/api/credentials/route-9eb8a19109bbd711.js +1 -0
  216. package/.next/static/chunks/app/api/custom-scripts/route-9eb8a19109bbd711.js +1 -0
  217. package/.next/static/chunks/app/api/fs/route-9eb8a19109bbd711.js +1 -0
  218. package/.next/static/chunks/app/api/git/action/route-9eb8a19109bbd711.js +1 -0
  219. package/.next/static/chunks/app/api/git/branches/route-9eb8a19109bbd711.js +1 -0
  220. package/.next/static/chunks/app/api/git/diff/route-9eb8a19109bbd711.js +1 -0
  221. package/.next/static/chunks/app/api/git/log/route-9eb8a19109bbd711.js +1 -0
  222. package/.next/static/chunks/app/api/git/status/route-9eb8a19109bbd711.js +1 -0
  223. package/.next/static/chunks/app/api/repos/route-9eb8a19109bbd711.js +1 -0
  224. package/.next/static/chunks/app/api/settings/route-9eb8a19109bbd711.js +1 -0
  225. package/.next/static/chunks/app/credentials/page-23c1807ad9f68a40.js +1 -0
  226. package/.next/static/chunks/app/layout-a06241c385e50ae0.js +1 -0
  227. package/.next/static/chunks/app/page-c8404790f5191fd7.js +1 -0
  228. package/.next/static/chunks/app/workspace/changes/page-8cca17f80e66f534.js +3 -0
  229. package/.next/static/chunks/app/workspace/custom-scripts/page-ee24b9dd7779244f.js +1 -0
  230. package/.next/static/chunks/app/workspace/history/page-b7017fea285c804c.js +1 -0
  231. package/.next/static/chunks/app/workspace/layout-cb86c00fdcd8db08.js +1 -0
  232. package/.next/static/chunks/app/workspace/page-21211acc90c08509.js +1 -0
  233. package/.next/static/chunks/app/workspace/settings/page-7ccb58ece31492e9.js +1 -0
  234. package/.next/static/chunks/app/workspace/stashes/page-0e1b8304c4b0f4f3.js +1 -0
  235. package/.next/static/chunks/framework-75892d61b920805f.js +1 -0
  236. package/.next/static/chunks/main-app-221e1b945693a142.js +1 -0
  237. package/.next/static/chunks/main-f1d55fffd3cc9809.js +5 -0
  238. package/.next/static/chunks/next/dist/client/components/builtin/app-error-9eb8a19109bbd711.js +1 -0
  239. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-9eb8a19109bbd711.js +1 -0
  240. package/.next/static/chunks/next/dist/client/components/builtin/global-error-f2a2c324f3740ed8.js +1 -0
  241. package/.next/static/chunks/next/dist/client/components/builtin/not-found-9eb8a19109bbd711.js +1 -0
  242. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-9eb8a19109bbd711.js +1 -0
  243. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  244. package/.next/static/chunks/webpack-7dec27dc3bfe207a.js +1 -0
  245. package/.next/static/css/36968b90b2339533.css +1 -0
  246. package/.next/static/css/c56c0e57397a3a60.css +1 -0
  247. package/.next/static/css/de70bee13400563f.css +1 -0
  248. package/.next/static/media/4cf2300e9c8272f7-s.p.woff2 +0 -0
  249. package/.next/static/media/747892c23ea88013-s.woff2 +0 -0
  250. package/.next/static/media/8d697b304b401681-s.woff2 +0 -0
  251. package/.next/static/media/93f479601ee12b01-s.p.woff2 +0 -0
  252. package/.next/static/media/9610d9e46709d722-s.woff2 +0 -0
  253. package/.next/static/media/ba015fad6dcf6784-s.woff2 +0 -0
  254. package/README.md +2 -0
  255. package/bin/trident-git.mjs +9 -11
  256. package/{next.config.ts → next.config.mjs} +7 -8
  257. package/package.json +8 -6
  258. package/eslint.config.mjs +0 -18
  259. package/postcss.config.mjs +0 -7
  260. package/src/app/api/credentials/route.ts +0 -113
  261. package/src/app/api/custom-scripts/route.ts +0 -203
  262. package/src/app/api/fs/route.ts +0 -75
  263. package/src/app/api/git/action/route.ts +0 -383
  264. package/src/app/api/git/branches/route.ts +0 -20
  265. package/src/app/api/git/diff/route.ts +0 -104
  266. package/src/app/api/git/log/route.ts +0 -28
  267. package/src/app/api/git/status/route.ts +0 -28
  268. package/src/app/api/repos/route.ts +0 -84
  269. package/src/app/api/settings/route.ts +0 -37
  270. package/src/app/credentials/page.tsx +0 -408
  271. package/src/app/globals.css +0 -109
  272. package/src/app/layout.tsx +0 -38
  273. package/src/app/page.tsx +0 -10
  274. package/src/app/providers.tsx +0 -21
  275. package/src/app/workspace/changes/page.tsx +0 -27
  276. package/src/app/workspace/custom-scripts/page.tsx +0 -247
  277. package/src/app/workspace/history/page.tsx +0 -27
  278. package/src/app/workspace/layout.tsx +0 -26
  279. package/src/app/workspace/page.tsx +0 -27
  280. package/src/app/workspace/settings/page.tsx +0 -233
  281. package/src/app/workspace/stashes/page.tsx +0 -395
  282. package/src/components/command-palette.tsx +0 -178
  283. package/src/components/context-menu.tsx +0 -200
  284. package/src/components/fs-browser.tsx +0 -154
  285. package/src/components/git/diff-view.tsx +0 -137
  286. package/src/components/git/git-graph.tsx +0 -489
  287. package/src/components/git/grouped-diff-viewer.tsx +0 -332
  288. package/src/components/git/history-view.tsx +0 -4862
  289. package/src/components/git/image-diff-view.tsx +0 -342
  290. package/src/components/git/status-view.tsx +0 -597
  291. package/src/components/home-settings-modal.tsx +0 -192
  292. package/src/components/layout/sidebar.tsx +0 -256
  293. package/src/components/repo-list.tsx +0 -206
  294. package/src/components/theme-toggle.tsx +0 -37
  295. package/src/components/toaster.tsx +0 -36
  296. package/src/components/workspace-repo-open-tracker.tsx +0 -39
  297. package/src/hooks/use-credentials.ts +0 -123
  298. package/src/hooks/use-escape-dismiss.ts +0 -72
  299. package/src/hooks/use-git.ts +0 -448
  300. package/src/hooks/use-toast.ts +0 -280
  301. package/src/hooks/use-workspace-title.ts +0 -23
  302. package/src/lib/api-utils.ts +0 -24
  303. package/src/lib/branch-colors.ts +0 -98
  304. package/src/lib/credentials.ts +0 -404
  305. package/src/lib/git.ts +0 -1510
  306. package/src/lib/graph-utils.ts +0 -253
  307. package/src/lib/store.ts +0 -145
  308. package/src/lib/types.ts +0 -95
  309. package/src/lib/utils.ts +0 -266
  310. package/tsconfig.json +0 -34
  311. /package/{src/app/icon.png → .next/server/app/icon.png.body} +0 -0
package/src/lib/git.ts DELETED
@@ -1,1510 +0,0 @@
1
- import { simpleGit, SimpleGit, SimpleGitOptions } from 'simple-git';
2
- import { GitStatus, GitLog } from './types';
3
- import { execFile } from 'node:child_process';
4
- import { promisify } from 'node:util';
5
- import { mkdtemp, unlink } from 'node:fs/promises';
6
- import { join } from 'node:path';
7
- import { tmpdir } from 'node:os';
8
-
9
- const execFileAsync = promisify(execFile);
10
-
11
- // Cache simple-git instances to avoid spawning too many processes if possible,
12
- // though simple-git is lightweight.
13
- const gitInstances: Record<string, SimpleGit> = {};
14
-
15
- export function getGit(repoPath: string): SimpleGit {
16
- if (!gitInstances[repoPath]) {
17
- const options: Partial<SimpleGitOptions> = {
18
- baseDir: repoPath,
19
- binary: 'git',
20
- maxConcurrentProcesses: 6,
21
- trimmed: false,
22
- };
23
- const git = simpleGit(options);
24
-
25
- // Configure git to not prompt for credentials - fail instead of hang
26
- git.env({
27
- ...process.env,
28
- GIT_TERMINAL_PROMPT: '0',
29
- GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=no',
30
- });
31
-
32
- gitInstances[repoPath] = git;
33
- }
34
- return gitInstances[repoPath];
35
- }
36
-
37
- export class GitService {
38
- constructor(private repoPath: string) { }
39
-
40
- private get git(): SimpleGit {
41
- return getGit(this.repoPath);
42
- }
43
-
44
- async cleanupLockFile(): Promise<boolean> {
45
- const lockFilePath = join(this.repoPath, '.git', 'index.lock');
46
- try {
47
- await unlink(lockFilePath);
48
- return true;
49
- } catch (e) {
50
- // Ignore if file doesn't exist
51
- return false;
52
- }
53
- }
54
-
55
- async getStatus(): Promise<GitStatus> {
56
- const status = await this.git.status();
57
- // Transform simple-git status to our generic definitions if needed,
58
- // but simple-git's StatusResult is compatible enough for now,
59
- // except we might want to sanitize paths.
60
- // For now, we return it as is, casting to our interface (which matches simple-git mostly).
61
- return status as unknown as GitStatus;
62
- }
63
-
64
- async getLog(limit: number = 100): Promise<GitLog> {
65
- // Custom format to ensure we get parents and refs correctly
66
- const log = await this.git.log({
67
- '--all': null,
68
- // Keep refs stable regardless of user-level git config (e.g. log.decorate=full).
69
- '--decorate': 'short',
70
- '--max-count': limit,
71
- format: {
72
- hash: '%h',
73
- parents: '%p',
74
- date: '%ai',
75
- message: '%s',
76
- refs: '%d',
77
- author_name: '%an',
78
- author_email: '%ae',
79
- body: '%b'
80
- }
81
- });
82
-
83
- // Transform simple-git ListLogLine to our Commit type
84
- // simple-git handles the parsing if we pass the format object keys correctly matching our type, mostly.
85
- // Parents in simple-git are usually just space separated string in the custom format result unless processed.
86
- // We might need to map it.
87
-
88
- const commits = log.all.map((c: any) => ({
89
- ...c,
90
- parents: c.parents ? c.parents.split(' ').filter(Boolean) : []
91
- }));
92
-
93
- return {
94
- all: commits,
95
- total: log.total,
96
- latest: commits[0] || null
97
- } as unknown as GitLog;
98
- }
99
-
100
- async getLatestCommitMessage(branch: string): Promise<string> {
101
- const message = await this.git.raw(['show', '-s', '--format=%B', branch]);
102
- return message.trimEnd();
103
- }
104
-
105
- async fetch(): Promise<void> {
106
- await this.git.raw(['fetch', '--prune']);
107
- }
108
-
109
- async fetchRemote(remote: string): Promise<void> {
110
- await this.git.raw(['fetch', '--prune', remote]);
111
- }
112
-
113
- async fetchAllRemotes(): Promise<void> {
114
- await this.git.raw(['fetch', '--all', '--prune']);
115
- }
116
-
117
- async addRemote(name: string, url: string): Promise<void> {
118
- const trimmedName = name.trim();
119
- const trimmedUrl = url.trim();
120
-
121
- if (!trimmedName) throw new Error('Remote name is required');
122
- if (!trimmedUrl) throw new Error('Remote URL is required');
123
-
124
- await this.git.raw(['remote', 'add', trimmedName, trimmedUrl]);
125
- await this.git.raw(['fetch', '--prune', trimmedName]);
126
- }
127
-
128
- async renameRemote(oldName: string, newName: string): Promise<void> {
129
- const trimmedOldName = oldName.trim();
130
- const trimmedNewName = newName.trim();
131
-
132
- if (!trimmedOldName) throw new Error('Old remote name is required');
133
- if (!trimmedNewName) throw new Error('New remote name is required');
134
- if (trimmedOldName === trimmedNewName) return;
135
-
136
- await this.git.raw(['remote', 'rename', trimmedOldName, trimmedNewName]);
137
- }
138
-
139
- async deleteRemote(name: string): Promise<void> {
140
- const trimmedName = name.trim();
141
- if (!trimmedName) throw new Error('Remote name is required to delete remote');
142
-
143
- await this.git.raw(['remote', 'remove', trimmedName]);
144
- }
145
-
146
- async pull(): Promise<void> {
147
- await this.git.pull();
148
- }
149
-
150
- async push(options: { credentials?: { username: string; token: string } } = {}): Promise<void> {
151
- const { credentials } = options;
152
-
153
- if (credentials) {
154
- // If credentials are provided, we must use pushToRemote with the authenticated URL
155
- // We need to resolve the current branch and its upstream
156
- const branchSummary = await this.git.branchLocal();
157
- const currentBranch = branchSummary.current;
158
-
159
- const tracking = await this.getTrackingBranch(currentBranch);
160
- if (!tracking) {
161
- throw new Error(`No upstream configured for branch '${currentBranch}'. Cannot push with credentials.`);
162
- }
163
-
164
- await this.pushToRemote(currentBranch, tracking.remote, tracking.branch, { credentials });
165
- } else {
166
- await this.git.push();
167
- }
168
- }
169
-
170
- async commit(message: string, files?: string[]): Promise<void> {
171
- if (files && files.length > 0) {
172
- await this.git.add(files);
173
- }
174
- await this.git.commit(message);
175
- }
176
-
177
- async stage(files: string[]): Promise<void> {
178
- await this.git.add(files);
179
- }
180
-
181
- async unstage(files: string[]): Promise<void> {
182
- await this.git.reset(['HEAD', ...files]);
183
- }
184
-
185
- async discardUnstagedChanges(options: { includeUntracked?: boolean } = {}): Promise<void> {
186
- const { includeUntracked = true } = options;
187
-
188
- // Restore tracked working tree changes from index (keeps staged changes intact).
189
- await this.git.raw(['checkout', '--', '.']);
190
-
191
- if (includeUntracked) {
192
- // Remove untracked files/directories, but keep ignored files.
193
- await this.git.raw(['clean', '-fd']);
194
- }
195
- }
196
-
197
- // Get raw file content for diffing
198
- async getFileContent(path: string, ref: string = 'HEAD'): Promise<string> {
199
- try {
200
- return await this.git.show([`${ref}:${path}`]);
201
- } catch (e) {
202
- // If file is new (untracked), we might want to read from fs?
203
- // But for "HEAD", it fails.
204
- // Let's assume frontend handles untracked files by reading fs API directly?
205
- // Or we fallback here?
206
- // For now, let it throw or return empty.
207
- console.error(e);
208
- return "";
209
- }
210
- }
211
-
212
- private async getBlobFromRef(refSpec: string): Promise<Buffer | null> {
213
- try {
214
- const { stdout } = await execFileAsync('git', ['show', refSpec], {
215
- cwd: this.repoPath,
216
- encoding: 'buffer',
217
- maxBuffer: 50 * 1024 * 1024, // 50MB safety cap
218
- });
219
- return Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout);
220
- } catch {
221
- return null;
222
- }
223
- }
224
-
225
- async getFileContentBuffer(path: string, ref: string = 'HEAD'): Promise<Buffer | null> {
226
- return this.getBlobFromRef(`${ref}:${path}`);
227
- }
228
-
229
- async getDiff(path: string): Promise<string> {
230
- // Get diff of working directory vs index (unstaged changes)
231
- // or index vs HEAD (staged changes)
232
- // This is a complex topic.
233
- // Keep comparing against HEAD, but force pathspec parsing with `--`
234
- // so deleted files (missing from working tree) are still treated as paths.
235
- return await this.git.diff(['HEAD', '--', path]);
236
- }
237
-
238
- async getBranches() {
239
- // 1. Get current branch (HEAD)
240
- let currentBranch = '';
241
- try {
242
- currentBranch = (await this.git.revparse(['--abbrev-ref', 'HEAD'])).trim();
243
- } catch (e) {
244
- console.warn('Failed to get current branch:', e);
245
- }
246
-
247
- // 2. Get all refs (local and remote) with details
248
- // Format: refname|short_hash|upstream|upstream_track
249
- // Use a delimiter that is unlikely to be in branch names. '|' is good.
250
- const format = '%(refname)|%(objectname:short)|%(upstream:short)|%(upstream:track)';
251
- let rawRefs = '';
252
- try {
253
- rawRefs = await this.git.raw(['for-each-ref', `--format=${format}`, 'refs/heads', 'refs/remotes']);
254
- } catch (e) {
255
- console.error('Failed to get refs:', e);
256
- throw e;
257
- }
258
-
259
- const branches: string[] = [];
260
- const branchCommits: Record<string, string> = {};
261
- const remotes: Record<string, string[]> = {};
262
- const trackingInfo: Record<string, { upstream: string; ahead: number; behind: number }> = {};
263
-
264
- const lines = rawRefs.trim().split('\n');
265
-
266
- for (const line of lines) {
267
- if (!line) continue;
268
-
269
- const [refname, hash, upstream, track] = line.split('|');
270
-
271
- if (refname.startsWith('refs/heads/')) {
272
- // Local branch
273
- const branchName = refname.slice('refs/heads/'.length);
274
- branches.push(branchName);
275
- branchCommits[branchName] = hash;
276
-
277
- if (upstream) {
278
- // Parse track info: "[ahead 1, behind 2]"
279
- let ahead = 0;
280
- let behind = 0;
281
- if (track) {
282
- const aheadMatch = track.match(/ahead (\d+)/);
283
- if (aheadMatch) ahead = parseInt(aheadMatch[1], 10);
284
-
285
- const behindMatch = track.match(/behind (\d+)/);
286
- if (behindMatch) behind = parseInt(behindMatch[1], 10);
287
- }
288
-
289
- trackingInfo[branchName] = {
290
- upstream, // e.g. "origin/main"
291
- ahead,
292
- behind
293
- };
294
- }
295
- } else if (refname.startsWith('refs/remotes/')) {
296
- // Remote branch
297
- const withoutPrefix = refname.slice('refs/remotes/'.length);
298
- // Format: origin/main
299
- const slashIndex = withoutPrefix.indexOf('/');
300
- if (slashIndex > 0) {
301
- const remoteName = withoutPrefix.slice(0, slashIndex);
302
- const branchName = withoutPrefix.slice(slashIndex + 1);
303
-
304
- // Skip HEAD symbolic refs
305
- if (branchName === 'HEAD' || branchName.startsWith('HEAD ')) continue;
306
-
307
- if (!remotes[remoteName]) {
308
- remotes[remoteName] = [];
309
- }
310
- remotes[remoteName].push(branchName);
311
-
312
- // Store commit hash with full remote ref path as used in frontend/original code
313
- // Original code used `branch` from `git.branch(['-a'])` which returns `remotes/origin/main`
314
- branchCommits[`remotes/${withoutPrefix}`] = hash;
315
- }
316
- }
317
- }
318
-
319
- // 3. Get remote URLs
320
- const remoteList = await this.git.getRemotes(true);
321
- const remoteUrls: Record<string, string> = {};
322
- for (const r of remoteList) {
323
- remoteUrls[r.name] = r.refs.fetch || r.refs.push;
324
- if (!remotes[r.name]) {
325
- remotes[r.name] = [];
326
- }
327
- }
328
-
329
- return {
330
- branches,
331
- current: currentBranch,
332
- branchCommits,
333
- remotes, // { "origin": ["main", "feature"], "upstream": ["main"] }
334
- remoteUrls,
335
- trackingInfo, // { "main": { upstream: "origin/main", ahead: 5, behind: 1 } }
336
- };
337
- }
338
-
339
- async checkout(branch: string): Promise<void> {
340
- await this.git.checkout(branch);
341
- }
342
-
343
- async checkoutRemoteToLocal(remoteBranch: string, localBranch: string): Promise<void> {
344
- // remoteBranch is in format: remotes/origin/branch-name
345
- // We need to extract origin/branch-name for the tracking setup
346
-
347
- // First, verify the remote branch exists
348
- try {
349
- await this.git.revparse(['--verify', `refs/${remoteBranch}`]);
350
- } catch {
351
- throw new Error(`Remote branch '${remoteBranch}' does not exist`);
352
- }
353
-
354
- // Check if local branch already exists
355
- try {
356
- await this.git.revparse(['--verify', `refs/heads/${localBranch}`]);
357
- throw new Error(`Local branch '${localBranch}' already exists`);
358
- } catch (e) {
359
- // Branch doesn't exist, which is what we want (unless it's our "already exists" error)
360
- if ((e as Error).message.includes('already exists')) {
361
- throw e;
362
- }
363
- }
364
-
365
- // Extract the remote name and branch name from remotes/origin/branch-name
366
- const withoutRemotesPrefix = remoteBranch.replace(/^remotes\//, '');
367
- const slashIndex = withoutRemotesPrefix.indexOf('/');
368
- if (slashIndex <= 0) {
369
- throw new Error(`Invalid remote branch format: ${remoteBranch}`);
370
- }
371
- const remoteName = withoutRemotesPrefix.slice(0, slashIndex);
372
- const remoteBranchName = withoutRemotesPrefix.slice(slashIndex + 1);
373
-
374
- // Create local branch from remote branch and set up tracking
375
- // git checkout -b <local-branch> --track <remote>/<branch>
376
- await this.git.checkout(['-b', localBranch, '--track', `${remoteName}/${remoteBranchName}`]);
377
- }
378
-
379
- async createBranch(branch: string, fromRef?: string): Promise<void> {
380
- if (!fromRef) {
381
- await this.git.checkoutLocalBranch(branch);
382
- return;
383
- }
384
-
385
- // For remote refs, use "<remote>/<branch>" as start point.
386
- const startPoint = fromRef.startsWith('remotes/')
387
- ? fromRef.replace(/^remotes\//, '')
388
- : fromRef;
389
-
390
- await this.git.checkout(['-b', branch, startPoint]);
391
- }
392
-
393
- async createTag(
394
- tagName: string,
395
- commitHash: string,
396
- options: {
397
- pushToRemote?: boolean;
398
- remote?: string;
399
- credentials?: { username: string; token: string };
400
- } = {}
401
- ): Promise<void> {
402
- const { pushToRemote = false, remote, credentials } = options;
403
-
404
- const cleanTagName = tagName.trim();
405
- const cleanCommitHash = commitHash.trim();
406
- if (!cleanTagName) {
407
- throw new Error('Tag name is required');
408
- }
409
- if (!cleanCommitHash) {
410
- throw new Error('Commit hash is required');
411
- }
412
-
413
- await this.git.raw(['tag', cleanTagName, cleanCommitHash]);
414
-
415
- if (!pushToRemote) return;
416
-
417
- const allRemotes = await this.git.getRemotes();
418
- if (allRemotes.length === 0) {
419
- throw new Error('No remotes configured for this repository');
420
- }
421
-
422
- let selectedRemote = remote?.trim() || '';
423
- if (!selectedRemote) {
424
- const currentBranch = (await this.git.branchLocal()).current;
425
- if (currentBranch) {
426
- const tracking = await this.getTrackingBranch(currentBranch);
427
- if (tracking?.remote) {
428
- selectedRemote = tracking.remote;
429
- }
430
- }
431
- }
432
-
433
- if (!selectedRemote || !allRemotes.some((item) => item.name === selectedRemote)) {
434
- selectedRemote = allRemotes.some((item) => item.name === 'origin') ? 'origin' : allRemotes[0].name;
435
- }
436
-
437
- let targetRemote = selectedRemote;
438
- if (credentials) {
439
- const remoteUrl = await this.getRemoteUrl(selectedRemote);
440
- if (remoteUrl) {
441
- try {
442
- const urlObj = new URL(remoteUrl);
443
- urlObj.username = credentials.username;
444
- urlObj.password = credentials.token;
445
- targetRemote = urlObj.toString();
446
- } catch (e) {
447
- console.warn('[createTag] Failed to construct authenticated URL, falling back to remote name', e);
448
- }
449
- } else {
450
- console.warn(`[createTag] Could not resolve URL for remote '${selectedRemote}', falling back to remote name`);
451
- }
452
- }
453
-
454
- await this.git.push([targetRemote, `refs/tags/${cleanTagName}`]);
455
- }
456
-
457
- async deleteBranch(branch: string): Promise<void> {
458
- await this.git.deleteLocalBranch(branch, true);
459
- }
460
-
461
- async deleteRemoteBranch(remote: string, branch: string, credentials?: { username: string; token: string }): Promise<void> {
462
- let targetRemote = remote;
463
-
464
- if (credentials) {
465
- const remoteUrl = await this.getRemoteUrl(remote);
466
- if (remoteUrl) {
467
- try {
468
- const urlObj = new URL(remoteUrl);
469
- urlObj.username = credentials.username;
470
- urlObj.password = credentials.token;
471
- targetRemote = urlObj.toString();
472
- } catch (e) {
473
- console.warn('[deleteRemoteBranch] Failed to construct authenticated URL, falling back to remote name', e);
474
- }
475
- } else {
476
- console.warn(`[deleteRemoteBranch] Could not resolve URL for remote '${remote}', falling back to remote name`);
477
- }
478
- }
479
-
480
- try {
481
- await this.git.push([targetRemote, '--delete', branch]);
482
- } catch (e: any) {
483
- const errorMessage = e?.message || '';
484
- if (errorMessage.includes('remote ref does not exist')) {
485
- console.debug(`[deleteRemoteBranch] Remote branch ${branch} does not exist on remote ${remote}, ignoring error.`);
486
- } else {
487
- throw e;
488
- }
489
- }
490
-
491
- // Also delete the remote-tracking branch locally to update the view immediately
492
- // This is necessary because if we used a URL for targetRemote, git won't automatically prune the named remote's ref
493
- try {
494
- await this.git.branch(['-r', '-D', `${remote}/${branch}`]);
495
- } catch (e) {
496
- // Ignore error if branch doesn't exist locally or if deletion fails for some reason
497
- console.debug(`[deleteRemoteBranch] Could not delete local remote-tracking branch ${remote}/${branch}:`, e);
498
- }
499
- }
500
-
501
- async deleteTag(tag: string): Promise<void> {
502
- await this.git.raw(['tag', '-d', tag]);
503
- }
504
-
505
- async deleteRemoteTag(remote: string, tag: string, credentials?: { username: string; token: string }): Promise<void> {
506
- let targetRemote = remote;
507
-
508
- if (credentials) {
509
- const remoteUrl = await this.getRemoteUrl(remote);
510
- if (remoteUrl) {
511
- try {
512
- const urlObj = new URL(remoteUrl);
513
- urlObj.username = credentials.username;
514
- urlObj.password = credentials.token;
515
- targetRemote = urlObj.toString();
516
- } catch (e) {
517
- console.warn('[deleteRemoteTag] Failed to construct authenticated URL, falling back to remote name', e);
518
- }
519
- } else {
520
- console.warn(`[deleteRemoteTag] Could not resolve URL for remote '${remote}', falling back to remote name`);
521
- }
522
- }
523
-
524
- await this.git.push([targetRemote, '--delete', `refs/tags/${tag}`]);
525
- }
526
-
527
- async renameBranch(
528
- oldName: string,
529
- newName: string,
530
- options: {
531
- renameTrackingRemote?: boolean;
532
- credentials?: { username: string; token: string };
533
- } = {}
534
- ): Promise<void> {
535
- const { renameTrackingRemote = false, credentials } = options;
536
- const trackingBeforeRename = renameTrackingRemote
537
- ? await this.getTrackingBranch(oldName)
538
- : null;
539
-
540
- await this.git.branch(['-m', oldName, newName]);
541
-
542
- if (!renameTrackingRemote || !trackingBeforeRename) return;
543
-
544
- await this.renameRemoteBranch(
545
- trackingBeforeRename.remote,
546
- trackingBeforeRename.branch,
547
- newName,
548
- credentials
549
- );
550
-
551
- // Keep the renamed local branch tracking the renamed remote branch.
552
- try {
553
- await this.git.branch(['--set-upstream-to', `${trackingBeforeRename.remote}/${newName}`, newName]);
554
- } catch (e) {
555
- console.debug(`[renameBranch] Could not set upstream for ${newName} to ${trackingBeforeRename.remote}/${newName}:`, e);
556
- }
557
- }
558
-
559
- async renameRemoteBranch(
560
- remote: string,
561
- oldName: string,
562
- newName: string,
563
- credentials?: { username: string; token: string }
564
- ): Promise<void> {
565
- if (oldName === newName) return;
566
-
567
- let targetRemote = remote;
568
-
569
- if (credentials) {
570
- const remoteUrl = await this.getRemoteUrl(remote);
571
- if (remoteUrl) {
572
- try {
573
- const urlObj = new URL(remoteUrl);
574
- urlObj.username = credentials.username;
575
- urlObj.password = credentials.token;
576
- targetRemote = urlObj.toString();
577
- } catch (e) {
578
- console.warn('[renameRemoteBranch] Failed to construct authenticated URL, falling back to remote name', e);
579
- }
580
- } else {
581
- console.warn(`[renameRemoteBranch] Could not resolve URL for remote '${remote}', falling back to remote name`);
582
- }
583
- }
584
-
585
- // Create the new remote branch from the old remote-tracking ref, then delete the old remote branch.
586
- // Using the remote-tracking ref avoids requiring a same-named local branch to exist.
587
- const oldTrackingRef = `refs/remotes/${remote}/${oldName}`;
588
- await this.git.push([targetRemote, `${oldTrackingRef}:refs/heads/${newName}`]);
589
- await this.git.push([targetRemote, '--delete', oldName]);
590
-
591
- // Update remote-tracking refs locally so the branches tree reflects the rename immediately.
592
- const newTrackingRef = `refs/remotes/${remote}/${newName}`;
593
- try {
594
- const oldTrackingHash = (await this.git.revparse([oldTrackingRef])).trim();
595
- await this.git.raw(['update-ref', newTrackingRef, oldTrackingHash]);
596
- } catch (e) {
597
- console.debug(`[renameRemoteBranch] Could not update local tracking ref ${newTrackingRef}:`, e);
598
- }
599
-
600
- try {
601
- await this.git.branch(['-r', '-D', `${remote}/${oldName}`]);
602
- } catch (e) {
603
- console.debug(`[renameRemoteBranch] Could not delete old local remote-tracking branch ${remote}/${oldName}:`, e);
604
- }
605
- }
606
-
607
- async reset(commitHash: string, mode: 'hard' | 'soft' | 'mixed' = 'hard'): Promise<void> {
608
- await this.git.reset([`--${mode}`, commitHash]);
609
- }
610
-
611
- async revert(commitHash: string): Promise<void> {
612
- await this.git.raw(['revert', '--no-edit', commitHash]);
613
- }
614
-
615
- async cherryPick(commitHash: string): Promise<void> {
616
- await this.git.raw(['cherry-pick', commitHash]);
617
- }
618
-
619
- async cherryPickMultiple(commitHashes: string[]): Promise<void> {
620
- if (commitHashes.length === 0) return;
621
- await this.git.raw(['cherry-pick', ...commitHashes]);
622
- }
623
-
624
- async abortCherryPick(): Promise<void> {
625
- await this.git.raw(['cherry-pick', '--abort']);
626
- }
627
-
628
- async rebase(ontoBranch: string, stashChanges: boolean = true): Promise<void> {
629
- if (stashChanges) {
630
- // Stash any local changes before rebasing
631
- const status = await this.git.status();
632
- const hasChanges = status.files.length > 0;
633
-
634
- if (hasChanges) {
635
- await this.git.stash(['push', '-m', 'auto-stash before rebase']);
636
- }
637
-
638
- try {
639
- await this.git.rebase([ontoBranch]);
640
-
641
- // Reapply stashed changes if we stashed anything
642
- if (hasChanges) {
643
- await this.git.stash(['pop']);
644
- }
645
- } catch (e) {
646
- // If rebase fails, try to pop stash anyway so user doesn't lose changes
647
- if (hasChanges) {
648
- try {
649
- await this.git.stash(['pop']);
650
- } catch {
651
- // Stash pop might fail if there are conflicts, that's ok
652
- }
653
- }
654
- throw e;
655
- }
656
- } else {
657
- // Discard local changes by resetting before rebase
658
- await this.git.reset(['--hard', 'HEAD']);
659
- await this.git.rebase([ontoBranch]);
660
- }
661
- }
662
-
663
- async reword(commitHash: string, newMessage: string, branch?: string): Promise<void> {
664
- const branchSummary = await this.git.branchLocal();
665
- const currentBranch = branchSummary.current;
666
- const targetBranch = branch || currentBranch;
667
- const needsCheckout = targetBranch !== currentBranch;
668
-
669
- // Check if we have uncommitted changes
670
- const status = await this.git.status();
671
- const hasChanges = status.files.length > 0;
672
-
673
- if (hasChanges) {
674
- // Stash changes before checkout
675
- await this.git.stash(['push', '-m', 'auto-stash before reword']);
676
- }
677
-
678
- // Checkout the target branch if it's not the current one
679
- if (needsCheckout) {
680
- await this.git.checkout(targetBranch);
681
- }
682
-
683
- try {
684
- // Verify that the commit to be reworded is the latest commit (HEAD)
685
- const headCommit = await this.git.revparse(['HEAD']);
686
- // We compare full hashes or short hashes? simple-git revparse returns full hash.
687
- // commitHash might be short or full. Let's resolve commitHash to full hash first.
688
- const resolvedCommitHash = await this.git.revparse([commitHash]);
689
-
690
- if (headCommit.trim() !== resolvedCommitHash.trim()) {
691
- throw new Error(`Commit ${commitHash} is not the latest commit on branch ${targetBranch}. Only the latest commit can be reworded.`);
692
- }
693
-
694
- // Reword the commit
695
- // We use raw command to ensure we don't accidentally include other options or files,
696
- // and because simple-git's commit(message, options) signature can be tricky with overloading.
697
- // Also, since we stashed everything, the index is clean, so --amend will only change the message.
698
- await this.git.raw(['commit', '--amend', '-m', newMessage]);
699
-
700
- // If we switched branches, switch back
701
- if (needsCheckout) {
702
- await this.git.checkout(currentBranch);
703
- }
704
-
705
- // Pop stashed changes if we stashed them
706
- if (hasChanges) {
707
- try {
708
- await this.git.stash(['pop']);
709
- } catch {
710
- // Stash pop might fail if there are conflicts
711
- console.warn('Failed to pop stash after reword');
712
- }
713
- }
714
- } catch (e) {
715
- // If error occurs, try to restore state
716
- if (needsCheckout) {
717
- try {
718
- await this.git.checkout(currentBranch);
719
- } catch {
720
- // Ignore
721
- }
722
- }
723
-
724
- if (hasChanges) {
725
- try {
726
- await this.git.stash(['pop']);
727
- } catch {
728
- // Ignore
729
- }
730
- }
731
- throw e;
732
- }
733
- }
734
-
735
- async getCommitDiff(commitHash: string): Promise<{ files: { path: string; additions: number; deletions: number; status: string }[]; diff: string }> {
736
- // Get the list of files changed in this commit with stats
737
- // Use -m --first-parent to handle merge commits properly:
738
- // - For regular commits: compares against the single parent (same behavior as before)
739
- // - For merge commits: compares against the first parent (the branch being merged INTO)
740
- const diffStat = await this.git.raw(['diff-tree', '-m', '--first-parent', '--no-commit-id', '--name-status', '-r', commitHash]);
741
- const files = diffStat.trim().split('\n').filter(Boolean).map(line => {
742
- const [status, ...pathParts] = line.split('\t');
743
- const path = pathParts.join('\t'); // Handle paths with tabs (rare)
744
- return { path, status, additions: 0, deletions: 0 };
745
- });
746
-
747
- // Get the full diff for this commit
748
- // Use -m --first-parent for merge commits to show the diff against first parent
749
- const diff = await this.git.raw(['show', '-m', '--first-parent', '--format=', commitHash]);
750
-
751
- return { files, diff };
752
- }
753
-
754
- async getCommitFileDiff(commitHash: string, filePath: string): Promise<{ before: string; after: string; diff: string }> {
755
- // Get file content before the commit (parent)
756
- let before = '';
757
- let after = '';
758
-
759
- try {
760
- before = await this.git.show([`${commitHash}^:${filePath}`]);
761
- } catch {
762
- // File didn't exist before this commit (new file)
763
- before = '';
764
- }
765
-
766
- try {
767
- after = await this.git.show([`${commitHash}:${filePath}`]);
768
- } catch {
769
- // File was deleted in this commit
770
- after = '';
771
- }
772
-
773
- const diff = await this.getCommitFilePatch(commitHash, filePath);
774
-
775
- return { before, after, diff };
776
- }
777
-
778
- async getCommitFilePatch(commitHash: string, filePath: string): Promise<string> {
779
- try {
780
- // Use show to get the diff (log message suppressed by format=)
781
- return await this.git.raw(['show', '--format=', commitHash, '--', filePath]);
782
- } catch (e) {
783
- console.warn('Failed to get commit file diff:', e);
784
- return '';
785
- }
786
- }
787
-
788
- async willMergeHaveConflicts(sourceBranch: string, targetBranch?: string): Promise<boolean> {
789
- const branchSummary = await this.git.branchLocal();
790
- const mergeTargetBranch = targetBranch || branchSummary.current;
791
-
792
- if (!mergeTargetBranch) {
793
- throw new Error('Could not determine target branch for merge conflict check');
794
- }
795
-
796
- if (sourceBranch === mergeTargetBranch) {
797
- return false;
798
- }
799
-
800
- // Ensure both refs can be resolved before checking for conflicts.
801
- await this.git.revparse(['--verify', sourceBranch]);
802
- await this.git.revparse(['--verify', mergeTargetBranch]);
803
-
804
- try {
805
- // Modern Git: non-destructive conflict detection using exit code.
806
- await this.git.raw(['merge-tree', '--write-tree', mergeTargetBranch, sourceBranch]);
807
- return false;
808
- } catch (e) {
809
- const errorOutput = e as { message?: string; stdout?: string; stderr?: string };
810
- const rawOutput = `${errorOutput.message ?? ''}\n${errorOutput.stdout ?? ''}\n${errorOutput.stderr ?? ''}`;
811
- const normalizedOutput = rawOutput.toLowerCase();
812
-
813
- if (normalizedOutput.includes('conflict')) {
814
- return true;
815
- }
816
-
817
- const isWriteTreeUnsupported =
818
- normalizedOutput.includes('unknown option') ||
819
- normalizedOutput.includes('usage: git merge-tree');
820
-
821
- if (!isWriteTreeUnsupported) {
822
- throw e;
823
- }
824
- }
825
-
826
- // Fallback for older Git versions without --write-tree.
827
- const mergeBase = (await this.git.raw(['merge-base', mergeTargetBranch, sourceBranch])).trim();
828
- const mergeTreeOutput = await this.git.raw(['merge-tree', mergeBase, mergeTargetBranch, sourceBranch]);
829
- const normalizedOutput = mergeTreeOutput.toLowerCase();
830
-
831
- return (
832
- mergeTreeOutput.includes('<<<<<<<') ||
833
- normalizedOutput.includes('changed in both') ||
834
- normalizedOutput.includes('added in both') ||
835
- normalizedOutput.includes('removed in both') ||
836
- normalizedOutput.includes('conflict')
837
- );
838
- }
839
-
840
- async willRebaseHaveConflicts(ontoBranch: string, sourceBranch?: string): Promise<boolean> {
841
- const branchSummary = await this.git.branchLocal();
842
- const rebaseSourceBranch = sourceBranch || branchSummary.current;
843
-
844
- if (!rebaseSourceBranch) {
845
- throw new Error('Could not determine source branch for rebase conflict check');
846
- }
847
-
848
- if (rebaseSourceBranch === ontoBranch) {
849
- return false;
850
- }
851
-
852
- // Ensure refs exist before creating a temporary worktree.
853
- await this.git.revparse(['--verify', rebaseSourceBranch]);
854
- await this.git.revparse(['--verify', ontoBranch]);
855
-
856
- const tempWorktreePath = await mkdtemp(join(tmpdir(), 'git-web-rebase-check-'));
857
- let worktreeAdded = false;
858
-
859
- try {
860
- // Detached worktree avoids locking local branch refs and keeps check isolated.
861
- await this.git.raw(['worktree', 'add', '--detach', tempWorktreePath, rebaseSourceBranch]);
862
- worktreeAdded = true;
863
-
864
- const tempGit = simpleGit({
865
- baseDir: tempWorktreePath,
866
- binary: 'git',
867
- maxConcurrentProcesses: 2,
868
- trimmed: false,
869
- });
870
-
871
- tempGit.env({
872
- ...process.env,
873
- GIT_TERMINAL_PROMPT: '0',
874
- GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=no',
875
- });
876
-
877
- await tempGit.rebase([ontoBranch]);
878
- return false;
879
- } catch (e) {
880
- const errorOutput = e as { message?: string; stdout?: string; stderr?: string };
881
- const rawOutput = `${errorOutput.message ?? ''}\n${errorOutput.stdout ?? ''}\n${errorOutput.stderr ?? ''}`;
882
- const normalizedOutput = rawOutput.toLowerCase();
883
-
884
- if (normalizedOutput.includes('conflict') || normalizedOutput.includes('could not apply')) {
885
- return true;
886
- }
887
-
888
- throw e;
889
- } finally {
890
- if (worktreeAdded) {
891
- try {
892
- await this.git.raw(['worktree', 'remove', '--force', tempWorktreePath]);
893
- } catch (e) {
894
- console.debug(`[willRebaseHaveConflicts] Failed to remove temp worktree ${tempWorktreePath}:`, e);
895
- }
896
- }
897
- }
898
- }
899
-
900
- async merge(
901
- targetBranch: string,
902
- options: {
903
- rebaseBeforeMerge?: boolean;
904
- squash?: boolean;
905
- fastForward?: boolean;
906
- squashMessage?: string;
907
- } = {}
908
- ): Promise<void> {
909
- const { rebaseBeforeMerge, squash, fastForward, squashMessage } = options;
910
-
911
- // Get the current branch name (the branch we want to merge INTO)
912
- const branchSummary = await this.git.branchLocal();
913
- const currentBranch = branchSummary.current;
914
-
915
- // Rebase the target branch onto the current branch before merging if requested
916
- // This ensures a cleaner history or fast-forward merge
917
- if (rebaseBeforeMerge) {
918
- // We must switch to the target branch to rebase it
919
- await this.git.checkout(targetBranch);
920
- await this.git.rebase([currentBranch]);
921
- // Switch back to the branch we want to merge INTO
922
- await this.git.checkout(currentBranch);
923
- }
924
-
925
- // Build merge arguments
926
- const mergeArgs: string[] = [];
927
-
928
- if (squash) {
929
- mergeArgs.push('--squash');
930
- }
931
-
932
- if (fastForward) {
933
- mergeArgs.push('--ff-only');
934
- } else if (!squash) {
935
- // Use no-ff by default unless squashing (squash doesn't create a merge commit anyway)
936
- mergeArgs.push('--no-ff');
937
- }
938
-
939
- // Merge the target branch into current branch
940
- mergeArgs.push(targetBranch);
941
-
942
- await this.git.merge(mergeArgs);
943
-
944
- // If squash merge, we need to commit with the provided message
945
- if (squash) {
946
- const message = squashMessage || `Squash merge branch '${targetBranch}'`;
947
- await this.git.commit(message);
948
- }
949
- }
950
-
951
- async getRemotes(): Promise<string[]> {
952
- const remotes = await this.git.getRemotes();
953
- return remotes.map(r => r.name);
954
- }
955
-
956
- async getRemoteBranches(remote: string): Promise<string[]> {
957
- // Fetch from remote first to get latest branches
958
- await this.git.fetch(remote);
959
-
960
- const allBranches = await this.git.branch(['-r']);
961
- const remoteBranches: string[] = [];
962
-
963
- for (const branch of allBranches.all) {
964
- // Remote branches look like: origin/main, origin/feature
965
- if (branch.startsWith(`${remote}/`)) {
966
- const branchName = branch.slice(`${remote}/`.length);
967
- // Skip HEAD symbolic ref
968
- if (branchName === 'HEAD' || branchName.startsWith('HEAD ')) continue;
969
- remoteBranches.push(branchName);
970
- }
971
- }
972
-
973
- return remoteBranches;
974
- }
975
-
976
- async getTrackingBranch(localBranch: string): Promise<{ remote: string; branch: string } | null> {
977
- try {
978
- const upstream = await this.git.raw(['for-each-ref', '--format=%(upstream:short)', `refs/heads/${localBranch}`]);
979
- const upstreamBranch = upstream.trim();
980
-
981
- if (upstreamBranch) {
982
- // Parse "origin/main" format
983
- const slashIndex = upstreamBranch.indexOf('/');
984
- if (slashIndex > 0) {
985
- return {
986
- remote: upstreamBranch.slice(0, slashIndex),
987
- branch: upstreamBranch.slice(slashIndex + 1)
988
- };
989
- }
990
- }
991
- return null;
992
- } catch {
993
- return null;
994
- }
995
- }
996
-
997
- async pullFromRemote(
998
- localBranch: string,
999
- remote: string,
1000
- remoteBranch: string,
1001
- options: {
1002
- rebase?: boolean;
1003
- } = {}
1004
- ): Promise<void> {
1005
- const { rebase = true } = options;
1006
-
1007
- console.log('[pullFromRemote] Starting pull:', { localBranch, remote, remoteBranch, options });
1008
-
1009
- // Get current branch to see if we need to checkout
1010
- const branchSummary = await this.git.branchLocal();
1011
- const initialBranch = branchSummary.current;
1012
- const needsCheckout = localBranch !== initialBranch;
1013
-
1014
- // First, fetch from remote to get latest refs
1015
- console.log('[pullFromRemote] Fetching from remote:', remote);
1016
- await this.git.fetch(remote);
1017
- console.log('[pullFromRemote] Fetch completed');
1018
-
1019
- const remoteFull = `${remote}/${remoteBranch}`;
1020
-
1021
- // Check if remote branch exists
1022
- try {
1023
- await this.git.revparse(['--verify', `refs/remotes/${remoteFull}`]);
1024
- console.log('[pullFromRemote] Remote branch exists:', remoteFull);
1025
- } catch {
1026
- throw new Error(`Remote branch '${remoteFull}' does not exist`);
1027
- }
1028
-
1029
- // Check if we have uncommitted changes
1030
- const status = await this.git.status();
1031
- const hasChanges = status.files.length > 0;
1032
- console.log('[pullFromRemote] Has uncommitted changes:', hasChanges);
1033
-
1034
- if (hasChanges) {
1035
- // Stash changes before pull
1036
- console.log('[pullFromRemote] Stashing changes...');
1037
- await this.git.stash(['push', '-m', 'auto-stash before pull']);
1038
- }
1039
-
1040
- // Checkout the target branch if it's not the current one
1041
- if (needsCheckout) {
1042
- console.log(`[pullFromRemote] Checking out branch ${localBranch}...`);
1043
- await this.git.checkout(localBranch);
1044
- }
1045
-
1046
- try {
1047
- if (rebase) {
1048
- // Rebase onto remote branch
1049
- console.log('[pullFromRemote] Rebasing onto:', remoteFull);
1050
- await this.git.rebase([remoteFull]);
1051
- console.log('[pullFromRemote] Rebase completed');
1052
- } else {
1053
- // Merge remote branch
1054
- console.log('[pullFromRemote] Merging:', remoteFull);
1055
- await this.git.merge([remoteFull]);
1056
- console.log('[pullFromRemote] Merge completed');
1057
- }
1058
-
1059
- // If we switched branches, try to switch back
1060
- if (needsCheckout) {
1061
- console.log(`[pullFromRemote] Returning to original branch ${initialBranch}...`);
1062
- await this.git.checkout(initialBranch);
1063
- }
1064
-
1065
- // Pop stashed changes if we stashed them
1066
- if (hasChanges) {
1067
- try {
1068
- console.log('[pullFromRemote] Popping stashed changes...');
1069
- await this.git.stash(['pop']);
1070
- } catch {
1071
- throw new Error('Pull succeeded but failed to restore local changes. Run "git stash pop" manually.');
1072
- }
1073
- }
1074
-
1075
- // Refresh remote refs again after pull so remote branch updates are reflected in the UI.
1076
- console.log('[pullFromRemote] Refreshing remote refs after pull:', remote);
1077
- await this.git.fetch(remote);
1078
- console.log('[pullFromRemote] Post-pull fetch completed');
1079
-
1080
- console.log('[pullFromRemote] Operation completed successfully');
1081
- } catch (e) {
1082
- console.error('[pullFromRemote] Error:', e);
1083
-
1084
- // If we are in a conflicted state, we DON'T checkout back to the initial branch
1085
- // as the user needs to resolve conflicts on the localBranch.
1086
- // However, we should still try to inform them.
1087
-
1088
- const isConflict = (e as any).message?.toLowerCase().includes('conflict') ||
1089
- (e as any).stdout?.toLowerCase().includes('conflict') ||
1090
- (e as any).stderr?.toLowerCase().includes('conflict');
1091
-
1092
- if (!isConflict) {
1093
- // If it wasn't a conflict, try to abort and return to initial state
1094
- try {
1095
- if (rebase) {
1096
- await this.git.rebase(['--abort']);
1097
- } else {
1098
- await this.git.merge(['--abort']);
1099
- }
1100
- } catch {
1101
- // Abort might fail
1102
- }
1103
-
1104
- if (needsCheckout) {
1105
- try {
1106
- await this.git.checkout(initialBranch);
1107
- } catch {
1108
- // Checkout back might fail
1109
- }
1110
- }
1111
-
1112
- if (hasChanges) {
1113
- try {
1114
- await this.git.stash(['pop']);
1115
- } catch {
1116
- // Pop might fail
1117
- }
1118
- }
1119
- } else {
1120
- // It IS a conflict. We stay on localBranch.
1121
- // We cannot pop the stash here because it will definitely conflict further or fail.
1122
- }
1123
-
1124
- throw e;
1125
- }
1126
- }
1127
-
1128
- async getRemoteUrl(remoteName: string): Promise<string | null> {
1129
- try {
1130
- const remotes = await this.git.getRemotes(true);
1131
- const remote = remotes.find(r => r.name === remoteName);
1132
- return remote ? (remote.refs.push || remote.refs.fetch) : null;
1133
- } catch {
1134
- return null;
1135
- }
1136
- }
1137
-
1138
- // Stash operations
1139
- async stash(message?: string): Promise<void> {
1140
- const args = ['push'];
1141
- if (message) {
1142
- args.push('-m', message);
1143
- }
1144
- await this.git.stash(args);
1145
- }
1146
-
1147
- async getStashes(): Promise<{ index: number; message: string; date: string; hash: string }[]> {
1148
- try {
1149
- // Use git stash list with custom format to get useful info
1150
- const result = await this.git.raw(['stash', 'list', '--format=%gd|%s|%ai|%H']);
1151
- if (!result.trim()) {
1152
- return [];
1153
- }
1154
-
1155
- return result.trim().split('\n').map((line, idx) => {
1156
- const [ref, message, date, hash] = line.split('|');
1157
- // ref is like "stash@{0}", extract the index
1158
- const indexMatch = ref.match(/stash@\{(\d+)\}/);
1159
- const index = indexMatch ? parseInt(indexMatch[1], 10) : idx;
1160
- return { index, message: message || 'No message', date, hash };
1161
- });
1162
- } catch {
1163
- return [];
1164
- }
1165
- }
1166
-
1167
- async applyStash(index: number): Promise<void> {
1168
- await this.git.stash(['apply', `stash@{${index}}`]);
1169
- }
1170
-
1171
- async dropStash(index: number): Promise<void> {
1172
- await this.git.stash(['drop', `stash@{${index}}`]);
1173
- }
1174
-
1175
- async popStash(index: number): Promise<void> {
1176
- await this.git.stash(['pop', `stash@{${index}}`]);
1177
- }
1178
-
1179
- async getStashFiles(index: number): Promise<{ path: string; status: string }[]> {
1180
- try {
1181
- // Get list of files changed in this stash
1182
- const result = await this.git.raw(['stash', 'show', '--name-status', `stash@{${index}}`]);
1183
- if (!result.trim()) {
1184
- return [];
1185
- }
1186
-
1187
- return result.trim().split('\n').map(line => {
1188
- const [status, ...pathParts] = line.split('\t');
1189
- const path = pathParts.join('\t');
1190
- return { path, status };
1191
- });
1192
- } catch {
1193
- return [];
1194
- }
1195
- }
1196
-
1197
- async getStashFileDiff(index: number, filePath: string): Promise<{ left: string; right: string; diff: string }> {
1198
- // Get the content before the stash (from the parent commit of the stash)
1199
- // and the content in the stash itself
1200
- let left = '';
1201
- let right = '';
1202
-
1203
- try {
1204
- // stash@{n}^1 is the parent commit (the state before stashing)
1205
- // We need to get the file content from the parent
1206
- left = await this.git.show([`stash@{${index}}^1:${filePath}`]);
1207
- } catch {
1208
- // File might not exist before the stash (new file)
1209
- left = '';
1210
- }
1211
-
1212
- try {
1213
- // Get the file content from the stash itself
1214
- right = await this.git.show([`stash@{${index}}:${filePath}`]);
1215
- } catch {
1216
- // File might be deleted in the stash
1217
- right = '';
1218
- }
1219
-
1220
- const diff = await this.getStashFilePatch(index, filePath);
1221
-
1222
- return { left, right, diff };
1223
- }
1224
-
1225
- async getStashFilePatch(index: number, filePath: string): Promise<string> {
1226
- try {
1227
- // git stash show -p stash@{index} -- filePath
1228
- return await this.git.raw(['stash', 'show', '-p', `stash@{${index}}`, '--', filePath]);
1229
- } catch (e) {
1230
- console.warn('Failed to get stash file diff:', e);
1231
- return '';
1232
- }
1233
- }
1234
-
1235
- private async getRemoteTagNames(remote: string): Promise<Set<string>> {
1236
- const output = await this.git.raw(['ls-remote', '--tags', remote]);
1237
- const remoteTags = new Set<string>();
1238
-
1239
- for (const line of output.split('\n')) {
1240
- const trimmed = line.trim();
1241
- if (!trimmed) continue;
1242
-
1243
- const parts = trimmed.split('\t');
1244
- if (parts.length < 2) continue;
1245
- const ref = parts[1];
1246
- if (!ref.startsWith('refs/tags/')) continue;
1247
- if (ref.endsWith('^{}')) continue;
1248
-
1249
- remoteTags.add(ref.slice('refs/tags/'.length));
1250
- }
1251
-
1252
- return remoteTags;
1253
- }
1254
-
1255
- private async pushLocalOnlyTagsToRemote(targetRemote: string, remoteForDiscovery: string): Promise<number> {
1256
- const localTags = (await this.git.tags()).all;
1257
- if (localTags.length === 0) return 0;
1258
-
1259
- const remoteTags = await this.getRemoteTagNames(remoteForDiscovery);
1260
- const localOnlyTags = localTags.filter((tag) => !remoteTags.has(tag));
1261
- if (localOnlyTags.length === 0) return 0;
1262
-
1263
- const tagRefSpecs = localOnlyTags.map((tag) => `refs/tags/${tag}`);
1264
- await this.git.push([targetRemote, ...tagRefSpecs]);
1265
- return localOnlyTags.length;
1266
- }
1267
-
1268
- async pushToRemote(
1269
- localBranch: string,
1270
- remote: string,
1271
- remoteBranch: string,
1272
- options: {
1273
- rebaseFirst?: boolean;
1274
- forcePush?: boolean;
1275
- pushLocalOnlyTags?: boolean;
1276
- setUpstream?: boolean;
1277
- squash?: boolean;
1278
- squashMessage?: string;
1279
- credentials?: { username: string; token: string };
1280
- } = {}
1281
- ): Promise<void> {
1282
- const { rebaseFirst, forcePush, pushLocalOnlyTags, setUpstream, squash, squashMessage, credentials } = options;
1283
-
1284
- // Mask token for logging
1285
- const logOptions = { ...options };
1286
- if (logOptions.credentials) {
1287
- logOptions.credentials = { ...logOptions.credentials, token: '***' };
1288
- }
1289
-
1290
- console.log('[pushToRemote] Starting push:', { localBranch, remote, remoteBranch, options: logOptions });
1291
-
1292
- // Get current branch to see if we need to checkout
1293
- const branchSummary = await this.git.branchLocal();
1294
- const initialBranch = branchSummary.current;
1295
- const needsCheckout = localBranch !== initialBranch;
1296
-
1297
- // Check if we have uncommitted changes
1298
- const status = await this.git.status();
1299
- const hasChanges = status.files.length > 0;
1300
- console.log('[pushToRemote] Has uncommitted changes:', hasChanges);
1301
-
1302
- if (hasChanges) {
1303
- // Stash changes before push (including any rebase/merge operations)
1304
- console.log('[pushToRemote] Stashing changes...');
1305
- await this.git.stash(['push', '-m', 'auto-stash before push']);
1306
- }
1307
-
1308
- // Checkout the target branch if it's not the current one
1309
- if (needsCheckout) {
1310
- console.log(`[pushToRemote] Checking out branch ${localBranch}...`);
1311
- await this.git.checkout(localBranch);
1312
- }
1313
-
1314
- try {
1315
- // Determine the remote URL or name to use
1316
- let targetRemote = remote;
1317
-
1318
- if (credentials) {
1319
- const remoteUrl = await this.getRemoteUrl(remote);
1320
- if (remoteUrl) {
1321
- try {
1322
- // Inject credentials into the URL
1323
- const urlObj = new URL(remoteUrl);
1324
- urlObj.username = credentials.username;
1325
- urlObj.password = credentials.token;
1326
- targetRemote = urlObj.toString();
1327
- console.log('[pushToRemote] Using authenticated URL for push');
1328
- } catch (e) {
1329
- console.warn('[pushToRemote] Failed to construct authenticated URL, falling back to remote name', e);
1330
- }
1331
- } else {
1332
- console.warn(`[pushToRemote] Could not resolve URL for remote '${remote}', falling back to remote name`);
1333
- }
1334
- }
1335
-
1336
- // First, fetch from remote to update our local refs (general fetch, not specific branch)
1337
- // This updates our knowledge of what branches exist on the remote
1338
- console.log('[pushToRemote] Fetching from remote:', remote);
1339
- // Note: We use the original remote name for fetch, assuming fetch auth is handled or same as push
1340
- // If fetch requires auth, we might need to use targetRemote here too, but simple-git might handle it if configured
1341
- // For now, let's try using targetRemote for fetch as well if we have credentials
1342
- await this.git.fetch(targetRemote);
1343
- console.log('[pushToRemote] Fetch completed');
1344
-
1345
- const remoteFull = `${remote}/${remoteBranch}`;
1346
-
1347
- // Check if remote branch exists by trying to resolve it
1348
- let remoteBranchExists = false;
1349
- try {
1350
- await this.git.revparse(['--verify', `refs/remotes/${remoteFull}`]);
1351
- remoteBranchExists = true;
1352
- console.log('[pushToRemote] Remote branch exists:', remoteFull);
1353
- } catch {
1354
- // Remote branch doesn't exist
1355
- remoteBranchExists = false;
1356
- console.log('[pushToRemote] Remote branch does not exist:', remoteFull);
1357
- }
1358
-
1359
- // Force push should overwrite remote history with local state.
1360
- // Do not integrate remote commits first, otherwise remote history is preserved.
1361
- const shouldIntegrateRemote = remoteBranchExists && !forcePush;
1362
-
1363
- // Only rebase/merge if the remote branch exists and force push is not requested
1364
- if (shouldIntegrateRemote) {
1365
- if (rebaseFirst) {
1366
- // Remote branch exists, rebase onto it
1367
- console.log('[pushToRemote] Rebasing onto:', remoteFull);
1368
- await this.git.rebase([remoteFull]);
1369
- console.log('[pushToRemote] Rebase completed');
1370
- } else {
1371
- // Remote branch exists, merge it
1372
- console.log('[pushToRemote] Merging:', remoteFull);
1373
- await this.git.merge([remoteFull]);
1374
- console.log('[pushToRemote] Merge completed');
1375
- }
1376
-
1377
- // Handle squash if requested
1378
- if (squash) {
1379
- console.log('[pushToRemote] Squashing commits onto:', remoteFull);
1380
- // Reset soft to remote branch to stage all changes
1381
- await this.git.reset(['--soft', remoteFull]);
1382
-
1383
- // Commit all staged changes
1384
- const message = squashMessage || `Squash commits before push to ${remoteBranch}`;
1385
- await this.git.commit(message);
1386
- console.log('[pushToRemote] Squash completed');
1387
- }
1388
- } else if (remoteBranchExists && forcePush) {
1389
- console.log('[pushToRemote] Force push requested; skipping pre-push rebase/merge to avoid preserving remote history');
1390
- } else if (squash) {
1391
- console.warn('[pushToRemote] Cannot squash: remote branch does not exist');
1392
- // We could throw here, or just continue without squashing.
1393
- // For now, warning is safer than failing unexpectedly if user just ticked it by habit.
1394
- }
1395
-
1396
- // Build push options
1397
- const pushOptions: string[] = [];
1398
-
1399
- // If we squashed, we rewrote history relative to what might be on remote (if we didn't rebase perfectly or if we are overwriting),
1400
- // but actually, if we rebased onto remoteFull, then reset --soft remoteFull, then committed...
1401
- // We are now 1 commit ahead of remoteFull.
1402
- // So it SHOULD be a fast-forward push.
1403
- // UNLESS remote moved since our fetch?
1404
- // But generally, squash implies we are replacing our history with a single commit.
1405
- // If we are just appending to remote, fast-forward is fine.
1406
- // BUT, if we had *multiple* commits that were *already* on remote?
1407
- // No, we rebased onto remoteFull.
1408
- // So we incorporated all remote changes.
1409
- // So our new commit is child of remoteFull.
1410
- // So fast-forward should work.
1411
- // However, if the user intended to squash commits that were *already pushed* (e.g. fixing up a PR),
1412
- // they would need force push.
1413
- // If rebaseFirst is true, we rebased on remote.
1414
- // If remote has A->B. Local has A->B->C->D.
1415
- // Rebase: Local A->B->C->D.
1416
- // Reset soft to B (remoteFull).
1417
- // Commit: A->B->E (where E contains C+D).
1418
- // Push E. E's parent is B. Remote is at B.
1419
- // Fast-forward A->B->E.
1420
- // This works!
1421
- // BUT, what if local was A->B->C (pushed) -> D (local).
1422
- // Remote is at C.
1423
- // Fetch: remote is C.
1424
- // Rebase onto C: Local is A->B->C->D.
1425
- // Reset soft to C.
1426
- // Commit E (contains D). Parent C.
1427
- // Push E. Fast-forward to C->E.
1428
- // Wait, where did C go? C is on remote.
1429
- // So we squashed D into E?
1430
- // Yes. C remains individual.
1431
- // This squashes *local* commits (ahead of remote).
1432
- // It does NOT squash commits that are already on remote.
1433
- // This matches "squash all the local commits into one".
1434
- // Perfect.
1435
- // So no implicit force push needed.
1436
-
1437
- if (forcePush) {
1438
- pushOptions.push('--force');
1439
- }
1440
-
1441
- // Add --progress to see what's happening
1442
- pushOptions.push('--progress');
1443
-
1444
- console.log('[pushToRemote] Pushing to', remote, 'with refspec', `${localBranch}:${remoteBranch}`, 'options:', pushOptions);
1445
-
1446
- // Use simple-git's push method with explicit remote and branch
1447
- // This handles credentials better than raw commands
1448
- // Use targetRemote (which might contain credentials)
1449
- const pushResult = await this.git.push(targetRemote, `${localBranch}:${remoteBranch}`, pushOptions);
1450
- console.log('[pushToRemote] Push completed successfully, result:', pushResult);
1451
-
1452
- if (setUpstream) {
1453
- // Push may use an authenticated URL instead of the named remote, so set upstream
1454
- // explicitly against the remote name to guarantee tracking is configured.
1455
- await this.git.raw(['config', `branch.${localBranch}.remote`, remote]);
1456
- await this.git.raw(['config', `branch.${localBranch}.merge`, `refs/heads/${remoteBranch}`]);
1457
- }
1458
-
1459
- if (pushLocalOnlyTags ?? true) {
1460
- const pushedTagCount = await this.pushLocalOnlyTagsToRemote(targetRemote, remote);
1461
- console.log(`[pushToRemote] Pushed ${pushedTagCount} local-only tag(s) to ${remote}`);
1462
- }
1463
-
1464
- // If we switched branches, try to switch back
1465
- if (needsCheckout) {
1466
- console.log(`[pushToRemote] Returning to original branch ${initialBranch}...`);
1467
- await this.git.checkout(initialBranch);
1468
- }
1469
-
1470
- // Pop stashed changes if we stashed them
1471
- if (hasChanges) {
1472
- try {
1473
- console.log('[pushToRemote] Popping stashed changes...');
1474
- await this.git.stash(['pop']);
1475
- } catch {
1476
- // Stash pop might fail if there are conflicts
1477
- throw new Error('Push succeeded but failed to restore local changes. Run "git stash pop" manually.');
1478
- }
1479
- }
1480
-
1481
- console.log('[pushToRemote] Operation completed successfully');
1482
- } catch (e) {
1483
- console.error('[pushToRemote] Error:', e);
1484
-
1485
- const isConflict = (e as any).message?.toLowerCase().includes('conflict') ||
1486
- (e as any).stdout?.toLowerCase().includes('conflict') ||
1487
- (e as any).stderr?.toLowerCase().includes('conflict');
1488
-
1489
- if (!isConflict) {
1490
- if (needsCheckout) {
1491
- try {
1492
- await this.git.checkout(initialBranch);
1493
- } catch {
1494
- // Ignore
1495
- }
1496
- }
1497
-
1498
- if (hasChanges) {
1499
- try {
1500
- await this.git.stash(['pop']);
1501
- } catch {
1502
- // Ignore
1503
- }
1504
- }
1505
- }
1506
-
1507
- throw e;
1508
- }
1509
- }
1510
- }