@aiworker/web 1.22.2 → 1.23.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 (217) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  3. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  5. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
  11. package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
  14. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  15. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  21. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DJsQr-p7.js +10 -0
  25. package/dist/assets/es-BuYMlA9g.js +16 -0
  26. package/dist/assets/fr-ePDay33C.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-BD8ILbxF.js +40 -0
  29. package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
  30. package/dist/assets/index-zhgg-SDz.css +1 -0
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
  33. package/dist/assets/ja-BzR9xOY_.js +16 -0
  34. package/dist/assets/ko-BEjHAm14.js +16 -0
  35. package/dist/assets/main-BydQ1tr_.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
  37. package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
  40. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  41. package/dist/assets/pl-rQmpxNIF.js +16 -0
  42. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  44. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  46. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
  48. package/dist/assets/surface-COKb77Ua.js +1 -0
  49. package/dist/assets/tr-CoAMXPtI.js +7 -0
  50. package/dist/assets/uk-YlAX0rRU.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
  52. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  53. package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
  54. package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  57. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  58. package/dist/assets/vendor-zod-B6551dwG.js +39 -0
  59. package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
  60. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  61. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  62. package/dist/index.html +5 -43
  63. package/dist/mini-chat.html +4 -4
  64. package/dist/mobile.html +4 -4
  65. package/package.json +2 -3
  66. package/server/index.d.ts +15 -0
  67. package/server/index.js +48 -22
  68. package/server/lib/agent-memory/runtime.js +7 -2
  69. package/server/lib/agent-memory/runtime.test.js +22 -0
  70. package/server/lib/data-dir-migration.js +40 -0
  71. package/server/lib/data-dir-migration.test.js +59 -0
  72. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  73. package/server/lib/event-stream/global-hub.js +29 -4
  74. package/server/lib/event-stream/global-hub.test.js +22 -0
  75. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  76. package/server/lib/event-stream/protocol.js +21 -7
  77. package/server/lib/event-stream/runtime.js +9 -6
  78. package/server/lib/event-stream/runtime.test.js +72 -12
  79. package/server/lib/fs/DOCUMENTATION.md +3 -0
  80. package/server/lib/fs/routes.js +31 -0
  81. package/server/lib/fs/routes.test.js +110 -1
  82. package/server/lib/git/DOCUMENTATION.md +39 -6
  83. package/server/lib/git/routes.js +45 -12
  84. package/server/lib/git/routes.test.js +78 -0
  85. package/server/lib/git/service.js +343 -202
  86. package/server/lib/git/service.test.js +496 -25
  87. package/server/lib/github/DOCUMENTATION.md +5 -0
  88. package/server/lib/github/routes.js +2 -2
  89. package/server/lib/inherited-env.js +16 -9
  90. package/server/lib/inherited-env.test.js +8 -9
  91. package/server/lib/message-queue/DOCUMENTATION.md +11 -1
  92. package/server/lib/message-queue/runtime.js +13 -0
  93. package/server/lib/message-queue/runtime.test.js +31 -1
  94. package/server/lib/network-defaults.js +23 -0
  95. package/server/lib/network-defaults.test.js +52 -0
  96. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  97. package/server/lib/notifications/emitter-runtime.js +4 -3
  98. package/server/lib/opencode/DOCUMENTATION.md +35 -5
  99. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  100. package/server/lib/opencode/env-runtime.js +5 -1
  101. package/server/lib/opencode/env-runtime.test.js +72 -0
  102. package/server/lib/opencode/feature-routes-runtime.js +17 -1
  103. package/server/lib/opencode/lifecycle.js +4 -3
  104. package/server/lib/opencode/lifecycle.test.js +29 -0
  105. package/server/lib/opencode/managed-plugin-config.js +1 -1
  106. package/server/lib/opencode/openchamber-routes.js +64 -4
  107. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  108. package/server/lib/opencode/proxy.js +33 -0
  109. package/server/lib/opencode/routes.js +5 -3
  110. package/server/lib/opencode/settings-files.js +214 -0
  111. package/server/lib/opencode/settings-helpers.js +88 -41
  112. package/server/lib/opencode/settings-helpers.test.js +176 -1
  113. package/server/lib/opencode/settings-registry.json +750 -0
  114. package/server/lib/opencode/settings-runtime.js +175 -27
  115. package/server/lib/opencode/settings-runtime.test.js +321 -7
  116. package/server/lib/project-context/DOCUMENTATION.md +38 -4
  117. package/server/lib/project-context/routes.http.test.js +18 -0
  118. package/server/lib/project-context/routes.js +15 -0
  119. package/server/lib/project-context/runtime.js +256 -25
  120. package/server/lib/project-context/runtime.test.js +160 -3
  121. package/server/lib/projects/DOCUMENTATION.md +77 -0
  122. package/server/lib/projects/project-config.js +179 -17
  123. package/server/lib/projects/project-config.test.js +97 -0
  124. package/server/lib/projects/project-id.js +43 -0
  125. package/server/lib/projects/project-setup.js +409 -0
  126. package/server/lib/projects/project-setup.test.js +557 -0
  127. package/server/lib/projects/routes.js +59 -0
  128. package/server/lib/quota/DOCUMENTATION.md +31 -1
  129. package/server/lib/quota/providers/cline-pass.js +125 -0
  130. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  131. package/server/lib/quota/providers/index.js +7 -0
  132. package/server/lib/quota/providers/neuralwatt.js +3 -6
  133. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  134. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  135. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  136. package/server/lib/quota/providers/opencode-go.js +1 -1
  137. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  138. package/server/lib/quota/providers/openrouter.js +119 -24
  139. package/server/lib/quota/providers/openrouter.test.js +330 -0
  140. package/server/lib/relay/DOCUMENTATION.md +75 -0
  141. package/server/lib/relay/cross-compat.test.js +26 -0
  142. package/server/lib/relay/downstream-scheduler.js +154 -0
  143. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  144. package/server/lib/relay/e2ee.js +10 -8
  145. package/server/lib/relay/flow-control.test.js +177 -0
  146. package/server/lib/relay/host-client.js +44 -8
  147. package/server/lib/relay/host-client.test.js +14 -0
  148. package/server/lib/relay/tunnel-codec.js +9 -0
  149. package/server/lib/relay/tunnel-host.js +66 -17
  150. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  151. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  152. package/server/lib/scheduled-tasks/runtime.js +12 -2
  153. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  154. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  155. package/server/lib/session-assist/context.js +127 -0
  156. package/server/lib/session-assist/context.test.js +132 -0
  157. package/server/lib/session-assist/prompt.js +54 -0
  158. package/server/lib/session-assist/runtime.js +117 -267
  159. package/server/lib/session-assist/runtime.test.js +203 -0
  160. package/server/lib/session-goal/runtime.js +4 -9
  161. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  162. package/server/lib/small-model/call.js +9 -4
  163. package/server/lib/small-model/call.test.js +29 -0
  164. package/server/lib/small-model/index.js +5 -10
  165. package/server/lib/small-model/runtime-providers.js +15 -7
  166. package/server/lib/small-model/runtime-providers.test.js +7 -1
  167. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  168. package/server/lib/terminal/runtime.js +11 -6
  169. package/server/lib/terminal/runtime.test.js +20 -13
  170. package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
  171. package/server/lib/walkthrough/model-settings.js +8 -12
  172. package/server/lib/walkthrough/pr-source.test.js +21 -0
  173. package/server/lib/walkthrough/prompt.js +3 -1
  174. package/server/lib/walkthrough/pull-request.js +7 -4
  175. package/server/lib/walkthrough/pull-request.test.js +16 -0
  176. package/server/lib/walkthrough/routes.js +19 -0
  177. package/server/lib/walkthrough/routes.test.js +26 -0
  178. package/server/lib/walkthrough/sources.js +36 -4
  179. package/server/opencode-worktree-readiness.test.js +130 -0
  180. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  181. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  182. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  183. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  184. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  185. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  186. package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
  187. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  188. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  189. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  190. package/dist/assets/de-B5JaA3SA.js +0 -10
  191. package/dist/assets/es-EPIH88ii.js +0 -16
  192. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  193. package/dist/assets/index-DF5kMP-i.js +0 -39
  194. package/dist/assets/index-Dl3xbAB_.css +0 -1
  195. package/dist/assets/ja-CifOE1VN.js +0 -16
  196. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  197. package/dist/assets/main-MhSaOZoB.js +0 -930
  198. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  199. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  200. package/dist/assets/pl-DgEHYfFM.js +0 -16
  201. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  202. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  203. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  204. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  205. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  206. package/dist/assets/tr-D5m2yIWL.js +0 -7
  207. package/dist/assets/uk-D_q60oog.js +0 -16
  208. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  209. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  210. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  211. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  212. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  213. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  214. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
  215. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  216. package/server/lib/mcp-reconnect/runtime.js +0 -122
  217. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -352,16 +352,17 @@ const buildGitEnv = async () => {
352
352
  return env;
353
353
  };
