diff --git a/package.json b/package.json index 807be30..15501f3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,10 @@ { "name": "gitlens", "version": "0.1.3", - "author": "Eric Amodio", + "author": { + "name": "Eric Amodio", + "email": "eamodio@gmail.com" + }, "publisher": "eamodio", "engines": { "vscode": "^1.3.0" @@ -93,6 +96,11 @@ "git.history" ], "description": "Specifies the command executed when the authors CodeLens is clicked. Annotate - toggles blame annotations. Explorer - opens the blame explorer. History - opens a file history picker, which requires the Git History (git log) extension" + }, + "gitlens.advanced.caching.enabled": { + "type": "boolean", + "default": true, + "description": "Specifies whether git blame output will be cached" } } }, diff --git a/src/commands.ts b/src/commands.ts index 81db4b2..0b781c6 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -3,8 +3,8 @@ import {commands, DecorationOptions, Disposable, OverviewRulerLane, Position, Ra import {BuiltInCommands, Commands} from './constants'; import GitProvider from './gitProvider'; import GitBlameController from './gitBlameController'; -import {basename} from 'path'; import * as moment from 'moment'; +import * as path from 'path'; abstract class Command extends Disposable { private _subscriptions: Disposable; @@ -52,9 +52,9 @@ export class DiffWithPreviousCommand extends EditorCommand { if (!blame) return; if (UncommitedRegex.test(blame.commit.sha)) { - return commands.executeCommand(Commands.DiffWithWorking, uri, blame.commit.previousSha, blame.commit.toPreviousUri(), line); + return commands.executeCommand(Commands.DiffWithWorking, uri, blame.commit.previousSha, blame.commit.previousUri, line); } - return commands.executeCommand(Commands.DiffWithPrevious, uri, blame.commit.sha, blame.commit.toUri(), blame.commit.previousSha, blame.commit.toPreviousUri(), line); + return commands.executeCommand(Commands.DiffWithPrevious, uri, blame.commit.sha, blame.commit.uri, blame.commit.previousSha, blame.commit.previousUri, line); }); } @@ -66,7 +66,7 @@ export class DiffWithPreviousCommand extends EditorCommand { // which for a diff could be the first difference return Promise.all([this.git.getVersionedFile(uri.fsPath, sha), this.git.getVersionedFile(uri.fsPath, compareWithSha)]) .catch(ex => console.error('[GitLens.DiffWithPreviousCommand]', 'getVersionedFile', ex)) - .then(values => commands.executeCommand(BuiltInCommands.Diff, Uri.file(values[1]), Uri.file(values[0]), `${basename(compareWithUri.fsPath)} (${compareWithSha}) ↔ ${basename(shaUri.fsPath)} (${sha})`) + .then(values => commands.executeCommand(BuiltInCommands.Diff, Uri.file(values[1]), Uri.file(values[0]), `${path.basename(compareWithUri.fsPath)} (${compareWithSha}) ↔ ${path.basename(shaUri.fsPath)} (${sha})`) .then(() => commands.executeCommand(BuiltInCommands.RevealLine, {lineNumber: line, at: 'center'}))); } } @@ -85,9 +85,9 @@ export class DiffWithWorkingCommand extends EditorCommand { if (!blame) return; if (UncommitedRegex.test(blame.commit.sha)) { - return commands.executeCommand(Commands.DiffWithWorking, uri, blame.commit.previousSha, blame.commit.toPreviousUri(), line); + return commands.executeCommand(Commands.DiffWithWorking, uri, blame.commit.previousSha, blame.commit.previousUri, line); } - return commands.executeCommand(Commands.DiffWithWorking, uri, blame.commit.sha, blame.commit.toUri(), line) + return commands.executeCommand(Commands.DiffWithWorking, uri, blame.commit.sha, blame.commit.uri, line) }); }; @@ -95,7 +95,7 @@ export class DiffWithWorkingCommand extends EditorCommand { // which for a diff could be the first difference return this.git.getVersionedFile(shaUri.fsPath, sha) .catch(ex => console.error('[GitLens.DiffWithWorkingCommand]', 'getVersionedFile', ex)) - .then(compare => commands.executeCommand(BuiltInCommands.Diff, Uri.file(compare), uri, `${basename(shaUri.fsPath)} (${sha}) ↔ ${basename(uri.fsPath)} (index)`) + .then(compare => commands.executeCommand(BuiltInCommands.Diff, Uri.file(compare), uri, `${path.basename(shaUri.fsPath)} (${sha}) ↔ ${path.basename(uri.fsPath)} (index)`) .then(() => commands.executeCommand(BuiltInCommands.RevealLine, {lineNumber: line, at: 'center'}))); } } diff --git a/src/configuration.ts b/src/configuration.ts index 9523591..7f26f94 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,3 +1,5 @@ +'use strict' + export type BlameAnnotationStyle = 'compact' | 'expanded'; export const BlameAnnotationStyle = { Compact: 'compact' as BlameAnnotationStyle, @@ -29,4 +31,16 @@ export interface ICodeLensConfig { export interface ICodeLensesConfig { recentChange: ICodeLensConfig; authors: ICodeLensConfig; +} + +export interface IAdvancedConfig { + caching: { + enabled: boolean + } +} + +export interface IConfig { + blame: IBlameConfig, + codeLens: ICodeLensesConfig, + advanced: IAdvancedConfig } \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 097870c..4ed5a67 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,8 +4,7 @@ import GitContentProvider from './gitContentProvider'; import GitBlameCodeLensProvider from './gitBlameCodeLensProvider'; import GitBlameContentProvider from './gitBlameContentProvider'; import GitBlameController from './gitBlameController'; -import GitProvider from './gitProvider'; -import Git from './git'; +import GitProvider, {Git} from './gitProvider'; import {DiffWithPreviousCommand, DiffWithWorkingCommand, ShowBlameCommand, ShowBlameHistoryCommand, ToggleBlameCommand} from './commands'; import {ICodeLensesConfig} from './configuration'; import {WorkspaceState} from './constants'; @@ -32,10 +31,7 @@ export function activate(context: ExtensionContext) { context.subscriptions.push(workspace.registerTextDocumentContentProvider(GitContentProvider.scheme, new GitContentProvider(context, git))); context.subscriptions.push(workspace.registerTextDocumentContentProvider(GitBlameContentProvider.scheme, new GitBlameContentProvider(context, git))); - const config = workspace.getConfiguration('gitlens').get('codeLens'); - if (config.recentChange.enabled || config.authors.enabled) { - context.subscriptions.push(languages.registerCodeLensProvider(GitBlameCodeLensProvider.selector, new GitBlameCodeLensProvider(context, git))); - } + context.subscriptions.push(languages.registerCodeLensProvider(GitBlameCodeLensProvider.selector, new GitBlameCodeLensProvider(context, git))); const blameController = new GitBlameController(context, git); context.subscriptions.push(blameController); diff --git a/src/git.ts b/src/git.ts index 6aac36e..5d1bd22 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,9 +1,11 @@ 'use strict'; -import {basename, dirname, extname, isAbsolute, relative} from 'path'; import * as fs from 'fs'; +import * as path from 'path'; import * as tmp from 'tmp'; import {spawnPromise} from 'spawn-rx'; +export * from './gitEnrichment'; + function gitCommand(cwd: string, ...args) { return spawnPromise('git', args, { cwd: cwd }) .then(s => { @@ -21,12 +23,19 @@ function gitCommand(cwd: string, ...args) { }); } +export type GitBlameFormat = '--incremental' | '--line-porcelain' | '--porcelain'; +export const GitBlameFormat = { + incremental: '--incremental' as GitBlameFormat, + linePorcelain: '--line-porcelain' as GitBlameFormat, + porcelain: '--porcelain' as GitBlameFormat +} + export default class Git { - static normalizePath(fileName: string, repoPath: string) { + static normalizePath(fileName: string, repoPath?: string) { fileName = fileName.replace(/\\/g, '/'); repoPath = repoPath.replace(/\\/g, '/'); - if (isAbsolute(fileName) && fileName.startsWith(repoPath)) { - fileName = relative(repoPath, fileName).replace(/\\/g, '/'); + if (path.isAbsolute(fileName) && fileName.startsWith(repoPath)) { + fileName = path.relative(repoPath, fileName).replace(/\\/g, '/'); } return fileName; } @@ -35,41 +44,20 @@ export default class Git { return gitCommand(cwd, 'rev-parse', '--show-toplevel').then(data => data.replace(/\r?\n|\r/g, '').replace(/\\/g, '/')); } - static blame(fileName: string, repoPath: string, sha?: string) { + static blame(format: GitBlameFormat, fileName: string, repoPath: string, sha?: string) { fileName = Git.normalizePath(fileName, repoPath); if (sha) { - return gitCommand(repoPath, 'blame', '-fn', '--root', `${sha}^`, '--', fileName); + return gitCommand(repoPath, 'blame', format, '--root', `${sha}^`, '--', fileName); } - - return gitCommand(repoPath, 'blame', '-fn', '--root', '--', fileName); - } - - static blamePorcelain(fileName: string, repoPath: string, sha?: string) { - fileName = Git.normalizePath(fileName, repoPath); - - if (sha) { - return gitCommand(repoPath, 'blame', '--porcelain', '--root', `${sha}^`, '--', fileName); - } - - return gitCommand(repoPath, 'blame', '--porcelain', '--root', '--', fileName); - } - - static blameLinePorcelain(fileName: string, repoPath: string, sha?: string) { - fileName = Git.normalizePath(fileName, repoPath); - - if (sha) { - return gitCommand(repoPath, 'blame', '--line-porcelain', '--root', `${sha}^`, '--', fileName); - } - - return gitCommand(repoPath, 'blame', '--line-porcelain', '--root', '--', fileName); + return gitCommand(repoPath, 'blame', format, '--root', '--', fileName); } static getVersionedFile(fileName: string, repoPath: string, sha: string) { return new Promise((resolve, reject) => { Git.getVersionedFileText(fileName, repoPath, sha).then(data => { - let ext = extname(fileName); - tmp.file({ prefix: `${basename(fileName, ext)}-${sha}_`, postfix: ext }, (err, destination, fd, cleanupCallback) => { + const ext = path.extname(fileName); + tmp.file({ prefix: `${path.basename(fileName, ext)}-${sha}_`, postfix: ext }, (err, destination, fd, cleanupCallback) => { if (err) { reject(err); return; @@ -92,7 +80,7 @@ export default class Git { fileName = Git.normalizePath(fileName, repoPath); sha = sha.replace('^', ''); - return gitCommand(repoPath, 'show', `${sha}:${fileName}`); + return gitCommand(repoPath, 'show', `${sha}:./${fileName}`); } // static getCommitMessage(sha: string, repoPath: string) { diff --git a/src/gitBlameCodeLensProvider.ts b/src/gitBlameCodeLensProvider.ts index 7e91e09..2369dc6 100644 --- a/src/gitBlameCodeLensProvider.ts +++ b/src/gitBlameCodeLensProvider.ts @@ -2,8 +2,8 @@ import {CancellationToken, CodeLens, CodeLensProvider, commands, DocumentSelector, ExtensionContext, Location, Position, Range, SymbolInformation, SymbolKind, TextDocument, Uri} from 'vscode'; import {BuiltInCommands, Commands, DocumentSchemes, WorkspaceState} from './constants'; import GitProvider, {IGitBlame, IGitCommit} from './gitProvider'; -import {join} from 'path'; import * as moment from 'moment'; +import * as path from 'path'; export class GitDiffWithWorkingTreeCodeLens extends CodeLens { constructor(private git: GitProvider, public fileName: string, public sha: string, range: Range) { @@ -23,7 +23,7 @@ export default class GitBlameCodeLensProvider implements CodeLensProvider { constructor(context: ExtensionContext, private git: GitProvider) { } provideCodeLenses(document: TextDocument, token: CancellationToken): CodeLens[] | Thenable { - const data = this.git.fromBlameUri(document.uri); + const data = GitProvider.fromBlameUri(document.uri); const fileName = data.fileName; const sha = data.sha; @@ -73,7 +73,7 @@ export default class GitBlameCodeLensProvider implements CodeLensProvider { lens.command = { title: `Compare with Working Tree`, command: Commands.DiffWithWorking, - arguments: [Uri.file(join(this.git.repoPath, lens.fileName)), lens.sha] + arguments: [Uri.file(path.join(this.git.repoPath, lens.fileName)), lens.sha] }; return Promise.resolve(lens); } @@ -82,7 +82,7 @@ export default class GitBlameCodeLensProvider implements CodeLensProvider { lens.command = { title: `Compare with Previous (${lens.compareWithSha})`, command: Commands.DiffWithPrevious, - arguments: [Uri.file(join(this.git.repoPath, lens.fileName)), lens.sha, lens.compareWithSha] + arguments: [Uri.file(path.join(this.git.repoPath, lens.fileName)), lens.sha, lens.compareWithSha] }; return Promise.resolve(lens); } diff --git a/src/gitBlameContentProvider.ts b/src/gitBlameContentProvider.ts index c61840e..c4b093a 100644 --- a/src/gitBlameContentProvider.ts +++ b/src/gitBlameContentProvider.ts @@ -47,7 +47,7 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi } provideTextDocumentContent(uri: Uri): string | Thenable { - const data = this.git.fromBlameUri(uri); + const data = GitProvider.fromBlameUri(uri); //const editor = this._findEditor(Uri.file(join(data.repoPath, data.file))); diff --git a/src/gitCodeActionProvider.ts b/src/gitCodeActionProvider.ts index b3f89b5..6851380 100644 --- a/src/gitCodeActionProvider.ts +++ b/src/gitCodeActionProvider.ts @@ -25,7 +25,7 @@ export default class GitCodeActionProvider implements CodeActionProvider { command: Commands.DiffWithWorking, arguments: [ Uri.file(document.fileName), - blame.commit.sha, blame.commit.toUri(), + blame.commit.sha, blame.commit.uri, blame.line.line ] }); @@ -37,8 +37,8 @@ export default class GitCodeActionProvider implements CodeActionProvider { command: Commands.DiffWithPrevious, arguments: [ Uri.file(document.fileName), - blame.commit.sha, blame.commit.toUri(), - blame.commit.previousSha, blame.commit.toPreviousUri(), + blame.commit.sha, blame.commit.uri, + blame.commit.previousSha, blame.commit.previousUri, blame.line.line ] }); diff --git a/src/gitCodeLensProvider.ts b/src/gitCodeLensProvider.ts index f3f236d..406bac0 100644 --- a/src/gitCodeLensProvider.ts +++ b/src/gitCodeLensProvider.ts @@ -3,8 +3,8 @@ import {CancellationToken, CodeLens, CodeLensProvider, commands, DocumentSelecto import {BuiltInCommands, Commands, DocumentSchemes, WorkspaceState} from './constants'; import {CodeLensCommand, ICodeLensesConfig} from './configuration'; import GitProvider, {IGitBlame, IGitBlameLines, IGitCommit} from './gitProvider'; -import * as moment from 'moment'; import * as _ from 'lodash'; +import * as moment from 'moment'; export class GitRecentChangeCodeLens extends CodeLens { constructor(private git: GitProvider, public fileName: string, public symbolKind: SymbolKind, public blameRange: Range, range: Range) { diff --git a/src/gitContentProvider.ts b/src/gitContentProvider.ts index 49abbbd..dedbf0d 100644 --- a/src/gitContentProvider.ts +++ b/src/gitContentProvider.ts @@ -9,7 +9,7 @@ export default class GitContentProvider implements TextDocumentContentProvider { constructor(context: ExtensionContext, private git: GitProvider) { } provideTextDocumentContent(uri: Uri): string | Thenable { - const data = this.git.fromGitUri(uri); + const data = GitProvider.fromGitUri(uri); return this.git.getVersionedFileText(data.originalFileName || data.fileName, data.sha); } } \ No newline at end of file diff --git a/src/gitEnrichment.ts b/src/gitEnrichment.ts new file mode 100644 index 0000000..a1fa647 --- /dev/null +++ b/src/gitEnrichment.ts @@ -0,0 +1,171 @@ +'use strict' +import {Uri} from 'vscode'; +import {GitBlameFormat} from './git' +import * as moment from 'moment'; +import * as path from 'path'; + +const blamePorcelainMatcher = /^([\^0-9a-fA-F]{40})\s([0-9]+)\s([0-9]+)(?:\s([0-9]+))?$\n(?:^author\s(.*)$\n^author-mail\s(.*)$\n^author-time\s(.*)$\n^author-tz\s(.*)$\n^committer\s(.*)$\n^committer-mail\s(.*)$\n^committer-time\s(.*)$\n^committer-tz\s(.*)$\n^summary\s(.*)$\n(?:^previous\s(.*)?\s(.*)$\n)?^filename\s(.*)$\n)?^(.*)$/gm; +const blameLinePorcelainMatcher = /^([\^0-9a-fA-F]{40})\s([0-9]+)\s([0-9]+)(?:\s([0-9]+))?$\n^author\s(.*)$\n^author-mail\s(.*)$\n^author-time\s(.*)$\n^author-tz\s(.*)$\n^committer\s(.*)$\n^committer-mail\s(.*)$\n^committer-time\s(.*)$\n^committer-tz\s(.*)$\n^summary\s(.*)$\n(?:^previous\s(.*)?\s(.*)$\n)?^filename\s(.*)$\n^(.*)$/gm; + +interface IGitEnricher { + enrich(data: string, ...args): T; +} + +export class GitBlameEnricher implements IGitEnricher { + private _matcher: RegExp; + + constructor(public format: GitBlameFormat, private repoPath: string) { + if (format === GitBlameFormat.porcelain) { + this._matcher = blamePorcelainMatcher; + } else if (format === GitBlameFormat.linePorcelain) { + this._matcher = blamePorcelainMatcher; + } else { + throw new Error(`Invalid blame format=${format}`); + } + } + + enrich(data: string, fileName: string): IGitBlame { + if (!data) return null; + + const authors: Map = new Map(); + const commits: Map = new Map(); + const lines: Array = []; + + let m: Array; + while ((m = this._matcher.exec(data)) != null) { + const sha = m[1].substring(0, 8); + const previousSha = m[14]; + let commit = commits.get(sha); + if (!commit) { + const authorName = m[5].trim(); + let author = authors.get(authorName); + if (!author) { + author = { + name: authorName, + lineCount: 0 + }; + authors.set(authorName, author); + } + + commit = new GitCommit(this.repoPath, sha, fileName, authorName, moment(`${m[7]} ${m[8]}`, 'X Z').toDate(), m[13]); + + const originalFileName = m[16]; + if (!fileName.toLowerCase().endsWith(originalFileName.toLowerCase())) { + commit.originalFileName = originalFileName; + } + + if (previousSha) { + commit.previousSha = previousSha.substring(0, 8); + commit.previousFileName = m[15]; + } + + commits.set(sha, commit); + } + + const line: IGitCommitLine = { + sha, + line: parseInt(m[3], 10) - 1, + originalLine: parseInt(m[2], 10) - 1 + //code: m[17] + } + + if (previousSha) { + line.previousSha = previousSha.substring(0, 8); + } + + commit.lines.push(line); + lines.push(line); + } + + commits.forEach(c => authors.get(c.author).lineCount += c.lines.length); + + const sortedAuthors: Map = new Map(); + const values = Array.from(authors.values()) + .sort((a, b) => b.lineCount - a.lineCount) + .forEach(a => sortedAuthors.set(a.name, a)); + + const sortedCommits: Map = new Map(); + Array.from(commits.values()) + .sort((a, b) => b.date.getTime() - a.date.getTime()) + .forEach(c => sortedCommits.set(c.sha, c)); + + return { + authors: sortedAuthors, + commits: sortedCommits, + lines: lines + }; + } +} + +export interface IGitBlame { + authors: Map; + commits: Map; + lines: IGitCommitLine[]; +} + +export interface IGitBlameLine { + author: IGitAuthor; + commit: IGitCommit; + line: IGitCommitLine; +} + +export interface IGitBlameLines extends IGitBlame { + allLines: IGitCommitLine[]; +} + +export interface IGitBlameCommitLines { + author: IGitAuthor; + commit: IGitCommit; + lines: IGitCommitLine[]; +} + +export interface IGitAuthor { + name: string; + lineCount: number; +} + +export interface IGitCommit { + sha: string; + fileName: string; + author: string; + date: Date; + message: string; + lines: IGitCommitLine[]; + originalFileName?: string; + previousSha?: string; + previousFileName?: string; + + previousUri: Uri; + uri: Uri; +} + +export class GitCommit implements IGitCommit { + lines: IGitCommitLine[]; + originalFileName?: string; + previousSha?: string; + previousFileName?: string; + + constructor(private repoPath: string, public sha: string, public fileName: string, public author: string, public date: Date, public message: string, + lines?: IGitCommitLine[], originalFileName?: string, previousSha?: string, previousFileName?: string) { + this.lines = lines || []; + this.originalFileName = originalFileName; + this.previousSha = previousSha; + this.previousFileName = previousFileName; + } + + get previousUri(): Uri { + return this.previousFileName ? Uri.file(path.join(this.repoPath, this.previousFileName)) : this.uri; + } + + get uri(): Uri { + return Uri.file(path.join(this.repoPath, this.originalFileName || this.fileName)); + } +} + +export interface IGitCommitLine { + sha: string; + previousSha?: string; + line: number; + originalLine: number; + code?: string; +} \ No newline at end of file diff --git a/src/gitProvider.ts b/src/gitProvider.ts index 7a5e8f2..95884a6 100644 --- a/src/gitProvider.ts +++ b/src/gitProvider.ts @@ -1,17 +1,17 @@ 'use strict' import {Disposable, ExtensionContext, languages, Location, Position, Range, Uri, workspace} from 'vscode'; import {DocumentSchemes, WorkspaceState} from './constants'; +import {IConfig} from './configuration'; import GitCodeLensProvider from './gitCodeLensProvider'; -import Git from './git'; -import {basename, dirname, extname, join} from 'path'; -import * as moment from 'moment'; -import * as _ from 'lodash'; -import {exists, readFile} from 'fs' +import Git, {GitBlameEnricher, GitBlameFormat, GitCommit, IGitAuthor, IGitBlame, IGitBlameCommitLines, IGitBlameLine, IGitBlameLines, IGitCommit} from './git'; +import * as fs from 'fs' import * as ignore from 'ignore'; +import * as _ from 'lodash'; +import * as moment from 'moment'; +import * as path from 'path'; -const commitMessageMatcher = /^([\^0-9a-fA-F]{7})\s(.*)$/gm; -const blamePorcelainMatcher = /^([\^0-9a-fA-F]{40})\s([0-9]+)\s([0-9]+)(?:\s([0-9]+))?$\n(?:^author\s(.*)$\n^author-mail\s(.*)$\n^author-time\s(.*)$\n^author-tz\s(.*)$\n^committer\s(.*)$\n^committer-mail\s(.*)$\n^committer-time\s(.*)$\n^committer-tz\s(.*)$\n^summary\s(.*)$\n(?:^previous\s(.*)?\s(.*)$\n)?^filename\s(.*)$\n)?^(.*)$/gm; -const blameLinePorcelainMatcher = /^([\^0-9a-fA-F]{40})\s([0-9]+)\s([0-9]+)(?:\s([0-9]+))?$\n^author\s(.*)$\n^author-mail\s(.*)$\n^author-time\s(.*)$\n^author-tz\s(.*)$\n^committer\s(.*)$\n^committer-mail\s(.*)$\n^committer-time\s(.*)$\n^committer-tz\s(.*)$\n^summary\s(.*)$\n(?:^previous\s(.*)?\s(.*)$\n)?^filename\s(.*)$\n^(.*)$/gm; +export { Git }; +export * from './git'; interface IBlameCacheEntry { //date: Date; @@ -28,26 +28,29 @@ enum RemoveCacheReason { export default class GitProvider extends Disposable { public repoPath: string; - private _blames: Map; + private _blameCache: Map; + private _blameCacheDisposable: Disposable; + + private _config: IConfig; private _disposable: Disposable; - private _codeLensProviderSubscription: Disposable; + private _codeLensProviderDisposable: Disposable; private _gitignore: Promise; - // TODO: Needs to be a Map so it can debounce per file - private _removeCachedBlameFn: ((string, boolean) => void) & _.Cancelable; - static BlameEmptyPromise = Promise.resolve(null); + static BlameFormat = GitBlameFormat.porcelain; constructor(private context: ExtensionContext) { super(() => this.dispose()); this.repoPath = context.workspaceState.get(WorkspaceState.RepoPath) as string; + this._onConfigure(); + this._gitignore = new Promise((resolve, reject) => { - const gitignorePath = join(this.repoPath, '.gitignore'); - exists(gitignorePath, e => { + const gitignorePath = path.join(this.repoPath, '.gitignore'); + fs.exists(gitignorePath, e => { if (e) { - readFile(gitignorePath, 'utf8', (err, data) => { + fs.readFile(gitignorePath, 'utf8', (err, data) => { if (!err) { resolve(ignore().add(data)); return; @@ -60,33 +63,60 @@ export default class GitProvider extends Disposable { }); }); - // TODO: Cache needs to be cleared on file changes -- createFileSystemWatcher or timeout? - this._blames = new Map(); - this._registerCodeLensProvider(); - this._removeCachedBlameFn = _.debounce(this._removeCachedBlame.bind(this), 2500); - const subscriptions: Disposable[] = []; - // TODO: Maybe stop clearing on close and instead limit to a certain number of recent blames - subscriptions.push(workspace.onDidCloseTextDocument(d => this._removeCachedBlame(d.fileName, RemoveCacheReason.DocumentClosed))); - subscriptions.push(workspace.onDidSaveTextDocument(d => this._removeCachedBlameFn(d.fileName, RemoveCacheReason.DocumentSaved))); - subscriptions.push(workspace.onDidChangeTextDocument(e => this._removeCachedBlameFn(e.document.fileName, RemoveCacheReason.DocumentChanged))); - subscriptions.push(workspace.onDidChangeConfiguration(() => this._registerCodeLensProvider())); + subscriptions.push(workspace.onDidChangeConfiguration(() => this._onConfigure())); this._disposable = Disposable.from(...subscriptions); } dispose() { - this._blames.clear(); this._disposable && this._disposable.dispose(); - this._codeLensProviderSubscription && this._codeLensProviderSubscription.dispose(); + this._codeLensProviderDisposable && this._codeLensProviderDisposable.dispose(); + this._blameCacheDisposable && this._blameCacheDisposable.dispose(); + this._blameCache && this._blameCache.clear(); } - private _registerCodeLensProvider() { - if (this._codeLensProviderSubscription) { - this._codeLensProviderSubscription.dispose(); + public get UseCaching() { + return !!this._blameCache; + } + + private _onConfigure() { + const config = workspace.getConfiguration().get('gitlens'); + + if (!_.isEqual(config.codeLens, this._config && this._config.codeLens)) { + this._codeLensProviderDisposable && this._codeLensProviderDisposable.dispose(); + if (config.codeLens.recentChange.enabled || config.codeLens.authors.enabled) { + this._codeLensProviderDisposable = languages.registerCodeLensProvider(GitCodeLensProvider.selector, new GitCodeLensProvider(this.context, this)); + } else { + this._codeLensProviderDisposable = null; + } } - this._codeLensProviderSubscription = languages.registerCodeLensProvider(GitCodeLensProvider.selector, new GitCodeLensProvider(this.context, this)); + + if (!_.isEqual(config.advanced, this._config && this._config.advanced)) { + if (config.advanced.caching.enabled) { + // TODO: Cache needs to be cleared on file changes -- createFileSystemWatcher or timeout? + this._blameCache = new Map(); + + const disposables: Disposable[] = []; + + // TODO: Maybe stop clearing on close and instead limit to a certain number of recent blames + disposables.push(workspace.onDidCloseTextDocument(d => this._removeCachedBlame(d.fileName, RemoveCacheReason.DocumentClosed))); + + const removeCachedBlameFn = _.debounce(this._removeCachedBlame.bind(this), 2500); + disposables.push(workspace.onDidSaveTextDocument(d => removeCachedBlameFn(d.fileName, RemoveCacheReason.DocumentSaved))); + disposables.push(workspace.onDidChangeTextDocument(e => removeCachedBlameFn(e.document.fileName, RemoveCacheReason.DocumentChanged))); + + this._blameCacheDisposable = Disposable.from(...disposables); + } else { + this._blameCacheDisposable && this._blameCacheDisposable.dispose(); + this._blameCacheDisposable = null; + this._blameCache && this._blameCache.clear(); + this._blameCache = null; + } + } + + this._config = config; } private _getBlameCacheKey(fileName: string) { @@ -94,16 +124,18 @@ export default class GitProvider extends Disposable { } private _removeCachedBlame(fileName: string, reason: RemoveCacheReason) { + if (!this.UseCaching) return; + fileName = Git.normalizePath(fileName, this.repoPath); const cacheKey = this._getBlameCacheKey(fileName); if (reason === RemoveCacheReason.DocumentClosed) { // Don't remove broken blame on close (since otherwise we'll have to run the broken blame again) - const entry = this._blames.get(cacheKey); + const entry = this._blameCache.get(cacheKey); if (entry && entry.errorMessage) return; } - if (this._blames.delete(cacheKey)) { + if (this._blameCache.delete(cacheKey)) { console.log('[GitLens]', `Clear blame cache: cacheKey=${cacheKey}, reason=${RemoveCacheReason[reason]}`); // if (reason === RemoveCacheReason.DocumentSaved) { @@ -121,8 +153,10 @@ export default class GitProvider extends Disposable { fileName = Git.normalizePath(fileName, this.repoPath); const cacheKey = this._getBlameCacheKey(fileName); - let entry = this._blames.get(cacheKey); - if (entry !== undefined) return entry.blame; + if (this.UseCaching) { + let entry = this._blameCache.get(cacheKey); + if (entry !== undefined) return entry.blame; + } return this._gitignore.then(ignore => { let blame: Promise; @@ -130,109 +164,42 @@ export default class GitProvider extends Disposable { console.log('[GitLens]', `Skipping blame; ${fileName} is gitignored`); blame = GitProvider.BlameEmptyPromise; } else { - //blame = Git.blameLinePorcelain(fileName, this.repoPath) - blame = Git.blamePorcelain(fileName, this.repoPath) - .then(data => { - if (!data) return null; + const enricher = new GitBlameEnricher(GitProvider.BlameFormat, this.repoPath); + blame = Git.blame(GitProvider.BlameFormat, fileName, this.repoPath) + .then(data => enricher.enrich(data, fileName)); - const authors: Map = new Map(); - const commits: Map = new Map(); - const lines: Array = []; - - let m: Array; - //while ((m = blameLinePorcelainMatcher.exec(data)) != null) { - while ((m = blamePorcelainMatcher.exec(data)) != null) { - const sha = m[1].substring(0, 8); - const previousSha = m[14]; - let commit = commits.get(sha); - if (!commit) { - const authorName = m[5].trim(); - let author = authors.get(authorName); - if (!author) { - author = { - name: authorName, - lineCount: 0 - }; - authors.set(authorName, author); - } - - commit = new GitCommit(this.repoPath, sha, fileName, authorName, moment(`${m[7]} ${m[8]}`, 'X Z').toDate(), m[13]); - - const originalFileName = m[16]; - if (!fileName.toLowerCase().endsWith(originalFileName.toLowerCase())) { - commit.originalFileName = originalFileName; - } - - if (previousSha) { - commit.previousSha = previousSha.substring(0, 8); - commit.previousFileName = m[15]; - } - - commits.set(sha, commit); - } - - const line: IGitCommitLine = { - sha, - line: parseInt(m[3], 10) - 1, - originalLine: parseInt(m[2], 10) - 1 - //code: m[17] - } - - if (previousSha) { - line.previousSha = previousSha.substring(0, 8); - } - - commit.lines.push(line); - lines.push(line); + if (this.UseCaching) { + // Trap and cache expected blame errors + blame.catch(ex => { + const msg = ex && ex.toString(); + if (msg && (msg.includes('is outside repository') || msg.includes('no such path'))) { + console.log('[GitLens]', `Replace blame cache: cacheKey=${cacheKey}`); + this._blameCache.set(cacheKey, { + //date: new Date(), + blame: GitProvider.BlameEmptyPromise, + errorMessage: msg + }); + return GitProvider.BlameEmptyPromise; } - commits.forEach(c => authors.get(c.author).lineCount += c.lines.length); + const brokenBlame = this._blameCache.get(cacheKey); + if (brokenBlame) { + brokenBlame.errorMessage = msg; + this._blameCache.set(cacheKey, brokenBlame); + } - const sortedAuthors: Map = new Map(); - const values = Array.from(authors.values()) - .sort((a, b) => b.lineCount - a.lineCount) - .forEach(a => sortedAuthors.set(a.name, a)); - - const sortedCommits: Map = new Map(); - Array.from(commits.values()) - .sort((a, b) => b.date.getTime() - a.date.getTime()) - .forEach(c => sortedCommits.set(c.sha, c)); - - return { - authors: sortedAuthors, - commits: sortedCommits, - lines: lines - }; + throw ex; }); - - // Trap and cache expected blame errors - blame.catch(ex => { - const msg = ex && ex.toString(); - if (msg && (msg.includes('is outside repository') || msg.includes('no such path'))) { - console.log('[GitLens]', `Replace blame cache: cacheKey=${cacheKey}`); - this._blames.set(cacheKey, { - //date: new Date(), - blame: GitProvider.BlameEmptyPromise, - errorMessage: msg - }); - return GitProvider.BlameEmptyPromise; - } - - const brokenBlame = this._blames.get(cacheKey); - if (brokenBlame) { - brokenBlame.errorMessage = msg; - this._blames.set(cacheKey, brokenBlame); - } - - throw ex; - }); + } } - console.log('[GitLens]', `Add blame cache: cacheKey=${cacheKey}`); - this._blames.set(cacheKey, { - //date: new Date(), - blame: blame - }); + if (this.UseCaching) { + console.log('[GitLens]', `Add blame cache: cacheKey=${cacheKey}`); + this._blameCache.set(cacheKey, { + //date: new Date(), + blame: blame + }); + } return blame; }); @@ -326,9 +293,9 @@ export default class GitProvider extends Disposable { const locations: Array = []; Array.from(blame.commits.values()) .forEach((c, i) => { - const uri = c.toBlameUri(i + 1, commitCount, range); + const uri = GitProvider.toBlameUri(c, i + 1, commitCount, range); c.lines.forEach(l => locations.push(new Location(c.originalFileName - ? c.toBlameUri(i + 1, commitCount, range, c.originalFileName) + ? GitProvider.toBlameUri(c, i + 1, commitCount, range, c.originalFileName) : uri, new Position(l.originalLine, 0)))); }); @@ -357,6 +324,8 @@ export default class GitProvider extends Disposable { // }); // } + // const commitMessageMatcher = /^([\^0-9a-fA-F]{7})\s(.*)$/gm; + // getCommitMessage(sha: string) { // return Git.getCommitMessage(sha, this.repoPath); // } @@ -381,132 +350,56 @@ export default class GitProvider extends Disposable { return Git.getVersionedFileText(fileName, this.repoPath, sha); } - fromBlameUri(uri: Uri): IGitBlameUriData { + static fromBlameUri(uri: Uri): IGitBlameUriData { if (uri.scheme !== DocumentSchemes.GitBlame) throw new Error(`fromGitUri(uri=${uri}) invalid scheme`); - const data = this._fromGitUri(uri); + const data = GitProvider._fromGitUri(uri); data.range = new Range(data.range[0].line, data.range[0].character, data.range[1].line, data.range[1].character); return data; } - fromGitUri(uri: Uri) { + static fromGitUri(uri: Uri) { if (uri.scheme !== DocumentSchemes.Git) throw new Error(`fromGitUri(uri=${uri}) invalid scheme`); - return this._fromGitUri(uri); + return GitProvider._fromGitUri(uri); } - private _fromGitUri(uri: Uri): T { + private static _fromGitUri(uri: Uri): T { return JSON.parse(uri.query) as T; } -} -export interface IGitBlame { - authors: Map; - commits: Map; - lines: IGitCommitLine[]; -} - -export interface IGitBlameLine { - author: IGitAuthor; - commit: IGitCommit; - line: IGitCommitLine; -} - -export interface IGitBlameLines extends IGitBlame { - allLines: IGitCommitLine[]; -} - -export interface IGitBlameCommitLines { - author: IGitAuthor; - commit: IGitCommit; - lines: IGitCommitLine[]; -} - -export interface IGitAuthor { - name: string; - lineCount: number; -} - -export interface IGitCommit { - sha: string; - fileName: string; - author: string; - date: Date; - message: string; - lines: IGitCommitLine[]; - originalFileName?: string; - previousSha?: string; - previousFileName?: string; - - toPreviousUri(): Uri; - toUri(): Uri; - - toBlameUri(index: number, commitCount: number, range: Range, originalFileName?: string); - toGitUri(index: number, commitCount: number, originalFileName?: string); -} - -class GitCommit implements IGitCommit { - lines: IGitCommitLine[]; - originalFileName?: string; - previousSha?: string; - previousFileName?: string; - - constructor(private repoPath: string, public sha: string, public fileName: string, public author: string, public date: Date, public message: string, - lines?: IGitCommitLine[], originalFileName?: string, previousSha?: string, previousFileName?: string) { - this.lines = lines || []; - this.originalFileName = originalFileName; - this.previousSha = previousSha; - this.previousFileName = previousFileName; + static toBlameUri(commit: IGitCommit, index: number, commitCount: number, range: Range, originalFileName?: string) { + return GitProvider._toGitUri(commit, DocumentSchemes.GitBlame, commitCount, GitProvider._toGitBlameUriData(commit, index, range, originalFileName)); } - toPreviousUri(): Uri { - return this.previousFileName ? Uri.file(join(this.repoPath, this.previousFileName)) : this.toUri(); + static toGitUri(commit: IGitCommit, index: number, commitCount: number, originalFileName?: string) { + return GitProvider._toGitUri(commit, DocumentSchemes.Git, commitCount, GitProvider._toGitUriData(commit, index, originalFileName)); } - toUri(): Uri { - return Uri.file(join(this.repoPath, this.originalFileName || this.fileName)); - } - - toBlameUri(index: number, commitCount: number, range: Range, originalFileName?: string) { - return this._toGitUri(DocumentSchemes.GitBlame, commitCount, this._toGitBlameUriData(index, range, originalFileName)); - } - - toGitUri(index: number, commitCount: number, originalFileName?: string) { - return this._toGitUri(DocumentSchemes.Git, commitCount, this._toGitUriData(index, originalFileName)); - } - - private _toGitUri(scheme: DocumentSchemes, commitCount: number, data: IGitUriData | IGitBlameUriData) { + private static _toGitUri(commit: IGitCommit, scheme: DocumentSchemes, commitCount: number, data: IGitUriData | IGitBlameUriData) { const pad = n => ("0000000" + n).slice(-("" + commitCount).length); - const ext = extname(data.fileName); - // const path = `${dirname(data.fileName)}/${commit.sha}: ${basename(data.fileName, ext)}${ext}`; - const path = `${dirname(data.fileName)}/${this.sha}${ext}`; + const ext = path.extname(data.fileName); + // const uriPath = `${dirname(data.fileName)}/${commit.sha}: ${basename(data.fileName, ext)}${ext}`; + const uriPath = `${path.dirname(data.fileName)}/${commit.sha}${ext}`; // NOTE: Need to specify an index here, since I can't control the sort order -- just alphabetic or by file location - return Uri.parse(`${scheme}:${pad(data.index)}. ${this.author}, ${moment(this.date).format('MMM D, YYYY hh:MM a')} - ${path}?${JSON.stringify(data)}`); + return Uri.parse(`${scheme}:${pad(data.index)}. ${commit.author}, ${moment(commit.date).format('MMM D, YYYY hh:MM a')} - ${uriPath}?${JSON.stringify(data)}`); } - private _toGitUriData(index: number, originalFileName?: string): T { - const fileName = originalFileName || this.fileName; - const data = { fileName: this.fileName, sha: this.sha, index: index } as T; + private static _toGitUriData(commit: IGitCommit, index: number, originalFileName?: string): T { + const fileName = originalFileName || commit.fileName; + const data = { fileName: commit.fileName, sha: commit.sha, index: index } as T; if (originalFileName) { data.originalFileName = originalFileName; } return data; } - private _toGitBlameUriData(index: number, range: Range, originalFileName?: string) { - const data = this._toGitUriData(index, originalFileName); + private static _toGitBlameUriData(commit: IGitCommit, index: number, range: Range, originalFileName?: string) { + const data = this._toGitUriData(commit, index, originalFileName); data.range = range; return data; } } -export interface IGitCommitLine { - sha: string; - previousSha?: string; - line: number; - originalLine: number; - code?: string; -} - export interface IGitUriData { fileName: string, originalFileName?: string;