codeep 2.15.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/dist/acp/session.js +22 -1
- package/dist/config/providers.js +20 -14
- package/dist/renderer/App.d.ts +77 -0
- package/dist/renderer/App.js +283 -3
- package/dist/renderer/commands/helpers.d.ts +188 -0
- package/dist/renderer/commands/helpers.js +342 -0
- package/dist/renderer/commands/registry.js +2 -1
- package/dist/renderer/commands.js +193 -264
- package/dist/renderer/components/Autocomplete.d.ts +25 -0
- package/dist/renderer/components/Autocomplete.js +35 -0
- package/dist/renderer/layout.d.ts +5 -1
- package/dist/renderer/layout.js +12 -0
- package/dist/renderer/main.js +34 -1
- package/dist/utils/diffPreview.d.ts +31 -0
- package/dist/utils/diffPreview.js +102 -0
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +111 -1
- package/dist/utils/mentions.d.ts +195 -0
- package/dist/utils/mentions.js +672 -0
- package/dist/utils/tokenTracker.js +9 -3
- package/dist/utils/webFetch.d.ts +101 -0
- package/dist/utils/webFetch.js +375 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -31,3 +31,28 @@ export interface AutocompleteResult {
|
|
|
31
31
|
* @returns Match list, or `null` when the dropdown should be hidden.
|
|
32
32
|
*/
|
|
33
33
|
export declare function filterCommands(value: string, commands: string[]): AutocompleteResult | null;
|
|
34
|
+
/**
|
|
35
|
+
* The position and query of an in-progress `@mention`, or `null` when
|
|
36
|
+
* the cursor isn't inside a mention being typed.
|
|
37
|
+
*
|
|
38
|
+
* A mention is "in progress" when, scanning backwards from `cursorPos`:
|
|
39
|
+
* 1. We find an `@`.
|
|
40
|
+
* 2. Between `@` and the cursor there are only "path characters"
|
|
41
|
+
* (letters, digits, `/`, `.`, `_`, `-`, `\`) and no whitespace.
|
|
42
|
+
* 3. The `@` itself is at the start of the string OR preceded by a
|
|
43
|
+
* boundary char (space, `(`, `[`, …) — so `user@host` doesn't
|
|
44
|
+
* count. Mirrors `extractMentions` in `utils/mentions.ts`.
|
|
45
|
+
*/
|
|
46
|
+
export interface MentionQuery {
|
|
47
|
+
/** Start index of the `@` in the source string. */
|
|
48
|
+
atStart: number;
|
|
49
|
+
/** The text typed so far after the `@` (may be empty). */
|
|
50
|
+
query: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Detect whether the cursor sits inside an `@mention` being typed, and
|
|
54
|
+
* if so, return the query text (everything after `@`). Pure — no FS.
|
|
55
|
+
*
|
|
56
|
+
* Used by the autocomplete layer to know when to show the file picker.
|
|
57
|
+
*/
|
|
58
|
+
export declare function detectMentionQuery(text: string, cursorPos: number): MentionQuery | null;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* only triggered for command-shaped input) can be unit-tested without
|
|
6
6
|
* the editor / render machinery.
|
|
7
7
|
*/
|
|
8
|
+
import { MENTION_BOUNDARY } from '../../utils/mentions.js';
|
|
8
9
|
/**
|
|
9
10
|
* Filter `commands` to those that start with the typed prefix and the
|
|
10
11
|
* dropdown should appear.
|
|
@@ -38,3 +39,37 @@ export function filterCommands(value, commands) {
|
|
|
38
39
|
return { items: [], index: 0 };
|
|
39
40
|
return { items, index: 0 };
|
|
40
41
|
}
|
|
42
|
+
const PATH_CHAR = /[A-Za-z0-9._\/\\-]/;
|
|
43
|
+
/**
|
|
44
|
+
* Detect whether the cursor sits inside an `@mention` being typed, and
|
|
45
|
+
* if so, return the query text (everything after `@`). Pure — no FS.
|
|
46
|
+
*
|
|
47
|
+
* Used by the autocomplete layer to know when to show the file picker.
|
|
48
|
+
*/
|
|
49
|
+
export function detectMentionQuery(text, cursorPos) {
|
|
50
|
+
if (cursorPos < 1 || cursorPos > text.length)
|
|
51
|
+
return null;
|
|
52
|
+
// Scan backwards from the cursor, collecting path chars until we hit `@`.
|
|
53
|
+
let i = cursorPos - 1;
|
|
54
|
+
let query = '';
|
|
55
|
+
while (i >= 0) {
|
|
56
|
+
const ch = text[i];
|
|
57
|
+
if (ch === '@') {
|
|
58
|
+
// Found the `@`. Check the preceding char is a boundary (or start).
|
|
59
|
+
// Boundary set mirrors `extractMentions` in `utils/mentions.ts`.
|
|
60
|
+
const before = i > 0 ? text[i - 1] : '';
|
|
61
|
+
// Keep in lockstep with `MENTION_RE`'s lookbehind in utils/mentions.ts.
|
|
62
|
+
// If this set is looser, the picker offers a completion the expander
|
|
63
|
+
// then refuses to treat as a mention and the file is never attached.
|
|
64
|
+
if (before === '' || MENTION_BOUNDARY.test(before)) {
|
|
65
|
+
return { atStart: i, query };
|
|
66
|
+
}
|
|
67
|
+
return null; // `@` not at a boundary → email/handle, not a mention.
|
|
68
|
+
}
|
|
69
|
+
if (!PATH_CHAR.test(ch))
|
|
70
|
+
return null; // hit a non-path char before `@`.
|
|
71
|
+
query = ch + query;
|
|
72
|
+
i--;
|
|
73
|
+
}
|
|
74
|
+
return null; // no `@` found before the cursor.
|
|
75
|
+
}
|
|
@@ -38,6 +38,9 @@ export interface LayoutSnapshot {
|
|
|
38
38
|
readonly settingsCount: number;
|
|
39
39
|
readonly showAutocomplete: boolean;
|
|
40
40
|
readonly autocompleteItemCount: number;
|
|
41
|
+
readonly hunkPickerOpen: boolean;
|
|
42
|
+
readonly mentionPickerOpen: boolean;
|
|
43
|
+
readonly mentionItemCount: number;
|
|
41
44
|
}
|
|
42
45
|
/**
|
|
43
46
|
* Compute how many terminal rows the bottom panel (paste info, agent box,
|
|
@@ -144,7 +147,7 @@ export declare function statusBarRightHint(args: {
|
|
|
144
147
|
isLoading: boolean;
|
|
145
148
|
}): string;
|
|
146
149
|
/** The panel that currently owns keyboard focus, in priority order. */
|
|
147
|
-
export type ActivePanel = 'pasteInfo' | 'permission' | 'sessionPicker' | 'confirm' | 'status' | 'help' | 'settings' | 'search' | 'export' | 'logout' | 'login' | 'menu' | 'autocomplete' | 'chat';
|
|
150
|
+
export type ActivePanel = 'pasteInfo' | 'permission' | 'sessionPicker' | 'confirm' | 'status' | 'help' | 'settings' | 'search' | 'export' | 'logout' | 'login' | 'menu' | 'autocomplete' | 'hunkPicker' | 'chat';
|
|
148
151
|
export interface PanelState {
|
|
149
152
|
readonly pasteInfoOpen: boolean;
|
|
150
153
|
readonly permissionOpen: boolean;
|
|
@@ -159,6 +162,7 @@ export interface PanelState {
|
|
|
159
162
|
readonly loginOpen: boolean;
|
|
160
163
|
readonly menuOpen: boolean;
|
|
161
164
|
readonly showAutocomplete: boolean;
|
|
165
|
+
readonly hunkPickerOpen: boolean;
|
|
162
166
|
}
|
|
163
167
|
/**
|
|
164
168
|
* Return the highest-priority open panel. `chat` is the fallback when no
|
package/dist/renderer/layout.js
CHANGED
|
@@ -36,6 +36,10 @@ export function bottomPanelHeight(s) {
|
|
|
36
36
|
if (s.confirmOpen) {
|
|
37
37
|
return s.confirmMessageCount + 5; // title + messages + buttons + padding
|
|
38
38
|
}
|
|
39
|
+
if (s.hunkPickerOpen) {
|
|
40
|
+
// Title + progress + path + header + up to 12 diff lines + more marker + legend.
|
|
41
|
+
return 18;
|
|
42
|
+
}
|
|
39
43
|
if (s.statusOpen) {
|
|
40
44
|
return 16; // Status info panel
|
|
41
45
|
}
|
|
@@ -65,6 +69,12 @@ export function bottomPanelHeight(s) {
|
|
|
65
69
|
if (s.showAutocomplete && s.autocompleteItemCount > 0) {
|
|
66
70
|
return Math.min(s.autocompleteItemCount + 3, 12);
|
|
67
71
|
}
|
|
72
|
+
// `@`-mention picker: separator + title + up to 8 rows + footer. Without
|
|
73
|
+
// this branch the panel measured 0 while the picker still painted its rows,
|
|
74
|
+
// so it drew straight over the bottom of the chat transcript.
|
|
75
|
+
if (s.mentionPickerOpen && s.mentionItemCount > 0) {
|
|
76
|
+
return Math.min(s.mentionItemCount, 8) + 3;
|
|
77
|
+
}
|
|
68
78
|
return 0;
|
|
69
79
|
}
|
|
70
80
|
export function chatLayout(height, panelHeight) {
|
|
@@ -237,6 +247,8 @@ export function activePanel(s) {
|
|
|
237
247
|
return 'login';
|
|
238
248
|
if (s.menuOpen)
|
|
239
249
|
return 'menu';
|
|
250
|
+
if (s.hunkPickerOpen)
|
|
251
|
+
return 'hunkPicker';
|
|
240
252
|
if (s.showAutocomplete)
|
|
241
253
|
return 'autocomplete';
|
|
242
254
|
return 'chat';
|
package/dist/renderer/main.js
CHANGED
|
@@ -20,6 +20,8 @@ import { getSessionStats, getCostBreakdown } from '../utils/tokenTracker.js';
|
|
|
20
20
|
import { isGitRepository } from '../utils/git.js';
|
|
21
21
|
import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
|
|
22
22
|
import { checkApiRateLimit } from '../utils/ratelimit.js';
|
|
23
|
+
import { expandFileAndFolderMentions, expandGitMentions } from '../utils/mentions.js';
|
|
24
|
+
import { expandWebMentions } from '../utils/webFetch.js';
|
|
23
25
|
import { handleCommand as dispatchCommand } from './commands.js';
|
|
24
26
|
import { logAppError } from '../utils/logger.js';
|
|
25
27
|
import { executeAgentTask, runAgentTask, } from './agentExecution.js';
|
|
@@ -163,11 +165,41 @@ async function handleSubmit(message) {
|
|
|
163
165
|
try {
|
|
164
166
|
app.startStreaming();
|
|
165
167
|
const history = app.getChatHistory();
|
|
168
|
+
// Expand inline @-mentions (@src/file.ts) into the prompt's context.
|
|
169
|
+
// Done after history capture (mentions are per-message) but before
|
|
170
|
+
// deriveSessionName so the title reflects what the user typed, not the
|
|
171
|
+
// expanded path.
|
|
172
|
+
const mentionRoot = projectContext?.root || projectPath || process.cwd();
|
|
173
|
+
// Expand @folder and @file mentions in one pass, merged into a single
|
|
174
|
+
// [Attached files] block.
|
|
175
|
+
const { enrichedPrompt: fileExpanded, loaded: loadedMentions, failures: mentionFailures } = expandFileAndFolderMentions(message, { root: mentionRoot });
|
|
176
|
+
if (loadedMentions.length > 0) {
|
|
177
|
+
app.notify(`Loaded ${loadedMentions.length} file(s) from @mentions/@folder`);
|
|
178
|
+
}
|
|
179
|
+
for (const f of mentionFailures) {
|
|
180
|
+
app.notify(`${f.mention}: ${f.reason}`);
|
|
181
|
+
}
|
|
182
|
+
// Expand @git <ref> mentions — resolve diffs / file-at-ref / commit
|
|
183
|
+
// patches into a [Git ref] block. Runs between file and web mentions
|
|
184
|
+
// so the prompt flows: [files] [git] [web] <text>.
|
|
185
|
+
const { enrichedPrompt: gitExpanded, failures: gitFailures } = await expandGitMentions(fileExpanded, { root: mentionRoot });
|
|
186
|
+
for (const f of gitFailures) {
|
|
187
|
+
app.notify(`${f.mention}: ${f.reason}`);
|
|
188
|
+
}
|
|
189
|
+
// Expand @web <url> mentions — fetch each page and prepend its text.
|
|
190
|
+
// Runs after file mentions so the prompt flows: [files] [web] <text>.
|
|
191
|
+
const { enrichedPrompt: webExpanded, loaded: loadedPages, failures: webFailures } = await expandWebMentions(gitExpanded);
|
|
192
|
+
if (loadedPages.length > 0) {
|
|
193
|
+
app.notify(`Fetched ${loadedPages.length} page(s) from @web`);
|
|
194
|
+
}
|
|
195
|
+
for (const f of webFailures) {
|
|
196
|
+
app.notify(`${f.mention}: ${f.reason}`);
|
|
197
|
+
}
|
|
166
198
|
if (!sessionDisplayName && history.filter(m => m.role === 'user').length === 0) {
|
|
167
199
|
sessionDisplayName = deriveSessionName(message);
|
|
168
200
|
}
|
|
169
201
|
const fileContext = formatAddedFilesContext();
|
|
170
|
-
const enrichedMessage = fileContext ? fileContext +
|
|
202
|
+
const enrichedMessage = fileContext ? fileContext + webExpanded : webExpanded;
|
|
171
203
|
await chat(enrichedMessage, history, (chunk) => app.addStreamChunk(chunk), undefined, projectContext, undefined);
|
|
172
204
|
app.endStreaming();
|
|
173
205
|
autoSaveSession(app.getMessages(), projectPath);
|
|
@@ -621,6 +653,7 @@ Commands (in chat):
|
|
|
621
653
|
getStatus,
|
|
622
654
|
hasWriteAccess: () => hasWriteAccess,
|
|
623
655
|
hasProjectContext: () => projectContext !== null,
|
|
656
|
+
getProjectRoot: () => projectContext?.root || projectPath || process.cwd(),
|
|
624
657
|
});
|
|
625
658
|
const provider = getCurrentProvider();
|
|
626
659
|
const providers = getProviderList();
|
|
@@ -55,3 +55,34 @@ export declare function formatDiffPreview(diffs: FileDiff[]): string;
|
|
|
55
55
|
* Calculate diff statistics
|
|
56
56
|
*/
|
|
57
57
|
export declare function getDiffStats(diffs: FileDiff[]): DiffPreviewResult;
|
|
58
|
+
/**
|
|
59
|
+
* Apply a subset of a file diff's hunks to the original content.
|
|
60
|
+
*
|
|
61
|
+
* Hunk indices in `acceptedHunks` refer to positions in `diff.hunks`
|
|
62
|
+
* (0-based). Hunks not in the set are skipped — their original lines
|
|
63
|
+
* stay, their additions are dropped.
|
|
64
|
+
*
|
|
65
|
+
* Returns the resulting file content. The caller writes it to disk.
|
|
66
|
+
*
|
|
67
|
+
* For `type === 'create'`, the whole file is either accepted (any hunk
|
|
68
|
+
* accepted) or rejected (empty set) — there's no original to merge
|
|
69
|
+
* against. For `type === 'delete'`, accepting any hunk deletes the file.
|
|
70
|
+
*/
|
|
71
|
+
export declare function applyHunks(diff: FileDiff, acceptedHunks: Set<number>): string;
|
|
72
|
+
/**
|
|
73
|
+
* Apply accepted hunks across multiple file diffs and return the
|
|
74
|
+
* resulting content for each. The caller writes the files to disk.
|
|
75
|
+
*
|
|
76
|
+
* `accepted` maps file path → set of accepted hunk indices. Files not
|
|
77
|
+
* in the map are skipped entirely.
|
|
78
|
+
*/
|
|
79
|
+
export declare function applyHunksToFiles(diffs: FileDiff[], accepted: Map<string, Set<number>>): Array<{
|
|
80
|
+
path: string;
|
|
81
|
+
content: string;
|
|
82
|
+
type: FileDiff['type'];
|
|
83
|
+
}>;
|
|
84
|
+
/**
|
|
85
|
+
* Count how many hunks in a diff contain actual changes (not just
|
|
86
|
+
* context). Used to label hunks in the UI ("hunk 2/5").
|
|
87
|
+
*/
|
|
88
|
+
export declare function countChangeHunks(diff: FileDiff): number;
|
|
@@ -404,3 +404,105 @@ export function getDiffStats(diffs) {
|
|
|
404
404
|
totalFiles: diffs.length,
|
|
405
405
|
};
|
|
406
406
|
}
|
|
407
|
+
// ─── Selective apply (per-hunk accept/reject) ───────────────────────────────
|
|
408
|
+
/**
|
|
409
|
+
* Apply a subset of a file diff's hunks to the original content.
|
|
410
|
+
*
|
|
411
|
+
* Hunk indices in `acceptedHunks` refer to positions in `diff.hunks`
|
|
412
|
+
* (0-based). Hunks not in the set are skipped — their original lines
|
|
413
|
+
* stay, their additions are dropped.
|
|
414
|
+
*
|
|
415
|
+
* Returns the resulting file content. The caller writes it to disk.
|
|
416
|
+
*
|
|
417
|
+
* For `type === 'create'`, the whole file is either accepted (any hunk
|
|
418
|
+
* accepted) or rejected (empty set) — there's no original to merge
|
|
419
|
+
* against. For `type === 'delete'`, accepting any hunk deletes the file.
|
|
420
|
+
*/
|
|
421
|
+
export function applyHunks(diff, acceptedHunks) {
|
|
422
|
+
// Create: accept-all-or-nothing — there's no original content to
|
|
423
|
+
// selectively merge into.
|
|
424
|
+
if (diff.type === 'create') {
|
|
425
|
+
return acceptedHunks.size > 0 ? (diff.newContent ?? '') : (diff.oldContent ?? '');
|
|
426
|
+
}
|
|
427
|
+
const oldLines = (diff.oldContent ?? '').split('\n');
|
|
428
|
+
// No accepted hunks → original content unchanged.
|
|
429
|
+
if (acceptedHunks.size === 0) {
|
|
430
|
+
return oldLines.join('\n');
|
|
431
|
+
}
|
|
432
|
+
const acceptedHunkList = diff.hunks
|
|
433
|
+
.map((h, i) => ({ hunk: h, index: i }))
|
|
434
|
+
.filter(({ index }) => acceptedHunks.has(index));
|
|
435
|
+
// Per-original-line union model.
|
|
436
|
+
//
|
|
437
|
+
// The obvious implementation — walk the hunks in order, copying original
|
|
438
|
+
// lines between them and replaying each hunk's line list — is wrong for
|
|
439
|
+
// hunks that sit close together, because unified-diff context OVERLAPS:
|
|
440
|
+
// hunk N's trailing context is hunk N+1's leading context, so the shared
|
|
441
|
+
// lines get emitted twice. Worse, with a small gap a later hunk's `remove`
|
|
442
|
+
// can fall *inside* an earlier hunk's already-emitted context, so the
|
|
443
|
+
// deletion is silently lost. Both corrupt the user's file on `/apply`.
|
|
444
|
+
//
|
|
445
|
+
// Instead reduce every accepted hunk to two facts per original line — is it
|
|
446
|
+
// removed, and what is inserted after it — then rebuild the file once.
|
|
447
|
+
// Overlap becomes a set union rather than double emission, and the result
|
|
448
|
+
// is independent of hunk order.
|
|
449
|
+
const removed = new Set(); // old line numbers deleted
|
|
450
|
+
const insertAfter = new Map(); // old line number → inserted lines (0 = file head)
|
|
451
|
+
for (const { hunk } of acceptedHunkList) {
|
|
452
|
+
// Adds before any context in this hunk belong right before its start,
|
|
453
|
+
// not at the top of the file.
|
|
454
|
+
let anchor = Math.max(0, hunk.oldStart - 1);
|
|
455
|
+
for (const line of hunk.lines) {
|
|
456
|
+
if (line.type === 'add') {
|
|
457
|
+
const at = insertAfter.get(anchor);
|
|
458
|
+
if (at)
|
|
459
|
+
at.push(line.content);
|
|
460
|
+
else
|
|
461
|
+
insertAfter.set(anchor, [line.content]);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (line.oldLineNum === undefined)
|
|
465
|
+
continue;
|
|
466
|
+
anchor = line.oldLineNum;
|
|
467
|
+
if (line.type === 'remove')
|
|
468
|
+
removed.add(line.oldLineNum);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const result = [];
|
|
472
|
+
for (const head of insertAfter.get(0) ?? [])
|
|
473
|
+
result.push(head);
|
|
474
|
+
for (let n = 1; n <= oldLines.length; n++) {
|
|
475
|
+
if (!removed.has(n))
|
|
476
|
+
result.push(oldLines[n - 1]);
|
|
477
|
+
const added = insertAfter.get(n);
|
|
478
|
+
if (added)
|
|
479
|
+
for (const line of added)
|
|
480
|
+
result.push(line);
|
|
481
|
+
}
|
|
482
|
+
return result.join('\n');
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Apply accepted hunks across multiple file diffs and return the
|
|
486
|
+
* resulting content for each. The caller writes the files to disk.
|
|
487
|
+
*
|
|
488
|
+
* `accepted` maps file path → set of accepted hunk indices. Files not
|
|
489
|
+
* in the map are skipped entirely.
|
|
490
|
+
*/
|
|
491
|
+
export function applyHunksToFiles(diffs, accepted) {
|
|
492
|
+
const results = [];
|
|
493
|
+
for (const diff of diffs) {
|
|
494
|
+
const acceptedSet = accepted.get(diff.path);
|
|
495
|
+
if (!acceptedSet)
|
|
496
|
+
continue;
|
|
497
|
+
const content = applyHunks(diff, acceptedSet);
|
|
498
|
+
results.push({ path: diff.path, content, type: diff.type });
|
|
499
|
+
}
|
|
500
|
+
return results;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Count how many hunks in a diff contain actual changes (not just
|
|
504
|
+
* context). Used to label hunks in the UI ("hunk 2/5").
|
|
505
|
+
*/
|
|
506
|
+
export function countChangeHunks(diff) {
|
|
507
|
+
return diff.hunks.filter((h) => h.lines.some((l) => l.type === 'add' || l.type === 'remove')).length;
|
|
508
|
+
}
|
package/dist/utils/git.d.ts
CHANGED
|
@@ -83,3 +83,31 @@ export declare function createBranchAndCommit(prompt: string, actions: ActionLog
|
|
|
83
83
|
hash?: string;
|
|
84
84
|
error?: string;
|
|
85
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* Result of resolving a `@git <ref>` mention.
|
|
88
|
+
*/
|
|
89
|
+
export interface GitContentResult {
|
|
90
|
+
success: boolean;
|
|
91
|
+
/** Raw output from git (diff text, file content, or commit metadata). */
|
|
92
|
+
content: string;
|
|
93
|
+
/** A short label for the [Attached files]-style block header. */
|
|
94
|
+
label: string;
|
|
95
|
+
error?: string;
|
|
96
|
+
}
|
|
97
|
+
/** Max bytes we'll inline from a single `@git` mention. */
|
|
98
|
+
export declare const MAX_GIT_BYTES: number;
|
|
99
|
+
export declare function isSafeGitRef(token: string): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Resolve a `@git <ref>` mention to inline content. The `ref` can be:
|
|
102
|
+
*
|
|
103
|
+
* - `diff` — unstaged changes (`git diff`)
|
|
104
|
+
* - `diff --staged` — staged changes (`git diff --cached`)
|
|
105
|
+
* - `diff a..b` — diff between two refs (`git diff a..b`)
|
|
106
|
+
* - `HEAD` — the latest commit's full diff vs its parent
|
|
107
|
+
* - `<sha>` — a specific commit's patch (`git show <sha>`)
|
|
108
|
+
* - `<ref>:<path>` — a file at a ref (`git show main:src/x.ts`)
|
|
109
|
+
* - `<ref>` — any other git ref → `git show`
|
|
110
|
+
*
|
|
111
|
+
* Sync (spawn-based) so it slots into the mention-expansion pipeline.
|
|
112
|
+
*/
|
|
113
|
+
export declare function getGitContent(ref: string, cwd?: string): GitContentResult;
|
package/dist/utils/git.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execSync, spawnSync } from 'child_process';
|
|
1
|
+
import { execSync, execFileSync, spawnSync } from 'child_process';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
/**
|
|
@@ -397,3 +397,113 @@ export function createBranchAndCommit(prompt, actions, cwd = process.cwd()) {
|
|
|
397
397
|
hash: commitResult.hash,
|
|
398
398
|
};
|
|
399
399
|
}
|
|
400
|
+
/** Max bytes we'll inline from a single `@git` mention. */
|
|
401
|
+
export const MAX_GIT_BYTES = 64 * 1024;
|
|
402
|
+
/**
|
|
403
|
+
* Characters a git ref/pathspec may contain for `@git` mentions. Deliberately
|
|
404
|
+
* conservative: word chars plus the punctuation real refs use
|
|
405
|
+
* (`main..feature`, `HEAD~3`, `v1.2.0^{}`, `main:src/x.ts`, `origin/main`).
|
|
406
|
+
* A leading `-` is rejected separately so a ref can never be read as a flag.
|
|
407
|
+
*/
|
|
408
|
+
const SAFE_GIT_REF = /^[A-Za-z0-9._/:~^@{}-]+$/;
|
|
409
|
+
export function isSafeGitRef(token) {
|
|
410
|
+
return token.length > 0 && !token.startsWith('-') && SAFE_GIT_REF.test(token);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* The only flags `@git diff …` may pass through. An allowlist rather than a
|
|
414
|
+
* deny-list because several git flags write files or run commands
|
|
415
|
+
* (`--output=`, `--ext-diff`), which would turn a mention into a side effect.
|
|
416
|
+
*/
|
|
417
|
+
const GIT_DIFF_FLAG_ALLOWLIST = new Set([
|
|
418
|
+
'--staged', '--cached', '--stat', '--numstat', '--shortstat',
|
|
419
|
+
'--name-only', '--name-status', '--patch', '-p', '--no-color',
|
|
420
|
+
]);
|
|
421
|
+
/**
|
|
422
|
+
* Resolve a `@git <ref>` mention to inline content. The `ref` can be:
|
|
423
|
+
*
|
|
424
|
+
* - `diff` — unstaged changes (`git diff`)
|
|
425
|
+
* - `diff --staged` — staged changes (`git diff --cached`)
|
|
426
|
+
* - `diff a..b` — diff between two refs (`git diff a..b`)
|
|
427
|
+
* - `HEAD` — the latest commit's full diff vs its parent
|
|
428
|
+
* - `<sha>` — a specific commit's patch (`git show <sha>`)
|
|
429
|
+
* - `<ref>:<path>` — a file at a ref (`git show main:src/x.ts`)
|
|
430
|
+
* - `<ref>` — any other git ref → `git show`
|
|
431
|
+
*
|
|
432
|
+
* Sync (spawn-based) so it slots into the mention-expansion pipeline.
|
|
433
|
+
*/
|
|
434
|
+
export function getGitContent(ref, cwd = process.cwd()) {
|
|
435
|
+
if (!isGitRepository(cwd)) {
|
|
436
|
+
return { success: false, content: '', label: ref, error: 'not a git repository' };
|
|
437
|
+
}
|
|
438
|
+
const trimmed = ref.trim();
|
|
439
|
+
if (!trimmed) {
|
|
440
|
+
return { success: false, content: '', label: ref, error: 'empty git ref' };
|
|
441
|
+
}
|
|
442
|
+
// Pick the git subcommand based on the ref shape. NOTE: the ref comes from
|
|
443
|
+
// free-form prompt text (`@git <ref>`), which may be pasted from an issue,
|
|
444
|
+
// a log, or model output — so it is UNTRUSTED. We therefore (a) build an
|
|
445
|
+
// argv array and spawn git directly with `execFileSync` (no `/bin/sh`, so
|
|
446
|
+
// `;`, `|`, backticks and friends are inert), and (b) validate every token,
|
|
447
|
+
// because argv alone doesn't stop *argument* injection — a ref that starts
|
|
448
|
+
// with `-` would still be read by git as a flag (e.g. `--output=…` writes a
|
|
449
|
+
// file). Anything unrecognized is rejected rather than guessed at.
|
|
450
|
+
let args;
|
|
451
|
+
let label;
|
|
452
|
+
if (trimmed === 'diff') {
|
|
453
|
+
args = ['diff'];
|
|
454
|
+
label = 'diff (unstaged)';
|
|
455
|
+
}
|
|
456
|
+
else if (trimmed === 'diff --staged' || trimmed === 'diff --cached' || trimmed === 'staged') {
|
|
457
|
+
args = ['diff', '--cached'];
|
|
458
|
+
label = 'diff (staged)';
|
|
459
|
+
}
|
|
460
|
+
else if (trimmed.startsWith('diff ')) {
|
|
461
|
+
// e.g. `diff main..feature` or `diff HEAD~3` or `diff --stat`
|
|
462
|
+
const rest = trimmed.slice('diff '.length).trim();
|
|
463
|
+
const tokens = rest.split(/\s+/).filter(Boolean);
|
|
464
|
+
const bad = tokens.find(t => !(isSafeGitRef(t) || GIT_DIFF_FLAG_ALLOWLIST.has(t)));
|
|
465
|
+
if (bad) {
|
|
466
|
+
return { success: false, content: '', label: ref, error: `unsupported git argument: ${bad}` };
|
|
467
|
+
}
|
|
468
|
+
args = ['diff', ...tokens];
|
|
469
|
+
label = `diff (${rest})`;
|
|
470
|
+
}
|
|
471
|
+
else if (trimmed === 'HEAD' || trimmed === '@') {
|
|
472
|
+
// Show the latest commit's patch.
|
|
473
|
+
args = ['show', 'HEAD'];
|
|
474
|
+
label = 'HEAD';
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
// Any other ref → `git show`. Works for SHAs, tags, branches, and
|
|
478
|
+
// `<ref>:<path>` (file-at-ref) forms.
|
|
479
|
+
if (!isSafeGitRef(trimmed)) {
|
|
480
|
+
return { success: false, content: '', label: ref, error: `unsupported git ref: ${trimmed}` };
|
|
481
|
+
}
|
|
482
|
+
args = ['show', trimmed];
|
|
483
|
+
label = trimmed;
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const out = execFileSync('git', args, {
|
|
487
|
+
cwd,
|
|
488
|
+
encoding: 'utf-8',
|
|
489
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
490
|
+
});
|
|
491
|
+
const content = (out ?? '').trimEnd();
|
|
492
|
+
if (!content) {
|
|
493
|
+
return { success: false, content: '', label, error: 'empty result (no changes / unknown ref)' };
|
|
494
|
+
}
|
|
495
|
+
// Truncate to the cap so a massive diff can't blow the context.
|
|
496
|
+
const capped = content.length > MAX_GIT_BYTES
|
|
497
|
+
? content.slice(0, MAX_GIT_BYTES) + `\n\n… (truncated at ${MAX_GIT_BYTES / 1024}KB)`
|
|
498
|
+
: content;
|
|
499
|
+
return { success: true, content: capped, label };
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
503
|
+
// git show exits non-zero on unknown refs; surface a friendly reason.
|
|
504
|
+
const reason = /unknown revision|bad revision|ambiguous argument/i.test(msg)
|
|
505
|
+
? `unknown git ref: ${trimmed}`
|
|
506
|
+
: msg;
|
|
507
|
+
return { success: false, content: '', label, error: reason };
|
|
508
|
+
}
|
|
509
|
+
}
|