@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.
- package/bin/cli.js +7 -0
- package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
- package/dist/assets/DiffView-DC9RX3MF.js +4 -0
- package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
- package/dist/assets/FilesView-BFZkuu4O.js +13 -0
- package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
- package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
- package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
- package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
- package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
- package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
- package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
- package/dist/assets/PlanView-BmQUYT_z.js +5 -0
- package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
- package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
- package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
- package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
- package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
- package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
- package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
- package/dist/assets/de-DJsQr-p7.js +10 -0
- package/dist/assets/es-BuYMlA9g.js +16 -0
- package/dist/assets/fr-ePDay33C.js +14 -0
- package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
- package/dist/assets/index-BD8ILbxF.js +40 -0
- package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
- package/dist/assets/index-zhgg-SDz.css +1 -0
- package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
- package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
- package/dist/assets/ja-BzR9xOY_.js +16 -0
- package/dist/assets/ko-BEjHAm14.js +16 -0
- package/dist/assets/main-BydQ1tr_.js +930 -0
- package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
- package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
- package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
- package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
- package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
- package/dist/assets/pl-rQmpxNIF.js +16 -0
- package/dist/assets/pt-BR-CorbsSW-.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
- package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
- package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
- package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
- package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
- package/dist/assets/surface-COKb77Ua.js +1 -0
- package/dist/assets/tr-CoAMXPtI.js +7 -0
- package/dist/assets/uk-YlAX0rRU.js +16 -0
- package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
- package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
- package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
- package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
- package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/vendor-zod-B6551dwG.js +39 -0
- package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
- package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
- package/dist/assets/zh-TW-CQKaCayr.js +16 -0
- package/dist/index.html +5 -43
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +2 -3
- package/server/index.d.ts +15 -0
- package/server/index.js +48 -22
- package/server/lib/agent-memory/runtime.js +7 -2
- package/server/lib/agent-memory/runtime.test.js +22 -0
- package/server/lib/data-dir-migration.js +40 -0
- package/server/lib/data-dir-migration.test.js +59 -0
- package/server/lib/event-stream/DOCUMENTATION.md +3 -0
- package/server/lib/event-stream/global-hub.js +29 -4
- package/server/lib/event-stream/global-hub.test.js +22 -0
- package/server/lib/event-stream/global-ws-bridge.js +15 -18
- package/server/lib/event-stream/protocol.js +21 -7
- package/server/lib/event-stream/runtime.js +9 -6
- package/server/lib/event-stream/runtime.test.js +72 -12
- package/server/lib/fs/DOCUMENTATION.md +3 -0
- package/server/lib/fs/routes.js +31 -0
- package/server/lib/fs/routes.test.js +110 -1
- package/server/lib/git/DOCUMENTATION.md +39 -6
- package/server/lib/git/routes.js +45 -12
- package/server/lib/git/routes.test.js +78 -0
- package/server/lib/git/service.js +343 -202
- package/server/lib/git/service.test.js +496 -25
- package/server/lib/github/DOCUMENTATION.md +5 -0
- package/server/lib/github/routes.js +2 -2
- package/server/lib/inherited-env.js +16 -9
- package/server/lib/inherited-env.test.js +8 -9
- package/server/lib/message-queue/DOCUMENTATION.md +11 -1
- package/server/lib/message-queue/runtime.js +13 -0
- package/server/lib/message-queue/runtime.test.js +31 -1
- package/server/lib/network-defaults.js +23 -0
- package/server/lib/network-defaults.test.js +52 -0
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/emitter-runtime.js +4 -3
- package/server/lib/opencode/DOCUMENTATION.md +35 -5
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/env-runtime.js +5 -1
- package/server/lib/opencode/env-runtime.test.js +72 -0
- package/server/lib/opencode/feature-routes-runtime.js +17 -1
- package/server/lib/opencode/lifecycle.js +4 -3
- package/server/lib/opencode/lifecycle.test.js +29 -0
- package/server/lib/opencode/managed-plugin-config.js +1 -1
- package/server/lib/opencode/openchamber-routes.js +64 -4
- package/server/lib/opencode/openchamber-routes.test.js +128 -1
- package/server/lib/opencode/proxy.js +33 -0
- package/server/lib/opencode/routes.js +5 -3
- package/server/lib/opencode/settings-files.js +214 -0
- package/server/lib/opencode/settings-helpers.js +88 -41
- package/server/lib/opencode/settings-helpers.test.js +176 -1
- package/server/lib/opencode/settings-registry.json +750 -0
- package/server/lib/opencode/settings-runtime.js +175 -27
- package/server/lib/opencode/settings-runtime.test.js +321 -7
- package/server/lib/project-context/DOCUMENTATION.md +38 -4
- package/server/lib/project-context/routes.http.test.js +18 -0
- package/server/lib/project-context/routes.js +15 -0
- package/server/lib/project-context/runtime.js +256 -25
- package/server/lib/project-context/runtime.test.js +160 -3
- package/server/lib/projects/DOCUMENTATION.md +77 -0
- package/server/lib/projects/project-config.js +179 -17
- package/server/lib/projects/project-config.test.js +97 -0
- package/server/lib/projects/project-id.js +43 -0
- package/server/lib/projects/project-setup.js +409 -0
- package/server/lib/projects/project-setup.test.js +557 -0
- package/server/lib/projects/routes.js +59 -0
- package/server/lib/quota/DOCUMENTATION.md +31 -1
- package/server/lib/quota/providers/cline-pass.js +125 -0
- package/server/lib/quota/providers/cline-pass.test.js +103 -0
- package/server/lib/quota/providers/index.js +7 -0
- package/server/lib/quota/providers/neuralwatt.js +3 -6
- package/server/lib/quota/providers/neuralwatt.test.js +7 -7
- package/server/lib/quota/providers/ollama-cloud.js +31 -0
- package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
- package/server/lib/quota/providers/opencode-go.js +1 -1
- package/server/lib/quota/providers/opencode-go.test.js +26 -1
- package/server/lib/quota/providers/openrouter.js +119 -24
- package/server/lib/quota/providers/openrouter.test.js +330 -0
- package/server/lib/relay/DOCUMENTATION.md +75 -0
- package/server/lib/relay/cross-compat.test.js +26 -0
- package/server/lib/relay/downstream-scheduler.js +154 -0
- package/server/lib/relay/downstream-scheduler.test.js +162 -0
- package/server/lib/relay/e2ee.js +10 -8
- package/server/lib/relay/flow-control.test.js +177 -0
- package/server/lib/relay/host-client.js +44 -8
- package/server/lib/relay/host-client.test.js +14 -0
- package/server/lib/relay/tunnel-codec.js +9 -0
- package/server/lib/relay/tunnel-host.js +66 -17
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
- package/server/lib/scheduled-tasks/runtime.js +12 -2
- package/server/lib/scheduled-tasks/runtime.test.js +53 -0
- package/server/lib/session-assist/DOCUMENTATION.md +131 -66
- package/server/lib/session-assist/context.js +127 -0
- package/server/lib/session-assist/context.test.js +132 -0
- package/server/lib/session-assist/prompt.js +54 -0
- package/server/lib/session-assist/runtime.js +117 -267
- package/server/lib/session-assist/runtime.test.js +203 -0
- package/server/lib/session-goal/runtime.js +4 -9
- package/server/lib/small-model/DOCUMENTATION.md +3 -2
- package/server/lib/small-model/call.js +9 -4
- package/server/lib/small-model/call.test.js +29 -0
- package/server/lib/small-model/index.js +5 -10
- package/server/lib/small-model/runtime-providers.js +15 -7
- package/server/lib/small-model/runtime-providers.test.js +7 -1
- package/server/lib/terminal/DOCUMENTATION.md +4 -4
- package/server/lib/terminal/runtime.js +11 -6
- package/server/lib/terminal/runtime.test.js +20 -13
- package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
- package/server/lib/walkthrough/model-settings.js +8 -12
- package/server/lib/walkthrough/pr-source.test.js +21 -0
- package/server/lib/walkthrough/prompt.js +3 -1
- package/server/lib/walkthrough/pull-request.js +7 -4
- package/server/lib/walkthrough/pull-request.test.js +16 -0
- package/server/lib/walkthrough/routes.js +19 -0
- package/server/lib/walkthrough/routes.test.js +26 -0
- package/server/lib/walkthrough/sources.js +36 -4
- package/server/opencode-worktree-readiness.test.js +130 -0
- package/dist/assets/DiffView-hDBKesA_.js +0 -4
- package/dist/assets/FilesView-CdWzeOp9.js +0 -17
- package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
- package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
- package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
- package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
- package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
- package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
- package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
- package/dist/assets/de-B5JaA3SA.js +0 -10
- package/dist/assets/es-EPIH88ii.js +0 -16
- package/dist/assets/fr-CAMfxUdZ.js +0 -14
- package/dist/assets/index-DF5kMP-i.js +0 -39
- package/dist/assets/index-Dl3xbAB_.css +0 -1
- package/dist/assets/ja-CifOE1VN.js +0 -16
- package/dist/assets/ko-BBDyAyYQ.js +0 -16
- package/dist/assets/main-MhSaOZoB.js +0 -930
- package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
- package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
- package/dist/assets/pl-DgEHYfFM.js +0 -16
- package/dist/assets/pt-BR-DuWHojUw.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
- package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
- package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
- package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
- package/dist/assets/tr-D5m2yIWL.js +0 -7
- package/dist/assets/uk-D_q60oog.js +0 -16
- package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
- package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
- package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
- package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
- package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
- package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
- package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
- package/server/lib/mcp-reconnect/runtime.js +0 -122
- 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
|
-
|
|
363
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2679
|
+
const paths = [];
|
|
2679
2680
|
if (filePath) {
|
|
2680
|
-
|
|
2681
|
-
|
|
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
|
-
|
|
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
|
|
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] || '')
|
|
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
|
|
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
|
|
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
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
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
|
-
|
|
4058
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
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
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
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
|
-
|
|
4996
|
-
const basePath = extractGitNumstatDestinationPath(file.path);
|
|
5100
|
+
const commitShowArgs = (hash) => ['show', '--format=', '--root', '--diff-merges=first-parent', '--find-renames', hash];
|
|
4997
5101
|
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
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
|
-
|
|
5005
|
-
}
|
|
5006
|
-
|
|
5007
|
-
|
|
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) {
|