@aiworker/web 1.22.2 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-B1Gizi3a.js} +1 -1
  3. package/dist/assets/DiffView-DXjVsEOw.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-aig6zJjg.js} +1 -1
  5. package/dist/assets/FilesView-CJdG0-wk.js +17 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-BXsS3jG6.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-D_hpXBnF.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-BoUiLQG1.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-B_B_a9KS.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-xgyyLiBM.js +12 -0
  11. package/dist/assets/OnboardingScreen-DIp84OWt.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-8p6nLJaU.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DjC8nTk9.js +161 -0
  14. package/dist/assets/{PlanView-DyDt_nZ_.js → PlanView-CJTmOF6I.js} +2 -2
  15. package/dist/assets/SettingsView-DD-JqIJx.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Cpb4hM6q.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-DfaKZXNU.js} +6 -6
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-DtZLzrSK.js} +2 -2
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-C8i9HpTB.js} +1 -1
  21. package/dist/assets/WalkthroughView-DeKMvZlL.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BBI7ZwrC.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DCrsQ5zS.js +10 -0
  25. package/dist/assets/es-Dwv4YfH6.js +16 -0
  26. package/dist/assets/fr-BCWg4Cnx.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-B68h5S5B.css +1 -0
  29. package/dist/assets/index-DVwMarHI.js +39 -0
  30. package/dist/assets/{index-Bl3LSbjn.js → index-jXSK5RrB.js} +1 -1
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-B_x_rVon.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-DW_xMer1.js} +1 -1
  33. package/dist/assets/ja-Bruw3RYC.js +16 -0
  34. package/dist/assets/ko-idhXGF8j.js +16 -0
  35. package/dist/assets/main-BkyaQhJX.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-CzdPuL0s.js} +2 -2
  37. package/dist/assets/markdownCore-Cn4Tyejo.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-CCkqKOLp.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-mvxTo055.js} +2 -2
  40. package/dist/assets/patchFileDiff-Csk4iNUi.js +13 -0
  41. package/dist/assets/pl-CGZWUqZJ.js +16 -0
  42. package/dist/assets/pt-BR-B8V7u7tA.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +2 -0
  44. package/dist/assets/renderMobileApp-B48CsP4r.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +17 -0
  46. package/dist/assets/scheduledTasksApi-BW0Etzwz.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-CRl0SUC9.js} +1 -1
  48. package/dist/assets/surface-oMvhsog3.js +1 -0
  49. package/dist/assets/tr-BK_KiO2V.js +7 -0
  50. package/dist/assets/uk-CG74xV8N.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-CPKeOpUo.js} +160 -157
  52. package/dist/assets/useGitComparison-DH220WmO.js +1 -0
  53. package/dist/assets/useInlineCommentController-BiWz4AVb.js +2 -0
  54. package/dist/assets/useRootScrollLock-BS4-kr7P.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Bg4AEeCr.js} +2 -2
  57. package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
  58. package/dist/assets/{web-components-_mNEMjFY.js → web-components-z7hfnS8n.js} +1 -1
  59. package/dist/assets/zh-CN-T4AXofZI.js +16 -0
  60. package/dist/assets/zh-TW-Dte40sxp.js +16 -0
  61. package/dist/index.html +5 -43
  62. package/dist/mini-chat.html +4 -4
  63. package/dist/mobile.html +4 -4
  64. package/package.json +2 -3
  65. package/server/index.d.ts +15 -0
  66. package/server/index.js +45 -9
  67. package/server/lib/data-dir-migration.js +40 -0
  68. package/server/lib/data-dir-migration.test.js +59 -0
  69. package/server/lib/event-stream/runtime.test.js +12 -12
  70. package/server/lib/fs/DOCUMENTATION.md +3 -0
  71. package/server/lib/fs/routes.js +31 -0
  72. package/server/lib/fs/routes.test.js +110 -1
  73. package/server/lib/git/DOCUMENTATION.md +29 -5
  74. package/server/lib/git/routes.js +24 -4
  75. package/server/lib/git/service.js +184 -198
  76. package/server/lib/git/service.test.js +422 -25
  77. package/server/lib/inherited-env.js +16 -9
  78. package/server/lib/inherited-env.test.js +8 -9
  79. package/server/lib/message-queue/DOCUMENTATION.md +4 -1
  80. package/server/lib/network-defaults.js +23 -0
  81. package/server/lib/network-defaults.test.js +52 -0
  82. package/server/lib/opencode/DOCUMENTATION.md +17 -1
  83. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  84. package/server/lib/opencode/feature-routes-runtime.js +2 -0
  85. package/server/lib/opencode/openchamber-routes.js +64 -4
  86. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  87. package/server/lib/opencode/routes.js +5 -3
  88. package/server/lib/opencode/settings-files.js +214 -0
  89. package/server/lib/opencode/settings-helpers.js +85 -41
  90. package/server/lib/opencode/settings-helpers.test.js +166 -1
  91. package/server/lib/opencode/settings-registry.json +747 -0
  92. package/server/lib/opencode/settings-runtime.js +139 -18
  93. package/server/lib/opencode/settings-runtime.test.js +163 -5
  94. package/server/lib/project-context/DOCUMENTATION.md +25 -1
  95. package/server/lib/project-context/routes.http.test.js +18 -0
  96. package/server/lib/project-context/routes.js +15 -0
  97. package/server/lib/project-context/runtime.js +248 -23
  98. package/server/lib/project-context/runtime.test.js +106 -3
  99. package/server/lib/projects/DOCUMENTATION.md +76 -0
  100. package/server/lib/projects/project-config.js +142 -9
  101. package/server/lib/projects/project-id.js +17 -0
  102. package/server/lib/projects/project-setup.js +409 -0
  103. package/server/lib/projects/project-setup.test.js +557 -0
  104. package/server/lib/projects/routes.js +59 -0
  105. package/server/lib/quota/DOCUMENTATION.md +31 -1
  106. package/server/lib/quota/providers/cline-pass.js +125 -0
  107. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  108. package/server/lib/quota/providers/index.js +7 -0
  109. package/server/lib/quota/providers/neuralwatt.js +3 -6
  110. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  111. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  112. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  113. package/server/lib/quota/providers/opencode-go.js +1 -1
  114. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  115. package/server/lib/quota/providers/openrouter.js +119 -24
  116. package/server/lib/quota/providers/openrouter.test.js +330 -0
  117. package/server/lib/relay/DOCUMENTATION.md +55 -0
  118. package/server/lib/relay/cross-compat.test.js +26 -0
  119. package/server/lib/relay/downstream-scheduler.js +154 -0
  120. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  121. package/server/lib/relay/e2ee.js +10 -8
  122. package/server/lib/relay/flow-control.test.js +177 -0
  123. package/server/lib/relay/host-client.js +37 -8
  124. package/server/lib/relay/tunnel-codec.js +9 -0
  125. package/server/lib/relay/tunnel-host.js +66 -17
  126. package/server/lib/session-assist/runtime.js +6 -10
  127. package/server/lib/session-goal/runtime.js +4 -9
  128. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  129. package/server/lib/small-model/call.js +9 -4
  130. package/server/lib/small-model/call.test.js +29 -0
  131. package/server/lib/small-model/index.js +5 -10
  132. package/server/lib/small-model/runtime-providers.js +15 -7
  133. package/server/lib/small-model/runtime-providers.test.js +7 -1
  134. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  135. package/server/lib/terminal/runtime.js +11 -6
  136. package/server/lib/terminal/runtime.test.js +20 -13
  137. package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
  138. package/server/lib/walkthrough/model-settings.js +8 -12
  139. package/server/lib/walkthrough/prompt.js +3 -1
  140. package/server/lib/walkthrough/sources.js +23 -2
  141. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  142. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  143. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  144. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  145. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  146. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  147. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  148. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  149. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  150. package/dist/assets/de-B5JaA3SA.js +0 -10
  151. package/dist/assets/es-EPIH88ii.js +0 -16
  152. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  153. package/dist/assets/index-DF5kMP-i.js +0 -39
  154. package/dist/assets/index-Dl3xbAB_.css +0 -1
  155. package/dist/assets/ja-CifOE1VN.js +0 -16
  156. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  157. package/dist/assets/main-MhSaOZoB.js +0 -930
  158. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  159. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  160. package/dist/assets/pl-DgEHYfFM.js +0 -16
  161. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  162. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  163. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  164. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  165. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  166. package/dist/assets/tr-D5m2yIWL.js +0 -7
  167. package/dist/assets/uk-D_q60oog.js +0 -16
  168. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  169. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  170. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  171. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  172. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  173. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
