Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/git/src/historyProvider.ts
3314 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
7
import { CancellationToken, Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, SourceControlHistoryItem, SourceControlHistoryItemChange, SourceControlHistoryOptions, SourceControlHistoryProvider, ThemeIcon, Uri, window, LogOutputChannel, SourceControlHistoryItemRef, l10n, SourceControlHistoryItemRefsChangeEvent, workspace, ConfigurationChangeEvent } from 'vscode';
8
import { Repository, Resource } from './repository';
9
import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent, truncate } from './util';
10
import { toMultiFileDiffEditorUris } from './uri';
11
import { AvatarQuery, AvatarQueryCommit, Branch, LogOptions, Ref, RefType } from './api/git';
12
import { emojify, ensureEmojis } from './emoji';
13
import { Commit } from './git';
14
import { OperationKind, OperationResult } from './operation';
15
import { ISourceControlHistoryItemDetailsProviderRegistry, provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider';
16
import { throttle } from './decorators';
17
18
function compareSourceControlHistoryItemRef(ref1: SourceControlHistoryItemRef, ref2: SourceControlHistoryItemRef): number {
19
const getOrder = (ref: SourceControlHistoryItemRef): number => {
20
if (ref.id.startsWith('refs/heads/')) {
21
return 1;
22
} else if (ref.id.startsWith('refs/remotes/')) {
23
return 2;
24
} else if (ref.id.startsWith('refs/tags/')) {
25
return 3;
26
}
27
28
return 99;
29
};
30
31
const ref1Order = getOrder(ref1);
32
const ref2Order = getOrder(ref2);
33
34
if (ref1Order !== ref2Order) {
35
return ref1Order - ref2Order;
36
}
37
38
return ref1.name.localeCompare(ref2.name);
39
}
40
41
export class GitHistoryProvider implements SourceControlHistoryProvider, FileDecorationProvider, IDisposable {
42
private readonly _onDidChangeDecorations = new EventEmitter<Uri[]>();
43
readonly onDidChangeFileDecorations: Event<Uri[]> = this._onDidChangeDecorations.event;
44
45
private _currentHistoryItemRef: SourceControlHistoryItemRef | undefined;
46
get currentHistoryItemRef(): SourceControlHistoryItemRef | undefined { return this._currentHistoryItemRef; }
47
48
private _currentHistoryItemRemoteRef: SourceControlHistoryItemRef | undefined;
49
get currentHistoryItemRemoteRef(): SourceControlHistoryItemRef | undefined { return this._currentHistoryItemRemoteRef; }
50
51
private _currentHistoryItemBaseRef: SourceControlHistoryItemRef | undefined;
52
get currentHistoryItemBaseRef(): SourceControlHistoryItemRef | undefined { return this._currentHistoryItemBaseRef; }
53
54
private readonly _onDidChangeCurrentHistoryItemRefs = new EventEmitter<void>();
55
readonly onDidChangeCurrentHistoryItemRefs: Event<void> = this._onDidChangeCurrentHistoryItemRefs.event;
56
57
private readonly _onDidChangeHistoryItemRefs = new EventEmitter<SourceControlHistoryItemRefsChangeEvent>();
58
readonly onDidChangeHistoryItemRefs: Event<SourceControlHistoryItemRefsChangeEvent> = this._onDidChangeHistoryItemRefs.event;
59
60
private _HEAD: Branch | undefined;
61
private _historyItemRefs: SourceControlHistoryItemRef[] = [];
62
63
private commitShortHashLength = 7;
64
private historyItemDecorations = new Map<string, FileDecoration>();
65
66
private disposables: Disposable[] = [];
67
68
constructor(
69
private historyItemDetailProviderRegistry: ISourceControlHistoryItemDetailsProviderRegistry,
70
private readonly repository: Repository,
71
private readonly logger: LogOutputChannel
72
) {
73
this.disposables.push(workspace.onDidChangeConfiguration(this.onDidChangeConfiguration));
74
this.onDidChangeConfiguration();
75
76
const onDidRunWriteOperation = filterEvent(repository.onDidRunOperation, e => !e.operation.readOnly);
77
this.disposables.push(onDidRunWriteOperation(this.onDidRunWriteOperation, this));
78
79
this.disposables.push(window.registerFileDecorationProvider(this));
80
}
81
82
private onDidChangeConfiguration(e?: ConfigurationChangeEvent): void {
83
if (e && !e.affectsConfiguration('git.commitShortHashLength')) {
84
return;
85
}
86
87
const config = workspace.getConfiguration('git', Uri.file(this.repository.root));
88
this.commitShortHashLength = config.get<number>('commitShortHashLength', 7);
89
}
90
91
@throttle
92
private async onDidRunWriteOperation(result: OperationResult): Promise<void> {
93
if (!this.repository.HEAD) {
94
this.logger.trace('[GitHistoryProvider][onDidRunWriteOperation] repository.HEAD is undefined');
95
this._currentHistoryItemRef = this._currentHistoryItemRemoteRef = this._currentHistoryItemBaseRef = undefined;
96
this._onDidChangeCurrentHistoryItemRefs.fire();
97
98
return;
99
}
100
101
// Refs (alphabetically)
102
const historyItemRefs = this.repository.refs
103
.map(ref => this.toSourceControlHistoryItemRef(ref))
104
.sort((a, b) => a.id.localeCompare(b.id));
105
106
const delta = deltaHistoryItemRefs(this._historyItemRefs, historyItemRefs);
107
this._historyItemRefs = historyItemRefs;
108
109
let historyItemRefId = '';
110
let historyItemRefName = '';
111
112
switch (this.repository.HEAD.type) {
113
case RefType.Head: {
114
if (this.repository.HEAD.name !== undefined) {
115
// Branch
116
historyItemRefId = `refs/heads/${this.repository.HEAD.name}`;
117
historyItemRefName = this.repository.HEAD.name;
118
119
// Remote
120
if (this.repository.HEAD.upstream) {
121
if (this.repository.HEAD.upstream.remote === '.') {
122
// Local branch
123
this._currentHistoryItemRemoteRef = {
124
id: `refs/heads/${this.repository.HEAD.upstream.name}`,
125
name: this.repository.HEAD.upstream.name,
126
revision: this.repository.HEAD.upstream.commit,
127
icon: new ThemeIcon('gi-branch')
128
};
129
} else {
130
// Remote branch
131
this._currentHistoryItemRemoteRef = {
132
id: `refs/remotes/${this.repository.HEAD.upstream.remote}/${this.repository.HEAD.upstream.name}`,
133
name: `${this.repository.HEAD.upstream.remote}/${this.repository.HEAD.upstream.name}`,
134
revision: this.repository.HEAD.upstream.commit,
135
icon: new ThemeIcon('cloud')
136
};
137
}
138
} else {
139
this._currentHistoryItemRemoteRef = undefined;
140
}
141
142
// Base
143
if (this._HEAD?.name !== this.repository.HEAD.name) {
144
// Compute base if the branch has changed
145
const mergeBase = await this.resolveHEADMergeBase();
146
147
this._currentHistoryItemBaseRef = mergeBase && mergeBase.name && mergeBase.remote &&
148
(mergeBase.remote !== this.repository.HEAD.upstream?.remote ||
149
mergeBase.name !== this.repository.HEAD.upstream?.name) ? {
150
id: `refs/remotes/${mergeBase.remote}/${mergeBase.name}`,
151
name: `${mergeBase.remote}/${mergeBase.name}`,
152
revision: mergeBase.commit,
153
icon: new ThemeIcon('cloud')
154
} : undefined;
155
} else {
156
// Update base revision if it has changed
157
const mergeBaseModified = delta.modified
158
.find(ref => ref.id === this._currentHistoryItemBaseRef?.id);
159
160
if (this._currentHistoryItemBaseRef && mergeBaseModified) {
161
this._currentHistoryItemBaseRef = {
162
...this._currentHistoryItemBaseRef,
163
revision: mergeBaseModified.revision
164
};
165
}
166
}
167
} else {
168
// Detached commit
169
historyItemRefId = this.repository.HEAD.commit ?? '';
170
historyItemRefName = this.repository.HEAD.commit ?? '';
171
172
this._currentHistoryItemRemoteRef = undefined;
173
this._currentHistoryItemBaseRef = undefined;
174
}
175
break;
176
}
177
case RefType.Tag: {
178
// Tag
179
historyItemRefId = `refs/tags/${this.repository.HEAD.name}`;
180
historyItemRefName = this.repository.HEAD.name ?? this.repository.HEAD.commit ?? '';
181
182
this._currentHistoryItemRemoteRef = undefined;
183
this._currentHistoryItemBaseRef = undefined;
184
break;
185
}
186
}
187
188
this._HEAD = this.repository.HEAD;
189
190
this._currentHistoryItemRef = {
191
id: historyItemRefId,
192
name: historyItemRefName,
193
revision: this.repository.HEAD.commit,
194
icon: new ThemeIcon('target'),
195
};
196
197
this._onDidChangeCurrentHistoryItemRefs.fire();
198
this.logger.trace(`[GitHistoryProvider][onDidRunWriteOperation] currentHistoryItemRef: ${JSON.stringify(this._currentHistoryItemRef)}`);
199
this.logger.trace(`[GitHistoryProvider][onDidRunWriteOperation] currentHistoryItemRemoteRef: ${JSON.stringify(this._currentHistoryItemRemoteRef)}`);
200
this.logger.trace(`[GitHistoryProvider][onDidRunWriteOperation] currentHistoryItemBaseRef: ${JSON.stringify(this._currentHistoryItemBaseRef)}`);
201
202
// Auto-fetch
203
const silent = result.operation.kind === OperationKind.Fetch && result.operation.showProgress === false;
204
this._onDidChangeHistoryItemRefs.fire({ ...delta, silent });
205
206
const deltaLog = {
207
added: delta.added.map(ref => ref.id),
208
modified: delta.modified.map(ref => ref.id),
209
removed: delta.removed.map(ref => ref.id),
210
silent
211
};
212
this.logger.trace(`[GitHistoryProvider][onDidRunWriteOperation] historyItemRefs: ${JSON.stringify(deltaLog)}`);
213
}
214
215
async provideHistoryItemRefs(historyItemRefs: string[] | undefined): Promise<SourceControlHistoryItemRef[]> {
216
const refs = await this.repository.getRefs({ pattern: historyItemRefs });
217
218
const branches: SourceControlHistoryItemRef[] = [];
219
const remoteBranches: SourceControlHistoryItemRef[] = [];
220
const tags: SourceControlHistoryItemRef[] = [];
221
222
for (const ref of refs) {
223
switch (ref.type) {
224
case RefType.RemoteHead:
225
remoteBranches.push(this.toSourceControlHistoryItemRef(ref));
226
break;
227
case RefType.Tag:
228
tags.push(this.toSourceControlHistoryItemRef(ref));
229
break;
230
default:
231
branches.push(this.toSourceControlHistoryItemRef(ref));
232
break;
233
}
234
}
235
236
return [...branches, ...remoteBranches, ...tags];
237
}
238
239
async provideHistoryItems(options: SourceControlHistoryOptions, token: CancellationToken): Promise<SourceControlHistoryItem[]> {
240
if (!this.currentHistoryItemRef || !options.historyItemRefs) {
241
return [];
242
}
243
244
// Deduplicate refNames
245
const refNames = Array.from(new Set<string>(options.historyItemRefs));
246
247
let logOptions: LogOptions = { refNames, shortStats: true };
248
249
try {
250
if (options.limit === undefined || typeof options.limit === 'number') {
251
logOptions = { ...logOptions, maxEntries: options.limit ?? 50 };
252
} else if (typeof options.limit.id === 'string') {
253
// Get the common ancestor commit, and commits
254
const commit = await this.repository.getCommit(options.limit.id);
255
const commitParentId = commit.parents.length > 0 ? commit.parents[0] : await this.repository.getEmptyTree();
256
257
logOptions = { ...logOptions, range: `${commitParentId}..` };
258
}
259
260
if (typeof options.skip === 'number') {
261
logOptions = { ...logOptions, skip: options.skip };
262
}
263
264
const commits = typeof options.filterText === 'string' && options.filterText !== ''
265
? await this._searchHistoryItems(options.filterText.trim(), logOptions, token)
266
: await this.repository.log({ ...logOptions, silent: true }, token);
267
268
if (token.isCancellationRequested) {
269
return [];
270
}
271
272
// Avatars
273
const avatarQuery = {
274
commits: commits.map(c => ({
275
hash: c.hash,
276
authorName: c.authorName,
277
authorEmail: c.authorEmail
278
} satisfies AvatarQueryCommit)),
279
size: 20
280
} satisfies AvatarQuery;
281
282
const commitAvatars = await provideSourceControlHistoryItemAvatar(
283
this.historyItemDetailProviderRegistry, this.repository, avatarQuery);
284
285
await ensureEmojis();
286
287
const historyItems: SourceControlHistoryItem[] = [];
288
for (const commit of commits) {
289
const message = emojify(commit.message);
290
const messageWithLinks = await provideSourceControlHistoryItemMessageLinks(
291
this.historyItemDetailProviderRegistry, this.repository, message) ?? message;
292
293
const newLineIndex = message.indexOf('\n');
294
const subject = newLineIndex !== -1
295
? `${truncate(message, newLineIndex, false)}`
296
: message;
297
298
const avatarUrl = commitAvatars?.get(commit.hash);
299
const references = this._resolveHistoryItemRefs(commit);
300
301
historyItems.push({
302
id: commit.hash,
303
parentIds: commit.parents,
304
subject,
305
message: messageWithLinks,
306
author: commit.authorName,
307
authorEmail: commit.authorEmail,
308
authorIcon: avatarUrl ? Uri.parse(avatarUrl) : new ThemeIcon('account'),
309
displayId: truncate(commit.hash, this.commitShortHashLength, false),
310
timestamp: commit.authorDate?.getTime(),
311
statistics: commit.shortStat ?? { files: 0, insertions: 0, deletions: 0 },
312
references: references.length !== 0 ? references : undefined
313
} satisfies SourceControlHistoryItem);
314
}
315
316
return historyItems;
317
} catch (err) {
318
this.logger.error(`[GitHistoryProvider][provideHistoryItems] Failed to get history items with options '${JSON.stringify(options)}': ${err}`);
319
return [];
320
}
321
}
322
323
async provideHistoryItemChanges(historyItemId: string, historyItemParentId: string | undefined): Promise<SourceControlHistoryItemChange[]> {
324
historyItemParentId = historyItemParentId ?? await this.repository.getEmptyTree();
325
326
const historyItemChangesUri: Uri[] = [];
327
const historyItemChanges: SourceControlHistoryItemChange[] = [];
328
const changes = await this.repository.diffTrees(historyItemParentId, historyItemId);
329
330
for (const change of changes) {
331
const historyItemUri = change.uri.with({
332
query: `ref=${historyItemId}`
333
});
334
335
// History item change
336
historyItemChanges.push({
337
uri: historyItemUri,
338
...toMultiFileDiffEditorUris(change, historyItemParentId, historyItemId)
339
} satisfies SourceControlHistoryItemChange);
340
341
// History item change decoration
342
const letter = Resource.getStatusLetter(change.status);
343
const tooltip = Resource.getStatusText(change.status);
344
const color = Resource.getStatusColor(change.status);
345
const fileDecoration = new FileDecoration(letter, tooltip, color);
346
this.historyItemDecorations.set(historyItemUri.toString(), fileDecoration);
347
348
historyItemChangesUri.push(historyItemUri);
349
}
350
351
this._onDidChangeDecorations.fire(historyItemChangesUri);
352
return historyItemChanges;
353
}
354
355
async resolveHistoryItemChatContext(historyItemId: string): Promise<string | undefined> {
356
try {
357
const commitDetails = await this.repository.showCommit(historyItemId);
358
return commitDetails;
359
} catch (err) {
360
this.logger.error(`[GitHistoryProvider][resolveHistoryItemChatContext] Failed to resolve history item '${historyItemId}': ${err}`);
361
}
362
363
return undefined;
364
}
365
366
async resolveHistoryItemRefsCommonAncestor(historyItemRefs: string[]): Promise<string | undefined> {
367
try {
368
if (historyItemRefs.length === 0) {
369
// TODO@lszomoru - log
370
return undefined;
371
} else if (historyItemRefs.length === 1 && historyItemRefs[0] === this.currentHistoryItemRef?.id) {
372
// Remote
373
if (this.currentHistoryItemRemoteRef) {
374
const ancestor = await this.repository.getMergeBase(historyItemRefs[0], this.currentHistoryItemRemoteRef.id);
375
return ancestor;
376
}
377
378
// Base
379
if (this.currentHistoryItemBaseRef) {
380
const ancestor = await this.repository.getMergeBase(historyItemRefs[0], this.currentHistoryItemBaseRef.id);
381
return ancestor;
382
}
383
384
// First commit
385
const commits = await this.repository.log({ maxParents: 0, refNames: ['HEAD'] });
386
if (commits.length > 0) {
387
return commits[0].hash;
388
}
389
} else if (historyItemRefs.length > 1) {
390
const ancestor = await this.repository.getMergeBase(historyItemRefs[0], historyItemRefs[1], ...historyItemRefs.slice(2));
391
return ancestor;
392
}
393
}
394
catch (err) {
395
this.logger.error(`[GitHistoryProvider][resolveHistoryItemRefsCommonAncestor] Failed to resolve common ancestor for ${historyItemRefs.join(',')}: ${err}`);
396
}
397
398
return undefined;
399
}
400
401
provideFileDecoration(uri: Uri): FileDecoration | undefined {
402
return this.historyItemDecorations.get(uri.toString());
403
}
404
405
private _resolveHistoryItemRefs(commit: Commit): SourceControlHistoryItemRef[] {
406
const references: SourceControlHistoryItemRef[] = [];
407
408
for (const ref of commit.refNames) {
409
if (ref === 'refs/remotes/origin/HEAD') {
410
continue;
411
}
412
413
switch (true) {
414
case ref.startsWith('HEAD -> refs/heads/'):
415
references.push({
416
id: ref.substring('HEAD -> '.length),
417
name: ref.substring('HEAD -> refs/heads/'.length),
418
revision: commit.hash,
419
category: l10n.t('branches'),
420
icon: new ThemeIcon('target')
421
});
422
break;
423
case ref.startsWith('refs/heads/'):
424
references.push({
425
id: ref,
426
name: ref.substring('refs/heads/'.length),
427
revision: commit.hash,
428
category: l10n.t('branches'),
429
icon: new ThemeIcon('git-branch')
430
});
431
break;
432
case ref.startsWith('refs/remotes/'):
433
references.push({
434
id: ref,
435
name: ref.substring('refs/remotes/'.length),
436
revision: commit.hash,
437
category: l10n.t('remote branches'),
438
icon: new ThemeIcon('cloud')
439
});
440
break;
441
case ref.startsWith('tag: refs/tags/'):
442
references.push({
443
id: ref.substring('tag: '.length),
444
name: ref.substring('tag: refs/tags/'.length),
445
revision: commit.hash,
446
category: l10n.t('tags'),
447
icon: new ThemeIcon('tag')
448
});
449
break;
450
}
451
}
452
453
return references.sort(compareSourceControlHistoryItemRef);
454
}
455
456
private async resolveHEADMergeBase(): Promise<Branch | undefined> {
457
try {
458
if (this.repository.HEAD?.type !== RefType.Head || !this.repository.HEAD?.name) {
459
return undefined;
460
}
461
462
const mergeBase = await this.repository.getBranchBase(this.repository.HEAD.name);
463
return mergeBase;
464
} catch (err) {
465
this.logger.error(`[GitHistoryProvider][resolveHEADMergeBase] Failed to resolve merge base for ${this.repository.HEAD?.name}: ${err}`);
466
return undefined;
467
}
468
}
469
470
private async _searchHistoryItems(filterText: string, options: LogOptions, token: CancellationToken): Promise<Commit[]> {
471
if (token.isCancellationRequested) {
472
return [];
473
}
474
475
const commits = new Map<string, Commit>();
476
477
// Search by author and commit message in parallel
478
const [authorResults, grepResults] = await Promise.all([
479
this.repository.log({ ...options, refNames: undefined, author: filterText, silent: true }, token),
480
this.repository.log({ ...options, refNames: undefined, grep: filterText, silent: true }, token)
481
]);
482
483
for (const commit of [...authorResults, ...grepResults]) {
484
if (!commits.has(commit.hash)) {
485
commits.set(commit.hash, commit);
486
}
487
}
488
489
return Array.from(commits.values()).slice(0, options.maxEntries ?? 50);
490
}
491
492
private toSourceControlHistoryItemRef(ref: Ref): SourceControlHistoryItemRef {
493
switch (ref.type) {
494
case RefType.RemoteHead:
495
return {
496
id: `refs/remotes/${ref.name}`,
497
name: ref.name ?? '',
498
description: ref.commit ? l10n.t('Remote branch at {0}', truncate(ref.commit, this.commitShortHashLength, false)) : undefined,
499
revision: ref.commit,
500
icon: new ThemeIcon('cloud'),
501
category: l10n.t('remote branches')
502
};
503
case RefType.Tag:
504
return {
505
id: `refs/tags/${ref.name}`,
506
name: ref.name ?? '',
507
description: ref.commit ? l10n.t('Tag at {0}', truncate(ref.commit, this.commitShortHashLength, false)) : undefined,
508
revision: ref.commit,
509
icon: new ThemeIcon('tag'),
510
category: l10n.t('tags')
511
};
512
default:
513
return {
514
id: `refs/heads/${ref.name}`,
515
name: ref.name ?? '',
516
description: ref.commit ? truncate(ref.commit, this.commitShortHashLength, false) : undefined,
517
revision: ref.commit,
518
icon: new ThemeIcon('git-branch'),
519
category: l10n.t('branches')
520
};
521
}
522
}
523
524
dispose(): void {
525
dispose(this.disposables);
526
}
527
}
528
529