354
354
 
355
- const createGit = async (directory, { allowUnsafeSshCommand = false } = {}) => {
355
+ const createGit = async (directory, { allowUnsafeSshCommand = false, allowUnsafeCredentialHelper = false } = {}) => {
356
356
  const env = await buildGitEnv();
357
357
  const spawnOptions = { windowsHide: true };
358
358
  const binary = getGitBinary();
359
359
  const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe';
360
- const unsafe = hasCustomBinary || allowUnsafeSshCommand
360
+ const unsafe = hasCustomBinary || allowUnsafeSshCommand || allowUnsafeCredentialHelper
361
361
  ? {
362
- ...(hasCustomBinary && { allowUnsafeCustomBinary: true }),
363
- ...(allowUnsafeSshCommand && { allowUnsafeSshCommand: true }),
364
- }
362
+ ...(hasCustomBinary && { allowUnsafeCustomBinary: true }),
363
+ ...(allowUnsafeSshCommand && { allowUnsafeSshCommand: true }),
364
+ ...(allowUnsafeCredentialHelper && { allowUnsafeCredentialHelper: true }),
365
+ }
365
366
  : undefined;
366
367
  // Always pin simple-git to an explicit working directory. Omitting baseDir
367
368
  // makes simple-git use process.cwd(), which breaks when the AiWorker
@@ -941,7 +942,7 @@ const runGitCommand = async (cwd, args) => {
941
942
  } catch (error) {
942
943
  return {
943
944
  success: false,
944
- exitCode: typeof error?.code === 'number' ? error.code : 1,
945
+ exitCode: Number.isInteger(error?.code) ? error.code : null,
945
946
  stdout: String(error?.stdout || ''),
946
947
  stderr: String(error?.stderr || ''),
947
948
  message: parseGitErrorText(error),
@@ -2146,7 +2147,7 @@ export async function hasLocalIdentity(directory) {
2146
2147
  }
2147
2148
 
2148
2149
  export async function setLocalIdentity(directory, profile) {
2149
- const git = await createGit(directory, { allowUnsafeSshCommand: true });
2150
+ const git = await createGit(directory, { allowUnsafeSshCommand: true, allowUnsafeCredentialHelper: true });
2150
2151
 
2151
2152
  try {
2152
2153
 
@@ -2464,11 +2465,26 @@ export async function getStatus(directory, options = {}) {
2464
2465
  }
2465
2466
  }
2466
2467
 
2468
+ const getNoIndexDiff = async (repoRoot, repoPath, contextLines) => {
2469
+ const args = ['diff', '--no-color', '--full-index'];
2470
+ if (Number.isFinite(contextLines)) {
2471
+ args.push(`-U${Math.max(0, contextLines)}`);
2472
+ }
2473
+ args.push('--no-index', '--', '/dev/null', repoPath);
2474
+ const result = await runGitCommand(repoRoot, args);
2475
+ // Exit 1 means differences, even when Git also writes warnings to stderr.
2476
+ // Spawn and buffer errors have no numeric exit code and must still fail.
2477
+ if (result.exitCode === 0 || result.exitCode === 1) {
2478
+ return result.stdout;
2479
+ }
2480
+ throw new Error(result.stderr || result.message || 'Failed to get untracked Git diff');
2481
+ };
2482
+
2467
2483
  export async function getDiff(directory, { path: filePath, staged = false, contextLines = 3 } = {}) {
2468
2484
  const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
2469
2485
 
2470
2486
  try {
2471
- const args = ['diff', '--no-color'];
2487
+ const args = ['diff', '--no-color', '--full-index'];
2472
2488
  const fileContext = filePath ? await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot) : null;
2473
2489
 
2474
2490
  if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
@@ -2514,21 +2530,7 @@ export async function getDiff(directory, { path: filePath, staged = false, conte
2514
2530
  ].join('\n');
2515
2531
  }
2516
2532
 
2517
- const noIndexArgs = ['diff', '--no-color'];
2518
- if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
2519
- noIndexArgs.push(`-U${Math.max(0, contextLines)}`);
2520
- }
2521
- noIndexArgs.push('--no-index', '--', '/dev/null', fileContext.repoPath);
2522
- try {
2523
- const noIndexDiff = await git.raw(noIndexArgs);
2524
- return noIndexDiff;
2525
- } catch (noIndexError) {
2526
- // git diff --no-index returns exit code 1 when differences exist (not a real error)
2527
- if (noIndexError.exitCode === 1 && noIndexError.message) {
2528
- return noIndexError.message;
2529
- }
2530
- throw noIndexError;
2531
- }
2533
+ return await getNoIndexDiff(repoRoot, fileContext.repoPath, contextLines);
2532
2534
  }
2533
2535
  } catch (error) {
2534
2536
  console.error('Failed to get Git diff:', error);
@@ -2577,7 +2579,7 @@ export async function getUntrackedDiffs(directory, filePaths = [], { concurrency
2577
2579
  const paths = (Array.isArray(filePaths) ? filePaths : []).filter((value) => typeof value === 'string' && value);
2578
2580
  if (paths.length === 0) return [];
2579
2581
 
2580
- const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
2582
+ const { directoryPath, directoryGit, repoRoot } = await createRepositoryGitContext(directory);
2581
2583
  const results = new Array(paths.length).fill('');
2582
2584
  let cursor = 0;
2583
2585
 
@@ -2586,18 +2588,7 @@ export async function getUntrackedDiffs(directory, filePaths = [], { concurrency
2586
2588
  const index = cursor++;
2587
2589
  try {
2588
2590
  const fileContext = await resolveGitFileContext(directoryPath, directoryGit, paths[index], repoRoot);
2589
- const args = ['diff', '--no-color'];
2590
- if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
2591
- args.push(`-U${Math.max(0, contextLines)}`);
2592
- }
2593
- args.push('--no-index', '--', '/dev/null', fileContext.repoPath);
2594
- try {
2595
- results[index] = await git.raw(args);
2596
- } catch (error) {
2597
- // `git diff --no-index` exits 1 whenever there are differences, which
2598
- // for a new file is always.
2599
- results[index] = error?.exitCode === 1 && error?.message ? error.message : '';
2600
- }
2591
+ results[index] = await getNoIndexDiff(repoRoot, fileContext.repoPath, contextLines);
2601
2592
  } catch {
2602
2593
  results[index] = '';
2603
2594
  }
@@ -2627,7 +2618,51 @@ async function assertRangeRefsResolve(git, refs) {
2627
2618
  }
2628
2619
  }
2629
2620
 
2630
- export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) {
2621
+ // A private index lets git include untracked paths in the same tree comparison
2622
+ // as tracked files, including a staged deletion recreated at the same path.
2623
+ // Intent-to-add records only their existence; diff reads current file contents.
2624
+ async function runWorkingTreeRangeDiff(context, baseRef, headRef, args, paths = []) {
2625
+ const { git, repoRoot } = context;
2626
+ const readHead = async () => {
2627
+ const commit = (await git.raw(['rev-parse', '--verify', 'HEAD'])).trim();
2628
+ const ref = (await git.raw(['symbolic-ref', '--quiet', 'HEAD'])).trim();
2629
+ return `${commit}\n${ref}`;
2630
+ };
2631
+ const startingHead = await readHead();
2632
+ const [headCommit, currentRef] = startingHead.split('\n');
2633
+ const requestedRef = (await git.raw(['rev-parse', '--verify', '--symbolic-full-name', '--end-of-options', headRef])).trim();
2634
+ if (requestedRef !== currentRef) {
2635
+ throw new Error('Working-tree comparisons require the checked-out branch. Refresh and try again.');
2636
+ }
2637
+ const mergeBase = (await git.raw(['merge-base', baseRef, headCommit])).trim();
2638
+ const readDiff = async (comparisonGit) => {
2639
+ const diff = await comparisonGit.raw([...args, mergeBase, '--', ...paths]);
2640
+ if (await readHead() !== startingHead) {
2641
+ throw new Error('The checked-out branch changed during comparison. Refresh and try again.');
2642
+ }
2643
+ return diff;
2644
+ };
2645
+ const untracked = await git.raw(['ls-files', '--others', '--exclude-standard', '-z', '--', ...paths]);
2646
+ if (!untracked) return readDiff(git);
2647
+
2648
+ const temporaryDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), 'openchamber-branch-diff-'));
2649
+ try {
2650
+ const indexPath = (await git.raw(['rev-parse', '--git-path', 'index'])).trim();
2651
+ const temporaryIndex = path.join(temporaryDirectory, 'index');
2652
+ await fsp.copyFile(path.resolve(repoRoot, indexPath), temporaryIndex);
2653
+ const pathspecFile = path.join(temporaryDirectory, 'paths');
2654
+ await fsp.writeFile(pathspecFile, untracked);
2655
+ const comparisonGit = await createGit(repoRoot);
2656
+ comparisonGit.env('GIT_INDEX_FILE', temporaryIndex);
2657
+ comparisonGit.env('GIT_LITERAL_PATHSPECS', '1');
2658
+ await comparisonGit.raw(['add', '--intent-to-add', '--pathspec-from-file=' + pathspecFile, '--pathspec-file-nul']);
2659
+ return await readDiff(comparisonGit);
2660
+ } finally {
2661
+ await fsp.rm(temporaryDirectory, { recursive: true, force: true });
2662
+ }
2663
+ }
2664
+
2665
+ export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3, includeWorkingTree = false } = {}) {
2631
2666
  const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
2632
2667
  const baseRef = typeof base === 'string' ? base.trim() : '';
2633
2668
  const headRef = typeof head === 'string' ? head.trim() : '';
@@ -2635,51 +2670,39 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
2635
2670
  throw new Error('base and head are required');
2636
2671
  }