@@ -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];
@@ -4678,12 +4689,11 @@ export async function getLog(directory, options = {}) {
4678
4689
  };
4679
4690
  const resolvedFrom = await resolveBaseRefForLog(options.from, checkRef);
4680
4691
 
4681
- const baseLog = await git.log({
4682
- maxCount,
4683
- from: resolvedFrom,
4684
- to: options.to,
4685
- file: filePath
4686
- });
4692
+ // simple-git's `to` alone means HEAD..to, which is empty for the current
4693
+ // branch. A single requested ref means its reachable history instead.
4694
+ const baseLog = options.to && !resolvedFrom
4695
+ ? await git.log([`--max-count=${maxCount}`, options.to, ...(filePath ? ['--', filePath] : [])])
4696
+ : await git.log({ maxCount, from: resolvedFrom, to: options.to, file: filePath });
4687
4697
 
4688
4698
  const logArgs = [
4689
4699
  'log',
@@ -4927,85 +4937,61 @@ export async function canonicalizeWorktreeState(directory) {
4927
4937
  };
4928
4938
  }
4929
4939
 
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
- }
4940
+ async function resolveCommitHash(git, hash) {
4941
+ if (!/^[0-9a-f]{7,64}$/i.test(hash)) throw new Error('A commit hash is required');
4942
+ return (await git.raw(['rev-parse', '--verify', '--end-of-options', `${hash}^{commit}`])).trim();
4943
+ }
4994
4944
 
