Path: blob/main/extensions/copilot/src/extension/prompt/vscode-node/gitDiffService.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { type CancellationToken, Uri, workspace } from 'vscode';6import { Diff, IGitDiffService } from '../../../platform/git/common/gitDiffService';7import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';8import { Change, Repository } from '../../../platform/git/vscode/git';9import { IIgnoreService } from '../../../platform/ignore/common/ignoreService';10import { ILogService } from '../../../platform/log/common/logService';11import { isUri } from '../../../util/common/types';12import { CancellationError } from '../../../util/vs/base/common/errors';13import * as path from '../../../util/vs/base/common/path';14import { isEqual } from '../../../util/vs/base/common/resources';1516/**17* Maximum file size (in bytes) for reading untracked file content.18* Files larger than this will have their diff omitted.19*/20const MAX_UNTRACKED_FILE_SIZE = 1 * 1024 * 1024; // 1 MB2122/**23* Maximum size (in characters) for a single diff output.24* Diffs larger than this will be truncated.25*/26const MAX_DIFF_SIZE = 100_000; // ~100KB2728export class GitDiffService implements IGitDiffService {29declare readonly _serviceBrand: undefined;3031constructor(32@IGitExtensionService private readonly _gitExtensionService: IGitExtensionService,33@IIgnoreService private readonly _ignoreService: IIgnoreService,34@ILogService private readonly _logService: ILogService35) { }3637private async _resolveRepository(repositoryOrUri: Repository | Uri): Promise<Repository | null | undefined> {38if (isUri(repositoryOrUri)) {39const extensionApi = this._gitExtensionService.getExtensionApi();40return extensionApi?.getRepository(repositoryOrUri) ?? await extensionApi?.openRepository(repositoryOrUri) ?? extensionApi?.repositories.find((repo) => isEqual(repo.rootUri, repositoryOrUri));41}42return repositoryOrUri;43}4445// Get the diff between the current state of the repository and the specified ref for each of the provided changes46async getWorkingTreeDiffsFromRef(repositoryOrUri: Repository | Uri, changes: Change[], ref: string, token?: CancellationToken): Promise<Diff[]> {47this._logService.debug(`[GitDiffService] Getting working tree diffs from ref ${ref} for ${changes.length} file(s)`);4849const repository = await this._resolveRepository(repositoryOrUri);50if (!repository) {51this._logService.debug(`[GitDiffService] Repository not found for uri: ${repositoryOrUri.toString()}`);52return [];53}5455const diffs: Diff[] = [];56for (const change of changes) {57if (token?.isCancellationRequested) {58throw new CancellationError();59}6061if (await this._ignoreService.isCopilotIgnored(change.uri)) {62this._logService.debug(`[GitDiffService] Ignoring change due to content exclusion rule based on uri: ${change.uri.toString()}`);63continue;64}6566let diff: string;67if (change.status === 7 /* UNTRACKED */) {68// For untracked files, generate a patch showing all content as additions69diff = await this._getUntrackedChangePatch(repository, change.uri);70} else {71// For all other changes, get diff from ref to current working tree state72diff = await repository.diffWith(ref, change.uri.fsPath);73}7475diffs.push({76originalUri: change.originalUri,77renameUri: change.renameUri,78status: change.status,79uri: change.uri,80diff: this._truncateDiff(diff, change.uri)81});82}8384this._logService.debug(`[GitDiffService] Working tree diffs from ref (after context exclusion): ${diffs.length} file(s)`);8586return diffs;87}8889async getChangeDiffs(repositoryOrUri: Repository | Uri, changes: Change[], token?: CancellationToken): Promise<Diff[]> {90this._logService.debug(`[GitDiffService] Changes (before context exclusion): ${changes.length} file(s)`);9192const repository = await this._resolveRepository(repositoryOrUri);93if (!repository) {94this._logService.debug(`[GitDiffService] Repository not found for uri: ${repositoryOrUri.toString()}`);95return [];96}9798const diffs: Diff[] = [];99for (const change of changes) {100if (token?.isCancellationRequested) {101throw new CancellationError();102}103104if (await this._ignoreService.isCopilotIgnored(change.uri)) {105this._logService.debug(`[GitDiffService] Ignoring change due to content exclusion rule based on uri: ${change.uri.toString()}`);106continue;107}108109let diff: string;110switch (change.status) {111case 0 /* INDEX_MODIFIED */:112case 1 /* INDEX_ADDED */:113case 2 /* INDEX_DELETED */:114case 3 /* INDEX_RENAMED */:115case 4 /* INDEX_COPIED */:116diff = await repository.diffIndexWithHEAD(change.uri.fsPath);117break;118case 7 /* UNTRACKED */:119diff = await this._getUntrackedChangePatch(repository, change.uri);120break;121default:122diff = await repository.diffWithHEAD(change.uri.fsPath);123break;124}125126diffs.push({127originalUri: change.originalUri,128renameUri: change.renameUri,129status: change.status,130uri: change.uri,131diff: this._truncateDiff(diff, change.uri)132});133}134135this._logService.debug(`[GitDiffService] Changes (after context exclusion): ${diffs.length} file(s)`);136137return diffs;138}139140private async _getUntrackedChangePatch(repository: Repository, resource: Uri): Promise<string> {141const patch: string[] = [];142const relativePath = path.relative(repository.rootUri.fsPath, resource.fsPath);143144// Check file size before reading to avoid OOM with large/binary files145try {146const stat = await workspace.fs.stat(resource);147if (stat.size > MAX_UNTRACKED_FILE_SIZE) {148this._logService.debug(`[GitDiffService] Skipping untracked file (too large: ${stat.size} bytes): ${resource.toString()}`);149// Return a minimal patch header indicating the file is new but too large to diff150patch.push(`diff --git a/${relativePath} b/${relativePath}`);151patch.push('new file mode 100644');152patch.push('--- /dev/null', `+++ b/${relativePath}`);153patch.push(`\\ File too large to diff (${Math.round(stat.size / 1024)} KB)`);154return patch.join('\n') + '\n';155}156} catch {157// stat failed - proceed to try reading the file anyway158}159160try {161const buffer = await workspace.fs.readFile(resource);162const content = buffer.toString();163164// Header165patch.push(`diff --git a/${relativePath} b/${relativePath}`);166// 100644 is standard file mode for new git files. Saves us from trying to check file permissions and handling167// UNIX vs Windows permission differences. Skipping calculating the SHA1 hashes as well since they are not strictly necessary168// to apply the patch.169patch.push('new file mode 100644');170patch.push('--- /dev/null', `+++ b/${relativePath}`);171172// For non-empty files, add range header and content (empty files omit this)173if (content.length > 0) {174const lines = content.split('\n');175if (content.endsWith('\n')) {176// Prevent an extra empty line at the end177lines.pop();178}179180// Range header and content181patch.push(`@@ -0,0 +1,${lines.length} @@`);182patch.push(...lines.map(line => `+${line}`));183184// Git standard to add this comment if the file does not end with a newline185if (!content.endsWith('\n')) {186patch.push('\\ No newline at end of file');187}188}189} catch (err) {190this._logService.warn(`[GitDiffService] Failed to generate patch file for untracked file: ${resource.toString()}: ${err}`);191}192193// The patch itself should always end with a newline per git patch standards194return patch.join('\n') + '\n';195}196197private _truncateDiff(diff: string, uri: Uri): string {198if (diff.length > MAX_DIFF_SIZE) {199this._logService.debug(`[GitDiffService] Truncating diff for ${uri.toString()} (${diff.length} chars -> ${MAX_DIFF_SIZE} chars)`);200return diff.substring(0, MAX_DIFF_SIZE) + '\n... [diff truncated]\n';201}202return diff;203}204}205206207