diff --git a/CHANGELOG.md b/CHANGELOG.md index c088ba0..9003c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Adds the ability to filter comparisons to show only either the left-side or right-side file differences - Adds the _Open Folder History_ command to root folders — closes [#1505](https://github.com/eamodio/vscode-gitlens/issues/1505) +- Adds the ability to show contributor statistics, files changed as well as lines added and deleted (can take a while to compute depending on the repository) — closes [#1489](https://github.com/eamodio/vscode-gitlens/issues/1489) + - Adds a _Show Statistics_ / _Hide Statistics_ toggle to the `...` menu of the _Contributors_ view + - Adds a `gitlens.views.contributors.showStatistics` settings to specify whether to show contributor statistics in the _Contributors_ view ### Changed diff --git a/README.md b/README.md index 2ada58a..424c4cd 100644 --- a/README.md +++ b/README.md @@ -884,6 +884,7 @@ See also [View Settings](#view-settings- 'Jump to the View settings') | `gitlens.views.contributors.pullRequests.enabled` | Specifies whether to query for pull requests associated with the current branch and commits in the _Contributors_ view. Requires a connection to a supported remote service (e.g. GitHub) | | `gitlens.views.contributors.pullRequests.showForCommits` | Specifies whether to show pull requests (if any) associated with the current branch in the _Contributors_ view. Requires a connection to a supported remote service (e.g. GitHub) | | `gitlens.views.contributors.showAllBranches` | Specifies whether to show commits from all branches in the _Contributors_ view | +| `gitlens.views.contributors.showStatistics` | Specifies whether to show contributor statistics in the _Contributors_ view. This can take a while to compute depending on the repository size | ## Search & Compare View Settings [#](#search-&-compare-view-settings- 'Search & Compare View Settings') diff --git a/package.json b/package.json index ea0b233..af84546 100644 --- a/package.json +++ b/package.json @@ -1978,6 +1978,12 @@ "markdownDescription": "Specifies whether to show commits from all branches in the _Contributors_ view", "scope": "window" }, + "gitlens.views.contributors.showStatistics": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to show contributor statistics in the _Contributors_ view. This can take a while to compute depending on the repository size", + "scope": "window" + }, "gitlens.views.defaultItemLimit": { "type": "number", "default": 10, @@ -4628,6 +4634,16 @@ "category": "GitLens" }, { + "command": "gitlens.views.contributors.setShowStatisticsOn", + "title": "Show Statistics", + "category": "GitLens" + }, + { + "command": "gitlens.views.contributors.setShowStatisticsOff", + "title": "Hide Statistics", + "category": "GitLens" + }, + { "command": "gitlens.views.fileHistory.changeBase", "title": "Change Base...", "category": "GitLens", @@ -6190,6 +6206,14 @@ "when": "false" }, { + "command": "gitlens.views.contributors.setShowStatisticsOn", + "when": "false" + }, + { + "command": "gitlens.views.contributors.setShowStatisticsOff", + "when": "false" + }, + { "command": "gitlens.views.fileHistory.changeBase", "when": "false" }, @@ -7165,6 +7189,16 @@ "group": "5_gitlens@0" }, { + "command": "gitlens.views.contributors.setShowStatisticsOn", + "when": "view =~ /^gitlens\\.views\\.contributors/ && !config.gitlens.views.contributors.showStatistics", + "group": "5_gitlens@1" + }, + { + "command": "gitlens.views.contributors.setShowStatisticsOff", + "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.showStatistics", + "group": "5_gitlens@1" + }, + { "command": "gitlens.views.fileHistory.setEditorFollowingOn", "when": "view =~ /^gitlens\\.views\\.fileHistory/ && gitlens:views:fileHistory:canPin && !gitlens:views:fileHistory:editorFollowing", "group": "navigation@10" diff --git a/src/config.ts b/src/config.ts index c657727..2c9b751 100644 --- a/src/config.ts +++ b/src/config.ts @@ -563,6 +563,7 @@ export interface ContributorsViewConfig { showForCommits: boolean; }; showAllBranches: boolean; + showStatistics: boolean; } export interface FileHistoryViewConfig { diff --git a/src/git/git.ts b/src/git/git.ts index e47e959..7e9f5e5 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -753,7 +753,7 @@ export namespace Git { }: { all?: boolean; authors?: string[]; - format?: 'default' | 'refs' | 'shortlog'; + format?: 'default' | 'refs' | 'shortlog' | 'shortlog+stats'; limit?: number; merges?: boolean; ordering?: string | null; @@ -767,7 +767,7 @@ export namespace Git { `--format=${ format === 'refs' ? GitLogParser.simpleRefs - : format === 'shortlog' + : format === 'shortlog' || format === 'shortlog+stats' ? GitLogParser.shortlog : GitLogParser.defaultFormat }`, @@ -778,6 +778,8 @@ export namespace Git { if (format === 'default') { params.push('--name-status'); + } else if (format === 'shortlog+stats') { + params.push('--shortstat'); } if (ordering) { diff --git a/src/git/gitService.ts b/src/git/gitService.ts index fa198bf..f24222b 100644 --- a/src/git/gitService.ts +++ b/src/git/gitService.ts @@ -201,10 +201,11 @@ export class GitService implements Disposable { if (e.changed(RepositoryChange.Heads, RepositoryChange.Remotes, RepositoryChangeComparisonMode.Any)) { this._branchesCache.delete(repo.path); this._contributorsCache.delete(repo.path); + this._contributorsCache.delete(`stats|${repo.path}`); + } - if (e.changed(RepositoryChange.Remotes, RepositoryChangeComparisonMode.Any)) { - this._remotesWithApiProviderCache.clear(); - } + if (e.changed(RepositoryChange.Remotes, RepositoryChange.RemoteProviders, RepositoryChangeComparisonMode.Any)) { + this._remotesWithApiProviderCache.clear(); } if (e.changed(RepositoryChange.Index, RepositoryChange.Unknown, RepositoryChangeComparisonMode.Any)) { @@ -1530,21 +1531,29 @@ export class GitService implements Disposable { } @log() - async getContributors(repoPath: string, options?: { all?: boolean; ref?: string }): Promise { + async getContributors( + repoPath: string, + options?: { all?: boolean; ref?: string; stats?: boolean }, + ): Promise { if (repoPath == null) return []; - let contributors = this.useCaching ? this._contributorsCache.get(repoPath) : undefined; + const key = options?.stats ? `stats|${repoPath}` : repoPath; + + let contributors = this.useCaching ? this._contributorsCache.get(key) : undefined; if (contributors == null) { async function load(this: GitService) { try { const currentUser = await this.getCurrentUser(repoPath); - const data = await Git.log(repoPath, options?.ref, { all: options?.all, format: 'shortlog' }); + const data = await Git.log(repoPath, options?.ref, { + all: options?.all, + format: options?.stats ? 'shortlog+stats' : 'shortlog', + }); const shortlog = GitShortLogParser.parseFromLog(data, repoPath, currentUser); return shortlog != null ? shortlog.contributors : []; } catch (ex) { - this._contributorsCache.delete(repoPath); + this._contributorsCache.delete(key); return []; } @@ -1553,10 +1562,10 @@ export class GitService implements Disposable { contributors = load.call(this); if (this.useCaching) { - this._contributorsCache.set(repoPath, contributors); + this._contributorsCache.set(key, contributors); if (!(await this.getRepository(repoPath))?.supportsChangeEvents) { - this._contributorsCache.delete(repoPath); + this._contributorsCache.delete(key); } } } diff --git a/src/git/models/contributor.ts b/src/git/models/contributor.ts index 9d42bdc..b21679f 100644 --- a/src/git/models/contributor.ts +++ b/src/git/models/contributor.ts @@ -68,6 +68,11 @@ export class GitContributor { public readonly email: string, public readonly count: number, public readonly date: Date, + public readonly stats?: { + files: number; + additions: number; + deletions: number; + }, public readonly current: boolean = false, ) {} diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index 6c462c0..89d94c0 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -64,6 +64,7 @@ export const enum RepositoryChange { Merge = 'merge', Rebase = 'rebase', Remotes = 'remotes', + RemoteProviders = 'providers', Stash = 'stash', /* * Union of Cherry, Merge, and Rebase @@ -571,7 +572,7 @@ export class Repository implements Disposable { return Container.git.getCommit(this.path, ref); } - getContributors(options?: { all?: boolean; ref?: string }): Promise { + getContributors(options?: { all?: boolean; ref?: string; stats?: boolean }): Promise { return Container.git.getContributors(this.path, options); } @@ -635,7 +636,7 @@ export class Repository implements Disposable { ...Iterables.filterMap(await remotes, r => { if (!RichRemoteProvider.is(r.provider)) return undefined; - return r.provider.onDidChange(() => this.fireChange(RepositoryChange.Remotes)); + return r.provider.onDidChange(() => this.fireChange(RepositoryChange.RemoteProviders)); }), ); } diff --git a/src/git/parsers/shortlogParser.ts b/src/git/parsers/shortlogParser.ts index dbd7132..14276d4 100644 --- a/src/git/parsers/shortlogParser.ts +++ b/src/git/parsers/shortlogParser.ts @@ -3,6 +3,8 @@ import { GitContributor, GitShortLog, GitUser } from '../git'; import { debug } from '../../system'; const shortlogRegex = /^(.*?)\t(.*?) <(.*?)>$/gm; +const shortstatRegex = + /(?\d+) files? changed(?:, (?\d+) insertions?\(\+\))?(?:, (?\d+) deletions?\(-\))?/; export class GitShortLogParser { @debug({ args: false, singleLine: true }) @@ -48,12 +50,39 @@ export class GitShortLogParser { email: string; count: number; timestamp: number; + stats?: { + files: number; + additions: number; + deletions: number; + }; }; const contributors = new Map(); - for (const line of data.trim().split('\n')) { - const [sha, author, email, date] = line.trim().split('\0'); + const lines = data.trim().split('\n'); + for (let i = 0; i < lines.length; i++) { + const [sha, author, email, date] = lines[i].trim().split('\0'); + + let stats: + | { + files: number; + additions: number; + deletions: number; + } + | undefined; + if (lines[i + 1] === '') { + i += 2; + const match = shortstatRegex.exec(lines[i]); + + if (match?.groups != null) { + const { files, additions, deletions } = match.groups; + stats = { + files: Number(files || 0), + additions: Number(additions || 0), + deletions: Number(deletions || 0), + }; + } + } const timestamp = Number(date); @@ -65,9 +94,19 @@ export class GitShortLogParser { email: email, count: 1, timestamp: timestamp, + stats: stats, }); } else { contributor.count++; + if (stats != null) { + if (contributor.stats == null) { + contributor.stats = stats; + } else { + contributor.stats.files += stats.files; + contributor.stats.additions += stats.additions; + contributor.stats.deletions += stats.deletions; + } + } if (timestamp > contributor.timestamp) { contributor.timestamp = timestamp; } @@ -88,6 +127,7 @@ export class GitShortLogParser { c.email, c.count, new Date(Number(c.timestamp) * 1000), + c.stats, currentUser != null ? currentUser.name === c.name && currentUser.email === c.email : false, diff --git a/src/views/branchesView.ts b/src/views/branchesView.ts index 352b951..454ee18 100644 --- a/src/views/branchesView.ts +++ b/src/views/branchesView.ts @@ -54,6 +54,7 @@ export class BranchesRepositoryNode extends RepositoryFolderNode { this.view.description = `${Strings.pad(GlyphChars.Warning, 0, 2)}Auto-refresh unavailable`; } - const all = Container.config.views.contributors.showAllBranches; - - let ref: string | undefined; - // If we aren't getting all branches, get the upstream of the current branch if there is one - if (!all) { - try { - const branch = await Container.git.getBranch(this.uri.repoPath); - if (branch?.upstream?.name != null && !branch.upstream.missing) { - ref = '@{u}'; - } - } catch {} - } - - const contributors = await child.repo.getContributors({ all: all, ref: ref }); - if (contributors.length === 0) { + const children = await child.getChildren(); + + // const all = Container.config.views.contributors.showAllBranches; + + // let ref: string | undefined; + // // If we aren't getting all branches, get the upstream of the current branch if there is one + // if (!all) { + // try { + // const branch = await Container.git.getBranch(this.uri.repoPath); + // if (branch?.upstream?.name != null && !branch.upstream.missing) { + // ref = '@{u}'; + // } + // } catch {} + // } + + // const contributors = await child.repo.getContributors({ all: all, ref: ref }); + if (children.length === 0) { this.view.message = 'No contributors could be found.'; this.view.title = 'Contributors'; @@ -94,9 +96,9 @@ export class ContributorsViewNode extends ViewNode { } this.view.message = undefined; - this.view.title = `Contributors (${contributors.length})`; + this.view.title = `Contributors (${children.length})`; - return child.getChildren(); + return children; } return this.children; @@ -183,6 +185,17 @@ export class ContributorsView extends ViewBase this.setShowAvatars(true), this); commands.registerCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this); + + commands.registerCommand( + this.getQualifiedCommand('setShowStatisticsOn'), + () => this.setShowStatistics(true), + this, + ); + commands.registerCommand( + this.getQualifiedCommand('setShowStatisticsOff'), + () => this.setShowStatistics(false), + this, + ); } protected override filterConfigurationChanged(e: ConfigurationChangeEvent) { @@ -214,4 +227,8 @@ export class ContributorsView extends ViewBase implements PageableViewNode { static key = ':contributor'; @@ -79,19 +80,60 @@ export class ContributorNode extends ViewNode impl RepositoryChange.Index, RepositoryChange.Heads, RepositoryChange.Remotes, + RepositoryChange.RemoteProviders, RepositoryChange.Status, RepositoryChange.Unknown, RepositoryChangeComparisonMode.Any, diff --git a/src/views/nodes/lineHistoryNode.ts b/src/views/nodes/lineHistoryNode.ts index 5fbe3c6..27d512b 100644 --- a/src/views/nodes/lineHistoryNode.ts +++ b/src/views/nodes/lineHistoryNode.ts @@ -281,6 +281,7 @@ export class LineHistoryNode RepositoryChange.Index, RepositoryChange.Heads, RepositoryChange.Remotes, + RepositoryChange.RemoteProviders, RepositoryChange.Status, RepositoryChange.Unknown, RepositoryChangeComparisonMode.Any, diff --git a/src/views/nodes/repositoryNode.ts b/src/views/nodes/repositoryNode.ts index 5569c79..158711d 100644 --- a/src/views/nodes/repositoryNode.ts +++ b/src/views/nodes/repositoryNode.ts @@ -421,7 +421,7 @@ export class RepositoryNode extends SubscribeableViewNode { return; } - if (e.changed(RepositoryChange.Remotes, RepositoryChangeComparisonMode.Any)) { + if (e.changed(RepositoryChange.Remotes, RepositoryChange.RemoteProviders, RepositoryChangeComparisonMode.Any)) { const node = this._children.find(c => c instanceof RemotesNode); if (node != null) { void this.view.triggerNodeChange(node); diff --git a/src/views/remotesView.ts b/src/views/remotesView.ts index b25bdb5..041fae3 100644 --- a/src/views/remotesView.ts +++ b/src/views/remotesView.ts @@ -49,6 +49,7 @@ export class RemotesRepositoryNode extends RepositoryFolderNode