diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index ea4b00dcd43..08f11185d5e 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -7,7 +7,7 @@ import { Model } from '../model'; import { Repository as BaseRepository, Resource } from '../repository'; -import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, SourceControlHistoryItemDetailsProvider, GitErrorCodes, CloneOptions, CommitShortStat } from './git'; +import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, SourceControlHistoryItemDetailsProvider, GitErrorCodes, CloneOptions, CommitShortStat, DiffChange } from './git'; import { Event, SourceControlInputBox, Uri, SourceControl, Disposable, commands, CancellationToken } from 'vscode'; import { combinedDisposable, filterEvent, mapEvent } from '../util'; import { toGitUri } from '../uri'; @@ -199,6 +199,10 @@ export class ApiRepository implements Repository { return this.#repository.diffBetween(ref1, ref2, path); } + diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise { + return this.#repository.diffBetweenWithStats(ref1, ref2, path); + } + hashObject(data: string): Promise { return this.#repository.hashObject(data); } diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 19059520705..8341f0e801e 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -121,6 +121,11 @@ export interface Change { readonly status: Status; } +export interface DiffChange extends Change { + readonly insertions: number; + readonly deletions: number; +} + export interface RepositoryState { readonly HEAD: Branch | undefined; readonly refs: Ref[]; diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index ec90bc10a9d..9132427161f 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -3198,7 +3198,7 @@ export class CommandCenter { } try { - const changes = await repository.diffBetween2(ref1.id, ref2.id); + const changes = await repository.diffBetweenWithStats(ref1.id, ref2.id); if (changes.length === 0) { window.showInformationMessage(l10n.t('There are no changes between "{0}" and "{1}".', ref1.displayId ?? ref1.id, ref2.displayId ?? ref2.id)); @@ -4785,7 +4785,7 @@ export class CommandCenter { const commit = await repository.getCommit(item.ref); const commitParentId = commit.parents.length > 0 ? commit.parents[0] : await repository.getEmptyTree(); - const changes = await repository.diffBetween2(commitParentId, commit.hash); + const changes = await repository.diffBetweenWithStats(commitParentId, commit.hash); const resources = changes.map(c => toMultiFileDiffEditorUris(c, commitParentId, commit.hash)); const title = `${item.shortRef} - ${subject(commit.message)}`; @@ -5059,7 +5059,7 @@ export class CommandCenter { const multiDiffSourceUri = Uri.from({ scheme: 'scm-history-item', path: `${repository.root}/${historyItemParentId}..${historyItemId}` }); - const changes = await repository.diffBetween2(historyItemParentId, historyItemId); + const changes = await repository.diffBetweenWithStats(historyItemParentId, historyItemId); const resources = changes.map(c => toMultiFileDiffEditorUris(c, historyItemParentId, historyItemId)); const reveal = revealUri ? { modifiedUri: toGitUri(revealUri, historyItemId) } : undefined; diff --git a/extensions/git/src/decorationProvider.ts b/extensions/git/src/decorationProvider.ts index b8b5fc26723..fb895d5aff2 100644 --- a/extensions/git/src/decorationProvider.ts +++ b/extensions/git/src/decorationProvider.ts @@ -257,7 +257,7 @@ class GitIncomingChangesFileDecorationProvider implements FileDecorationProvider return []; } - const changes = await this.repository.diffBetween2(ancestor, currentHistoryItemRemoteRef.id); + const changes = await this.repository.diffBetweenWithStats(ancestor, currentHistoryItemRemoteRef.id); return changes; } catch (err) { return []; diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 5c47f7f5b6b..f6f6b7ba81f 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -13,7 +13,7 @@ import { EventEmitter } from 'events'; import * as filetype from 'file-type'; import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter, Versions, isWindows, pathEquals, isMacintosh, isDescendant, relativePathWithNoFallback, Mutable } from './util'; import { CancellationError, CancellationToken, ConfigurationChangeEvent, LogOutputChannel, Progress, Uri, workspace } from 'vscode'; -import { Commit as ApiCommit, Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, RefQuery as ApiRefQuery, InitOptions, Worktree } from './api/git'; +import { Commit as ApiCommit, Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, RefQuery as ApiRefQuery, InitOptions, Worktree, DiffChange } from './api/git'; import * as byline from 'byline'; import { StringDecoder } from 'string_decoder'; @@ -1084,6 +1084,79 @@ function parseGitChanges(repositoryRoot: string, raw: string): Change[] { return result; } +function parseGitChangesRaw(repositoryRoot: string, raw: string): DiffChange[] { + const changes: Change[] = []; + const numStats = new Map(); + + let index = 0; + const segments = raw.trim().split('\x00').filter(s => s); + + segmentsLoop: + while (index < segments.length) { + const segment = segments[index++]; + if (!segment) { + break; + } + + if (segment.startsWith(':')) { + // Parse --raw output + const [, , , , change] = segment.split(' '); + const filePath = segments[index++]; + const originalUri = Uri.file(path.isAbsolute(filePath) ? filePath : path.join(repositoryRoot, filePath)); + + let uri = originalUri; + let renameUri = originalUri; + let status = Status.UNTRACKED; + + switch (change[0]) { + case 'A': + status = Status.INDEX_ADDED; + break; + case 'M': + status = Status.MODIFIED; + break; + case 'D': + status = Status.DELETED; + break; + case 'R': { + if (index >= segments.length) { + break; + } + const newPath = segments[index++]; + if (!newPath) { + break; + } + + status = Status.INDEX_RENAMED; + uri = renameUri = Uri.file(path.isAbsolute(newPath) ? newPath : path.join(repositoryRoot, newPath)); + break; + } + default: + // Unknown status + break segmentsLoop; + } + + changes.push({ status, uri, originalUri, renameUri }); + } else { + // Parse --numstat output + const [insertions, deletions, filePath] = segment.split('\t'); + numStats.set( + path.isAbsolute(filePath) + ? filePath + : path.join(repositoryRoot, filePath), { + insertions: insertions === '-' ? 0 : parseInt(insertions), + deletions: deletions === '-' ? 0 : parseInt(deletions), + }); + } + } + + return changes.map(change => ({ + ...change, + insertions: numStats.get(change.uri.fsPath)?.insertions ?? 0, + deletions: numStats.get(change.uri.fsPath)?.deletions ?? 0, + })); +} + export interface BlameInformation { readonly hash: string; readonly subject?: string; @@ -1694,8 +1767,24 @@ export class Repository { return result.stdout.trim(); } - async diffBetween2(ref1: string, ref2: string, options: { similarityThreshold?: number }): Promise { - return await this.diffFiles(`${ref1}...${ref2}`, { cached: false, similarityThreshold: options.similarityThreshold }); + async diffBetweenWithStats(ref: string, options: { path?: string; similarityThreshold?: number }): Promise { + const args = ['diff', '--raw', '--numstat', '--diff-filter=ADMR', '-z',]; + + if (options.similarityThreshold) { + args.push(`--find-renames=${options.similarityThreshold}%`); + } + + args.push(...[ref, '--']); + if (options.path) { + args.push(this.sanitizeRelativePath(options.path)); + } + + const gitResult = await this.exec(args); + if (gitResult.exitCode) { + return []; + } + + return parseGitChangesRaw(this.repositoryRoot, gitResult.stdout); } private async diffFiles(ref: string | undefined, options: { cached: boolean; similarityThreshold?: number }): Promise { @@ -1749,8 +1838,8 @@ export class Repository { } - async diffTrees(treeish1: string, treeish2?: string, options?: { similarityThreshold?: number }): Promise { - const args = ['diff-tree', '-r', '--name-status', '-z', '--diff-filter=ADMR']; + async diffTrees(treeish1: string, treeish2?: string, options?: { similarityThreshold?: number }): Promise { + const args = ['diff-tree', '-r', '--raw', '--numstat', '--diff-filter=ADMR', '-z']; if (options?.similarityThreshold) { args.push(`--find-renames=${options.similarityThreshold}%`); @@ -1769,7 +1858,7 @@ export class Repository { return []; } - return parseGitChanges(this.repositoryRoot, gitResult.stdout); + return parseGitChangesRaw(this.repositoryRoot, gitResult.stdout); } async getMergeBase(ref1: string, ref2: string, ...refs: string[]): Promise { diff --git a/extensions/git/src/historyProvider.ts b/extensions/git/src/historyProvider.ts index a1b02953fe5..f921f5734a5 100644 --- a/extensions/git/src/historyProvider.ts +++ b/extensions/git/src/historyProvider.ts @@ -339,7 +339,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec const historyItemChangesUri: Uri[] = []; const historyItemChanges: SourceControlHistoryItemChange[] = []; - const changes = await this.repository.diffBetween2(historyItemParentId, historyItemId); + const changes = await this.repository.diffBetweenWithStats(historyItemParentId, historyItemId); for (const change of changes) { const historyItemUri = change.uri.with({ diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 70957d2949c..27de2657817 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -10,7 +10,7 @@ import picomatch from 'picomatch'; import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, Disposable, Event, EventEmitter, FileDecoration, FileType, l10n, LogLevel, LogOutputChannel, Memento, ProgressLocation, ProgressOptions, QuickDiffProvider, RelativePattern, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, ThemeColor, ThemeIcon, Uri, window, workspace, WorkspaceEdit } from 'vscode'; import { ActionButton } from './actionButton'; import { ApiRepository } from './api/api1'; -import { Branch, BranchQuery, Change, CommitOptions, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status, Worktree } from './api/git'; +import { Branch, BranchQuery, Change, CommitOptions, DiffChange, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status, Worktree } from './api/git'; import { AutoFetcher } from './autofetch'; import { GitBranchProtectionProvider, IBranchProtectionProviderRegistry } from './branchProtection'; import { debounce, memoize, sequentialize, throttle } from './decorators'; @@ -1241,7 +1241,7 @@ export class Repository implements Disposable { return this.run(Operation.Diff, () => this.repository.diffBetween(ref1, ref2, path)); } - diffBetween2(ref1: string, ref2: string): Promise { + diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise { if (ref1 === this._EMPTY_TREE) { // Use git diff-tree to get the // changes in the first commit @@ -1251,10 +1251,11 @@ export class Repository implements Disposable { const scopedConfig = workspace.getConfiguration('git', Uri.file(this.root)); const similarityThreshold = scopedConfig.get('similarityThreshold', 50); - return this.run(Operation.Diff, () => this.repository.diffBetween2(ref1, ref2, { similarityThreshold })); + return this.run(Operation.Diff, () => + this.repository.diffBetweenWithStats(`${ref1}...${ref2}`, { path, similarityThreshold })); } - diffTrees(treeish1: string, treeish2?: string): Promise { + diffTrees(treeish1: string, treeish2?: string): Promise { const scopedConfig = workspace.getConfiguration('git', Uri.file(this.root)); const similarityThreshold = scopedConfig.get('similarityThreshold', 50);