Skip to content

Commit e2d0218

Browse files
Uses hashes for diff validation
Co-authored-by: Eric Amodio <[email protected]>
1 parent 6b07aab commit e2d0218

File tree

8 files changed

+93
-110
lines changed

8 files changed

+93
-110
lines changed

src/env/browser/crypto.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { base64 } from './base64';
2+
import { convertToHex } from './hex';
23
import { md5 as _md5 } from './md5';
34

45
export function getNonce(): string {
@@ -9,6 +10,12 @@ export function md5(data: string, encoding: 'base64' | 'hex' = 'hex'): string {
910
return _md5(data, encoding);
1011
}
1112

13+
export async function sha256(data: string, encoding: 'base64' | 'hex' = 'hex'): Promise<string> {
14+
const buffer = new TextEncoder().encode(data);
15+
const bytes = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', buffer));
16+
return encoding === 'base64' ? base64(bytes) : convertToHex(bytes);
17+
}
18+
1219
export function uuid(): string {
1320
return globalThis.crypto.randomUUID();
1421
}

src/env/browser/hex.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,18 @@ const precomputedByteToHex = [
2424
'f7', 'f8', 'f9', 'fa', 'fb', 'fc', 'fd', 'fe', 'ff',
2525
];
2626

27-
export function encodeUtf8Hex(s: string): string {
28-
const bytes = textEncoder.encode(s);
29-
27+
export function convertToHex(bytes: Uint8Array): string {
3028
const hex = new Array<string>(bytes.length);
3129
for (let i = 0; i < bytes.length; ++i) {
3230
hex[i] = precomputedByteToHex[bytes[i]];
3331
}
3432
return hex.join('');
3533
}
3634

35+
export function encodeUtf8Hex(s: string): string {
36+
return convertToHex(textEncoder.encode(s));
37+
}
38+
3739
export function decodeUtf8Hex(hex: string): string {
3840
const matches = hex.match(/(\w{2})/g);
3941
if (matches === null) return '';

src/env/node/crypto.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export function md5(data: string, encoding: 'base64' | 'hex' = 'hex'): string {
88
return createHash('md5').update(data).digest(encoding);
99
}
1010

11+
export async function sha256(data: string, encoding: 'base64' | 'hex' = 'hex'): Promise<string> {
12+
return Promise.resolve(createHash('sha256').update(data).digest(encoding));
13+
}
14+
1115
export function uuid(): string {
1216
return randomUUID();
1317
}

src/env/node/hex.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
export function convertToHex(bytes: Uint8Array): string {
2+
return Buffer.from(bytes).toString('hex');
3+
}
4+
15
export function encodeUtf8Hex(s: string): string {
26
return Buffer.from(s, 'utf8').toString('hex');
37
}

src/webviews/apps/plus/composer/components/app.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -72,23 +72,6 @@ const composerFeedbackUrl = 'https://github.com/gitkraken/vscode-gitlens/discuss
7272

7373
@customElement('gl-composer-app')
7474
export class ComposerApp extends LitElement {
75-
@consume({ context: stateContext, subscribe: true })
76-
state!: State;
77-
78-
@consume({ context: ipcContext })
79-
private _ipc!: HostIpc;
80-
81-
// Internal history management
82-
private history: ComposerHistory = {
83-
resetState: null,
84-
undoStack: [],
85-
redoStack: [],
86-
};
87-
88-
// Debounce timer for commit message updates
89-
private commitMessageDebounceTimer?: number;
90-
private commitMessageBeingEdited: string | null = null; // Track which commit is being edited
91-
9275
static override styles = [
9376
boxSizingBase,
9477
focusableBaseStyles,
@@ -359,6 +342,23 @@ export class ComposerApp extends LitElement {
359342
`,
360343
];
361344

345+
@consume({ context: stateContext, subscribe: true })
346+
state!: State;
347+
348+
@consume({ context: ipcContext })
349+
private _ipc!: HostIpc;
350+
351+
// Internal history management
352+
private history: ComposerHistory = {
353+
resetState: null,
354+
undoStack: [],
355+
redoStack: [],
356+
};
357+
358+
// Debounce timer for commit message updates
359+
private commitMessageDebounceTimer?: number;
360+
private commitMessageBeingEdited: string | null = null; // Track which commit is being edited
361+
362362
@query('gl-commits-panel')
363363
commitsPanel!: CommitsPanel;
364364

src/webviews/plus/composer/composerWebview.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ConfigurationChangeEvent } from 'vscode';
22
import { CancellationTokenSource, commands, Disposable, window } from 'vscode';
3-
import { md5 } from '@env/crypto';
3+
import { md5, sha256 } from '@env/crypto';
44
import type { ContextKeys } from '../../../constants.context';
55
import type { ComposerTelemetryContext, Sources } from '../../../constants.telemetry';
66
import type { Container } from '../../../container';
@@ -81,7 +81,7 @@ import {
8181
createHunksFromDiffs,
8282
createSafetyState,
8383
getWorkingTreeDiffs,
84-
validateCombinedDiff,
84+
validateResultingDiff,
8585
validateSafetyState,
8686
} from './utils';
8787

@@ -354,7 +354,7 @@ export class ComposerWebviewProvider implements WebviewProvider<State, State, Co
354354
};
355355

356356
// Create safety state snapshot for validation
357-
const safetyState = await createSafetyState(repo, diffs, currentBranch, baseCommit);
357+
const safetyState = await createSafetyState(repo, diffs, baseCommit.sha);
358358

359359
const aiEnabled = this.getAiEnabled();
360360
const aiModel = await this.container.ai.getModel({ silent: true }, { source: 'composer' });
@@ -1063,13 +1063,13 @@ export class ComposerWebviewProvider implements WebviewProvider<State, State, Co
10631063
throw new Error(errorMessage);
10641064
}
10651065

1066-
const combinedDiff = (
1066+
const resultingDiff = (
10671067
await repo.git.diff.getDiff?.(shas[shas.length - 1], params.baseCommit.sha, {
10681068
notation: '...',
10691069
})
10701070
)?.contents;
10711071

1072-
if (!combinedDiff) {
1072+
if (!resultingDiff) {
10731073
this._context.errors.operation.count++;
10741074
this._context.operations.finishAndCommit.errorCount++;
10751075
const errorMessage = 'Failed to get combined diff';
@@ -1080,7 +1080,13 @@ export class ComposerWebviewProvider implements WebviewProvider<State, State, Co
10801080
throw new Error(errorMessage);
10811081
}
10821082

1083-
if (!validateCombinedDiff(params.safetyState, combinedDiff, this._context.diff.unstagedIncluded)) {
1083+
if (
1084+
!validateResultingDiff(
1085+
params.safetyState,
1086+
await sha256(resultingDiff),
1087+
this._context.diff.unstagedIncluded,
1088+
)
1089+
) {
10841090
// Clear loading state and show safety error
10851091
await this.host.notify(DidFinishCommittingNotification, undefined);
10861092
this._context.errors.safety.count++;

src/webviews/plus/composer/protocol.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,27 @@ export interface ComposerBaseCommit {
4747
export interface ComposerSafetyState {
4848
repoPath: string;
4949
headSha: string;
50-
branchName: string;
51-
branchRefSha: string;
52-
worktreeName: string;
53-
stagedDiff: string | null; // null if no staged changes when composer opened
54-
unstagedDiff: string | null; // null if no unstaged changes when composer opened
55-
unifiedDiff: string | null; // null if no changes when composer opened
56-
timestamp: number;
50+
// branchName: string;
51+
// branchRefSha: string;
52+
// worktreeName: string;
53+
hashes: {
54+
staged: string | null;
55+
unstaged: string | null;
56+
unified: string | null;
57+
};
58+
59+
// stagedDiff: string | null; // null if no staged changes when composer opened
60+
// unstagedDiff: string | null; // null if no unstaged changes when composer opened
61+
// unifiedDiff: string | null; // null if no changes when composer opened
62+
// timestamp: number;
5763
}
5864

5965
export interface State extends WebviewState {
6066
// data model
6167
hunks: ComposerHunk[];
62-
commits: ComposerCommit[];
6368
hunkMap: ComposerHunkMap[];
69+
70+
commits: ComposerCommit[];
6471
baseCommit: ComposerBaseCommit;
6572
safetyState: ComposerSafetyState;
6673

@@ -121,13 +128,11 @@ export const initialState: Omit<State, keyof WebviewState> = {
121128
safetyState: {
122129
repoPath: '',
123130
headSha: '',
124-
branchName: '',
125-
branchRefSha: '',
126-
worktreeName: '',
127-
stagedDiff: null,
128-
unstagedDiff: null,
129-
unifiedDiff: null,
130-
timestamp: 0,
131+
hashes: {
132+
staged: null,
133+
unstaged: null,
134+
unified: null,
135+
},
131136
},
132137
selectedCommitId: null,
133138
selectedCommitIds: new Set<string>(),

src/webviews/plus/composer/utils.ts

Lines changed: 24 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type { GitBranch } from '../../../git/models/branch';
2-
import type { GitCommit } from '../../../git/models/commit';
1+
import { sha256 } from '@env/crypto';
32
import type { GitDiff, ParsedGitDiff } from '../../../git/models/diff';
43
import type { Repository } from '../../../git/models/repository';
54
import { uncommitted, uncommittedStaged } from '../../../git/models/revision';
@@ -336,36 +335,20 @@ export async function getWorkingTreeDiffs(repo: Repository): Promise<WorkingTree
336335
export async function createSafetyState(
337336
repo: Repository,
338337
diffs: WorkingTreeDiffs,
339-
currentBranch: GitBranch,
340-
headCommit: GitCommit,
338+
headSha: string,
341339
): Promise<ComposerSafetyState> {
342-
// Get current worktree information
343-
const worktrees = await repo.git.worktrees?.getWorktrees();
344-
const currentWorktree = worktrees?.find(wt => wt.branch?.id === currentBranch?.id);
345-
346-
if (!currentBranch?.name) {
347-
throw new Error('Cannot create safety state: no current branch found');
348-
}
349-
if (!currentBranch?.sha) {
350-
throw new Error('Cannot create safety state: no current branch SHA found');
351-
}
352-
if (!headCommit?.sha) {
340+
if (!headSha) {
353341
throw new Error('Cannot create safety state: no HEAD commit found');
354342
}
355-
if (!currentWorktree?.name) {
356-
throw new Error('Cannot create safety state: no current worktree found');
357-
}
358343

359344
return {
360345
repoPath: repo.path,
361-
headSha: headCommit.sha,
362-
branchName: currentBranch.name,
363-
branchRefSha: currentBranch.sha,
364-
worktreeName: currentWorktree.name,
365-
stagedDiff: diffs.staged?.contents ?? null,
366-
unstagedDiff: diffs.unstaged?.contents ?? null,
367-
unifiedDiff: diffs.unified?.contents ?? null,
368-
timestamp: Date.now(),
346+
headSha: headSha,
347+
hashes: {
348+
staged: diffs.staged?.contents ? await sha256(diffs.staged.contents) : null,
349+
unstaged: diffs.unstaged?.contents ? await sha256(diffs.unstaged.contents) : null,
350+
unified: diffs.unified?.contents ? await sha256(diffs.unified.contents) : null,
351+
},
369352
};
370353
}
371354

@@ -393,44 +376,25 @@ export async function validateSafetyState(
393376
errors.push(`HEAD commit changed from "${safetyState.headSha}" to "${currentHeadSha}"`);
394377
}
395378

396-
// 3. Check current branch
397-
const currentBranch = await repo.git.branches.getBranch();
398-
const currentBranchName = currentBranch?.name;
399-
if (!currentBranchName) {
400-
errors.push('Current branch could not be determined');
401-
} else if (currentBranchName !== safetyState.branchName) {
402-
errors.push(`Branch changed from "${safetyState.branchName}" to "${currentBranchName}"`);
403-
}
404-
405-
// 4. Check branch ref SHA
406-
const currentBranchSha = currentBranch?.sha;
407-
if (!currentBranchSha) {
408-
errors.push('Current branch SHA could not be determined');
409-
} else if (currentBranchSha !== safetyState.branchRefSha) {
410-
errors.push(`Branch ref changed from "${safetyState.branchRefSha}" to "${currentBranchSha}"`);
411-
}
379+
// 2. Smart diff validation - only check diffs for sources being committed
380+
if (hunksBeingCommitted?.length) {
381+
const { staged, unstaged /*, unified*/ } = await getWorkingTreeDiffs(repo);
412382

413-
// 5. Check worktree state
414-
const worktrees = await repo.git.worktrees?.getWorktrees();
415-
const currentWorktree = worktrees?.find(wt => wt.branch?.id === currentBranch?.id);
416-
const currentWorktreeName = currentWorktree?.name ?? 'main';
417-
if (currentWorktreeName !== safetyState.worktreeName) {
418-
errors.push(`Worktree changed from "${safetyState.worktreeName}" to "${currentWorktreeName}"`);
419-
}
420-
421-
// 6. Smart diff validation - only check diffs for sources being committed
422-
if (hunksBeingCommitted && hunksBeingCommitted.length > 0) {
423-
const { staged, unstaged } = await getWorkingTreeDiffs(repo);
383+
const hashes = {
384+
staged: staged?.contents ? await sha256(staged.contents) : null,
385+
unstaged: unstaged?.contents ? await sha256(unstaged.contents) : null,
386+
// unified: unified?.contents ? await sha256(unified.contents) : null,
387+
};
424388

425389
// Check if any hunks from staged source are being committed
426390
const hasStagedHunks = hunksBeingCommitted.some(h => h.source === 'staged');
427-
if (hasStagedHunks && (staged?.contents ?? null) !== safetyState.stagedDiff) {
391+
if (hasStagedHunks && hashes.staged !== safetyState.hashes.staged) {
428392
errors.push('Staged changes have been modified since composer opened');
429393
}
430394

431395
// Check if any hunks from unstaged source are being committed
432396
const hasUnstagedHunks = hunksBeingCommitted.some(h => h.source === 'unstaged');
433-
if (hasUnstagedHunks && (unstaged?.contents ?? null) !== safetyState.unstagedDiff) {
397+
if (hasUnstagedHunks && hashes.unstaged !== safetyState.hashes.unstaged) {
434398
errors.push('Unstaged changes have been modified since composer opened');
435399
}
436400
}
@@ -442,21 +406,12 @@ export async function validateSafetyState(
442406
}
443407
}
444408

445-
/** Validates combined output diff against input diff, based on whether unstaged changes are included or not */
446-
export function validateCombinedDiff(
409+
/** Validates resulting output diff against input diff, based on whether unstaged changes are included or not */
410+
export function validateResultingDiff(
447411
safetyState: ComposerSafetyState,
448-
combinedDiff: string,
412+
diffHash: string,
449413
includeUnstagedChanges: boolean,
450414
): boolean {
451-
try {
452-
const { stagedDiff, unifiedDiff } = safetyState;
453-
454-
if (includeUnstagedChanges) {
455-
return combinedDiff === unifiedDiff;
456-
}
457-
458-
return combinedDiff === stagedDiff;
459-
} catch {
460-
return false;
461-
}
415+
const { hashes } = safetyState;
416+
return diffHash === (includeUnstagedChanges ? hashes.unified : hashes.staged);
462417
}

0 commit comments

Comments
 (0)