Skip to content

Commit 2a9d406

Browse files
authored
Add per-file diff stats (insertions/deletions) to tree view (#143)
1 parent d8a2ffd commit 2a9d406

4 files changed

Lines changed: 116 additions & 12 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,5 @@ This feature works with both PRs from the same repository and PRs from forks.
8181
`gitTreeCompare.refSortOrder` Determines how refs (branches, tags) are sorted when changing the comparison base. Default is `committerdate` which sorts by most recently committed first, making it easy to find recently-used branches. Can be set to `alphabetically` for alphabetical sorting.
8282

8383
`gitTreeCompare.openChangesWithDifftool` When enabled, adds an "Open Changes with Difftool" command to the context menu for files. This command opens the changes in the external diff tool configured in Git (e.g., via `git config diff.tool <tool-name>`). Default is disabled. Note: This requires you to have a difftool configured in your Git settings.
84+
85+
`gitTreeCompare.showDiffStats` When enabled, shows insertion/deletion counts (+N -N) next to each file name in the tree view. Default is disabled.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,11 @@
597597
"type": "boolean",
598598
"description": "Whether to show the 'Open Changes with Difftool' command in the context menu for files. This command opens the changes in the external diff tool configured in Git (e.g., via 'git config diff.tool').",
599599
"default": false
600+
},
601+
"gitTreeCompare.showDiffStats": {
602+
"type": "boolean",
603+
"description": "Whether to show insertion/deletion counts (+N -N) next to each file.",
604+
"default": false
600605
}
601606
}
602607
}

src/gitHelper.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ export async function getHeadModificationDate(absGitDir: string): Promise<Date>
110110
return stats.mtime;
111111
}
112112

113+
export interface IDiffStats {
114+
insertions: number | undefined;
115+
deletions: number | undefined;
116+
isBinary: boolean;
117+
}
118+
113119
export interface IDiffStatus {
114120
/**
115121
* A Addition of a file
@@ -130,6 +136,9 @@ export interface IDiffStatus {
130136

131137
/** True if this was or is a submodule */
132138
isSubmodule: boolean
139+
140+
/** Per-file insertion/deletion counts (undefined when stats are disabled or unavailable) */
141+
stats: IDiffStats | undefined;
133142
}
134143

