Git - get the diff and num stats for a commit (#284403)

This commit is contained in:
Ladislau Szomoru
2025-12-19 10:18:29 +00:00
committed by GitHub
parent c4faa84e4e
commit c95739960f
7 changed files with 115 additions and 16 deletions

View File

@@ -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<DiffChange[]> {
return this.#repository.diffBetweenWithStats(ref1, ref2, path);
}
hashObject(data: string): Promise<string> {
return this.#repository.hashObject(data);
}

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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 [];

View File

@@ -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<string, { insertions: number; deletions: number }>();
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<Change[]> {
return await this.diffFiles(`${ref1}...${ref2}`, { cached: false, similarityThreshold: options.similarityThreshold });
async diffBetweenWithStats(ref: string, options: { path?: string; similarityThreshold?: number }): Promise<DiffChange[]> {
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<Change[]> {
@@ -1749,8 +1838,8 @@ export class Repository {
}
async diffTrees(treeish1: string, treeish2?: string, options?: { similarityThreshold?: number }): Promise<Change[]> {
const args = ['diff-tree', '-r', '--name-status', '-z', '--diff-filter=ADMR'];
async diffTrees(treeish1: string, treeish2?: string, options?: { similarityThreshold?: number }): Promise<DiffChange[]> {
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<string | undefined> {

View File

@@ -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({

View File

@@ -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<Change[]> {
diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise<DiffChange[]> {
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<number>('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<Change[]> {
diffTrees(treeish1: string, treeish2?: string): Promise<DiffChange[]> {
const scopedConfig = workspace.getConfiguration('git', Uri.file(this.root));
const similarityThreshold = scopedConfig.get<number>('similarityThreshold', 50);