Skip to content

Commit

Permalink
Fixes branch contribution overview
Browse files Browse the repository at this point in the history
  - Gets "best" merge-base
  - Adds more details and contributor scoring
  • Loading branch information
eamodio committed Dec 23, 2024
1 parent 7b1bf63 commit f313eb1
Show file tree
Hide file tree
Showing 23 changed files with 340 additions and 130 deletions.
10 changes: 9 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,15 @@ export const enum CodeLensCommand {
}

export type CodeLensScopes = 'document' | 'containers' | 'blocks';
export type ContributorSorting = 'count:desc' | 'count:asc' | 'date:desc' | 'date:asc' | 'name:asc' | 'name:desc';
export type ContributorSorting =
| 'count:desc'
| 'count:asc'
| 'date:desc'
| 'date:asc'
| 'name:asc'
| 'name:desc'
| 'score:desc'
| 'score:asc';
export type RepositoriesSorting = 'discovered' | 'lastFetched:desc' | 'lastFetched:asc' | 'name:asc' | 'name:desc';
export type CustomRemoteType =
| 'AzureDevOps'
Expand Down
193 changes: 153 additions & 40 deletions src/env/node/git/localGitProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
WorktreeDeleteErrorReason,
} from '../../../git/errors';
import type {
BranchContributorOverview,
BranchContributionsOverview,
GitCaches,
GitDir,
GitProvider,
Expand Down Expand Up @@ -75,6 +75,7 @@ import type { GitStashCommit } from '../../../git/models/commit';
import { GitCommit, GitCommitIdentity } from '../../../git/models/commit';
import type { GitContributorStats } from '../../../git/models/contributor';
import { GitContributor } from '../../../git/models/contributor';
import { calculateContributionScore } from '../../../git/models/contributor.utils';
import type {
GitDiff,
GitDiffFile,
Expand Down Expand Up @@ -3083,37 +3084,54 @@ export class LocalGitProvider implements GitProvider, Disposable {
const data = await this.git.log(repoPath, { ref: options?.ref }, ...args);

const contributors = new Map<string, GitContributor>();

const commits = parser.parse(data);
for (const c of commits) {
const key = `${c.author}|${c.email}`;
let contributor = contributors.get(key);
const timestamp = Number(c.date) * 1000;

let contributor: Mutable<GitContributor> | undefined = contributors.get(key);
if (contributor == null) {
contributor = new GitContributor(
repoPath,
c.author,
c.email,
1,
new Date(Number(c.date) * 1000),
new Date(timestamp),
new Date(timestamp),
isUserMatch(currentUser, c.author, c.email),
c.stats,
c.stats
? {
...c.stats,
contributionScore: calculateContributionScore(c.stats, timestamp),
}
: undefined,
);
contributors.set(key, contributor);
} else {
(contributor as PickMutable<GitContributor, 'count'>).count++;
if (options?.stats && c.stats != null) {
(contributor as PickMutable<GitContributor, 'stats'>).stats =
contributor.stats == null
? c.stats
: {
additions: contributor.stats.additions + c.stats.additions,
deletions: contributor.stats.deletions + c.stats.deletions,
files: contributor.stats.files + c.stats.files,
};
contributor.commits++;
const date = new Date(timestamp);
if (date > contributor.latestCommitDate!) {
contributor.latestCommitDate = date;
}
const date = new Date(Number(c.date) * 1000);
if (date > contributor.date!) {
(contributor as PickMutable<GitContributor, 'date'>).date = date;
if (date < contributor.firstCommitDate!) {
contributor.firstCommitDate = date;
}
if (options?.stats && c.stats != null) {
if (contributor.stats == null) {
contributor.stats = {
...c.stats,
contributionScore: calculateContributionScore(c.stats, timestamp),
};
} else {
contributor.stats = {
additions: contributor.stats.additions + c.stats.additions,
deletions: contributor.stats.deletions + c.stats.deletions,
files: contributor.stats.files + c.stats.files,
contributionScore:
contributor.stats.contributionScore +
calculateContributionScore(c.stats, timestamp),
};
}
}
}
}
Expand Down Expand Up @@ -3211,8 +3229,6 @@ export class LocalGitProvider implements GitProvider, Disposable {

@log({ exit: true })
async getBaseBranchName(repoPath: string, ref: string): Promise<string | undefined> {
const mergeBaseConfigKey: GitConfigKeys = `branch.${ref}.gk-merge-base`;

try {
const pattern = `^branch\\.${ref}\\.`;
const data = await this.git.config__get_regex(pattern, repoPath);
Expand All @@ -3238,27 +3254,38 @@ export class LocalGitProvider implements GitProvider, Disposable {
}

if (mergeBase != null) {
const [branch] = (await this.getBranches(repoPath, { filter: b => b.name === mergeBase })).values;
const branch = await this.getValidatedBranchName(repoPath, mergeBase);
if (branch != null) {
if (update) {
void this.setConfig(repoPath, mergeBaseConfigKey, branch.name);
void this.setBaseBranchName(repoPath, ref, branch);
}
return branch.name;
return branch;
}
}
}
} catch {}

const branch = await this.getBaseBranchFromReflog(repoPath, ref);
if (branch?.upstream != null) {
void this.setConfig(repoPath, mergeBaseConfigKey, branch.upstream.name);
return branch.upstream.name;
const branch = await this.getBaseBranchFromReflog(repoPath, ref, { upstream: true });
if (branch != null) {
void this.setBaseBranchName(repoPath, ref, branch);
return branch;
}

return undefined;
}

private async getBaseBranchFromReflog(repoPath: string, ref: string): Promise<GitBranch | undefined> {
@log()
async setBaseBranchName(repoPath: string, ref: string, base: string): Promise<void> {
const mergeBaseConfigKey: GitConfigKeys = `branch.${ref}.gk-merge-base`;

await this.setConfig(repoPath, mergeBaseConfigKey, base);
}

private async getBaseBranchFromReflog(
repoPath: string,
ref: string,
options?: { upstream: true },
): Promise<string | undefined> {
try {
let data = await this.git.reflog(repoPath, undefined, ref, '--grep-reflog=branch: Created from *.');

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

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

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

return undefined;
}

private async getValidatedBranchName(repoPath: string, name: string): Promise<string | undefined> {
const data = await this.git.git<string>(
{ cwd: repoPath },
'rev-parse',
'--verify',
'--quiet',
'--symbolic-full-name',
'--abbrev-ref',
name,
);
return data?.trim() || undefined;
}

@log({ exit: true })
async getDefaultBranchName(repoPath: string | undefined, remote?: string): Promise<string | undefined> {
if (repoPath == null) return undefined;
Expand All @@ -3316,12 +3356,30 @@ export class LocalGitProvider implements GitProvider, Disposable {

try {
const data = await this.git.symbolic_ref(repoPath, `refs/remotes/origin/HEAD`);
if (data != null) return data.trim();
return data?.trim() || undefined;
} catch {}

return undefined;
}

@log({ exit: true })
async getTargetBranchName(repoPath: string, ref: string): Promise<string | undefined> {
const targetBaseConfigKey: GitConfigKeys = `branch.${ref}.gk-target-base`;

let target = await this.getConfig(repoPath, targetBaseConfigKey);
if (target != null) {
target = await this.getValidatedBranchName(repoPath, target);
}
return target?.trim() || undefined;
}

@log()
async setTargetBranchName(repoPath: string, ref: string, target: string): Promise<void> {
const targetBaseConfigKey: GitConfigKeys = `branch.${ref}.gk-target-base`;

await this.setConfig(repoPath, targetBaseConfigKey, target);
}

@log()
async getDiff(
repoPath: string,
Expand Down Expand Up @@ -6439,19 +6497,74 @@ export class LocalGitProvider implements GitProvider, Disposable {
}

@log()
async getBranchContributorOverview(repoPath: string, ref: string): Promise<BranchContributorOverview | undefined> {
async getBranchContributionsOverview(
repoPath: string,
ref: string,
): Promise<BranchContributionsOverview | undefined> {
const scope = getLogScope();

try {
const base = await this.getBaseBranchName(repoPath, ref);
let baseOrTargetBranch = await this.getBaseBranchName(repoPath, ref);
// If the base looks like its remote branch, look for the target or default
if (baseOrTargetBranch == null || baseOrTargetBranch.endsWith(`/${ref}`)) {
baseOrTargetBranch = await this.getTargetBranchName(repoPath, ref);
baseOrTargetBranch ??= await this.getDefaultBranchName(repoPath);
if (baseOrTargetBranch == null) return undefined;
}

const mergeBase = await this.getMergeBase(repoPath, ref, baseOrTargetBranch);
if (mergeBase == null) return undefined;

const contributors = await this.getContributors(repoPath, {
ref: createRevisionRange(ref, base, '...'),
ref: createRevisionRange(mergeBase, ref, '..'),
stats: true,
});

sortContributors(contributors, { orderBy: 'count:desc' });
sortContributors(contributors, { orderBy: 'score:desc' });

let totalCommits = 0;
let totalFiles = 0;
let totalAdditions = 0;
let totalDeletions = 0;
let firstCommitTimestamp;
let latestCommitTimestamp;

for (const c of contributors) {
totalCommits += c.commits;
totalFiles += c.stats?.files ?? 0;
totalAdditions += c.stats?.additions ?? 0;
totalDeletions += c.stats?.deletions ?? 0;

const firstTimestamp = c.firstCommitDate?.getTime();
const latestTimestamp = c.latestCommitDate?.getTime();

if (firstTimestamp != null || latestTimestamp != null) {
firstCommitTimestamp =
firstCommitTimestamp != null
? Math.min(firstCommitTimestamp, firstTimestamp ?? Infinity, latestTimestamp ?? Infinity)
: firstTimestamp ?? latestTimestamp;

latestCommitTimestamp =
latestCommitTimestamp != null
? Math.max(latestCommitTimestamp, firstTimestamp ?? -Infinity, latestTimestamp ?? -Infinity)
: latestTimestamp ?? firstTimestamp;
}
}

return {
// owner: contributors.find(c => c.email === this.getCurrentUser(repoPath)?.email),
repoPath: repoPath,
branch: ref,
baseOrTargetBranch: baseOrTargetBranch,
mergeBase: mergeBase,

commits: totalCommits,
files: totalFiles,
additions: totalAdditions,
deletions: totalDeletions,

latestCommitDate: latestCommitTimestamp != null ? new Date(latestCommitTimestamp) : undefined,
firstCommitDate: firstCommitTimestamp != null ? new Date(firstCommitTimestamp) : undefined,

contributors: contributors,
};
} catch (ex) {
Expand Down
21 changes: 16 additions & 5 deletions src/git/gitProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Features } from '../features';
import type { GitUri } from './gitUri';
import type { GitBlame, GitBlameLine } from './models/blame';
import type { GitBranch } from './models/branch';
import type { GitCommit } from './models/commit';
import type { GitCommit, GitCommitStats } from './models/commit';
import type { GitContributor, GitContributorStats } from './models/contributor';
import type { GitDiff, GitDiffFile, GitDiffFiles, GitDiffFilter, GitDiffLine, GitDiffShortStat } from './models/diff';
import type { GitFile, GitFileChange } from './models/file';
Expand Down Expand Up @@ -115,9 +115,17 @@ export interface RepositoryVisibilityInfo {
remotesHash?: string;
}

export interface BranchContributorOverview {
readonly owner?: GitContributor;
readonly contributors?: GitContributor[];
export interface BranchContributionsOverview extends GitCommitStats<number> {
readonly repoPath: string;
readonly branch: string;
readonly baseOrTargetBranch: string;
readonly mergeBase: string;

readonly commits: number;
readonly latestCommitDate: Date | undefined;
readonly firstCommitDate: Date | undefined;

readonly contributors: GitContributor[];
}

export interface GitProviderRepository {
Expand Down Expand Up @@ -188,7 +196,7 @@ export interface GitProviderRepository {
sort?: boolean | BranchSortOptions | undefined;
},
): Promise<PagedResult<GitBranch>>;
getBranchContributorOverview?(repoPath: string, ref: string): Promise<BranchContributorOverview | undefined>;
getBranchContributionsOverview?(repoPath: string, ref: string): Promise<BranchContributionsOverview | undefined>;
getChangedFilesCount(repoPath: string, ref?: string): Promise<GitDiffShortStat | undefined>;
getCommit(repoPath: string, ref: string): Promise<GitCommit | undefined>;
getCommitBranches(
Expand Down Expand Up @@ -244,7 +252,10 @@ export interface GitProviderRepository {
): Promise<GitContributor[]>;
getCurrentUser(repoPath: string): Promise<GitUser | undefined>;
getBaseBranchName?(repoPath: string, ref: string): Promise<string | undefined>;
setBaseBranchName?(repoPath: string, ref: string, base: string): Promise<void>;
getDefaultBranchName(repoPath: string | undefined, remote?: string): Promise<string | undefined>;
getTargetBranchName?(repoPath: string, ref: string): Promise<string | undefined>;
setTargetBranchName?(repoPath: string, ref: string, target: string): Promise<void>;
getDiff?(
repoPath: string | Uri,
to: string,
Expand Down
Loading

0 comments on commit f313eb1

Please sign in to comment.