135144
const MODE_REGULAR_FILE = '100644';
@@ -140,6 +149,7 @@ class DiffStatus implements IDiffStatus {
140149
readonly srcAbsPath: string;
141150
readonly dstAbsPath: string;
142151
readonly isSubmodule: boolean;
152+
stats: IDiffStats | undefined;
143153

144154
constructor(repoRoot: string, public status: StatusCode, srcRelPath: string, dstRelPath: string | undefined, srcMode: string, dstMode: string) {
145155
this.srcAbsPath = path.join(repoRoot, srcRelPath);
@@ -194,7 +204,53 @@ function parseDiffIndexOutput(repoRoot: string, out: string): IDiffStatus[] {
194204
return entries;
195205
}
196206

197-
export async function diffIndex(repo: Repository, ref: string, refreshIndex: boolean, findRenames: boolean, renameThreshold: number, omitUntrackedFiles: boolean, omitUnstagedChanges: boolean): Promise<IDiffStatus[]> {
207+
function parseDiffNumstat(repoRoot: string, out: string): Map<string, IDiffStats> {
208+
const stats = new Map<string, IDiffStats>();
209+
const lines = out.split('\n').filter(line => line.length > 0);
210+
for (const line of lines) {
211+
const parts = line.split('\t');
212+
if (parts.length < 3) {
213+
continue;
214+
}
215+
const [ins, del, ...pathParts] = parts;
216+
const relPath = pathParts.join('\t');
217+
const absPath = path.join(repoRoot, relPath);
218+
if (ins === '-' && del === '-') {
219+
stats.set(absPath, { insertions: undefined, deletions: undefined, isBinary: true });
220+
} else {
221+
stats.set(absPath, { insertions: parseInt(ins, 10), deletions: parseInt(del, 10), isBinary: false });
222+
}
223+
}
224+
return stats;
225+
}
226+
227+
const BINARY_CHECK_BYTES = 8192;
228+
229+
async function computeUntrackedStats(entries: IDiffStatus[]): Promise<void> {
230+
await Promise.all(entries.map(async (entry) => {
231+
try {
232+
const buf = Buffer.alloc(BINARY_CHECK_BYTES);
233+
const handle = await fs.open(entry.dstAbsPath, 'r');
234+
try {
235+
const { bytesRead } = await handle.read(buf, 0, BINARY_CHECK_BYTES, 0);
236+
if (buf.subarray(0, bytesRead).includes(0)) {
237+
entry.stats = { insertions: undefined, deletions: undefined, isBinary: true };
238+
return;
239+
}
240+
} finally {
241+
await handle.close();
242+
}
243+
const content = await fs.readFile(entry.dstAbsPath, 'utf-8');
244+
const lines = content.split('\n');
245+
const lineCount = content.endsWith('\n') ? lines.length - 1 : lines.length;
246+
entry.stats = { insertions: lineCount, deletions: 0, isBinary: false };
247+
} catch {
248+
// File may be inaccessible
249+
}
250+
}));
251+
}
252+
253+
export async function diffIndex(repo: Repository, ref: string, refreshIndex: boolean, findRenames: boolean, renameThreshold: number, omitUntrackedFiles: boolean, omitUnstagedChanges: boolean, showDiffStats: boolean = false): Promise<IDiffStatus[]> {
198254
if (refreshIndex) {
199255
// avoid superfluous diff entries if files only got touched
200256
// (see https://github.com/letmaik/vscode-git-tree-compare/issues/37)
@@ -233,6 +289,22 @@ export async function diffIndex(repo: Repository, ref: string, refreshIndex: boo
233289
const filteredDiffIndexStatuses = diffIndexStatuses.filter(status => !untrackedAbsPaths.has(status.srcAbsPath));
234290

235291
const statuses = filteredDiffIndexStatuses.concat(untrackedStatuses);
292+
293+
if (showDiffStats) {
294+
const numstatResult = await repo.exec(['diff', '--numstat', renamesFlag, ref, '--']);
295+
const numstatMap = parseDiffNumstat(repoRoot, numstatResult.stdout);
296+
for (const entry of statuses) {
297+
const fileStats = numstatMap.get(entry.dstAbsPath) || numstatMap.get(entry.srcAbsPath);
298+
if (fileStats) {
299+
entry.stats = fileStats;
300+
}
301+
}
302+
303+
if (untrackedStatuses.length > 0) {
304+
await computeUntrackedStats(untrackedStatuses);
305+
}
306+
}
307+
236308
statuses.sort((s1, s2) => s1.dstAbsPath.localeCompare(s2.dstAbsPath))
237309
return statuses;
238310
}

src/treeProvider.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Repository, Git } from './git/git'
1111
import { Ref, RefType } from './git/api/git'
1212
import { anyEvent, filterEvent, eventToPromise } from './git/util'
1313
import { getDefaultBranch, getHeadModificationDate, getBranchCommit,
14-
diffIndex, IDiffStatus, StatusCode, getAbsGitDir,
14+
diffIndex, IDiffStatus, IDiffStats, StatusCode, getAbsGitDir,
1515
getWorkspaceFolders, getGitRepositoryFolders, hasUncommittedChanges, rmFile } from './gitHelper'
1616
import { tryDeepenForMergeBase } from './deepenHelper'
1717
import { debounce, throttle } from './git/decorators'
@@ -47,7 +47,8 @@ class FileElement implements IDiffStatus {
4747
public dstAbsPath: string,
4848
public dstRelPath: string,
4949
public status: StatusCode,
50-
public isSubmodule: boolean) {}
50+
public isSubmodule: boolean,
51+
public stats: IDiffStats | undefined = undefined) {}
5152

5253
get label(): string {
5354
return path.basename(this.dstAbsPath)
@@ -139,6 +140,7 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
139140
private omitUnstagedChanges: boolean;
140141
private sortOrder: SortOrder;
141142
private autoReveal: boolean;
143+
private showDiffStats: boolean;
142144

143145
// Dynamic options
144146
private repository: Repository | undefined;
@@ -421,6 +423,7 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
421423
this.omitUnstagedChanges = config.get<boolean>('omitUnstagedChanges', false);
422424
this.sortOrder = config.get<SortOrder>('sortOrder', 'path');
423425
this.autoReveal = config.get<boolean>('autoReveal', true);
426+
this.showDiffStats = config.get<boolean>('showDiffStats', false);
424427
}
425428

426429
private async getStoredBaseRef(): Promise<string | undefined> {
@@ -466,7 +469,7 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
466469
checkboxState = this.computeFolderCheckboxState(element);
467470
}
468471
}
469-
return toTreeItem(element, this.openChangesOnSelect, this.iconsMinimal, this.showCollapsed, this.viewAsList, checkboxState, this.asAbsolutePath);
472+
return toTreeItem(element, this.openChangesOnSelect, this.iconsMinimal, this.showCollapsed, this.viewAsList, this.showDiffStats, checkboxState, this.asAbsolutePath);
470473
}
471474

472475
getParent(element: Element): Element | undefined {
@@ -653,7 +656,7 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
653656
const filesInsideTreeRoot = new Map<FolderAbsPath, IDiffStatus[]>();
654657
const filesOutsideTreeRoot = new Map<FolderAbsPath, IDiffStatus[]>();
655658

656-
const diff = await diffIndex(this.repository!, this.mergeBase, this.refreshIndex, this.findRenames, this.renameThreshold, this.omitUntrackedFiles, this.omitUnstagedChanges);
659+
const diff = await diffIndex(this.repository!, this.mergeBase, this.refreshIndex, this.findRenames, this.renameThreshold, this.omitUntrackedFiles, this.omitUnstagedChanges, this.showDiffStats);
657660
const untrackedCount = diff.reduce((prev, cur, _) => prev + (cur.status === 'U' ? 1 : 0), 0);
658661
this.log(`${diff.length} diff entries (${untrackedCount} untracked)`);
659662

@@ -797,7 +800,7 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
797800
// Always refresh when sorting by recently modified in list view, as file mtimes may have changed
798801
const needsRefreshForSorting = this.viewAsList && this.sortOrder === 'recentlyModified';
799802

800-
if (fireChangeEvents && (treeHasChanged || needsRefreshForSorting)) {
803+
if (fireChangeEvents && (treeHasChanged || needsRefreshForSorting || this.showDiffStats)) {
801804
this.log('Refreshing tree')
802805
this.fireTreeDataChange();
803806
}
@@ -884,6 +887,7 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
884887
const oldOmitUntrackedFiles = this.omitUntrackedFiles;
885888
const oldOmitUnstagedChanges = this.omitUnstagedChanges;
886889
const oldSortOrder = this.sortOrder;
890+
const oldShowDiffStats = this.showDiffStats;
887891
this.readConfig();
888892
if (oldTreeRootIsRepo != this.treeRootIsRepo ||
889893
oldInclude != this.includeFilesOutsideWorkspaceFolderRoot ||
@@ -898,7 +902,8 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
898902
oldshowCheckboxes != this.showCheckboxes ||
899903
oldOmitUntrackedFiles != this.omitUntrackedFiles ||
900904
oldOmitUnstagedChanges != this.omitUnstagedChanges ||
901-
oldSortOrder != this.sortOrder) {
905+
oldSortOrder != this.sortOrder ||
906+
oldShowDiffStats != this.showDiffStats) {
902907

903908
if (!this.repository) {
904909
return;
@@ -916,7 +921,8 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
916921
(!oldAutoRefresh && this.autoRefresh) ||
917922
(!oldRefreshIndex && this.refreshIndex) ||
918923
oldOmitUntrackedFiles != this.omitUntrackedFiles ||
919-
oldOmitUnstagedChanges != this.omitUnstagedChanges) {
924+
oldOmitUnstagedChanges != this.omitUnstagedChanges ||
925+
oldShowDiffStats != this.showDiffStats) {
920926
try {
921927
await this.updateRefs(this.baseRef);
922928
await this.updateDiff(false);
@@ -983,7 +989,7 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
983989
for (const file of fileEntries) {
984990
if (this.matchesFilter(file.dstAbsPath, relPathBase)) {
985991
const dstRelPath = path.relative(relPathBase, file.dstAbsPath);
986-
entries.push(new FileElement(file.srcAbsPath, file.dstAbsPath, dstRelPath, file.status, file.isSubmodule));
992+
entries.push(new FileElement(file.srcAbsPath, file.dstAbsPath, dstRelPath, file.status, file.isSubmodule, file.stats));
987993
}
988994
}
989995
}
@@ -1046,7 +1052,7 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
10461052
for (const file of fileEntries) {
10471053
if (this.matchesFilter(file.dstAbsPath, relPathBase)) {
10481054
const dstRelPath = path.relative(relPathBase, file.dstAbsPath);
1049-
entries.push(new FileElement(file.srcAbsPath, file.dstAbsPath, dstRelPath, file.status, file.isSubmodule));
1055+
entries.push(new FileElement(file.srcAbsPath, file.dstAbsPath, dstRelPath, file.status, file.isSubmodule, file.stats));
10501056
}
10511057
}
10521058
}
@@ -1772,18 +1778,37 @@ function getElementId(element: Element): string {
17721778
}
17731779
}
17741780

1781+
function formatDiffStats(stats: IDiffStats): string {
1782+
if (stats.isBinary) {
1783+
return 'binary';
1784+
}
1785+
const parts: string[] = [];
1786+
if (stats.insertions !== undefined && stats.insertions > 0) {
1787+
parts.push(`+${stats.insertions}`);
1788+
}
1789+
if (stats.deletions !== undefined && stats.deletions > 0) {
1790+
parts.push(`-${stats.deletions}`);
1791+
}
1792+
return parts.join(' ');
1793+
}
1794+
17751795
function toTreeItem(element: Element, openChangesOnSelect: boolean, iconsMinimal: boolean,
1776-
showCollapsed: boolean, viewAsList: boolean,
1796+
showCollapsed: boolean, viewAsList: boolean, showDiffStats: boolean,
17771797
checkboxState: TreeItemCheckboxState | undefined,
17781798
asAbsolutePath: (relPath: string) => string): TreeItem {
17791799
const gitIconRoot = asAbsolutePath('resources/git-icons');
17801800
if (element instanceof FileElement) {
1781-
const item = new TreeItem(element.label);
1801+
const statsText = showDiffStats && element.stats ? formatDiffStats(element.stats) : '';
1802+
const displayLabel = statsText ? `${element.label} ${statsText}` : element.label;
1803+
const item = new TreeItem(displayLabel);
17821804
const statusText = getStatusText(element);
17831805
item.tooltip = `${element.dstAbsPath}${statusText}`;
17841806
if (element.srcAbsPath !== element.dstAbsPath) {
17851807
item.tooltip = `${element.srcAbsPath}${item.tooltip}`;
17861808
}
1809+
if (statsText) {
1810+
item.tooltip = `${item.tooltip}${statsText}`;
1811+
}
17871812
if (viewAsList) {
17881813
item.description = path.dirname(element.dstRelPath);
17891814
if (item.description === '.') {

0 commit comments

Comments
 (0)