2637
2672
 
2638
- // Prefer remote-tracking base ref so merged commits don't reappear
2639
- // when local base branch is stale (common when user stays on feature branch).
2640
- let resolvedBase = baseRef;
2641
- const originCandidate = `refs/remotes/origin/${baseRef}`;
2642
- try {
2643
- const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
2644
- if (verified && verified.trim()) {
2645
- resolvedBase = `origin/${baseRef}`;
2646
- }
2647
- } catch {
2648
- // ignore
2649
- }
2650
-
2651
- // Not every repository has an `origin`. When the base names a branch that
2652
- // exists only on another remote, a bare name does not resolve — git looks in
2653
- // refs/heads, not across remotes — and the diff fails with "ambiguous
2654
- // argument". Fall back to whichever remote actually carries it.
2655
- if (resolvedBase === baseRef && !/[*?[\]^~:\\]/.test(baseRef)) {
2656
- const resolvesLocally = await git
2657
- .raw(['rev-parse', '--verify', `refs/heads/${baseRef}`])
2658
- .then((value) => Boolean(String(value || '').trim()))
2659
- .catch(() => false);
2660
-
2661
- if (!resolvesLocally) {
2662
- const remoteMatch = await git
2663
- .raw(['for-each-ref', '--count=1', '--format=%(refname:short)', `refs/remotes/*/${baseRef}`])
2664
- .then((value) => String(value || '').trim())
2665
- .catch(() => '');
2666
- if (remoteMatch) {
2667
- resolvedBase = remoteMatch;
2668
- }
2669
- }
2670
- }
2671
-
2672
- await assertRangeRefsResolve(git, [resolvedBase, headRef]);
2673
+ await assertRangeRefsResolve(git, [baseRef, headRef]);
2673
2674
 
