Skip to content

Commit f3c1604

Browse files
Adds authorship to recomposed commits
1 parent 60edf64 commit f3c1604

File tree

5 files changed

+181
-11
lines changed

5 files changed

+181
-11
lines changed

src/env/node/git/sub-providers/patch.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
WorktreeCreateError,
1111
} from '../../../../git/errors';
1212
import type { GitPatchSubProvider } from '../../../../git/gitProvider';
13-
import type { GitCommit } from '../../../../git/models/commit';
13+
import type { GitCommit, GitCommitIdentityShape } from '../../../../git/models/commit';
1414
import { log } from '../../../../system/decorators/log';
1515
import { Logger } from '../../../../system/logger';
1616
import { getLogScope } from '../../../../system/logger.scope';
@@ -178,16 +178,16 @@ export class PatchGitSubProvider implements GitPatchSubProvider {
178178
async createUnreachableCommitsFromPatches(
179179
repoPath: string,
180180
base: string | undefined,
181-
patches: { message: string; patch: string }[],
181+
patches: { message: string; patch: string; author?: GitCommitIdentityShape }[],
182182
): Promise<string[]> {
183183
// Create a temporary index file
184184
await using disposableIndex = await this.provider.staging!.createTemporaryIndex(repoPath, base);
185185
const { env } = disposableIndex;
186186

187187
const shas: string[] = [];
188188

189-
for (const { message, patch } of patches) {
190-
const sha = await this.createUnreachableCommitForPatchCore(env, repoPath, base, message, patch);
189+
for (const { message, patch, author } of patches) {
190+
const sha = await this.createUnreachableCommitForPatchCore(env, repoPath, base, message, patch, author);
191191
shas.push(sha);
192192
base = sha;
193193
}
@@ -201,6 +201,7 @@ export class PatchGitSubProvider implements GitPatchSubProvider {
201201
base: string | undefined,
202202
message: string,
203203
patch: string,
204+
author?: GitCommitIdentityShape,
204205
): Promise<string> {
205206
const scope = getLogScope();
206207

@@ -221,9 +222,18 @@ export class PatchGitSubProvider implements GitPatchSubProvider {
221222
let result = await this.git.exec({ cwd: repoPath, env: env }, 'write-tree');
222223
const tree = result.stdout.trim();
223224

225+
// Set the author if provided
226+
const commitEnv = author
227+
? {
228+
...env,
229+
GIT_AUTHOR_NAME: author.name,
230+
GIT_AUTHOR_EMAIL: author.email || '',
231+
}
232+
: env;
233+
224234
// Create new commit from the tree
225235
result = await this.git.exec(
226-
{ cwd: repoPath, env: env },
236+
{ cwd: repoPath, env: commitEnv },
227237
'commit-tree',
228238
tree,
229239
...(base ? ['-p', base] : []),

src/git/gitProvider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { GitUri } from './gitUri';
1111
import type { GitConflictFile } from './models';
1212
import type { GitBlame, GitBlameLine } from './models/blame';
1313
import type { GitBranch } from './models/branch';
14-
import type { GitCommit, GitCommitStats, GitStashCommit } from './models/commit';
14+
import type { GitCommit, GitCommitIdentityShape, GitCommitStats, GitStashCommit } from './models/commit';
1515
import type { GitContributor, GitContributorsStats } from './models/contributor';
1616
import type {
1717
GitDiff,
@@ -536,7 +536,7 @@ export interface GitPatchSubProvider {
536536
createUnreachableCommitsFromPatches(
537537
repoPath: string,
538538
base: string | undefined,
539-
patches: { message: string; patch: string }[],
539+
patches: { message: string; patch: string; author?: GitCommitIdentityShape }[],
540540
): Promise<string[]>;
541541
createEmptyInitialCommit(repoPath: string): Promise<string>;
542542

src/webviews/plus/composer/composerWebview.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import {
9090
createComposerCommitsFromGitCommits,
9191
createHunksFromDiffs,
9292
createSafetyState,
93+
getAuthorAndCoAuthorsForCombinedDiffHunk,
9394
getBranchCommits,
9495
getComposerDiffs,
9596
validateResultingDiff,
@@ -966,6 +967,9 @@ export class ComposerWebviewProvider implements WebviewProvider<State, State, Co
966967

967968
const combinedHunks = createHunksFromDiffs(combinedDiff!.contents);
968969
for (const hunk of combinedHunks) {
970+
const { author, coAuthors } = getAuthorAndCoAuthorsForCombinedDiffHunk(this._hunks, hunk);
971+
hunk.author = author;
972+
hunk.coAuthors = coAuthors.length ? coAuthors : undefined;
969973
hunks.push({ ...hunk, assigned: true });
970974
}
971975
this._hunks = hunks;

src/webviews/plus/composer/protocol.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Sources } from '../../../constants.telemetry';
2+
import type { GitCommitIdentityShape } from '../../../git/models/commit';
23
import type { RepositoryShape } from '../../../git/models/repositoryShape';
34
import type { AIModel } from '../../../plus/ai/models/model';
45
import type { IpcScope, WebviewState } from '../../protocol';
@@ -23,6 +24,8 @@ export interface ComposerHunkBase {
2324
assigned?: boolean; // True when this hunk's index is in any commit's hunkIndices array
2425
isRename?: boolean; // True for rename-only hunks
2526
originalFileName?: string; // Original filename for renames
27+
author?: GitCommitIdentityShape; // Author of the commit this hunk belongs to, if any
28+
coAuthors?: GitCommitIdentityShape[]; // Co-authors of the commit this hunk belongs to, if any
2629
}
2730

2831
export interface ComposerCommit {

src/webviews/plus/composer/utils.ts

Lines changed: 157 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { sha256 } from '@env/crypto';
22
import type { Container } from '../../../container';
3-
import type { GitCommit } from '../../../git/models/commit';
3+
import type { GitCommit, GitCommitIdentityShape } from '../../../git/models/commit';
44
import type { GitDiff, ParsedGitDiff } from '../../../git/models/diff';
55
import type { Repository } from '../../../git/models/repository';
66
import { uncommitted, uncommittedStaged } from '../../../git/models/revision';
@@ -129,18 +129,138 @@ export function createCombinedDiffForCommit(hunks: ComposerHunk[]): {
129129
return { patch: commitPatch, filePatches: filePatches };
130130
}
131131

132+
// Given a group of hunks assigned to a single commit, each with their own author and co-authors, determine a single author and co-authors list for the commit
133+
// based on the amount of changes made by each author, measured in additions + deletions
134+
function getAuthorAndCoAuthorsForCommit(commitHunks: ComposerHunk[]): {
135+
author: GitCommitIdentityShape | undefined;
136+
coAuthors: GitCommitIdentityShape[];
137+
} {
138+
// Each hunk may or may not have an author. Determine the primary author based on the hunk with the largest diff, then assign the rest as co-authors.
139+
// If there is a tie for largest diff, use the first one.
140+
const authorContributionWeights = new Map<string, number>();
141+
const coAuthors = new Map<string, GitCommitIdentityShape>();
142+
for (const hunk of commitHunks) {
143+
if (hunk.author == null) continue;
144+
coAuthors.set(hunk.author.name, hunk.author);
145+
hunk.coAuthors?.forEach(coAuthor => coAuthors.set(coAuthor.name, coAuthor));
146+
authorContributionWeights.set(
147+
hunk.author.name,
148+
(authorContributionWeights.get(hunk.author.name) ?? 0) + hunk.additions + hunk.deletions,
149+
);
150+
}
151+
152+
let primary: GitCommitIdentityShape | undefined;
153+
let primaryScore = 0;
154+
for (const [author, score] of authorContributionWeights.entries()) {
155+
if (primary == null || score > primaryScore) {
156+
primary = coAuthors.get(author);
157+
primaryScore = score;
158+
}
159+
}
160+
161+
// Remove the primary author from the co-authors, if present
162+
if (primary != null) {
163+
coAuthors.delete(primary.name);
164+
}
165+
166+
return { author: primary, coAuthors: [...coAuthors.values()] };
167+
}
168+
169+
function overlap(range1: { start: number; count: number }, range2: { start: number; count: number }): number {
170+
const end1 = range1.start + range1.count;
171+
const end2 = range2.start + range2.count;
172+
const overlapStart = Math.max(range1.start, range2.start);
173+
const overlapEnd = Math.min(end1, end2);
174+
return Math.max(0, overlapEnd - overlapStart);
175+
}
176+
177+
// Calculates a similarity score between two hunks that touch the same file, based on the overlap between the lines in their hunk headers
178+
function getHunkSimilarityValue(hunk1: ComposerHunk, hunk2: ComposerHunk): number {
179+
const oldRange1 = hunk1.hunkHeader.match(/@@ -(\d+),(\d+)/);
180+
const newRange1 = hunk1.hunkHeader.match(/@@ -\d+,\d+ \+(\d+),(\d+)/);
181+
const oldRange2 = hunk2.hunkHeader.match(/@@ -(\d+),(\d+)/);
182+
const newRange2 = hunk2.hunkHeader.match(/@@ -\d+,\d+ \+(\d+),(\d+)/);
183+
if (oldRange1 == null || newRange1 == null || oldRange2 == null || newRange2 == null) {
184+
return 0;
185+
}
186+
return (
187+
overlap(
188+
{ start: parseInt(oldRange1[1], 10), count: parseInt(oldRange1[2], 10) },
189+
{ start: parseInt(oldRange2[1], 10), count: parseInt(oldRange2[2], 10) },
190+
) +
191+
overlap(
192+
{ start: parseInt(newRange1[1], 10), count: parseInt(newRange1[2], 10) },
193+
{ start: parseInt(newRange2[1], 10), count: parseInt(newRange2[2], 10) },
194+
)
195+
);
196+
}
197+
198+
// Given an array of hunks representing commit history between two commits, and a hunk from their combined diff, determine the author and co-authors of the
199+
// combined diff hunk based on similarity to the commit hunks
200+
export function getAuthorAndCoAuthorsForCombinedDiffHunk(
201+
commitHunks: ComposerHunk[],
202+
combinedDiffHunk: ComposerHunk,
203+
): { author: GitCommitIdentityShape | undefined; coAuthors: GitCommitIdentityShape[] } {
204+
const matches = commitHunks.filter(commitHunk => {
205+
return (
206+
commitHunk.author != null &&
207+
commitHunk.fileName === combinedDiffHunk.fileName &&
208+
(!combinedDiffHunk.isRename || commitHunk.isRename === combinedDiffHunk.isRename)
209+
);
210+
});
211+
212+
const similarityByHunkAuthor = new Map<string, number>();
213+
const coAuthors = new Map<string, GitCommitIdentityShape>();
214+
let maxSimilarity = 0;
215+
let primaryAuthor: GitCommitIdentityShape | undefined;
216+
for (const commitHunk of matches) {
217+
coAuthors.set(commitHunk.author!.name, commitHunk.author!);
218+
commitHunk.coAuthors?.forEach(coAuthor => coAuthors.set(coAuthor.name, coAuthor));
219+
let similarity = getHunkSimilarityValue(commitHunk, combinedDiffHunk);
220+
if (similarityByHunkAuthor.has(commitHunk.author!.name)) {
221+
similarity += similarityByHunkAuthor.get(commitHunk.author!.name)!;
222+
}
223+
224+
similarityByHunkAuthor.set(commitHunk.author!.name, similarity);
225+
if (primaryAuthor == null || similarity > maxSimilarity) {
226+
maxSimilarity = similarity;
227+
primaryAuthor = commitHunk.author;
228+
}
229+
}
230+
231+
// Remove the primary author from the co-authors, if present
232+
if (primaryAuthor != null) {
233+
coAuthors.delete(primaryAuthor.name);
234+
}
235+
236+
return { author: primaryAuthor, coAuthors: [...coAuthors.values()] };
237+
}
238+
132239
export function convertToComposerDiffInfo(
133240
commits: ComposerCommit[],
134241
hunks: ComposerHunk[],
135-
): Array<{ message: string; explanation?: string; filePatches: Map<string, string[]>; patch: string }> {
242+
): Array<{
243+
message: string;
244+
explanation?: string;
245+
filePatches: Map<string, string[]>;
246+
patch: string;
247+
author?: GitCommitIdentityShape;
248+
}> {
136249
return commits.map(commit => {
137250
const { patch, filePatches } = createCombinedDiffForCommit(getHunksForCommit(commit, hunks));
251+
const commitHunks = getHunksForCommit(commit, hunks);
252+
const { author, coAuthors } = getAuthorAndCoAuthorsForCommit(commitHunks);
253+
let message = commit.message;
254+
if (coAuthors.length > 0) {
255+
message += `\n${coAuthors.map(a => `\nCo-authored-by: ${a.name} <${a.email}>`).join()}`;
256+
}
138257

139258
return {
140-
message: commit.message,
259+
message: message,
141260
explanation: commit.aiExplanation,
142261
filePatches: filePatches,
143262
patch: patch,
263+
author: author,
144264
};
145265
});
146266
}
@@ -199,6 +319,8 @@ function convertDiffToComposerHunks(
199319
diff: ParsedGitDiff,
200320
source: 'staged' | 'unstaged' | 'commits',
201321
startingCount: number,
322+
author?: GitCommitIdentityShape,
323+
coAuthors?: GitCommitIdentityShape[],
202324
): { hunks: ComposerHunk[]; count: number } {
203325
const hunks: ComposerHunk[] = [];
204326
let counter = startingCount;
@@ -239,6 +361,8 @@ function convertDiffToComposerHunks(
239361
source: source,
240362
assigned: false,
241363
isRename: file.metadata.renamedOrCopied !== false,
364+
author: author,
365+
coAuthors: coAuthors,
242366
};
243367

244368
hunks.push(composerHunk);
@@ -262,6 +386,8 @@ function convertDiffToComposerHunks(
262386
source: source,
263387
assigned: false,
264388
isRename: false,
389+
author: author,
390+
coAuthors: coAuthors,
265391
};
266392

267393
hunks.push(composerHunk);
@@ -517,6 +643,22 @@ export async function getBranchCommits(
517643
}
518644
}
519645

646+
export function parseCoAuthorsFromGitCommit(commit: GitCommit): GitCommitIdentityShape[] {
647+
const coAuthors: GitCommitIdentityShape[] = [];
648+
if (!commit.message) return coAuthors;
649+
650+
const coAuthorRegex = /^Co-authored-by:\s*(.+?)(?:\s*<(.+?)>)?\s*$/gm;
651+
let match;
652+
while ((match = coAuthorRegex.exec(commit.message)) !== null) {
653+
const [, name, email] = match;
654+
if (name) {
655+
coAuthors.push({ name: name.trim(), email: email?.trim(), date: commit.date });
656+
}
657+
}
658+
659+
return coAuthors;
660+
}
661+
520662
/**
521663
* Creates ComposerCommit array from existing branch commits, preserving order and mapping hunks correctly
522664
*/
@@ -525,6 +667,7 @@ export async function createComposerCommitsFromGitCommits(
525667
commits: GitCommit[],
526668
): Promise<{ commits: ComposerCommit[]; hunks: ComposerHunk[] } | undefined> {
527669
try {
670+
const currentUser = await repo.git.config.getCurrentUser();
528671
const composerCommits: ComposerCommit[] = [];
529672
const allHunks: ComposerHunk[] = [];
530673
let count = 0;
@@ -545,8 +688,18 @@ export async function createComposerCommitsFromGitCommits(
545688
// Parse the diff to get hunks
546689
const parsedDiff = parseGitDiff(diff.contents);
547690
const commitHunkIndices: number[] = [];
691+
const author = {
692+
...commit.author,
693+
name: commit.author.name === 'You' ? (currentUser?.name ?? commit.author.name) : commit.author.name,
694+
};
548695

549-
const { hunks, count: newCount } = convertDiffToComposerHunks(parsedDiff, 'commits', count);
696+
const { hunks, count: newCount } = convertDiffToComposerHunks(
697+
parsedDiff,
698+
'commits',
699+
count,
700+
author,
701+
parseCoAuthorsFromGitCommit(commit),
702+
);
550703
allHunks.push(...hunks);
551704
count = newCount;
552705
commitHunkIndices.push(...hunks.map(h => h.index));

0 commit comments

Comments
 (0)