4995
- for (const file of files) {
4996
- const basePath = extractGitNumstatDestinationPath(file.path);
4945
+ const commitShowArgs = (hash) => ['show', '--format=', '--root', '--diff-merges=first-parent', '--find-renames', hash];
4997
4946
 
4998
- const status = statusMap.get(basePath) || statusMap.get(file.path);
4999
- if (status) {
5000
- file.changeType = status;
5001
- }
5002
- }
4947
+ export async function getCommitDiff(directory, { hash, path: filePath, previousPath, contextLines = 3 } = {}) {
4948
+ const { git } = await createRepositoryGitContext(directory);
4949
+ const commit = await resolveCommitHash(git, hash);
4950
+ const paths = [filePath, previousPath].filter(Boolean).map((value) => `:(literal)${value}`);
4951
+ return git.raw([
4952
+ ...commitShowArgs(commit), '--no-color', '--no-ext-diff', `-U${Math.max(0, contextLines)}`,
4953
+ '--', ...paths,
4954
+ ]);
4955
+ }
5003
4956
 
5004
- return { files };
5005
- } catch (error) {
5006
- console.error('Failed to get commit files:', error);
5007
- throw error;
4957
+ export async function getCommitFiles(directory, commitHash) {
4958
+ const { git } = await createRepositoryGitContext(directory);
4959
+ const hash = await resolveCommitHash(git, commitHash);
4960
+ const [numstat, nameStatus] = await Promise.all([
4961
+ git.raw([...commitShowArgs(hash), '--numstat', '-z', '--']),
4962
+ git.raw([...commitShowArgs(hash), '--name-status', '-z', '--']),
4963
+ ]);
4964
+ const stats = new Map();
4965
+ const tokens = numstat.split('\0');
4966
+ for (let index = 0; index < tokens.length; index += 1) {
4967
+ const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(tokens[index]);
4968
+ if (!match) continue;
4969
+ let destination = match[3];
4970
+ if (!destination) {
4971
+ destination = tokens[index + 2];
4972
+ index += 2;
4973
+ }
4974
+ stats.set(destination, {
4975
+ insertions: Number.parseInt(match[1], 10) || 0,
4976
+ deletions: Number.parseInt(match[2], 10) || 0,
4977
+ isBinary: match[1] === '-',
4978
+ });
5008
4979
  }
4980
+ const files = [];
4981
+ const names = nameStatus.split('\0');
4982
+ for (let index = 0; index < names.length; index += 1) {
4983
+ const changeType = names[index].charAt(0);
4984
+ if (!changeType) continue;
4985
+ const renamed = changeType === 'R' || changeType === 'C';
4986
+ const previousPath = renamed ? names[++index] : undefined;
4987
+ const filePath = names[++index];
4988
+ const fileStats = stats.get(filePath);
4989
+ if (!filePath || !fileStats) throw new Error('Incomplete commit file statistics');
4990
+ const entry = { path: filePath, ...fileStats, changeType };
4991
+ if (previousPath) entry.previousPath = previousPath;
4992
+ files.push(entry);
4993
+ }
4994
+ return { files };
5009
4995
  }
5010
4996
 
5011
4997
  export async function renameBranch(directory, oldName, newName) {