2674
2675
  const args = ['diff', '--no-color'];
2675
2676
  if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
2676
2677
  args.push(`-U${Math.max(0, contextLines)}`);
2677
2678
  }
2678
- args.push(`${resolvedBase}...${headRef}`);
2679
+ const paths = [];
2679
2680
  if (filePath) {
2680
- const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
2681
- args.push('--', fileContext.repoPath);
2681
+ try {
2682
+ const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
2683
+ paths.push(fileContext.repoPath);
2684
+ } catch (error) {
2685
+ if (error.message !== 'Invalid file path') throw error;
2686
+ // A committed deletion is absent from HEAD, the index, and the working
2687
+ // tree. It is still a valid range path when it exists at the merge base.
2688
+ const mergeBase = (await git.raw(['merge-base', baseRef, headRef])).trim();
2689
+ for (const root of new Set([repoRoot, directoryPath])) {
2690
+ const target = path.resolve(root, filePath);
2691
+ if (!isInsideOrSameDirectory(repoRoot, target)) continue;
2692
+ const repoPath = toGitPath(path.relative(repoRoot, target));
2693
+ const exists = await git.raw(['cat-file', '-e', `${mergeBase}:${repoPath}`]).then(() => true).catch(() => false);
2694
+ if (exists) {
2695
+ paths.push(repoPath);
2696
+ break;
2697
+ }
2698
+ }
2699
+ if (paths.length === 0) throw error;
2700
+ }
2701
+ }
2702
+ if (includeWorkingTree) {
2703
+ return runWorkingTreeRangeDiff({ git, repoRoot }, baseRef, headRef, args, paths);
2682
2704
  }
2705
+ args.push(`${baseRef}...${headRef}`, '--', ...paths);
2683
2706
  const diff = await git.raw(args);
2684
2707
  return diff;
2685
2708
  }
