@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
|
@@ -4,6 +4,8 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
6
|
import simpleGit from 'simple-git';
|
|
7
|
+
import { loadSourceSections, parseSource, sourceKey } from '../walkthrough/sources.js';
|
|
8
|
+
import { registerGitRoutes } from './routes.js';
|
|
7
9
|
|
|
8
10
|
import {
|
|
9
11
|
checkoutBranch,
|
|
@@ -14,9 +16,14 @@ import {
|
|
|
14
16
|
getBranches,
|
|
15
17
|
getUnpushedBranchCounts,
|
|
16
18
|
getRangeDiff,
|
|
19
|
+
getBranchBase,
|
|
20
|
+
getCommitDiff,
|
|
21
|
+
getCommitFiles,
|
|
22
|
+
getLog,
|
|
17
23
|
getStatus,
|
|
18
24
|
getWorktrees,
|
|
19
25
|
isGitRepository,
|
|
26
|
+
observeWorktreeTopology,
|
|
20
27
|
populateWorktreeWithLockRecovery,
|
|
21
28
|
removeWorktree,
|
|
22
29
|
resolvePrimaryWorktreeRoot,
|
|
@@ -26,9 +33,11 @@ import {
|
|
|
26
33
|
revertCommit,
|
|
27
34
|
setLocalIdentity,
|
|
28
35
|
stageFiles,
|
|
36
|
+
subscribeWorktreeTopologyChanges,
|
|
29
37
|
unstageFiles,
|
|
30
38
|
applyHunk,
|
|
31
39
|
getDiff,
|
|
40
|
+
getUntrackedDiffs,
|
|
32
41
|
getFileDiff,
|
|
33
42
|
validateWorktreeCreate,
|
|
34
43
|
parseBranchCreationSource,
|
|
@@ -183,28 +192,49 @@ describe.runIf(canRunGit())('setLocalIdentity', () => {
|
|
|
183
192
|
"ssh -i '/tmp/test key' -o IdentitiesOnly=yes"
|
|
184
193
|
);
|
|
185
194
|
});
|
|
195
|
+
|
|
196
|
+
it('configures the stored credential helper for token auth with the targeted simple-git opt-in', async () => {
|
|
197
|
+
const { tmpDir } = await createTempRepo();
|
|
198
|
+
|
|
199
|
+
await setLocalIdentity(tmpDir, {
|
|
200
|
+
userName: 'Token User',
|
|
201
|
+
userEmail: 'token@example.com',
|
|
202
|
+
authType: 'token',
|
|
203
|
+
host: 'github.com',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(runGit(tmpDir, ['config', '--local', '--get', 'credential.helper']).trim()).toBe('store');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('clears the stored credential helper when switching to SSH auth', async () => {
|
|
210
|
+
const { tmpDir } = await createTempRepo();
|
|
211
|
+
|
|
212
|
+
await setLocalIdentity(tmpDir, {
|
|
213
|
+
userName: 'Token User',
|
|
214
|
+
userEmail: 'token@example.com',
|
|
215
|
+
authType: 'token',
|
|
216
|
+
host: 'github.com',
|
|
217
|
+
});
|
|
218
|
+
await setLocalIdentity(tmpDir, {
|
|
219
|
+
userName: 'SSH User',
|
|
220
|
+
userEmail: 'ssh@example.com',
|
|
221
|
+
authType: 'ssh',
|
|
222
|
+
sshKey: '/tmp/test key',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(runGit(tmpDir, ['config', '--local', '--get', 'core.sshCommand']).trim()).toBe(
|
|
226
|
+
"ssh -i '/tmp/test key' -o IdentitiesOnly=yes"
|
|
227
|
+
);
|
|
228
|
+
expect(() => runGit(tmpDir, ['config', '--local', '--get', 'credential.helper'])).toThrow();
|
|
229
|
+
});
|
|
186
230
|
});
|
|
187
231
|
|
|
188
232
|
// ---------------------------------------------------------------------------
|
|
189
233
|
// applyHunk (per-hunk stage / unstage / discard)
|
|
190
234
|
// ---------------------------------------------------------------------------
|
|
191
235
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const lines = patch.split(/\r?\n/);
|
|
195
|
-
const headerEnd = lines.findIndex((line) => /^@@\s/.test(line));
|
|
196
|
-
if (headerEnd === -1) return [];
|
|
197
|
-
const header = lines.slice(0, headerEnd);
|
|
198
|
-
const hunks = [];
|
|
199
|
-
for (let i = headerEnd; i < lines.length; i += 1) {
|
|
200
|
-
const line = lines[i];
|
|
201
|
-
if (/^@@\s/.test(line)) hunks.push([...header, line]);
|
|
202
|
-
else if (hunks.length > 0) hunks[hunks.length - 1].push(line);
|
|
203
|
-
}
|
|
204
|
-
return hunks.map((hunk) => hunk.join('\n'))
|
|
205
|
-
.filter((hunk) => hunk.trim().length > 0)
|
|
206
|
-
.map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`));
|
|
207
|
-
};
|
|
236
|
+
// Exercise the actual client splitter against the server apply boundary.
|
|
237
|
+
import { splitPatchIntoHunks as splitHunks } from '../../../../ui/src/lib/diff/patchFileDiff.ts';
|
|
208
238
|
|
|
209
239
|
const writeFile = (repo, name, contents) =>
|
|
210
240
|
fs.promises.writeFile(path.join(repo, name), contents, 'utf8');
|
|
@@ -220,6 +250,77 @@ const readWorking = (repo) => fs.promises.readFile(path.join(repo, 'file.txt'),
|
|
|
220
250
|
const readStaged = async (git) => (await git.raw(['show', ':file.txt'])).replace(/\r\n/g, '\n');
|
|
221
251
|
|
|
222
252
|
describe('applyHunk', () => {
|
|
253
|
+
it('stages successive hunks and never discards a stale staged or committed patch', async () => {
|
|
254
|
+
if (!canRunGit()) return;
|
|
255
|
+
const { tmpDir, git } = await createTempRepo();
|
|
256
|
+
const original = Array.from({ length: 60 }, (_, index) => `line${index}`);
|
|
257
|
+
const changed = [...original];
|
|
258
|
+
changed[1] = 'FIRST'; changed[25] = 'SECOND'; changed[50] = 'THIRD';
|
|
259
|
+
await writeFile(tmpDir, 'file.txt', original.join('\n') + '\n');
|
|
260
|
+
await git.add('file.txt'); await git.commit('Initial');
|
|
261
|
+
await writeFile(tmpDir, 'file.txt', changed.join('\n') + '\n');
|
|
262
|
+
const historical = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
|
263
|
+
expect(historical).toHaveLength(3);
|
|
264
|
+
await applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'stage' });
|
|
265
|
+
const remaining = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
|
266
|
+
expect(remaining).toHaveLength(2);
|
|
267
|
+
await applyHunk(tmpDir, 'file.txt', { patch: remaining[0], action: 'stage' });
|
|
268
|
+
const stalePath = path.join(tmpDir, 'stale.patch');
|
|
269
|
+
await fs.promises.writeFile(stalePath, historical[0]);
|
|
270
|
+
// Git's reverse applicability check accepts it, but it is no longer an
|
|
271
|
+
// unstaged hunk. The server must reject it before touching the working file.
|
|
272
|
+
await git.raw(['apply', '--reverse', '--check', stalePath]);
|
|
273
|
+
await expect(applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'discard' })).rejects.toThrow('refresh and try again');
|
|
274
|
+
expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
|
|
275
|
+
const last = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
|
276
|
+
expect(last).toHaveLength(1);
|
|
277
|
+
await applyHunk(tmpDir, 'file.txt', { patch: last[0], action: 'discard' });
|
|
278
|
+
changed[50] = original[50];
|
|
279
|
+
expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
|
|
280
|
+
expect(await readStaged(git)).toBe(changed.join('\n') + '\n');
|
|
281
|
+
const staged = splitHunks(await getDiff(tmpDir, { path: 'file.txt', staged: true }));
|
|
282
|
+
await applyHunk(tmpDir, 'file.txt', { patch: staged[0], action: 'unstage' });
|
|
283
|
+
expect(await readWorking(tmpDir)).toBe(changed.join('\n') + '\n');
|
|
284
|
+
await git.add('file.txt'); await git.commit('Committed changes');
|
|
285
|
+
await expect(applyHunk(tmpDir, 'file.txt', { patch: historical[0], action: 'discard' })).rejects.toThrow('refresh and try again');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it.each(['crlf', 'mixed'])('preserves %s file bytes through stage, unstage and discard', async (endings) => {
|
|
289
|
+
if (!canRunGit()) return;
|
|
290
|
+
const { tmpDir, git } = await createTempRepo();
|
|
291
|
+
await git.addConfig('core.autocrlf', 'false');
|
|
292
|
+
const serialize = (first, last) => Array.from({ length: 30 }, (_, index) => {
|
|
293
|
+
const text = index === 0 ? first : index === 29 ? last : `line${index}`;
|
|
294
|
+
return text + (endings === 'crlf' || index % 2 === 0 ? '\r\n' : '\n');
|
|
295
|
+
}).join('');
|
|
296
|
+
const original = serialize('first', 'last');
|
|
297
|
+
const edited = serialize('FIRST', 'LAST');
|
|
298
|
+
await writeFile(tmpDir, 'file.txt', original);
|
|
299
|
+
await git.add('file.txt'); await git.commit('Initial');
|
|
300
|
+
await writeFile(tmpDir, 'file.txt', edited);
|
|
301
|
+
const hunks = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
|
302
|
+
await applyHunk(tmpDir, 'file.txt', { patch: hunks[0], action: 'stage' });
|
|
303
|
+
expect(await git.raw(['show', ':file.txt'])).toBe(serialize('FIRST', 'last'));
|
|
304
|
+
const staged = splitHunks(await getDiff(tmpDir, { path: 'file.txt', staged: true }));
|
|
305
|
+
await applyHunk(tmpDir, 'file.txt', { patch: staged[0], action: 'unstage' });
|
|
306
|
+
expect(await git.raw(['show', ':file.txt'])).toBe(original);
|
|
307
|
+
const working = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }));
|
|
308
|
+
await applyHunk(tmpDir, 'file.txt', { patch: working[0], action: 'discard' });
|
|
309
|
+
expect(await fs.promises.readFile(path.join(tmpDir, 'file.txt'), 'utf8')).toBe(serialize('first', 'LAST'));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('rejects extra files hidden before the requested patch', async () => {
|
|
313
|
+
if (!canRunGit()) return;
|
|
314
|
+
const { tmpDir, git } = await createTempRepo();
|
|
315
|
+
for (const name of ['file.txt', 'other.txt']) await writeFile(tmpDir, name, ORIGINAL_FILE);
|
|
316
|
+
await git.add('.'); await git.commit('Initial');
|
|
317
|
+
for (const name of ['file.txt', 'other.txt']) await writeFile(tmpDir, name, EDITED_FILE);
|
|
318
|
+
const other = splitHunks(await getDiff(tmpDir, { path: 'other.txt' }))[0];
|
|
319
|
+
const requested = splitHunks(await getDiff(tmpDir, { path: 'file.txt' }))[0];
|
|
320
|
+
await expect(applyHunk(tmpDir, 'file.txt', { patch: requested + other, action: 'stage' })).rejects.toThrow('refresh and try again');
|
|
321
|
+
expect(await git.raw(['diff', '--cached'])).toBe('');
|
|
322
|
+
});
|
|
323
|
+
|
|
223
324
|
it('rejects an invalid action or a patch without a hunk header', async () => {
|
|
224
325
|
const { tmpDir } = await createTempRepo();
|
|
225
326
|
await expect(applyHunk(tmpDir, 'file.txt', { patch: '@@ -1 +1 @@\n a\n', action: 'bogus' })).rejects.toThrow(
|
|
@@ -302,10 +403,9 @@ describe('applyHunk', () => {
|
|
|
302
403
|
);
|
|
303
404
|
});
|
|
304
405
|
|
|
305
|
-
it('accepts hunk patches for
|
|
406
|
+
it.each(['file name.txt', 'зміни.txt'])('accepts hunk patches for %s', async (filePath) => {
|
|
306
407
|
if (!canRunGit()) return;
|
|
307
408
|
const { tmpDir, git } = await createTempRepo();
|
|
308
|
-
const filePath = 'file name.txt';
|
|
309
409
|
await writeFile(tmpDir, filePath, ORIGINAL_FILE);
|
|
310
410
|
await git.add(filePath);
|
|
311
411
|
await git.commit('Initial');
|
|
@@ -322,6 +422,71 @@ describe('applyHunk', () => {
|
|
|
322
422
|
});
|
|
323
423
|
});
|
|
324
424
|
|
|
425
|
+
describe.runIf(canRunGit())('untracked diffs', () => {
|
|
426
|
+
it.each(['false', 'warn'])('returns only the patch with core.safecrlf=%s', async (safecrlf) => {
|
|
427
|
+
const { tmpDir, git } = await createTempRepo();
|
|
428
|
+
await git.addConfig('core.autocrlf', 'true');
|
|
429
|
+
await git.addConfig('core.safecrlf', safecrlf);
|
|
430
|
+
fs.writeFileSync(path.join(tmpDir, 'new file.txt'), 'first\nsecond\n');
|
|
431
|
+
|
|
432
|
+
// Confirm this fixture produces a real diff exit, including stderr in the warning case.
|
|
433
|
+
let expectedPatch;
|
|
434
|
+
try {
|
|
435
|
+
runGit(tmpDir, ['diff', '--no-color', '--full-index', '--no-index', '--', '/dev/null', 'new file.txt']);
|
|
436
|
+
throw new Error('Expected git diff to exit with differences');
|
|
437
|
+
} catch (error) {
|
|
438
|
+
expect(error.status).toBe(1);
|
|
439
|
+
expectedPatch = error.stdout;
|
|
440
|
+
if (safecrlf === 'warn') {
|
|
441
|
+
expect(error.stderr).toContain('LF will be replaced by CRLF');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const diff = await getDiff(tmpDir, { path: 'new file.txt' });
|
|
446
|
+
expect(diff).toBe(expectedPatch);
|
|
447
|
+
expect(diff).toContain('+first\n+second\n');
|
|
448
|
+
expect(diff).not.toContain('warning:');
|
|
449
|
+
expect(await getUntrackedDiffs(tmpDir, ['new file.txt'])).toEqual([diff]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('accepts an empty untracked file without a process error', async () => {
|
|
453
|
+
const { tmpDir } = await createTempRepo();
|
|
454
|
+
fs.writeFileSync(path.join(tmpDir, 'empty.txt'), '');
|
|
455
|
+
const diff = await getDiff(tmpDir, { path: 'empty.txt' });
|
|
456
|
+
expect(diff).toContain('new file mode 100644');
|
|
457
|
+
expect(diff).not.toContain('@@');
|
|
458
|
+
expect(await getUntrackedDiffs(tmpDir, ['empty.txt'])).toEqual([diff]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('rejects fatal conversion errors while preserving other batch entries', async () => {
|
|
462
|
+
const { tmpDir } = await createTempRepo();
|
|
463
|
+
runGit(tmpDir, ['config', 'diff.broken.textconv', 'false']);
|
|
464
|
+
fs.writeFileSync(path.join(tmpDir, '.gitattributes'), 'bad.txt diff=broken\n');
|
|
465
|
+
fs.writeFileSync(path.join(tmpDir, 'first.safe'), 'first\n');
|
|
466
|
+
fs.writeFileSync(path.join(tmpDir, 'bad.txt'), 'bad\n');
|
|
467
|
+
fs.writeFileSync(path.join(tmpDir, 'last.safe'), 'last\n');
|
|
468
|
+
|
|
469
|
+
await expect(getDiff(tmpDir, { path: 'bad.txt' })).rejects.toThrow('unable to read files to diff');
|
|
470
|
+
const diffs = await getUntrackedDiffs(tmpDir, ['first.safe', 'bad.txt', 'last.safe'], { concurrency: 1 });
|
|
471
|
+
expect(diffs).toHaveLength(3);
|
|
472
|
+
expect(diffs[0]).toContain('+first\n');
|
|
473
|
+
expect(diffs[1]).toBe('');
|
|
474
|
+
expect(diffs[2]).toContain('+last\n');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('rejects truncated patches when the process output exceeds the buffer limit', async () => {
|
|
478
|
+
const { tmpDir } = await createTempRepo();
|
|
479
|
+
fs.writeFileSync(path.join(tmpDir, 'large.txt'), 'x'.repeat(21 * 1024 * 1024) + '\n');
|
|
480
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
481
|
+
try {
|
|
482
|
+
await expect(getDiff(tmpDir, { path: 'large.txt' })).rejects.toThrow('maxBuffer');
|
|
483
|
+
expect(await getUntrackedDiffs(tmpDir, ['large.txt'])).toEqual(['']);
|
|
484
|
+
} finally {
|
|
485
|
+
errorSpy.mockRestore();
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
325
490
|
describe('symlink diffs', () => {
|
|
326
491
|
it('treats an untracked directory symlink as a link in patch and split diffs', async () => {
|
|
327
492
|
if (!canRunGit() || process.platform === 'win32') return;
|
|
@@ -517,6 +682,78 @@ describe('getWorktrees', () => {
|
|
|
517
682
|
expect(Array.isArray(result)).toBe(true);
|
|
518
683
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
519
684
|
});
|
|
685
|
+
it('notifies subscribers only when another git process changes the worktree set', async () => {
|
|
686
|
+
if (!canRunGit()) return;
|
|
687
|
+
|
|
688
|
+
const repo = createTempDir();
|
|
689
|
+
runGit(repo, ['init', '-b', 'main']);
|
|
690
|
+
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
691
|
+
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
692
|
+
runGit(repo, ['commit', '--allow-empty', '-m', 'init']);
|
|
693
|
+
const worktreePath = path.join(createTempDir(), 'feature');
|
|
694
|
+
|
|
695
|
+
const events = [];
|
|
696
|
+
const unsubscribe = subscribeWorktreeTopologyChanges((event) => events.push(event));
|
|
697
|
+
try {
|
|
698
|
+
await observeWorktreeTopology(repo);
|
|
699
|
+
await observeWorktreeTopology(repo);
|
|
700
|
+
expect(events).toHaveLength(0);
|
|
701
|
+
|
|
702
|
+
runGit(repo, ['worktree', 'add', worktreePath, '-b', 'feature']);
|
|
703
|
+
await observeWorktreeTopology(worktreePath);
|
|
704
|
+
expect(events).toHaveLength(1);
|
|
705
|
+
expect(events[0].directories).toEqual(expect.arrayContaining([repo, worktreePath]));
|
|
706
|
+
|
|
707
|
+
await observeWorktreeTopology(repo);
|
|
708
|
+
expect(events).toHaveLength(1);
|
|
709
|
+
|
|
710
|
+
runGit(repo, ['worktree', 'remove', worktreePath]);
|
|
711
|
+
await observeWorktreeTopology(repo);
|
|
712
|
+
expect(events).toHaveLength(2);
|
|
713
|
+
} finally {
|
|
714
|
+
unsubscribe();
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('publishes worktrees this server creates and removes', async () => {
|
|
719
|
+
if (!canRunGit()) return;
|
|
720
|
+
|
|
721
|
+
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
|
722
|
+
process.env.XDG_DATA_HOME = createTempDir();
|
|
723
|
+
const events = [];
|
|
724
|
+
const unsubscribe = subscribeWorktreeTopologyChanges((event) => events.push(event));
|
|
725
|
+
try {
|
|
726
|
+
const repo = createTempDir();
|
|
727
|
+
runGit(repo, ['init', '-b', 'main']);
|
|
728
|
+
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
729
|
+
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
730
|
+
runGit(repo, ['commit', '--allow-empty', '-m', 'init']);
|
|
731
|
+
await observeWorktreeTopology(repo);
|
|
732
|
+
|
|
733
|
+
const created = await createWorktree(repo, {
|
|
734
|
+
mode: 'new',
|
|
735
|
+
worktreeName: 'published',
|
|
736
|
+
branchName: 'aiworker/published',
|
|
737
|
+
});
|
|
738
|
+
expect(events).toHaveLength(1);
|
|
739
|
+
expect(events[0].directories).toContain(repo);
|
|
740
|
+
|
|
741
|
+
// The publish refreshed the baseline, so the next observation is quiet.
|
|
742
|
+
await observeWorktreeTopology(repo);
|
|
743
|
+
expect(events).toHaveLength(1);
|
|
744
|
+
|
|
745
|
+
await removeWorktree(repo, { directory: created.path });
|
|
746
|
+
expect(events).toHaveLength(2);
|
|
747
|
+
} finally {
|
|
748
|
+
unsubscribe();
|
|
749
|
+
if (previousXdgDataHome === undefined) {
|
|
750
|
+
delete process.env.XDG_DATA_HOME;
|
|
751
|
+
} else {
|
|
752
|
+
process.env.XDG_DATA_HOME = previousXdgDataHome;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
520
757
|
it('flags a worktree whose directory was deleted outside git as prunable', async () => {
|
|
521
758
|
const repo = createTempDir();
|
|
522
759
|
runGit(repo, ['init', '-b', 'main']);
|
|
@@ -1669,18 +1906,249 @@ describe.runIf(canRunGit())('getUnpushedBranchCounts', () => {
|
|
|
1669
1906
|
});
|
|
1670
1907
|
});
|
|
1671
1908
|
|
|
1909
|
+
describe.runIf(canRunGit())('commit comparisons', () => {
|
|
1910
|
+
it('shows only the selected commit and gives walkthrough the identical patch', async () => {
|
|
1911
|
+
const { repository } = createRepositoryWithRemote();
|
|
1912
|
+
fs.writeFileSync(path.join(repository, 'README.md'), 'selected version\n');
|
|
1913
|
+
runGit(repository, ['add', '.']);
|
|
1914
|
+
runGit(repository, ['commit', '-m', 'selected']);
|
|
1915
|
+
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
|
1916
|
+
fs.writeFileSync(path.join(repository, 'README.md'), 'later version\n');
|
|
1917
|
+
runGit(repository, ['add', '.']);
|
|
1918
|
+
runGit(repository, ['commit', '-m', 'later']);
|
|
1919
|
+
fs.writeFileSync(path.join(repository, 'README.md'), 'uncommitted version\n');
|
|
1920
|
+
const patch = await getCommitDiff(repository, { hash, path: 'README.md' });
|
|
1921
|
+
expect(patch).toContain('+selected version');
|
|
1922
|
+
expect(patch).not.toContain('later version');
|
|
1923
|
+
expect(patch).not.toContain('uncommitted version');
|
|
1924
|
+
expect((await getCommitFiles(repository, hash)).files).toEqual([
|
|
1925
|
+
{ path: 'README.md', insertions: 1, deletions: 1, isBinary: false, changeType: 'M' },
|
|
1926
|
+
]);
|
|
1927
|
+
const source = parseSource({ kind: 'commit', hash });
|
|
1928
|
+
expect(sourceKey(source)).toBe(`commit:${hash}`);
|
|
1929
|
+
expect((await loadSourceSections(repository, source)).sections).toEqual([{ scope: 'commit', patch }]);
|
|
1930
|
+
const routes = new Map();
|
|
1931
|
+
registerGitRoutes({
|
|
1932
|
+
get: (url, handler) => routes.set(url, handler), post() {}, put() {}, delete() {},
|
|
1933
|
+
});
|
|
1934
|
+
let response;
|
|
1935
|
+
await routes.get('/api/git/commit-diff')(
|
|
1936
|
+
{ query: { directory: repository, hash, path: 'README.md' } },
|
|
1937
|
+
{ json: (body) => { response = body; }, status: (code) => { throw new Error(`Unexpected status ${code}`); } },
|
|
1938
|
+
);
|
|
1939
|
+
expect(response).toEqual({ diff: patch });
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
it('handles root and empty commits and rejects invalid hashes', async () => {
|
|
1943
|
+
const { repository } = createRepositoryWithRemote();
|
|
1944
|
+
const root = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
|
1945
|
+
expect(await getCommitDiff(repository, { hash: root })).toContain('+# Test');
|
|
1946
|
+
expect((await getCommitFiles(repository, root)).files[0].changeType).toBe('A');
|
|
1947
|
+
runGit(repository, ['commit', '--allow-empty', '-m', 'empty']);
|
|
1948
|
+
const empty = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
|
1949
|
+
expect(await getCommitDiff(repository, { hash: empty })).toBe('');
|
|
1950
|
+
expect(await getCommitFiles(repository, empty)).toEqual({ files: [] });
|
|
1951
|
+
expect(() => parseSource({ kind: 'commit', hash: 'HEAD' })).toThrow();
|
|
1952
|
+
expect(() => parseSource({ kind: 'commit', hash: [root] })).toThrow();
|
|
1953
|
+
await expect(getCommitDiff(repository, { hash: 'HEAD' })).rejects.toThrow();
|
|
1954
|
+
await expect(getCommitFiles(repository, '0'.repeat(40))).rejects.toThrow();
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
it('keeps rename paths and original contents together, including whitespace in names', async () => {
|
|
1958
|
+
const { repository } = createRepositoryWithRemote();
|
|
1959
|
+
const destination = ' new\nname.md';
|
|
1960
|
+
runGit(repository, ['mv', 'README.md', destination]);
|
|
1961
|
+
runGit(repository, ['commit', '-m', 'rename']);
|
|
1962
|
+
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
|
1963
|
+
const { files } = await getCommitFiles(repository, hash);
|
|
1964
|
+
expect(files).toEqual([{ path: destination, previousPath: 'README.md', changeType: 'R', insertions: 0, deletions: 0, isBinary: false }]);
|
|
1965
|
+
const patch = await getCommitDiff(repository, { hash, path: destination, previousPath: files[0].previousPath });
|
|
1966
|
+
expect(patch).toContain('rename from README.md');
|
|
1967
|
+
expect(patch).toContain('similarity index 100%');
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
it('compares a merge commit against its first parent', async () => {
|
|
1971
|
+
const { repository } = createRepositoryWithRemote();
|
|
1972
|
+
runGit(repository, ['checkout', '-b', 'side']);
|
|
1973
|
+
fs.writeFileSync(path.join(repository, 'side.txt'), 'side\n');
|
|
1974
|
+
runGit(repository, ['add', '.']);
|
|
1975
|
+
runGit(repository, ['commit', '-m', 'side']);
|
|
1976
|
+
runGit(repository, ['checkout', 'next']);
|
|
1977
|
+
fs.writeFileSync(path.join(repository, 'main.txt'), 'main\n');
|
|
1978
|
+
runGit(repository, ['add', '.']);
|
|
1979
|
+
runGit(repository, ['commit', '-m', 'main']);
|
|
1980
|
+
runGit(repository, ['merge', '--no-ff', 'side', '-m', 'merge']);
|
|
1981
|
+
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
|
1982
|
+
expect((await getCommitFiles(repository, hash)).files.map((file) => file.path)).toEqual(['side.txt']);
|
|
1983
|
+
const patch = await getCommitDiff(repository, { hash });
|
|
1984
|
+
expect(patch).toContain('+side');
|
|
1985
|
+
expect(patch).not.toContain('main.txt');
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
it('limits current-branch history to 50 commits without including another branch', async () => {
|
|
1989
|
+
const { repository } = createRepositoryWithRemote();
|
|
1990
|
+
runGit(repository, ['checkout', '-b', 'other']);
|
|
1991
|
+
runGit(repository, ['commit', '--allow-empty', '-m', 'other branch only']);
|
|
1992
|
+
runGit(repository, ['checkout', 'next']);
|
|
1993
|
+
for (let index = 0; index < 51; index += 1) runGit(repository, ['commit', '--allow-empty', '-m', `current ${index}`]);
|
|
1994
|
+
const history = await getLog(repository, { maxCount: 50, to: 'refs/heads/next' });
|
|
1995
|
+
expect(history.all).toHaveLength(50);
|
|
1996
|
+
expect(history.all[0].message).toBe('current 50');
|
|
1997
|
+
expect(history.all.some((commit) => commit.message === 'other branch only')).toBe(false);
|
|
1998
|
+
});
|
|
1999
|
+
});
|
|
2000
|
+
|
|
1672
2001
|
describe.runIf(canRunGit())('getRangeDiff', () => {
|
|
1673
|
-
it('
|
|
2002
|
+
it('loads a committed deletion that no longer exists in HEAD or the working tree', async () => {
|
|
2003
|
+
const { repository } = createRepositoryWithRemote();
|
|
2004
|
+
runGit(repository, ['rm', 'README.md']);
|
|
2005
|
+
runGit(repository, ['commit', '-m', 'delete file']);
|
|
2006
|
+
const diff = await getRangeDiff(repository, { base: 'origin/react', head: 'next', path: 'README.md', includeWorkingTree: true });
|
|
2007
|
+
expect(diff).toContain('deleted file mode');
|
|
2008
|
+
expect(diff).toContain('-# Test');
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
it('carries the working-tree option through the actual HTTP route handlers', async () => {
|
|
2012
|
+
const { repository } = createRepositoryWithRemote();
|
|
2013
|
+
fs.writeFileSync(path.join(repository, 'local.txt'), 'current local work\n');
|
|
2014
|
+
const routes = new Map();
|
|
2015
|
+
registerGitRoutes({
|
|
2016
|
+
get: (url, handler) => routes.set(url, handler),
|
|
2017
|
+
post() {},
|
|
2018
|
+
put() {},
|
|
2019
|
+
delete() {},
|
|
2020
|
+
});
|
|
2021
|
+
const query = { directory: repository, base: 'origin/react', head: 'next', includeWorkingTree: 'true' };
|
|
2022
|
+
for (const endpoint of ['range-diff', 'range-files']) {
|
|
2023
|
+
let status = 200;
|
|
2024
|
+
let body;
|
|
2025
|
+
const response = {
|
|
2026
|
+
status(value) { status = value; return this; },
|
|
2027
|
+
json(value) { body = value; },
|
|
2028
|
+
};
|
|
2029
|
+
await routes.get(`/api/git/${endpoint}`)({ query }, response);
|
|
2030
|
+
expect(status).toBe(200);
|
|
2031
|
+
if (endpoint === 'range-diff') expect(body.diff).toContain('+current local work');
|
|
2032
|
+
else expect(body.files).toEqual([{ path: 'local.txt', status: 'A' }]);
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
it('asks for a new base after restacking and compares against the selected parent', async () => {
|
|
2037
|
+
const { repository } = createRepositoryWithRemote();
|
|
2038
|
+
runGit(repository, ['checkout', '-b', 'child', 'origin/react']);
|
|
2039
|
+
fs.writeFileSync(path.join(repository, 'child.txt'), 'child\n');
|
|
2040
|
+
runGit(repository, ['add', '.']);
|
|
2041
|
+
runGit(repository, ['commit', '-m', 'child']);
|
|
2042
|
+
expect(await getBranchBase(repository, 'child')).toEqual({ base: 'origin/react' });
|
|
2043
|
+
runGit(repository, ['checkout', '-b', 'parent', 'origin/react']);
|
|
2044
|
+
fs.writeFileSync(path.join(repository, 'parent.txt'), 'parent\n');
|
|
2045
|
+
runGit(repository, ['add', '.']);
|
|
2046
|
+
runGit(repository, ['commit', '-m', 'parent']);
|
|
2047
|
+
runGit(repository, ['checkout', 'child']);
|
|
2048
|
+
runGit(repository, ['rebase', 'parent']);
|
|
2049
|
+
expect(await getBranchBase(repository, 'child')).toEqual({ base: null });
|
|
2050
|
+
fs.writeFileSync(path.join(repository, 'child.txt'), 'current child\n');
|
|
2051
|
+
const options = { base: 'refs/heads/parent', head: 'child', includeWorkingTree: true };
|
|
2052
|
+
expect(await getRangeFiles(repository, options)).toEqual([{ path: 'child.txt', status: 'A' }]);
|
|
2053
|
+
const diff = await getRangeDiff(repository, options);
|
|
2054
|
+
expect(diff).toContain('+current child');
|
|
2055
|
+
expect(diff).not.toContain('parent.txt');
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
it('combines committed, staged, unstaged and untracked work without changing the real index', async () => {
|
|
2059
|
+
const { repository } = createRepositoryWithRemote();
|
|
2060
|
+
fs.writeFileSync(path.join(repository, 'README.md'), '# Committed\n');
|
|
2061
|
+
runGit(repository, ['add', 'README.md']);
|
|
2062
|
+
runGit(repository, ['commit', '-m', 'branch change']);
|
|
2063
|
+
fs.writeFileSync(path.join(repository, 'README.md'), '# Staged\n');
|
|
2064
|
+
fs.writeFileSync(path.join(repository, 'staged.txt'), 'staged only\n');
|
|
2065
|
+
runGit(repository, ['add', '.']);
|
|
2066
|
+
fs.writeFileSync(path.join(repository, 'README.md'), '# Current\n');
|
|
2067
|
+
fs.writeFileSync(path.join(repository, 'untracked.txt'), 'new local file\n');
|
|
2068
|
+
fs.writeFileSync(path.join(repository, ' leading space.txt'), 'space path\n');
|
|
2069
|
+
const indexBefore = fs.readFileSync(path.join(repository, '.git/index'));
|
|
2070
|
+
const options = { base: 'origin/react', head: 'next', includeWorkingTree: true };
|
|
2071
|
+
|
|
2072
|
+
const diff = await getRangeDiff(repository, options);
|
|
2073
|
+
expect(diff).toContain('-# Test');
|
|
2074
|
+
expect(diff).toContain('+# Current');
|
|
2075
|
+
expect(diff).not.toContain('+# Staged');
|
|
2076
|
+
expect(diff).not.toContain('+# Committed');
|
|
2077
|
+
expect(diff).toContain('+new local file');
|
|
2078
|
+
expect(diff).toContain('+staged only');
|
|
2079
|
+
expect(await getRangeFiles(repository, options)).toEqual(expect.arrayContaining([
|
|
2080
|
+
{ path: 'README.md', status: 'M' },
|
|
2081
|
+
{ path: 'staged.txt', status: 'A' },
|
|
2082
|
+
{ path: 'untracked.txt', status: 'A' },
|
|
2083
|
+
{ path: ' leading space.txt', status: 'A' },
|
|
2084
|
+
]));
|
|
2085
|
+
const { sections } = await loadSourceSections(repository, { kind: 'branch', baseRef: options.base, headRef: options.head });
|
|
2086
|
+
expect(sections).toEqual([{ scope: 'branch', patch: diff }]);
|
|
2087
|
+
expect(fs.readFileSync(path.join(repository, '.git/index'))).toEqual(indexBefore);
|
|
2088
|
+
|
|
2089
|
+
const committed = await getRangeDiff(repository, { base: options.base, head: options.head });
|
|
2090
|
+
expect(committed).toContain('+# Committed');
|
|
2091
|
+
expect(committed).not.toContain('+new local file');
|
|
2092
|
+
fs.writeFileSync(path.join(repository, 'README.md'), '# Latest\n');
|
|
2093
|
+
expect(await getRangeDiff(repository, { ...options, path: 'README.md' })).toContain('+# Latest');
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
it('reports the final file after a staged deletion is recreated, and omits undone branch changes', async () => {
|
|
2097
|
+
const { repository } = createRepositoryWithRemote();
|
|
2098
|
+
runGit(repository, ['rm', 'README.md']);
|
|
2099
|
+
fs.writeFileSync(path.join(repository, 'README.md'), '# Recreated\n');
|
|
2100
|
+
const options = { base: 'origin/react', head: 'next', includeWorkingTree: true };
|
|
2101
|
+
expect(await getRangeFiles(repository, options)).toEqual([{ path: 'README.md', status: 'M' }]);
|
|
2102
|
+
const diff = await getRangeDiff(repository, options);
|
|
2103
|
+
expect(diff).toContain('-# Test');
|
|
2104
|
+
expect(diff).toContain('+# Recreated');
|
|
2105
|
+
expect(diff.match(/diff --git/g)).toHaveLength(1);
|
|
2106
|
+
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n');
|
|
2107
|
+
expect(await getRangeFiles(repository, options)).toEqual([]);
|
|
2108
|
+
expect(await getRangeDiff(repository, options)).toBe('');
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
it('keeps local and remote bases distinct and rejects a different checked-out branch', async () => {
|
|
2112
|
+
const { repository } = createRepositoryWithRemote();
|
|
2113
|
+
runGit(repository, ['branch', 'react']);
|
|
2114
|
+
fs.writeFileSync(path.join(repository, 'parent.txt'), 'parent work\n');
|
|
2115
|
+
runGit(repository, ['add', '.']);
|
|
2116
|
+
runGit(repository, ['commit', '-m', 'parent work']);
|
|
2117
|
+
runGit(repository, ['branch', '-f', 'react', 'HEAD']);
|
|
2118
|
+
fs.writeFileSync(path.join(repository, 'child.txt'), 'child work\n');
|
|
2119
|
+
const options = { head: 'next', includeWorkingTree: true };
|
|
2120
|
+
const local = await getRangeDiff(repository, { ...options, base: 'react' });
|
|
2121
|
+
const remote = await getRangeDiff(repository, { ...options, base: 'origin/react' });
|
|
2122
|
+
expect(local).not.toContain('parent.txt');
|
|
2123
|
+
expect(remote).toContain('parent.txt');
|
|
2124
|
+
expect(local).toContain('child.txt');
|
|
2125
|
+
expect(await getRangeFiles(repository, { ...options, base: 'react' })).toEqual([{ path: 'child.txt', status: 'A' }]);
|
|
2126
|
+
runGit(repository, ['checkout', 'react']);
|
|
2127
|
+
await expect(getRangeDiff(repository, { ...options, base: 'origin/react' })).rejects.toThrow(/checked-out branch/);
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
it('includes untracked symlinks as links without reading their targets', async () => {
|
|
2131
|
+
const { repository } = createRepositoryWithRemote();
|
|
2132
|
+
const outside = path.join(createTempDir(), 'outside.txt');
|
|
2133
|
+
fs.writeFileSync(outside, 'must not be in a diff\n');
|
|
2134
|
+
fs.symlinkSync(outside, path.join(repository, 'link.txt'));
|
|
2135
|
+
const diff = await getRangeDiff(repository, { base: 'origin/react', head: 'next', includeWorkingTree: true });
|
|
2136
|
+
expect(diff).toContain('new file mode 120000');
|
|
2137
|
+
expect(diff).toContain(outside);
|
|
2138
|
+
expect(diff).not.toContain('must not be in a diff');
|
|
2139
|
+
});
|
|
2140
|
+
|
|
2141
|
+
it('uses an explicitly selected base on a remote other than origin', async () => {
|
|
1674
2142
|
const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' });
|
|
1675
|
-
//
|
|
1676
|
-
// bare name, so an unqualified `react...next` fails with "ambiguous argument".
|
|
2143
|
+
// The selected remote ref must work without a local branch of that name.
|
|
1677
2144
|
fs.writeFileSync(path.join(repository, 'feature.txt'), 'work\n');
|
|
1678
2145
|
runGit(repository, ['add', 'feature.txt']);
|
|
1679
2146
|
runGit(repository, ['commit', '-m', 'feature']);
|
|
1680
2147
|
|
|
1681
|
-
const diff = await getRangeDiff(repository, { base: 'react', head: 'next' });
|
|
2148
|
+
const diff = await getRangeDiff(repository, { base: 'upstream/react', head: 'next' });
|
|
1682
2149
|
|
|
1683
2150
|
expect(diff).toContain('feature.txt');
|
|
2151
|
+
await expect(getRangeDiff(repository, { base: 'react', head: 'next' })).rejects.toThrow(/is not available locally/);
|
|
1684
2152
|
});
|
|
1685
2153
|
|
|
1686
2154
|
it('names an unfetched remote-only ref instead of failing with git\'s ambiguous argument (#2735)', async () => {
|
|
@@ -1693,6 +2161,9 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
|
|
|
1693
2161
|
});
|
|
1694
2162
|
|
|
1695
2163
|
describe('parseBranchCreationSource', () => {
|
|
2164
|
+
it('does not reuse the creation base after a rebase', () => {
|
|
2165
|
+
expect(parseBranchCreationSource('rebase (finish): refs/heads/feature onto abc123\nbranch: Created from main')).toBeNull();
|
|
2166
|
+
});
|
|
1696
2167
|
it('returns the source ref from the oldest creation entry', () => {
|
|
1697
2168
|
// Reflog lists newest entries first; creation is the last line.
|
|
1698
2169
|
const reflog = [
|
|
@@ -1739,7 +2210,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
|
|
1739
2210
|
runGit(repository, ['add', 'added.txt', 'README.md']);
|
|
1740
2211
|
runGit(repository, ['commit', '-m', 'changes']);
|
|
1741
2212
|
|
|
1742
|
-
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
|
2213
|
+
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
|
1743
2214
|
|
|
1744
2215
|
expect(files).toEqual(expect.arrayContaining([
|
|
1745
2216
|
{ path: 'added.txt', status: 'A' },
|
|
@@ -1761,7 +2232,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
|
|
1761
2232
|
runGit(repository, ['add', '-A']);
|
|
1762
2233
|
runGit(repository, ['commit', '-m', 'rename']);
|
|
1763
2234
|
|
|
1764
|
-
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
|
2235
|
+
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
|
1765
2236
|
|
|
1766
2237
|
const renameEntry = files.find((file) => file.status === 'R');
|
|
1767
2238
|
expect(renameEntry).toBeDefined();
|
|
@@ -1783,7 +2254,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
|
|
1783
2254
|
runGit(repository, ['add', '-A']);
|
|
1784
2255
|
runGit(repository, ['commit', '-m', 'copy']);
|
|
1785
2256
|
|
|
1786
|
-
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
|
2257
|
+
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
|
1787
2258
|
|
|
1788
2259
|
const copyEntry = files.find((file) => file.status === 'C');
|
|
1789
2260
|
expect(copyEntry).toBeDefined();
|
|
@@ -63,6 +63,11 @@
|
|
|
63
63
|
|
|
64
64
|
## Consumers of PR data
|
|
65
65
|
|
|
66
|
+
PR list/search failures propagate as errors instead of successful empty lists.
|
|
67
|
+
Comparison pickers use these responses to offer retry rather than claiming the
|
|
68
|
+
repository has no PRs. A failed repository in a multi-repository listing fails
|
|
69
|
+
that page, so callers cannot mistake a partial page for a complete one.
|
|
70
|
+
|
|
66
71
|
- `packages/ui/src/components/session/SessionSidebar.tsx` reads all PR entries and maps them to `directory::branch`.
|
|
67
72
|
- `packages/ui/src/components/session/sidebar/SessionGroupSection.tsx` renders the compact badge, PR number, title, checks summary, and GitHub link.
|
|
68
73
|
- `packages/ui/src/components/views/git/PullRequestSection.tsx` uses the same shared entry for the full PR workflow.
|
|
@@ -1513,7 +1513,7 @@ export function registerGitHubRoutes(app) {
|
|
|
1513
1513
|
return res.json({ connected: true, repo, prs, page: effectivePage, hasMore });
|
|
1514
1514
|
} catch (error) {
|
|
1515
1515
|
console.error('Failed to search GitHub PRs:', error);
|
|
1516
|
-
|
|
1516
|
+
throw error;
|
|
1517
1517
|
}
|
|
1518
1518
|
}
|
|
1519
1519
|
|
|
@@ -1532,7 +1532,7 @@ export function registerGitHubRoutes(app) {
|
|
|
1532
1532
|
return { prs, hasMore };
|
|
1533
1533
|
} catch (error) {
|
|
1534
1534
|
console.warn(`Failed to list PRs for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error);
|
|
1535
|
-
|
|
1535
|
+
throw error;
|
|
1536
1536
|
}
|
|
1537
1537
|
};
|
|
1538
1538
|
|