Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts
5242 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Disposable } from '../../../../base/common/lifecycle.js';
7
import { relativePath } from '../../../../base/common/resources.js';
8
import { URI } from '../../../../base/common/uri.js';
9
import { linesDiffComputers } from '../../../../editor/common/diff/linesDiffComputers.js';
10
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
11
import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
12
import { IFileService } from '../../../../platform/files/common/files.js';
13
import { ILogService } from '../../../../platform/log/common/log.js';
14
import { Registry } from '../../../../platform/registry/common/platform.js';
15
import { IWorkbenchContribution } from '../../../common/contributions.js';
16
import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js';
17
import { ISCMService, ISCMResource } from '../../scm/common/scm.js';
18
import { IChatService } from '../common/chatService/chatService.js';
19
import { ChatConfiguration } from '../common/constants.js';
20
import { IChatModel, IExportableRepoData, IExportableRepoDiff } from '../common/model/chatModel.js';
21
import * as nls from '../../../../nls.js';
22
23
const MAX_CHANGES = 100;
24
const MAX_DIFFS_SIZE_BYTES = 900 * 1024;
25
const MAX_SESSIONS_WITH_FULL_DIFFS = 5;
26
/**
27
* Regex to match `url = <remote-url>` lines in git config.
28
*/
29
const RemoteMatcher = /^\s*url\s*=\s*(.+\S)\s*$/mg;
30
31
/**
32
* Extracts raw remote URLs from git config content.
33
*/
34
function getRawRemotes(text: string): string[] {
35
const remotes: string[] = [];
36
let match: RegExpExecArray | null;
37
while (match = RemoteMatcher.exec(text)) {
38
remotes.push(match[1]);
39
}
40
return remotes;
41
}
42
43
/**
44
* Extracts a hostname from a git remote URL.
45
*
46
* Supports:
47
* - URL-like remotes: https://github.com/..., ssh://[email protected]/..., git://github.com/...
48
* - SCP-like remotes: [email protected]:owner/repo.git
49
*/
50
function getRemoteHost(remoteUrl: string): string | undefined {
51
try {
52
// Try standard URL parsing first (works for https://, ssh://, git://)
53
const url = new URL(remoteUrl);
54
return url.hostname.toLowerCase();
55
} catch {
56
// Fallback for SCP-like syntax: [user@]host:path
57
const atIndex = remoteUrl.lastIndexOf('@');
58
const hostAndPath = atIndex !== -1 ? remoteUrl.slice(atIndex + 1) : remoteUrl;
59
const colonIndex = hostAndPath.indexOf(':');
60
if (colonIndex !== -1) {
61
const host = hostAndPath.slice(0, colonIndex);
62
return host ? host.toLowerCase() : undefined;
63
}
64
65
// Fallback for hostname/path format without scheme (e.g., devdiv.visualstudio.com/...)
66
const slashIndex = hostAndPath.indexOf('/');
67
if (slashIndex !== -1) {
68
const host = hostAndPath.slice(0, slashIndex);
69
return host ? host.toLowerCase() : undefined;
70
}
71
72
return undefined;
73
}
74
}
75
76
/**
77
* Determines the change type based on SCM resource properties.
78
*/
79
function determineChangeType(resource: ISCMResource, groupId: string): 'added' | 'modified' | 'deleted' | 'renamed' {
80
const contextValue = resource.contextValue?.toLowerCase() ?? '';
81
const groupIdLower = groupId.toLowerCase();
82
83
if (contextValue.includes('untracked') || contextValue.includes('add')) {
84
return 'added';
85
}
86
if (contextValue.includes('delete')) {
87
return 'deleted';
88
}
89
if (contextValue.includes('rename')) {
90
return 'renamed';
91
}
92
if (groupIdLower.includes('untracked')) {
93
return 'added';
94
}
95
if (resource.decorations.strikeThrough) {
96
return 'deleted';
97
}
98
if (!resource.multiDiffEditorOriginalUri) {
99
return 'added';
100
}
101
return 'modified';
102
}
103
104
/**
105
* Generates a unified diff string compatible with `git apply`.
106
*
107
* Note: This implementation has a known limitation - if the only change between
108
* files is the presence/absence of a trailing newline (content otherwise identical),
109
* no diff will be generated because VS Code's diff algorithm treats the lines as equal.
110
*/
111
async function generateUnifiedDiff(
112
fileService: IFileService,
113
relPath: string,
114
originalUri: URI | undefined,
115
modifiedUri: URI,
116
changeType: 'added' | 'modified' | 'deleted' | 'renamed'
117
): Promise<string | undefined> {
118
try {
119
let originalContent = '';
120
let modifiedContent = '';
121
122
if (originalUri && changeType !== 'added') {
123
try {
124
const originalFile = await fileService.readFile(originalUri);
125
originalContent = originalFile.value.toString();
126
} catch {
127
if (changeType === 'modified') {
128
return undefined;
129
}
130
}
131
}
132
133
if (changeType !== 'deleted') {
134
try {
135
const modifiedFile = await fileService.readFile(modifiedUri);
136
modifiedContent = modifiedFile.value.toString();
137
} catch {
138
return undefined;
139
}
140
}
141
142
const originalLines = originalContent.split('\n');
143
const modifiedLines = modifiedContent.split('\n');
144
145
// Track whether files end with newline for git apply compatibility
146
// split('\n') on "line1\nline2\n" gives ["line1", "line2", ""]
147
// split('\n') on "line1\nline2" gives ["line1", "line2"]
148
const originalEndsWithNewline = originalContent.length > 0 && originalContent.endsWith('\n');
149
const modifiedEndsWithNewline = modifiedContent.length > 0 && modifiedContent.endsWith('\n');
150
151
// Remove trailing empty element if file ends with newline
152
if (originalEndsWithNewline && originalLines.length > 0 && originalLines[originalLines.length - 1] === '') {
153
originalLines.pop();
154
}
155
if (modifiedEndsWithNewline && modifiedLines.length > 0 && modifiedLines[modifiedLines.length - 1] === '') {
156
modifiedLines.pop();
157
}
158
159
const diffLines: string[] = [];
160
const aPath = changeType === 'added' ? '/dev/null' : `a/${relPath}`;
161
const bPath = changeType === 'deleted' ? '/dev/null' : `b/${relPath}`;
162
163
diffLines.push(`--- ${aPath}`);
164
diffLines.push(`+++ ${bPath}`);
165
166
if (changeType === 'added') {
167
if (modifiedLines.length > 0) {
168
diffLines.push(`@@ -0,0 +1,${modifiedLines.length} @@`);
169
for (const line of modifiedLines) {
170
diffLines.push(`+${line}`);
171
}
172
if (!modifiedEndsWithNewline) {
173
diffLines.push('\\ No newline at end of file');
174
}
175
}
176
} else if (changeType === 'deleted') {
177
if (originalLines.length > 0) {
178
diffLines.push(`@@ -1,${originalLines.length} +0,0 @@`);
179
for (const line of originalLines) {
180
diffLines.push(`-${line}`);
181
}
182
if (!originalEndsWithNewline) {
183
diffLines.push('\\ No newline at end of file');
184
}
185
}
186
} else {
187
const hunks = computeDiffHunks(originalLines, modifiedLines, originalEndsWithNewline, modifiedEndsWithNewline);
188
for (const hunk of hunks) {
189
diffLines.push(hunk);
190
}
191
}
192
193
return diffLines.join('\n');
194
} catch {
195
return undefined;
196
}
197
}
198
199
/**
200
* Computes unified diff hunks using VS Code's diff algorithm.
201
* Merges adjacent/overlapping hunks to produce a valid patch.
202
*/
203
function computeDiffHunks(
204
originalLines: string[],
205
modifiedLines: string[],
206
originalEndsWithNewline: boolean,
207
modifiedEndsWithNewline: boolean
208
): string[] {
209
const contextSize = 3;
210
const result: string[] = [];
211
212
const diffComputer = linesDiffComputers.getDefault();
213
const diffResult = diffComputer.computeDiff(originalLines, modifiedLines, {
214
ignoreTrimWhitespace: false,
215
maxComputationTimeMs: 1000,
216
computeMoves: false
217
});
218
219
if (diffResult.changes.length === 0) {
220
return result;
221
}
222
223
// Group changes that should be merged into the same hunk
224
// Changes are merged if their context regions would overlap
225
type Change = typeof diffResult.changes[number];
226
const hunkGroups: Change[][] = [];
227
let currentGroup: Change[] = [];
228
229
for (const change of diffResult.changes) {
230
if (currentGroup.length === 0) {
231
currentGroup.push(change);
232
} else {
233
const lastChange = currentGroup[currentGroup.length - 1];
234
const lastContextEnd = lastChange.original.endLineNumberExclusive - 1 + contextSize;
235
const currentContextStart = change.original.startLineNumber - contextSize;
236
237
// Merge if context regions overlap or are adjacent
238
if (currentContextStart <= lastContextEnd + 1) {
239
currentGroup.push(change);
240
} else {
241
hunkGroups.push(currentGroup);
242
currentGroup = [change];
243
}
244
}
245
}
246
if (currentGroup.length > 0) {
247
hunkGroups.push(currentGroup);
248
}
249
250
// Generate a single hunk for each group
251
for (const group of hunkGroups) {
252
const firstChange = group[0];
253
const lastChange = group[group.length - 1];
254
255
const hunkOrigStart = Math.max(1, firstChange.original.startLineNumber - contextSize);
256
const hunkOrigEnd = Math.min(originalLines.length, lastChange.original.endLineNumberExclusive - 1 + contextSize);
257
const hunkModStart = Math.max(1, firstChange.modified.startLineNumber - contextSize);
258
259
const hunkLines: string[] = [];
260
// Track which line in hunkLines corresponds to the last line of each file
261
let lastOriginalLineIndex = -1;
262
let lastModifiedLineIndex = -1;
263
264
let origLineNum = hunkOrigStart;
265
let origCount = 0;
266
let modCount = 0;
267
268
// Process each change in the group, emitting context lines between them
269
for (const change of group) {
270
const origStart = change.original.startLineNumber;
271
const origEnd = change.original.endLineNumberExclusive;
272
const modStart = change.modified.startLineNumber;
273
const modEnd = change.modified.endLineNumberExclusive;
274
275
// Emit context lines before this change
276
while (origLineNum < origStart) {
277
const idx = hunkLines.length;
278
hunkLines.push(` ${originalLines[origLineNum - 1]}`);
279
// Context lines are in both files
280
if (origLineNum === originalLines.length) {
281
lastOriginalLineIndex = idx;
282
}
283
const modLineNum = hunkModStart + modCount;
284
if (modLineNum === modifiedLines.length) {
285
lastModifiedLineIndex = idx;
286
}
287
origLineNum++;
288
origCount++;
289
modCount++;
290
}
291
292
// Emit deleted lines
293
for (let i = origStart; i < origEnd; i++) {
294
const idx = hunkLines.length;
295
hunkLines.push(`-${originalLines[i - 1]}`);
296
if (i === originalLines.length) {
297
lastOriginalLineIndex = idx;
298
}
299
origLineNum++;
300
origCount++;
301
}
302
303
// Emit added lines
304
for (let i = modStart; i < modEnd; i++) {
305
const idx = hunkLines.length;
306
hunkLines.push(`+${modifiedLines[i - 1]}`);
307
if (i === modifiedLines.length) {
308
lastModifiedLineIndex = idx;
309
}
310
modCount++;
311
}
312
}
313
314
// Emit trailing context lines
315
while (origLineNum <= hunkOrigEnd) {
316
const idx = hunkLines.length;
317
hunkLines.push(` ${originalLines[origLineNum - 1]}`);
318
// Context lines are in both files
319
if (origLineNum === originalLines.length) {
320
lastOriginalLineIndex = idx;
321
}
322
const modLineNum = hunkModStart + modCount;
323
if (modLineNum === modifiedLines.length) {
324
lastModifiedLineIndex = idx;
325
}
326
origLineNum++;
327
origCount++;
328
modCount++;
329
}
330
331
result.push(`@@ -${hunkOrigStart},${origCount} +${hunkModStart},${modCount} @@`);
332
333
// Add "No newline at end of file" markers for git apply compatibility
334
// The marker must appear immediately after the line that lacks a newline
335
for (let i = 0; i < hunkLines.length; i++) {
336
result.push(hunkLines[i]);
337
338
const isLastOriginal = i === lastOriginalLineIndex;
339
const isLastModified = i === lastModifiedLineIndex;
340
341
if (isLastOriginal && isLastModified) {
342
// Context line is the last line of both files
343
// If either lacks newline, we need a marker (but only one)
344
if (!originalEndsWithNewline || !modifiedEndsWithNewline) {
345
result.push('\\ No newline at end of file');
346
}
347
} else if (isLastOriginal && !originalEndsWithNewline) {
348
// Deletion or context line that's only the last of original
349
result.push('\\ No newline at end of file');
350
} else if (isLastModified && !modifiedEndsWithNewline) {
351
// Addition or context line that's only the last of modified
352
result.push('\\ No newline at end of file');
353
}
354
}
355
}
356
357
return result;
358
}
359
360
/**
361
* Captures repository state from the first available SCM repository.
362
*/
363
export async function captureRepoInfo(scmService: ISCMService, fileService: IFileService): Promise<IExportableRepoData | undefined> {
364
const repositories = [...scmService.repositories];
365
if (repositories.length === 0) {
366
return undefined;
367
}
368
369
const repository = repositories[0];
370
const rootUri = repository.provider.rootUri;
371
if (!rootUri) {
372
return undefined;
373
}
374
375
let hasGit = false;
376
try {
377
const gitDirUri = rootUri.with({ path: `${rootUri.path}/.git` });
378
hasGit = await fileService.exists(gitDirUri);
379
} catch {
380
// ignore
381
}
382
383
if (!hasGit) {
384
return {
385
workspaceType: 'plain-folder',
386
syncStatus: 'no-git',
387
diffs: undefined
388
};
389
}
390
391
let remoteUrl: string | undefined;
392
try {
393
// TODO: Handle git worktrees where .git is a file pointing to the actual git directory
394
const gitConfigUri = rootUri.with({ path: `${rootUri.path}/.git/config` });
395
const exists = await fileService.exists(gitConfigUri);
396
if (exists) {
397
const content = await fileService.readFile(gitConfigUri);
398
const remotes = getRawRemotes(content.value.toString());
399
remoteUrl = remotes[0];
400
}
401
} catch {
402
// ignore
403
}
404
405
let localBranch: string | undefined;
406
let localHeadCommit: string | undefined;
407
let remoteTrackingBranch: string | undefined;
408
let remoteHeadCommit: string | undefined;
409
let remoteBaseBranch: string | undefined;
410
411
const historyProvider = repository.provider.historyProvider?.get();
412
if (historyProvider) {
413
const historyItemRef = historyProvider.historyItemRef.get();
414
localBranch = historyItemRef?.name;
415
localHeadCommit = historyItemRef?.revision;
416
417
const historyItemRemoteRef = historyProvider.historyItemRemoteRef.get();
418
if (historyItemRemoteRef) {
419
remoteTrackingBranch = historyItemRemoteRef.name;
420
remoteHeadCommit = historyItemRemoteRef.revision;
421
}
422
423
const historyItemBaseRef = historyProvider.historyItemBaseRef.get();
424
if (historyItemBaseRef) {
425
remoteBaseBranch = historyItemBaseRef.name;
426
}
427
}
428
429
let workspaceType: IExportableRepoData['workspaceType'];
430
let syncStatus: IExportableRepoData['syncStatus'];
431
432
if (!remoteUrl) {
433
workspaceType = 'local-git';
434
syncStatus = 'local-only';
435
} else {
436
workspaceType = 'remote-git';
437
438
if (!remoteTrackingBranch) {
439
syncStatus = 'unpublished';
440
} else if (localHeadCommit === remoteHeadCommit) {
441
syncStatus = 'synced';
442
} else {
443
syncStatus = 'unpushed';
444
}
445
}
446
447
let remoteVendor: IExportableRepoData['remoteVendor'];
448
if (remoteUrl) {
449
const host = getRemoteHost(remoteUrl);
450
if (host === 'github.com') {
451
remoteVendor = 'github';
452
} else if (host === 'dev.azure.com' || (host && host.endsWith('.visualstudio.com'))) {
453
remoteVendor = 'ado';
454
} else {
455
remoteVendor = 'other';
456
}
457
}
458
459
let totalChangeCount = 0;
460
for (const group of repository.provider.groups) {
461
totalChangeCount += group.resources.length;
462
}
463
464
const baseRepoData: Omit<IExportableRepoData, 'diffs' | 'diffsStatus' | 'changedFileCount'> = {
465
workspaceType,
466
syncStatus,
467
remoteUrl,
468
remoteVendor,
469
localBranch,
470
remoteTrackingBranch,
471
remoteBaseBranch,
472
localHeadCommit,
473
remoteHeadCommit,
474
};
475
476
if (totalChangeCount === 0) {
477
return {
478
...baseRepoData,
479
diffs: undefined,
480
diffsStatus: 'noChanges',
481
changedFileCount: 0
482
};
483
}
484
485
if (totalChangeCount > MAX_CHANGES) {
486
return {
487
...baseRepoData,
488
diffs: undefined,
489
diffsStatus: 'tooManyChanges',
490
changedFileCount: totalChangeCount
491
};
492
}
493
494
const diffs: IExportableRepoDiff[] = [];
495
const diffPromises: Promise<IExportableRepoDiff | undefined>[] = [];
496
497
for (const group of repository.provider.groups) {
498
for (const resource of group.resources) {
499
const relPath = relativePath(rootUri, resource.sourceUri) ?? resource.sourceUri.path;
500
const changeType = determineChangeType(resource, group.id);
501
502
const diffPromise = (async (): Promise<IExportableRepoDiff | undefined> => {
503
const unifiedDiff = await generateUnifiedDiff(
504
fileService,
505
relPath,
506
resource.multiDiffEditorOriginalUri,
507
resource.sourceUri,
508
changeType
509
);
510
511
return {
512
relativePath: relPath,
513
changeType,
514
status: group.label || group.id,
515
unifiedDiff
516
};
517
})();
518
519
diffPromises.push(diffPromise);
520
}
521
}
522
523
const generatedDiffs = await Promise.all(diffPromises);
524
for (const diff of generatedDiffs) {
525
if (diff) {
526
diffs.push(diff);
527
}
528
}
529
530
const diffsJson = JSON.stringify(diffs);
531
const diffsSizeBytes = new TextEncoder().encode(diffsJson).length;
532
533
if (diffsSizeBytes > MAX_DIFFS_SIZE_BYTES) {
534
return {
535
...baseRepoData,
536
diffs: undefined,
537
diffsStatus: 'tooLarge',
538
changedFileCount: totalChangeCount
539
};
540
}
541
542
return {
543
...baseRepoData,
544
diffs,
545
diffsStatus: 'included',
546
changedFileCount: totalChangeCount
547
};
548
}
549
550
/**
551
* Captures repository information for chat sessions on creation and first message.
552
*/
553
export class ChatRepoInfoContribution extends Disposable implements IWorkbenchContribution {
554
555
static readonly ID = 'workbench.contrib.chatRepoInfo';
556
557
private _configurationRegistered = false;
558
559
constructor(
560
@IChatService private readonly chatService: IChatService,
561
@IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService,
562
@ISCMService private readonly scmService: ISCMService,
563
@IFileService private readonly fileService: IFileService,
564
@ILogService private readonly logService: ILogService,
565
@IConfigurationService private readonly configurationService: IConfigurationService,
566
) {
567
super();
568
this.registerConfigurationIfInternal();
569
this._register(this.chatEntitlementService.onDidChangeEntitlement(() => {
570
this.registerConfigurationIfInternal();
571
}));
572
573
this._register(this.chatService.onDidSubmitRequest(async ({ chatSessionResource }) => {
574
const model = this.chatService.getSession(chatSessionResource);
575
if (!model) {
576
return;
577
}
578
await this.captureAndSetRepoData(model);
579
}));
580
}
581
582
private registerConfigurationIfInternal(): void {
583
if (this._configurationRegistered) {
584
return;
585
}
586
587
if (!this.chatEntitlementService.isInternal) {
588
return;
589
}
590
591
const registry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration);
592
registry.registerConfiguration({
593
id: 'chatRepoInfo',
594
title: nls.localize('chatRepoInfoConfigurationTitle', "Chat Repository Info"),
595
type: 'object',
596
properties: {
597
[ChatConfiguration.RepoInfoEnabled]: {
598
type: 'boolean',
599
description: nls.localize('chat.repoInfo.enabled', "Controls whether repository information (branch, commit, working tree diffs) is captured at the start of chat sessions for internal diagnostics."),
600
default: true,
601
}
602
}
603
});
604
605
this._configurationRegistered = true;
606
this.logService.debug('[ChatRepoInfo] Configuration registered for internal user');
607
}
608
609
private async captureAndSetRepoData(model: IChatModel): Promise<void> {
610
if (!this.chatEntitlementService.isInternal) {
611
return;
612
}
613
614
// Check if repo info capture is enabled via configuration
615
if (!this.configurationService.getValue<boolean>(ChatConfiguration.RepoInfoEnabled)) {
616
return;
617
}
618
619
if (model.repoData) {
620
return;
621
}
622
623
try {
624
const repoData = await captureRepoInfo(this.scmService, this.fileService);
625
if (repoData) {
626
model.setRepoData(repoData);
627
if (!repoData.localHeadCommit && repoData.workspaceType !== 'plain-folder') {
628
this.logService.warn('[ChatRepoInfo] Captured repo data without commit hash - git history may not be ready');
629
}
630
631
// Trim diffs from older sessions to manage storage
632
this.trimOldSessionDiffs();
633
} else {
634
this.logService.debug('[ChatRepoInfo] No SCM repository available for chat session');
635
}
636
} catch (error) {
637
this.logService.warn('[ChatRepoInfo] Failed to capture repo info:', error);
638
}
639
}
640
641
/**
642
* Trims diffs from older sessions, keeping full diffs only for the most recent sessions.
643
*/
644
private trimOldSessionDiffs(): void {
645
try {
646
// Get all sessions with repoData that has diffs
647
const sessionsWithDiffs: { model: IChatModel; timestamp: number }[] = [];
648
649
for (const model of this.chatService.chatModels.get()) {
650
if (model.repoData?.diffs && model.repoData.diffs.length > 0 && model.repoData.diffsStatus === 'included') {
651
sessionsWithDiffs.push({ model, timestamp: model.timestamp });
652
}
653
}
654
655
// Sort by timestamp descending (most recent first)
656
sessionsWithDiffs.sort((a, b) => b.timestamp - a.timestamp);
657
658
// Trim diffs from sessions beyond the limit
659
for (let i = MAX_SESSIONS_WITH_FULL_DIFFS; i < sessionsWithDiffs.length; i++) {
660
const { model } = sessionsWithDiffs[i];
661
if (model.repoData) {
662
const trimmedRepoData: IExportableRepoData = {
663
...model.repoData,
664
diffs: undefined,
665
diffsStatus: 'trimmedForStorage'
666
};
667
model.setRepoData(trimmedRepoData);
668
this.logService.trace(`[ChatRepoInfo] Trimmed diffs from older session: ${model.sessionResource.toString()}`);
669
}
670
}
671
} catch (error) {
672
this.logService.warn('[ChatRepoInfo] Failed to trim old session diffs:', error);
673
}
674
}
675
}
676
677