diff --git a/src/git/git.ts b/src/git/git.ts index a6ff32d..fdfec89 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -240,7 +240,6 @@ export class Git { params.push(`-L ${options.startLine},${options.endLine}`); } - // let stdin: Observable | undefined; let stdin: string | undefined; if (sha) { if (Git.isStagedUncommitted(sha)) { @@ -259,6 +258,25 @@ export class Git { return gitCommand({ cwd: root, stdin: stdin }, ...params, `--`, file); } + static async blame_contents(repoPath: string | undefined, fileName: string, contents: string, options: { ignoreWhitespace?: boolean, startLine?: number, endLine?: number } = {}) { + const [file, root] = Git.splitPath(fileName, repoPath); + + const params = [...defaultBlameParams]; + + if (options.ignoreWhitespace) { + params.push('-w'); + } + if (options.startLine != null && options.endLine != null) { + params.push(`-L ${options.startLine},${options.endLine}`); + } + + // Pipe the blame contents to stdin + params.push(`--contents`); + params.push('-'); + + return gitCommand({ cwd: root, stdin: contents }, ...params, `--`, file); + } + static branch(repoPath: string, options: { all: boolean } = { all: false }) { const params = [`branch`, `-vv`]; if (options.all) { diff --git a/src/git/models/logCommit.ts b/src/git/models/logCommit.ts index d42765c..e41273c 100644 --- a/src/git/models/logCommit.ts +++ b/src/git/models/logCommit.ts @@ -99,7 +99,7 @@ export class GitLogCommit extends GitCommit { let gravatar = gravatarCache.get(key); if (gravatar !== undefined) return gravatar; - gravatar = Uri.parse(`https://www.gravatar.com/avatar/${this.email ? Strings.md5(this.email) : '00000000000000000000000000000000'}.jpg?s=22&d=${fallback}`); + gravatar = Uri.parse(`https://www.gravatar.com/avatar/${this.email ? Strings.md5(this.email, 'hex') : '00000000000000000000000000000000'}.jpg?s=22&d=${fallback}`); // HACK: Monkey patch Uri.toString to avoid the unwanted query string encoding const originalToStringFn = gravatar.toString; diff --git a/src/gitService.ts b/src/gitService.ts index 619c82e..ee46e59 100644 --- a/src/gitService.ts +++ b/src/gitService.ts @@ -1,5 +1,5 @@ 'use strict'; -import { Functions, Iterables, Objects, TernarySearchTree } from './system'; +import { Functions, Iterables, Objects, Strings, TernarySearchTree } from './system'; import { ConfigurationChangeEvent, Disposable, Event, EventEmitter, Range, TextDocument, TextDocumentChangeEvent, TextEditor, Uri, window, WindowState, workspace, WorkspaceFolder, WorkspaceFoldersChangeEvent } from 'vscode'; import { configuration, IConfig, IRemotesConfig } from './configuration'; import { CommandContext, DocumentSchemes, setCommandContext } from './constants'; @@ -536,7 +536,7 @@ export class GitService extends Disposable { if (entry && entry.key) { this._onDidBlameFail.fire(entry.key); } - return await GitService.emptyPromise as GitBlame; + return GitService.emptyPromise as Promise; } const [file, root] = Git.splitPath(uri.fsPath, uri.repoPath, false); @@ -558,7 +558,82 @@ export class GitService extends Disposable { } as CachedBlame); this._onDidBlameFail.fire(entry.key); - return await GitService.emptyPromise as GitBlame; + return GitService.emptyPromise as Promise; + } + + return undefined; + } + } + + async getBlameForFileContents(uri: GitUri, contents: string): Promise { + const key = `blame:${Strings.sha1(contents)}`; + + let entry: GitCacheEntry | undefined; + if (this.UseCaching) { + const cacheKey = this.getCacheEntryKey(uri); + entry = this._gitCache.get(cacheKey); + + if (entry !== undefined) { + const cachedBlame = entry.get(key); + if (cachedBlame !== undefined) { + Logger.log(`getBlameForFileContents[Cached(${key})]('${uri.repoPath}', '${uri.fsPath}', '${uri.sha}')`); + return cachedBlame.item; + } + } + + Logger.log(`getBlameForFileContents[Not Cached(${key})]('${uri.repoPath}', '${uri.fsPath}', '${uri.sha}')`); + + if (entry === undefined) { + entry = new GitCacheEntry(cacheKey); + this._gitCache.set(entry.key, entry); + } + } + else { + Logger.log(`getBlameForFileContents('${uri.repoPath}', '${uri.fsPath}', '${uri.sha}')`); + } + + const promise = this.getBlameForFileContentsCore(uri, contents, entry, key); + + if (entry) { + Logger.log(`Add blame cache for '${entry.key}:${key}'`); + + entry.set(key, { + item: promise + } as CachedBlame); + } + + return promise; + } + + async getBlameForFileContentsCore(uri: GitUri, contents: string, entry: GitCacheEntry | undefined, key: string): Promise { + if (!(await this.isTracked(uri))) { + Logger.log(`Skipping blame; '${uri.fsPath}' is not tracked`); + if (entry && entry.key) { + this._onDidBlameFail.fire(entry.key); + } + return GitService.emptyPromise as Promise; + } + + const [file, root] = Git.splitPath(uri.fsPath, uri.repoPath, false); + + try { + const data = await Git.blame_contents(root, file, contents, { ignoreWhitespace: this.config.blame.ignoreWhitespace }); + const blame = GitBlameParser.parse(data, root, file); + return blame; + } + catch (ex) { + // Trap and cache expected blame errors + if (entry) { + const msg = ex && ex.toString(); + Logger.log(`Replace blame cache with empty promise for '${entry.key}:${key}'`); + + entry.set(key, { + item: GitService.emptyPromise, + errorMessage: msg + } as CachedBlame); + + this._onDidBlameFail.fire(entry.key); + return GitService.emptyPromise as Promise; } return undefined; @@ -582,16 +657,17 @@ export class GitService extends Disposable { if (commit === undefined) return undefined; return { - author: Object.assign({}, blame.authors.get(commit.author), { lineCount: commit.lines.length }), + author: { ...blame.authors.get(commit.author), lineCount: commit.lines.length }, commit: commit, line: blameLine } as GitBlameLine; } + const lineToBlame = line + 1; const fileName = uri.fsPath; try { - const data = await Git.blame(uri.repoPath, fileName, uri.sha, { ignoreWhitespace: this.config.blame.ignoreWhitespace, startLine: line + 1, endLine: line + 1 }); + const data = await Git.blame(uri.repoPath, fileName, uri.sha, { ignoreWhitespace: this.config.blame.ignoreWhitespace, startLine: lineToBlame, endLine: lineToBlame }); const blame = GitBlameParser.parse(data, uri.repoPath, fileName); if (blame === undefined) return undefined; @@ -601,7 +677,49 @@ export class GitService extends Disposable { line: blame.lines[line] } as GitBlameLine; } - catch (ex) { + catch { + return undefined; + } + } + + async getBlameForLineContents(uri: GitUri, line: number, contents: string): Promise { + Logger.log(`getBlameForLineContents('${uri.repoPath}', '${uri.fsPath}', ${line})`); + + if (this.UseCaching) { + const blame = await this.getBlameForFileContents(uri, contents); + if (blame === undefined) return undefined; + + let blameLine = blame.lines[line]; + if (blameLine === undefined) { + if (blame.lines.length !== line) return undefined; + blameLine = blame.lines[line - 1]; + } + + const commit = blame.commits.get(blameLine.sha); + if (commit === undefined) return undefined; + + return { + author: { ...blame.authors.get(commit.author), lineCount: commit.lines.length }, + commit: commit, + line: blameLine + } as GitBlameLine; + } + + const lineToBlame = line + 1; + const fileName = uri.fsPath; + + try { + const data = await Git.blame_contents(uri.repoPath, fileName, contents, { ignoreWhitespace: this.config.blame.ignoreWhitespace, startLine: lineToBlame, endLine: lineToBlame }); + const blame = GitBlameParser.parse(data, uri.repoPath, fileName); + if (blame === undefined) return undefined; + + return { + author: Iterables.first(blame.authors.values()), + commit: Iterables.first(blame.commits.values()), + line: blame.lines[line] + } as GitBlameLine; + } + catch { return undefined; } } @@ -618,10 +736,10 @@ export class GitService extends Disposable { getBlameForRangeSync(blame: GitBlame, uri: GitUri, range: Range): GitBlameLines | undefined { Logger.log(`getBlameForRangeSync('${uri.repoPath}', '${uri.fsPath}', '${uri.sha}', [${range.start.line}, ${range.end.line}])`); - if (blame.lines.length === 0) return Object.assign({ allLines: blame.lines }, blame); + if (blame.lines.length === 0) return { allLines: blame.lines, ...blame }; if (range.start.line === 0 && range.end.line === blame.lines.length - 1) { - return Object.assign({ allLines: blame.lines }, blame); + return { allLines: blame.lines, ...blame }; } const lines = blame.lines.slice(range.start.line, range.end.line + 1); @@ -778,7 +896,7 @@ export class GitService extends Disposable { errorMessage: msg } as CachedDiff); - return await GitService.emptyPromise as GitDiff; + return GitService.emptyPromise as Promise; } return undefined; @@ -982,7 +1100,7 @@ export class GitService extends Disposable { private async getLogForFileCore(repoPath: string | undefined, fileName: string, options: { maxCount?: number, range?: Range, ref?: string, reverse?: boolean, skipMerges?: boolean }, entry: GitCacheEntry | undefined, key: string): Promise { if (!(await this.isTracked(fileName, repoPath, options.ref))) { Logger.log(`Skipping log; '${fileName}' is not tracked`); - return await GitService.emptyPromise as GitLog; + return GitService.emptyPromise as Promise; } const [file, root] = Git.splitPath(fileName, repoPath, false); @@ -1015,7 +1133,7 @@ export class GitService extends Disposable { errorMessage: msg } as CachedLog); - return await GitService.emptyPromise as GitLog; + return GitService.emptyPromise as Promise; } return undefined; diff --git a/src/system/string.ts b/src/system/string.ts index 4813532..ce3ebcc 100644 --- a/src/system/string.ts +++ b/src/system/string.ts @@ -1,5 +1,5 @@ 'use strict'; -import * as crypto from 'crypto'; +import { createHash, HexBase64Latin1Encoding } from 'crypto'; export namespace Strings { const TokenRegex = /\$\{([^|]*?)(?:\|(\d+)(\-|\?)?)?\}/g; @@ -53,8 +53,8 @@ export namespace Strings { } } - export function md5(s: string): string { - return crypto.createHash('md5').update(s).digest('hex'); + export function md5(s: string, encoding: HexBase64Latin1Encoding = 'base64'): string { + return createHash('md5').update(s).digest(encoding); } export function pad(s: string, before: number = 0, after: number = 0, padding: string = `\u00a0`) { @@ -105,6 +105,10 @@ export namespace Strings { return s.replace(illegalCharsForFSRegEx, replacement); } + export function sha1(s: string, encoding: HexBase64Latin1Encoding = 'base64'): string { + return createHash('sha1').update(s).digest(encoding); + } + export function truncate(s: string, truncateTo: number, ellipsis: string = '\u2026') { if (!s) return s;