@@ -2701,6 +2724,9 @@ export function parseBranchCreationSource(reflogText) {
2701
2724
  .split('\n')
2702
2725
  .map((line) => line.trim())
2703
2726
  .filter(Boolean);
2727
+ // Rebase records its destination as a commit, not a parent branch. The
2728
+ // creation ref is no longer evidence of the current base after restacking.
2729
+ if (lines.some((line) => /^rebase(?:\s|\()/.test(line))) return null;
2704
2730
  // Reflog lists newest entries first; the creation entry is the oldest one.
2705
2731
  for (let index = lines.length - 1; index >= 0; index -= 1) {
2706
2732
  const match = lines[index].match(BRANCH_CREATION_SOURCE_RE);
@@ -2752,31 +2778,23 @@ export async function getBranchBase(directory, branch) {
2752
2778
  return { base: source };
2753
2779
  }
2754
2780
 
2755
- export async function getRangeFiles(directory, { base, head } = {}) {
2756
- const { git } = await createRepositoryGitContext(directory);
2781
+ export async function getRangeFiles(directory, { base, head, includeWorkingTree = false } = {}) {
2782
+ const { git, repoRoot } = await createRepositoryGitContext(directory);
2757
2783
  const baseRef = typeof base === 'string' ? base.trim() : '';
2758
2784
  const headRef = typeof head === 'string' ? head.trim() : '';
2759
2785
  if (!baseRef || !headRef) {
2760
2786
  throw new Error('base and head are required');
2761
2787
  }
2762
2788
 
2763
- let resolvedBase = baseRef;
2764
- const originCandidate = `refs/remotes/origin/${baseRef}`;
2765
- try {
2766
- const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
2767
- if (verified && verified.trim()) {
2768
- resolvedBase = `origin/${baseRef}`;
2769
- }
2770
- } catch {
2771
- // ignore
2772
- }
2773
-
2774
- await assertRangeRefsResolve(git, [resolvedBase, headRef]);
2789
+ await assertRangeRefsResolve(git, [baseRef, headRef]);
2775
2790
 
2776
2791
  // `-C` (copy detection among changed files only, so cheap) makes copies
2777
2792
  // surface as C entries instead of plain additions; rename detection is on
2778
2793
  // by default.
2779
- const raw = await git.raw(['diff', '--name-status', '-z', '-C', `${resolvedBase}...${headRef}`]);
2794
+ const args = ['diff', '--name-status', '-z', '-C'];
2795
+ const raw = includeWorkingTree
2796
+ ? await runWorkingTreeRangeDiff({ git, repoRoot }, baseRef, headRef, args)
2797
+ : await git.raw([...args, `${baseRef}...${headRef}`, '--']);
2780
2798
  // -z format: STATUS\0PATH\0[ORIG\0] repeated. For rename/copy entries
2781
2799
  // (`R100`, `C75`) the first path token is the ORIGINAL path and the second
2782
2800
  // is the DESTINATION — the diff (and the UI) must address the destination.
@@ -2786,7 +2804,7 @@ export async function getRangeFiles(directory, { base, head } = {}) {
2786
2804
  const status = (tokens[index] || '').trim();
2787
2805
  if (!status) continue;
2788
2806
  const isRenameOrCopy = status.startsWith('R') || status.startsWith('C');
2789
- const path = isRenameOrCopy ? (tokens[index + 2] || '').trim() : (tokens[index + 1] || '').trim();
2807
+ const path = isRenameOrCopy ? (tokens[index + 2] || '') : (tokens[index + 1] || '');
2790
2808
  index += isRenameOrCopy ? 2 : 1;
2791
2809
  if (path) {
2792
2810
  files.push({ path, status: status.charAt(0) });
@@ -2832,27 +2850,6 @@ const parseIsBinaryFromNumstat = (raw) => {
2832
2850
  return added === '-' || deleted === '-';
2833
2851
  };
2834
2852
 
2835
- const extractGitStatusPath = (status, pathPart) => {
2836
- if ((status === 'R' || status === 'C') && pathPart.includes('\t')) {
2837
- return pathPart.split('\t').pop() || pathPart;
2838
- }
2839
- return pathPart;
2840
- };
2841
-
2842
- const extractGitNumstatDestinationPath = (filePath) => {
2843
- if (!filePath.includes(' => ')) {
2844
- return filePath;
2845
- }
2846
-
2847
- const braceMatch = filePath.match(/^(.*)\{([^{}]*)\s=>\s([^{}]*)\}(.*)$/);
2848
- if (braceMatch) {
2849
- const [, prefix, , destination, suffix] = braceMatch;
2850
- return `${prefix}${destination}${suffix}`.replace(/\/+/g, '/');
2851
- }
2852
-
2853
- return filePath.split(' => ').pop()?.trim() || filePath;
2854
- };
2855
-
2856
2853
  const looksBinaryBySniff = async (absolutePath) => {
2857
2854
  try {
2858
2855
  const handle = await fsp.open(absolutePath, 'r');
@@ -3102,11 +3099,13 @@ const normalizePatchTargetPath = (value) => {
3102
3099
  };
3103
3100
 
3104
3101
  const extractPatchTargetPath = (patch) => {
3105
- const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)];
3102
+ const firstHunk = patch.search(/^@@\s/m);
3103
+ const header = firstHunk < 0 ? patch : patch.slice(0, firstHunk);
3104
+ const matches = [...header.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)];
3106
3105
  const realTargets = matches
3107
3106
  .map((match) => normalizePatchTargetPath(parsePatchPathToken(match[0])))
3108
3107
  .filter(Boolean);
3109
- return realTargets[0] || null;
3108
+ return realTargets.at(-1) || null;
3110
3109
  };
3111
3110
 
3112
3111
  const writeTempPatchFile = async (patch) => {
@@ -3134,9 +3133,21 @@ export async function applyHunk(directory, filePath, options = {}) {
3134
3133
  const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
3135
3134
  validateRepositoryFilePaths(repoRoot, [fileContext.repoPath]);
3136
3135
 
3137
- const targetPath = extractPatchTargetPath(patch);
3138
- if (targetPath && targetPath !== fileContext.repoPath && targetPath !== filePath) {
3139
- throw new Error('patch target path does not match the requested file');
3136
+ // Applicability alone is insufficient: a previously staged or committed
3137
+ // hunk may still reverse cleanly against the working tree. Accept only a
3138
+ // canonical hunk from this file's current working/index diff.
3139
+ const current = await getDiff(directory, { path: filePath, staged: action === 'unstage', contextLines: 3 });
3140
+ const starts = [...current.matchAll(/^@@\s/gm)].map((match) => match.index);
3141
+ const header = current.slice(0, starts[0] ?? 0);
3142
+ const isCurrentHunk = starts.some((start, index) => (
3143
+ header + current.slice(start, starts[index + 1] ?? current.length) === patch
3144
+ ));
3145
+ if (!isCurrentHunk) {
3146
+ const targetPath = extractPatchTargetPath(patch);
3147
+ if (targetPath && targetPath !== fileContext.repoPath && targetPath !== filePath) {
3148
+ throw new Error('patch target path does not match the requested file');
3149
+ }
3150
+ throw new Error('Hunk no longer applies — refresh and try again.');
3140
3151
  }
3141
3152
 
3142
3153
  const flags = HUNK_ACTION_FLAGS[action];
@@ -4053,14 +4064,167 @@ export async function getWorktrees(directory) {
4053
4064
  // OpenCode's working directory or an unconfigured project path), git
4054
4065
  // exits with "fatal: not a git repository ...". Treat that as an
4055
4066
  // authoritative empty result so the route handler can still respond
4056
- // 200 [] and the desktop main.log stays free of noise.
4057
- if (!isNotGitRepositoryError(error)) {
4058
- console.warn('Failed to list worktrees, returning empty list:', error?.message || error);
4067
+ // 200 [] and the desktop main.log stays free of noise. Any other failure
4068
+ // is a failure: callers keep their last known topology instead of
4069
+ // treating "git could not answer" as "there are no worktrees".
4070
+ if (isNotGitRepositoryError(error)) return [];
4071
+ throw error;
4072
+ }
4073
+ }
4074
+
4075
+ // ---------------------------------------------------------------------------
4076
+ // Worktree topology change tracking
4077
+ //
4078
+ // Linked worktrees are registered under the repository's common Git directory
4079
+ // (`<common>/worktrees/<name>`). Instead of watching the filesystem, the server
4080
+ // fingerprints that directory while handling requests clients already make
4081
+ // (status, worktree listing) and after its own worktree create/remove, and
4082
+ // tells connected clients when the set of worktrees changed. Cost scales with
4083
+ // user activity, never with the number of registered projects.
4084
+ // ---------------------------------------------------------------------------
4085
+
4086
+ const MAX_TRACKED_WORKTREE_DIRECTORIES = 500;
4087
+ const MAX_TRACKED_WORKTREE_REPOSITORIES = 200;
4088
+ const MAX_DIRECTORIES_PER_WORKTREE_REPOSITORY = 100;
4089
+ const worktreeTopologyListeners = new Set();
4090
+ const worktreeRepositoryKeyByDirectory = new Map();
4091
+ const worktreeTopologyByRepository = new Map();
4092
+
4093
+ export function subscribeWorktreeTopologyChanges(listener) {
4094
+ worktreeTopologyListeners.add(listener);
4095
+ return () => {
4096
+ worktreeTopologyListeners.delete(listener);
4097
+ };
4098
+ }
4099
+
4100
+ const rememberWorktreeRepositoryKey = (directoryPath, key) => {
4101
+ worktreeRepositoryKeyByDirectory.delete(directoryPath);
4102
+ worktreeRepositoryKeyByDirectory.set(directoryPath, key);
4103
+ while (worktreeRepositoryKeyByDirectory.size > MAX_TRACKED_WORKTREE_DIRECTORIES) {
4104
+ const oldest = worktreeRepositoryKeyByDirectory.keys().next().value;
4105
+ if (oldest === undefined) break;
4106
+ worktreeRepositoryKeyByDirectory.delete(oldest);
4107
+ }
4108
+ };
4109
+
4110
+ /**
4111
+ * Canonical common Git directory for `directoryPath`, resolved with git once per
4112
+ * directory and cached. Returns null when git cannot answer (not a repository,
4113
+ * missing directory).
4114
+ */
4115
+ const resolveWorktreeRepositoryKey = async (directoryPath) => {
4116
+ const cached = worktreeRepositoryKeyByDirectory.get(directoryPath);
4117
+ if (cached) {
4118
+ rememberWorktreeRepositoryKey(directoryPath, cached);
4119
+ return cached;
4120
+ }
4121
+ const result = await runGitCommand(directoryPath, ['rev-parse', '--git-common-dir']);
4122
+ const rawCommonDir = String(result.stdout || '').trim();
4123
+ if (!result.success || !rawCommonDir) {
4124
+ return null;
4125
+ }
4126
+ const commonDir = path.resolve(directoryPath, rawCommonDir);
4127
+ let key = commonDir;
4128
+ try {
4129
+ key = fs.realpathSync(commonDir);
4130
+ } catch {
4131
+ // Keep the resolved path; a missing common dir cannot register worktrees.
4132
+ }
4133
+ rememberWorktreeRepositoryKey(directoryPath, key);
4134
+ return key;
4135
+ };
4136
+
4137
+ /**
4138
+ * Cheap identity of the registered linked-worktree set: the `worktrees`
4139
+ * directory's mtime plus its entry names. Adding, removing, or pruning a
4140
+ * worktree changes at least one of them; `git worktree move` rewrites files
4141
+ * inside an entry and is not detected.
4142
+ */
4143
+ const readWorktreeTopologyFingerprint = (repositoryKey) => {
4144
+ const worktreesDir = path.join(repositoryKey, 'worktrees');
4145
+ try {
4146
+ const stat = fs.statSync(worktreesDir);
4147
+ const names = fs.readdirSync(worktreesDir).sort();
4148
+ return `${stat.mtimeMs}:${names.join('\0')}`;
4149
+ } catch {
4150
+ return 'none';
4151
+ }
4152
+ };
4153
+
4154
+ const trackWorktreeTopologyDirectory = (repositoryKey, directoryPath) => {
4155
+ let entry = worktreeTopologyByRepository.get(repositoryKey);
4156
+ if (!entry) {
4157
+ entry = { directories: new Set(), fingerprint: null };
4158
+ }
4159
+ // Re-insert so the map stays ordered by last use; the least recently used
4160
+ // repository is dropped first once the bound is reached.
4161
+ worktreeTopologyByRepository.delete(repositoryKey);
4162
+ worktreeTopologyByRepository.set(repositoryKey, entry);
4163
+ while (worktreeTopologyByRepository.size > MAX_TRACKED_WORKTREE_REPOSITORIES) {
4164
+ const oldest = worktreeTopologyByRepository.keys().next().value;
4165
+ if (oldest === undefined) break;
4166
+ worktreeTopologyByRepository.delete(oldest);
4167
+ }
4168
+ if (entry.directories.size < MAX_DIRECTORIES_PER_WORKTREE_REPOSITORY) {
4169
+ entry.directories.add(directoryPath);
4170
+ }
4171
+ return entry;
4172
+ };
4173
+
4174
+ const notifyWorktreeTopologyChanged = (entry) => {
4175
+ const event = { directories: [...entry.directories], at: Date.now() };
4176
+ for (const listener of worktreeTopologyListeners) {
4177
+ try {
4178
+ listener(event);
4179
+ } catch (error) {
4180
+ console.warn('Worktree topology listener failed:', error?.message || error);
4059
4181
  }
4060
- return [];
4182
+ }
4183
+ };
4184
+
4185
+ /**
4186
+ * Compare the repository's worktree set with the last one seen for it and
4187
+ * notify listeners when it changed. The first observation only records a
4188
+ * baseline. Called from request handlers that already touch the repository;
4189
+ * never throws.
4190
+ */
4191
+ export async function observeWorktreeTopology(directory) {
4192
+ const directoryPath = normalizeDirectoryPath(directory);
4193
+ if (!directoryPath) return;
4194
+ try {
4195
+ const repositoryKey = await resolveWorktreeRepositoryKey(directoryPath);
4196
+ if (!repositoryKey) return;
4197
+ const entry = trackWorktreeTopologyDirectory(repositoryKey, directoryPath);
4198
+ const fingerprint = readWorktreeTopologyFingerprint(repositoryKey);
4199
+ if (entry.fingerprint === fingerprint) return;
4200
+ const hadBaseline = entry.fingerprint !== null;
4201
+ entry.fingerprint = fingerprint;
4202
+ if (hadBaseline) notifyWorktreeTopologyChanged(entry);
4203
+ } catch (error) {
4204
+ console.warn('Failed to observe worktree topology:', error?.message || error);
4061
4205
  }
4062
4206
  }
4063
4207
 
4208
+ /**
4209
+ * Record that this server changed the repository's worktree set itself and
4210
+ * notify listeners right away. `directory` is any directory inside the
4211
+ * repository; never throws so a notification problem cannot fail the
4212
+ * operation that triggered it.
4213
+ */
4214
+ const publishWorktreeTopologyChange = async (directory) => {
4215
+ const directoryPath = normalizeDirectoryPath(directory);
4216
+ if (!directoryPath) return;
4217
+ try {
4218
+ const repositoryKey = await resolveWorktreeRepositoryKey(directoryPath);
4219
+ if (!repositoryKey) return;
4220
+ const entry = trackWorktreeTopologyDirectory(repositoryKey, directoryPath);
4221
+ entry.fingerprint = readWorktreeTopologyFingerprint(repositoryKey);
4222
+ notifyWorktreeTopologyChanged(entry);
4223
+ } catch (error) {
4224
+ console.warn('Failed to publish worktree topology change:', error?.message || error);
4225
+ }
4226
+ };
4227
+
4064
4228
  export async function validateWorktreeCreate(directory, input = {}) {
4065
4229
  const mode = input?.mode === 'existing' ? 'existing' : 'new';
4066
4230
  const errors = [];
@@ -4318,6 +4482,7 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
4318
4482
  }
4319
4483
 
4320
4484
  await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
4485
+ await publishWorktreeTopologyChange(context.primaryWorktree);
4321
4486
 
4322
4487
  const upstreamRemote = shouldSetUpstream
4323
4488
  ? String(input?.upstreamRemote || inferredUpstream?.remote || '').trim()
@@ -4540,6 +4705,7 @@ export async function removeWorktree(directory, input = {}) {
4540
4705
  ['worktree', 'remove', '--force', matchedEntry.worktree],
4541
4706
  'Failed to remove git worktree'
4542
4707
  );
4708
+ await publishWorktreeTopologyChange(context.primaryWorktree);
4543
4709
 
4544
4710
  if (deleteLocalBranch) {
4545
4711
  const branchName = cleanBranchName(String(matchedEntry.branchRef || matchedEntry.branch || '').trim());
@@ -4678,12 +4844,11 @@ export async function getLog(directory, options = {}) {
4678
4844
  };
4679
4845
  const resolvedFrom = await resolveBaseRefForLog(options.from, checkRef);
4680
4846
 
4681
- const baseLog = await git.log({
4682
- maxCount,
4683
- from: resolvedFrom,
4684
- to: options.to,
4685
- file: filePath
4686
- });
4847
+ // simple-git's `to` alone means HEAD..to, which is empty for the current
4848
+ // branch. A single requested ref means its reachable history instead.
4849
+ const baseLog = options.to && !resolvedFrom
4850
+ ? await git.log([`--max-count=${maxCount}`, options.to, ...(filePath ? ['--', filePath] : [])])
4851
+ : await git.log({ maxCount, from: resolvedFrom, to: options.to, file: filePath });
4687
4852
 
4688
4853
  const logArgs = [
4689
4854
  'log',
@@ -4927,85 +5092,61 @@ export async function canonicalizeWorktreeState(directory) {
4927
5092
  };
4928
5093
  }
4929
5094
 
4930
- export async function getCommitFiles(directory, commitHash) {
4931
- const { git } = await createRepositoryGitContext(directory);
4932
-
4933
- try {
4934
-
4935
- const numstatRaw = await git.raw([
4936
- 'show',
4937
- '--numstat',
4938
- '--format=',
4939
- commitHash
4940
- ]);
4941
-
4942
- const files = [];
4943
- const lines = numstatRaw.trim().split('\n').filter(Boolean);
4944
-
4945
- for (const line of lines) {
4946
- const parts = line.split('\t');
4947
- if (parts.length < 3) continue;
4948
-
4949
- const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
4950
- const filePath = pathParts.join('\t');
4951
- if (!filePath) continue;
4952
-
4953
- const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
4954
- const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
4955
- const isBinary = insertionsRaw === '-' && deletionsRaw === '-';
4956
-
4957
- let changeType = 'M';
4958
- let displayPath = filePath;
4959
-
4960
- if (filePath.includes(' => ')) {
4961
- changeType = 'R';
4962
-
4963
- const match = filePath.match(/(?:\{[^}]*\s=>\s[^}]*\}|.*\s=>\s.*)/);
4964
- if (match) {
4965
- displayPath = filePath;
4966
- }
4967
- }
4968
-
4969
- files.push({
4970
- path: displayPath,
4971
- insertions,
4972
- deletions,
4973
- isBinary,
4974
- changeType
4975
- });
4976
- }
4977
-
4978
- const nameStatusRaw = await git.raw([
4979
- 'show',
4980
- '--name-status',
4981
- '--format=',
4982
- commitHash
4983
- ]).catch(() => '');
4984
-
4985
- const statusMap = new Map();
4986
- const statusLines = nameStatusRaw.trim().split('\n').filter(Boolean);
4987
- for (const line of statusLines) {
4988
- const match = line.match(/^([AMDRC])\d*\t(.+)$/);
4989
- if (match) {
4990
- const [, status, pathPart] = match;
4991
- statusMap.set(extractGitStatusPath(status, pathPart), status);
4992
- }
4993
- }
5095
+ async function resolveCommitHash(git, hash) {
5096
+ if (!/^[0-9a-f]{7,64}$/i.test(hash)) throw new Error('A commit hash is required');
5097
+ return (await git.raw(['rev-parse', '--verify', '--end-of-options', `${hash}^{commit}`])).trim();
5098
+ }
4994
5099
 
4995
- for (const file of files) {
4996
- const basePath = extractGitNumstatDestinationPath(file.path);
5100
+ const commitShowArgs = (hash) => ['show', '--format=', '--root', '--diff-merges=first-parent', '--find-renames', hash];
4997
5101
 
4998
- const status = statusMap.get(basePath) || statusMap.get(file.path);
4999
- if (status) {
5000
- file.changeType = status;
5001
- }
5002
- }
5102
+ export async function getCommitDiff(directory, { hash, path: filePath, previousPath, contextLines = 3 } = {}) {
5103
+ const { git } = await createRepositoryGitContext(directory);
5104
+ const commit = await resolveCommitHash(git, hash);
5105
+ const paths = [filePath, previousPath].filter(Boolean).map((value) => `:(literal)${value}`);
5106
+ return git.raw([
5107
+ ...commitShowArgs(commit), '--no-color', '--no-ext-diff', `-U${Math.max(0, contextLines)}`,
5108
+ '--', ...paths,
5109
+ ]);
5110
+ }
5003
5111
 
5004
- return { files };
5005
- } catch (error) {
5006
- console.error('Failed to get commit files:', error);
5007
- throw error;
5112
+ export async function getCommitFiles(directory, commitHash) {
5113
+ const { git } = await createRepositoryGitContext(directory);
5114
+ const hash = await resolveCommitHash(git, commitHash);
5115
+ const [numstat, nameStatus] = await Promise.all([
5116
+ git.raw([...commitShowArgs(hash), '--numstat', '-z', '--']),
5117
+ git.raw([...commitShowArgs(hash), '--name-status', '-z', '--']),
5118
+ ]);
5119
+ const stats = new Map();
5120
+ const tokens = numstat.split('\0');
5121
+ for (let index = 0; index < tokens.length; index += 1) {
5122
+ const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(tokens[index]);
5123
+ if (!match) continue;
5124
+ let destination = match[3];
5125
+ if (!destination) {
5126
+ destination = tokens[index + 2];
5127
+ index += 2;
5128
+ }
5129
+ stats.set(destination, {
5130
+ insertions: Number.parseInt(match[1], 10) || 0,
5131
+ deletions: Number.parseInt(match[2], 10) || 0,
5132
+ isBinary: match[1] === '-',
5133
+ });
5008
5134
  }
5135
+ const files = [];
5136
+ const names = nameStatus.split('\0');
5137
+ for (let index = 0; index < names.length; index += 1) {
5138
+ const changeType = names[index].charAt(0);
5139
+ if (!changeType) continue;
5140
+ const renamed = changeType === 'R' || changeType === 'C';
5141
+ const previousPath = renamed ? names[++index] : undefined;
5142
+ const filePath = names[++index];
5143
+ const fileStats = stats.get(filePath);
5144
+ if (!filePath || !fileStats) throw new Error('Incomplete commit file statistics');
5145
+ const entry = { path: filePath, ...fileStats, changeType };
5146
+ if (previousPath) entry.previousPath = previousPath;
5147
+ files.push(entry);
5148
+ }
5149
+ return { files };
5009
5150
  }
5010
5151
 
5011
5152
  export async function renameBranch(directory, oldName, newName) {