Skip to content

Commit f313eb1

Browse files
committed
Fixes branch contribution overview
- Gets "best" merge-base - Adds more details and contributor scoring
1 parent 7b1bf63 commit f313eb1

File tree

23 files changed

+340
-130
lines changed

23 files changed

+340
-130
lines changed

src/config.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,15 @@ export const enum CodeLensCommand {
8383
}
8484

8585
export type CodeLensScopes = 'document' | 'containers' | 'blocks';
86-
export type ContributorSorting = 'count:desc' | 'count:asc' | 'date:desc' | 'date:asc' | 'name:asc' | 'name:desc';
86+
export type ContributorSorting =
87+
| 'count:desc'
88+
| 'count:asc'
89+
| 'date:desc'
90+
| 'date:asc'
91+
| 'name:asc'
92+
| 'name:desc'
93+
| 'score:desc'
94+
| 'score:asc';
8795
export type RepositoriesSorting = 'discovered' | 'lastFetched:desc' | 'lastFetched:asc' | 'name:asc' | 'name:desc';
8896
export type CustomRemoteType =
8997
| 'AzureDevOps'

src/env/node/git/localGitProvider.ts

Lines changed: 153 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
WorktreeDeleteErrorReason,
4242
} from '../../../git/errors';
4343
import type {
44-
BranchContributorOverview,
44+
BranchContributionsOverview,
4545
GitCaches,
4646
GitDir,
4747
GitProvider,
@@ -75,6 +75,7 @@ import type { GitStashCommit } from '../../../git/models/commit';
7575
import { GitCommit, GitCommitIdentity } from '../../../git/models/commit';
7676
import type { GitContributorStats } from '../../../git/models/contributor';
7777
import { GitContributor } from '../../../git/models/contributor';
78+
import { calculateContributionScore } from '../../../git/models/contributor.utils';
7879
import type {
7980
GitDiff,
8081
GitDiffFile,
@@ -3083,37 +3084,54 @@ export class LocalGitProvider implements GitProvider, Disposable {
30833084
const data = await this.git.log(repoPath, { ref: options?.ref }, ...args);
30843085

30853086
const contributors = new Map<string, GitContributor>();
3086-
30873087
const commits = parser.parse(data);
30883088
for (const c of commits) {
30893089
const key = `${c.author}|${c.email}`;
3090-
let contributor = contributors.get(key);
3090+
const timestamp = Number(c.date) * 1000;
3091+
3092+
let contributor: Mutable<GitContributor> | undefined = contributors.get(key);
30913093
if (contributor == null) {
30923094
contributor = new GitContributor(
30933095
repoPath,
30943096
c.author,
30953097
c.email,
30963098
1,
3097-
new Date(Number(c.date) * 1000),
3099+
new Date(timestamp),
3100+
new Date(timestamp),
30983101
isUserMatch(currentUser, c.author, c.email),
3099-
c.stats,
3102+
c.stats
3103+
? {
3104+
...c.stats,
3105+
contributionScore: calculateContributionScore(c.stats, timestamp),
3106+
}
3107+
: undefined,
31003108
);
31013109
contributors.set(key, contributor);
31023110
} else {
3103-
(contributor as PickMutable<GitContributor, 'count'>).count++;
3104-
if (options?.stats && c.stats != null) {
3105-
(contributor as PickMutable<GitContributor, 'stats'>).stats =
3106-
contributor.stats == null
3107-
? c.stats
3108-
: {
3109-
additions: contributor.stats.additions + c.stats.additions,
3110-
deletions: contributor.stats.deletions + c.stats.deletions,
3111-
files: contributor.stats.files + c.stats.files,
3112-
};
3111+
contributor.commits++;
3112+
const date = new Date(timestamp);
3113+
if (date > contributor.latestCommitDate!) {
3114+
contributor.latestCommitDate = date;
31133115
}
3114-
const date = new Date(Number(c.date) * 1000);
3115-
if (date > contributor.date!) {
3116-
(contributor as PickMutable<GitContributor, 'date'>).date = date;
3116+
if (date < contributor.firstCommitDate!) {
3117+
contributor.firstCommitDate = date;
3118+
}
3119+
if (options?.stats && c.stats != null) {
3120+
if (contributor.stats == null) {
3121+
contributor.stats = {
3122+
...c.stats,
3123+
contributionScore: calculateContributionScore(c.stats, timestamp),
3124+
};
3125+
} else {
3126+
contributor.stats = {
3127+
additions: contributor.stats.additions + c.stats.additions,
3128+
deletions: contributor.stats.deletions + c.stats.deletions,
3129+
files: contributor.stats.files + c.stats.files,
3130+
contributionScore:
3131+
contributor.stats.contributionScore +
3132+
calculateContributionScore(c.stats, timestamp),
3133+
};
3134+
}
31173135
}
31183136
}
31193137
}
@@ -3211,8 +3229,6 @@ export class LocalGitProvider implements GitProvider, Disposable {
32113229

32123230
@log({ exit: true })
32133231
async getBaseBranchName(repoPath: string, ref: string): Promise<string | undefined> {
3214-
const mergeBaseConfigKey: GitConfigKeys = `branch.${ref}.gk-merge-base`;
3215-
32163232
try {
32173233
const pattern = `^branch\\.${ref}\\.`;
32183234
const data = await this.git.config__get_regex(pattern, repoPath);
@@ -3238,27 +3254,38 @@ export class LocalGitProvider implements GitProvider, Disposable {
32383254
}
32393255

32403256
if (mergeBase != null) {
3241-
const [branch] = (await this.getBranches(repoPath, { filter: b => b.name === mergeBase })).values;
3257+
const branch = await this.getValidatedBranchName(repoPath, mergeBase);
32423258
if (branch != null) {
32433259
if (update) {
3244-
void this.setConfig(repoPath, mergeBaseConfigKey, branch.name);
3260+
void this.setBaseBranchName(repoPath, ref, branch);
32453261
}
3246-
return branch.name;
3262+
return branch;
32473263
}
32483264
}
32493265
}
32503266
} catch {}
32513267

3252-
const branch = await this.getBaseBranchFromReflog(repoPath, ref);
3253-
if (branch?.upstream != null) {
3254-
void this.setConfig(repoPath, mergeBaseConfigKey, branch.upstream.name);
3255-
return branch.upstream.name;
3268+
const branch = await this.getBaseBranchFromReflog(repoPath, ref, { upstream: true });
3269+
if (branch != null) {
3270+
void this.setBaseBranchName(repoPath, ref, branch);
3271+
return branch;
32563272
}
32573273

32583274
return undefined;
32593275
}
32603276

3261-
private async getBaseBranchFromReflog(repoPath: string, ref: string): Promise<GitBranch | undefined> {
3277+
@log()
3278+
async setBaseBranchName(repoPath: string, ref: string, base: string): Promise<void> {
3279+
const mergeBaseConfigKey: GitConfigKeys = `branch.${ref}.gk-merge-base`;
3280+
3281+
await this.setConfig(repoPath, mergeBaseConfigKey, base);
3282+
}
3283+
3284+
private async getBaseBranchFromReflog(
3285+
repoPath: string,
3286+
ref: string,
3287+
options?: { upstream: true },
3288+
): Promise<string | undefined> {
32623289
try {
32633290
let data = await this.git.reflog(repoPath, undefined, ref, '--grep-reflog=branch: Created from *.');
32643291

@@ -3268,10 +3295,10 @@ export class LocalGitProvider implements GitProvider, Disposable {
32683295
// Check if branch created from an explicit branch
32693296
let match = entries[0].match(/branch: Created from (.*)$/);
32703297
if (match != null && match.length === 2) {
3271-
const name = match[1];
3298+
let name: string | undefined = match[1];
32723299
if (name !== 'HEAD') {
3273-
const [branch] = (await this.getBranches(repoPath, { filter: b => b.name === name })).values;
3274-
return branch;
3300+
name = await this.getValidatedBranchName(repoPath, options?.upstream ? `${name}@{u}` : name);
3301+
if (name) return name;
32753302
}
32763303
}
32773304

@@ -3288,15 +3315,28 @@ export class LocalGitProvider implements GitProvider, Disposable {
32883315

32893316
match = entries[entries.length - 1].match(/checkout: moving from ([^\s]+)\s/);
32903317
if (match != null && match.length === 2) {
3291-
const name = match[1];
3292-
const [branch] = (await this.getBranches(repoPath, { filter: b => b.name === name })).values;
3293-
return branch;
3318+
let name: string | undefined = match[1];
3319+
name = await this.getValidatedBranchName(repoPath, options?.upstream ? `${name}@{u}` : name);
3320+
if (name) return name;
32943321
}
32953322
} catch {}
32963323

32973324
return undefined;
32983325
}
32993326

3327+
private async getValidatedBranchName(repoPath: string, name: string): Promise<string | undefined> {
3328+
const data = await this.git.git<string>(
3329+
{ cwd: repoPath },
3330+
'rev-parse',
3331+
'--verify',
3332+
'--quiet',
3333+
'--symbolic-full-name',
3334+
'--abbrev-ref',
3335+
name,
3336+
);
3337+
return data?.trim() || undefined;
3338+
}
3339+
33003340
@log({ exit: true })
33013341
async getDefaultBranchName(repoPath: string | undefined, remote?: string): Promise<string | undefined> {
33023342
if (repoPath == null) return undefined;
@@ -3316,12 +3356,30 @@ export class LocalGitProvider implements GitProvider, Disposable {
33163356

33173357
try {
33183358
const data = await this.git.symbolic_ref(repoPath, `refs/remotes/origin/HEAD`);
3319-
if (data != null) return data.trim();
3359+
return data?.trim() || undefined;
33203360
} catch {}
33213361

33223362
return undefined;
33233363
}
33243364

3365+
@log({ exit: true })
3366+
async getTargetBranchName(repoPath: string, ref: string): Promise<string | undefined> {
3367+
const targetBaseConfigKey: GitConfigKeys = `branch.${ref}.gk-target-base`;
3368+
3369+
let target = await this.getConfig(repoPath, targetBaseConfigKey);
3370+
if (target != null) {
3371+
target = await this.getValidatedBranchName(repoPath, target);
3372+
}
3373+
return target?.trim() || undefined;
3374+
}
3375+
3376+
@log()
3377+
async setTargetBranchName(repoPath: string, ref: string, target: string): Promise<void> {
3378+
const targetBaseConfigKey: GitConfigKeys = `branch.${ref}.gk-target-base`;
3379+
3380+
await this.setConfig(repoPath, targetBaseConfigKey, target);
3381+
}
3382+
33253383
@log()
33263384
async getDiff(
33273385
repoPath: string,
@@ -6439,19 +6497,74 @@ export class LocalGitProvider implements GitProvider, Disposable {
64396497
}
64406498

64416499
@log()
6442-
async getBranchContributorOverview(repoPath: string, ref: string): Promise<BranchContributorOverview | undefined> {
6500+
async getBranchContributionsOverview(
6501+
repoPath: string,
6502+
ref: string,
6503+
): Promise<BranchContributionsOverview | undefined> {
64436504
const scope = getLogScope();
64446505

64456506
try {
6446-
const base = await this.getBaseBranchName(repoPath, ref);
6507+
let baseOrTargetBranch = await this.getBaseBranchName(repoPath, ref);
6508+
// If the base looks like its remote branch, look for the target or default
6509+
if (baseOrTargetBranch == null || baseOrTargetBranch.endsWith(`/${ref}`)) {
6510+
baseOrTargetBranch = await this.getTargetBranchName(repoPath, ref);
6511+
baseOrTargetBranch ??= await this.getDefaultBranchName(repoPath);
6512+
if (baseOrTargetBranch == null) return undefined;
6513+
}
6514+
6515+
const mergeBase = await this.getMergeBase(repoPath, ref, baseOrTargetBranch);
6516+
if (mergeBase == null) return undefined;
6517+
64476518
const contributors = await this.getContributors(repoPath, {
6448-
ref: createRevisionRange(ref, base, '...'),
6519+
ref: createRevisionRange(mergeBase, ref, '..'),
64496520
stats: true,
64506521
});
64516522

6452-
sortContributors(contributors, { orderBy: 'count:desc' });
6523+
sortContributors(contributors, { orderBy: 'score:desc' });
6524+
6525+
let totalCommits = 0;
6526+
let totalFiles = 0;
6527+
let totalAdditions = 0;
6528+
let totalDeletions = 0;
6529+
let firstCommitTimestamp;
6530+
let latestCommitTimestamp;
6531+
6532+
for (const c of contributors) {
6533+
totalCommits += c.commits;
6534+
totalFiles += c.stats?.files ?? 0;
6535+
totalAdditions += c.stats?.additions ?? 0;
6536+
totalDeletions += c.stats?.deletions ?? 0;
6537+
6538+
const firstTimestamp = c.firstCommitDate?.getTime();
6539+
const latestTimestamp = c.latestCommitDate?.getTime();
6540+
6541+
if (firstTimestamp != null || latestTimestamp != null) {
6542+
firstCommitTimestamp =
6543+
firstCommitTimestamp != null
6544+
? Math.min(firstCommitTimestamp, firstTimestamp ?? Infinity, latestTimestamp ?? Infinity)
6545+
: firstTimestamp ?? latestTimestamp;
6546+
6547+
latestCommitTimestamp =
6548+
latestCommitTimestamp != null
6549+
? Math.max(latestCommitTimestamp, firstTimestamp ?? -Infinity, latestTimestamp ?? -Infinity)
6550+
: latestTimestamp ?? firstTimestamp;
6551+
}
6552+
}
6553+
64536554
return {
6454-
// owner: contributors.find(c => c.email === this.getCurrentUser(repoPath)?.email),
6555+
repoPath: repoPath,
6556+
branch: ref,
6557+
baseOrTargetBranch: baseOrTargetBranch,
6558+
mergeBase: mergeBase,
6559+
6560+
commits: totalCommits,
6561+
files: totalFiles,
6562+
additions: totalAdditions,
6563+
deletions: totalDeletions,
6564+
6565+
latestCommitDate: latestCommitTimestamp != null ? new Date(latestCommitTimestamp) : undefined,
6566+
firstCommitDate: firstCommitTimestamp != null ? new Date(firstCommitTimestamp) : undefined,
6567+
64556568
contributors: contributors,
64566569
};
64576570
} catch (ex) {

src/git/gitProvider.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { Features } from '../features';
77
import type { GitUri } from './gitUri';
88
import type { GitBlame, GitBlameLine } from './models/blame';
99
import type { GitBranch } from './models/branch';
10-
import type { GitCommit } from './models/commit';
10+
import type { GitCommit, GitCommitStats } from './models/commit';
1111
import type { GitContributor, GitContributorStats } from './models/contributor';
1212
import type { GitDiff, GitDiffFile, GitDiffFiles, GitDiffFilter, GitDiffLine, GitDiffShortStat } from './models/diff';
1313
import type { GitFile, GitFileChange } from './models/file';
@@ -115,9 +115,17 @@ export interface RepositoryVisibilityInfo {
115115
remotesHash?: string;
116116
}
117117

118-
export interface BranchContributorOverview {
119-
readonly owner?: GitContributor;
120-
readonly contributors?: GitContributor[];
118+
export interface BranchContributionsOverview extends GitCommitStats<number> {
119+
readonly repoPath: string;
120+
readonly branch: string;
121+
readonly baseOrTargetBranch: string;
122+
readonly mergeBase: string;
123+
124+
readonly commits: number;
125+
readonly latestCommitDate: Date | undefined;
126+
readonly firstCommitDate: Date | undefined;
127+
128+
readonly contributors: GitContributor[];
121129
}
122130

123131
export interface GitProviderRepository {
@@ -188,7 +196,7 @@ export interface GitProviderRepository {
188196
sort?: boolean | BranchSortOptions | undefined;
189197
},
190198
): Promise<PagedResult<GitBranch>>;
191-
getBranchContributorOverview?(repoPath: string, ref: string): Promise<BranchContributorOverview | undefined>;
199+
getBranchContributionsOverview?(repoPath: string, ref: string): Promise<BranchContributionsOverview | undefined>;
192200
getChangedFilesCount(repoPath: string, ref?: string): Promise<GitDiffShortStat | undefined>;
193201
getCommit(repoPath: string, ref: string): Promise<GitCommit | undefined>;
194202
getCommitBranches(
@@ -244,7 +252,10 @@ export interface GitProviderRepository {
244252
): Promise<GitContributor[]>;
245253
getCurrentUser(repoPath: string): Promise<GitUser | undefined>;
246254
getBaseBranchName?(repoPath: string, ref: string): Promise<string | undefined>;
255+
setBaseBranchName?(repoPath: string, ref: string, base: string): Promise<void>;
247256
getDefaultBranchName(repoPath: string | undefined, remote?: string): Promise<string | undefined>;
257+
getTargetBranchName?(repoPath: string, ref: string): Promise<string | undefined>;
258+
setTargetBranchName?(repoPath: string, ref: string, target: string): Promise<void>;
248259
getDiff?(
249260
repoPath: string | Uri,
250261
to: string,

0 commit comments

Comments
 (0)