'use strict'; import { Functions, Iterables, Objects } from './system'; import { Disposable, DocumentFilter, ExtensionContext, languages, Location, Position, Range, TextDocument, TextEditor, Uri, window, workspace } from 'vscode'; import { DocumentSchemes, WorkspaceState } from './constants'; import { CodeLensVisibility, IConfig } from './configuration'; import GitCodeLensProvider from './gitCodeLensProvider'; import Git, { GitBlameParserEnricher, GitBlameFormat, GitCommit, GitLogParserEnricher, IGitAuthor, IGitBlame, IGitBlameCommitLines, IGitBlameLine, IGitBlameLines, IGitCommit, IGitLog } from './git/git'; import { Logger } from './logger'; import * as fs from 'fs'; import * as ignore from 'ignore'; import * as moment from 'moment'; import * as path from 'path'; export { Git }; export * from './git/git'; class CacheEntry { blame?: ICachedBlame; log?: ICachedLog; get hasErrors() { return !!((this.blame && this.blame.errorMessage) || (this.log && this.log.errorMessage)); } } interface ICachedItem { //date: Date; item: Promise; errorMessage?: string; } interface ICachedBlame extends ICachedItem { } interface ICachedLog extends ICachedItem { } enum RemoveCacheReason { DocumentClosed, DocumentSaved, DocumentChanged } export default class GitProvider extends Disposable { private _cache: Map | null; private _cacheDisposable: Disposable | null; private _config: IConfig; private _disposable: Disposable; private _codeLensProviderDisposable: Disposable | null; private _codeLensProviderSelector: DocumentFilter; private _gitignore: Promise; static EmptyPromise: Promise = Promise.resolve(null); static BlameFormat = GitBlameFormat.incremental; constructor(private context: ExtensionContext) { super(() => this.dispose()); const repoPath = context.workspaceState.get(WorkspaceState.RepoPath) as string; this._onConfigure(); this._gitignore = new Promise((resolve, reject) => { const gitignorePath = path.join(repoPath, '.gitignore'); fs.exists(gitignorePath, e => { if (e) { fs.readFile(gitignorePath, 'utf8', (err, data) => { if (!err) { resolve(ignore().add(data)); return; } resolve(null); }); return; } resolve(null); }); }); const subscriptions: Disposable[] = []; subscriptions.push(workspace.onDidChangeConfiguration(this._onConfigure, this)); this._disposable = Disposable.from(...subscriptions); } dispose() { this._disposable && this._disposable.dispose(); this._codeLensProviderDisposable && this._codeLensProviderDisposable.dispose(); this._cacheDisposable && this._cacheDisposable.dispose(); this._cache && this._cache.clear(); } public get UseCaching() { return !!this._cache; } private _onConfigure() { const config = workspace.getConfiguration().get('gitlens'); if (!Objects.areEquivalent(config.codeLens, this._config && this._config.codeLens)) { this._codeLensProviderDisposable && this._codeLensProviderDisposable.dispose(); if (config.codeLens.visibility === CodeLensVisibility.Auto && (config.codeLens.recentChange.enabled || config.codeLens.authors.enabled)) { this._codeLensProviderSelector = GitCodeLensProvider.selector; this._codeLensProviderDisposable = languages.registerCodeLensProvider(this._codeLensProviderSelector, new GitCodeLensProvider(this.context, this)); } else { this._codeLensProviderDisposable = null; } } if (!Objects.areEquivalent(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._cache = 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._removeCachedEntry(d, RemoveCacheReason.DocumentClosed))); const removeCachedEntryFn = Functions.debounce(this._removeCachedEntry.bind(this), 2500); disposables.push(workspace.onDidSaveTextDocument(d => removeCachedEntryFn(d, RemoveCacheReason.DocumentSaved))); disposables.push(workspace.onDidChangeTextDocument(e => removeCachedEntryFn(e.document, RemoveCacheReason.DocumentChanged))); this._cacheDisposable = Disposable.from(...disposables); } else { this._cacheDisposable && this._cacheDisposable.dispose(); this._cacheDisposable = null; this._cache && this._cache.clear(); this._cache = null; } } this._config = config; } private _getCacheEntryKey(fileName: string) { return fileName.toLowerCase(); } private _removeCachedEntry(document: TextDocument, reason: RemoveCacheReason) { if (!this.UseCaching) return; if (document.uri.scheme !== DocumentSchemes.File) return; const fileName = Git.normalizePath(document.fileName); const cacheKey = this._getCacheEntryKey(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._cache.get(cacheKey); if (entry && entry.hasErrors) return; } if (this._cache.delete(cacheKey)) { Logger.log(`Clear cache entry for '${cacheKey}', reason=${RemoveCacheReason[reason]}`); // if (reason === RemoveCacheReason.DocumentSaved) { // // TODO: Killing the code lens provider is too drastic -- makes the editor jump around, need to figure out how to trigger a refresh // this._registerCodeLensProvider(); // } } } getRepoPath(cwd: string): Promise { return Git.repoPath(cwd); } async getBlameForFile(fileName: string): Promise { Logger.log(`getBlameForFile('${fileName}')`); fileName = Git.normalizePath(fileName); const cacheKey = this._getCacheEntryKey(fileName); let entry: CacheEntry | undefined = undefined; if (this.UseCaching) { entry = this._cache.get(cacheKey); if (entry !== undefined && entry.blame !== undefined) return entry.blame.item; if (entry === undefined) { entry = new CacheEntry(); } } const ignore = await this._gitignore; let blame: Promise; if (ignore && !ignore.filter([fileName]).length) { Logger.log(`Skipping blame; '${fileName}' is gitignored`); blame = GitProvider.EmptyPromise; } else { blame = Git.blame(GitProvider.BlameFormat, fileName) .then(data => new GitBlameParserEnricher(GitProvider.BlameFormat).enrich(data, fileName)) .catch(ex => { // Trap and cache expected blame errors if (this.UseCaching) { const msg = ex && ex.toString(); Logger.log(`Replace blame cache with empty promise for '${cacheKey}'`); entry.blame = { //date: new Date(), item: GitProvider.EmptyPromise, errorMessage: msg }; this._cache.set(cacheKey, entry); return GitProvider.EmptyPromise; } return null; }); } if (this.UseCaching) { Logger.log(`Add ${(blame === GitProvider.EmptyPromise ? 'empty promise to ' : '')}blame cache for '${cacheKey}'`); entry.blame = { //date: new Date(), item: blame }; this._cache.set(cacheKey, entry); } return blame; } async getBlameForLine(fileName: string, line: number, sha?: string, repoPath?: string): Promise { Logger.log(`getBlameForLine('${fileName}', ${line}, ${sha}, ${repoPath})`); if (this.UseCaching && !sha) { const blame = await this.getBlameForFile(fileName); const blameLine = blame && blame.lines[line]; if (!blameLine) return null; const commit = blame.commits.get(blameLine.sha); return { author: Object.assign({}, blame.authors.get(commit.author), { lineCount: commit.lines.length }), commit: commit, line: blameLine }; } fileName = Git.normalizePath(fileName); try { const data = await Git.blameLines(GitProvider.BlameFormat, fileName, line + 1, line + 1, sha, repoPath); const blame = new GitBlameParserEnricher(GitProvider.BlameFormat).enrich(data, fileName); if (!blame) return null; const commit = Iterables.first(blame.commits.values()); if (repoPath) { commit.repoPath = repoPath; } return { author: Iterables.first(blame.authors.values()), commit: commit, line: blame.lines[line] }; } catch (ex) { return null; } } async getBlameForRange(fileName: string, range: Range): Promise { Logger.log(`getBlameForRange('${fileName}', ${range})`); const blame = await this.getBlameForFile(fileName); if (!blame) return null; if (!blame.lines.length) return Object.assign({ allLines: blame.lines }, blame); if (range.start.line === 0 && range.end.line === blame.lines.length - 1) { return Object.assign({ allLines: blame.lines }, blame); } const lines = blame.lines.slice(range.start.line, range.end.line + 1); const shas: Set = new Set(); lines.forEach(l => shas.add(l.sha)); const authors: Map = new Map(); const commits: Map = new Map(); blame.commits.forEach(c => { if (!shas.has(c.sha)) return; const commit: IGitCommit = new GitCommit(c.repoPath, c.sha, c.fileName, c.author, c.date, c.message, c.lines.filter(l => l.line >= range.start.line && l.line <= range.end.line), c.originalFileName, c.previousSha, c.previousFileName); commits.set(c.sha, commit); let author = authors.get(commit.author); if (!author) { author = { name: commit.author, lineCount: 0 }; authors.set(author.name, author); } author.lineCount += commit.lines.length; }); const sortedAuthors: Map = new Map(); Array.from(authors.values()) .sort((a, b) => b.lineCount - a.lineCount) .forEach(a => sortedAuthors.set(a.name, a)); return { authors: sortedAuthors, commits: commits, lines: lines, allLines: blame.lines }; } async getBlameForShaRange(fileName: string, sha: string, range: Range): Promise { Logger.log(`getBlameForShaRange('${fileName}', ${sha}, ${range})`); const blame = await this.getBlameForFile(fileName); if (!blame) return null; const lines = blame.lines.slice(range.start.line, range.end.line + 1).filter(l => l.sha === sha); let commit = blame.commits.get(sha); commit = new GitCommit(commit.repoPath, commit.sha, commit.fileName, commit.author, commit.date, commit.message, lines, commit.originalFileName, commit.previousSha, commit.previousFileName); return { author: Object.assign({}, blame.authors.get(commit.author), { lineCount: commit.lines.length }), commit: commit, lines: lines }; } async getBlameLocations(fileName: string, range: Range): Promise { Logger.log(`getBlameForShaRange('${fileName}', ${range})`); const blame = await this.getBlameForRange(fileName, range); if (!blame) return null; const commitCount = blame.commits.size; const locations: Array = []; Iterables.forEach(blame.commits.values(), (c, i) => { if (c.isUncommitted) return; const uri = GitProvider.toBlameUri(c, i + 1, commitCount, range); c.lines.forEach(l => locations.push(new Location(c.originalFileName ? GitProvider.toBlameUri(c, i + 1, commitCount, range, c.originalFileName) : uri, new Position(l.originalLine, 0)))); }); return locations; } async getLogForFile(fileName: string) { Logger.log(`getLogForFile('${fileName}')`); fileName = Git.normalizePath(fileName); const cacheKey = this._getCacheEntryKey(fileName); let entry: CacheEntry = undefined; if (this.UseCaching) { entry = this._cache.get(cacheKey); if (entry !== undefined && entry.log !== undefined) return entry.log.item; if (entry === undefined) { entry = new CacheEntry(); } } const ignore = await this._gitignore; let log: Promise; if (ignore && !ignore.filter([fileName]).length) { Logger.log(`Skipping log; '${fileName}' is gitignored`); log = GitProvider.EmptyPromise; } else { log = Git.log(fileName) .then(data => new GitLogParserEnricher().enrich(data, fileName)) .catch(ex => { // Trap and cache expected blame errors if (this.UseCaching) { const msg = ex && ex.toString(); Logger.log(`Replace log cache with empty promise for '${cacheKey}'`); entry.log = { //date: new Date(), item: GitProvider.EmptyPromise, errorMessage: msg }; this._cache.set(cacheKey, entry); return GitProvider.EmptyPromise; } return null; }); } if (this.UseCaching) { Logger.log(`Add ${(log === GitProvider.EmptyPromise ? 'empty promise to ' : '')}log cache for '${cacheKey}'`); entry.log = { //date: new Date(), item: log }; this._cache.set(cacheKey, entry); } return log; } async getLogLocations(fileName: string): Promise { Logger.log(`getLogLocations('${fileName}')`); const log = await this.getLogForFile(fileName); if (!log) return null; const commitCount = log.commits.size; const locations: Array = []; Iterables.forEach(log.commits.values(), (c, i) => { if (c.isUncommitted) return; const decoration = `/*\n ${c.sha} - ${c.message}\n ${c.author}, ${moment(c.date).format('MMMM Do, YYYY h:MMa')}\n */`; locations.push(new Location(c.originalFileName ? GitProvider.toGitUri(c, i + 1, commitCount, c.originalFileName, decoration) : GitProvider.toGitUri(c, i + 1, commitCount, undefined, decoration), new Position(2, 0))); }); return locations; } getVersionedFile(fileName: string, repoPath: string, sha: string) { Logger.log(`getVersionedFile('${fileName}', ${repoPath}, ${sha})`); return Git.getVersionedFile(fileName, repoPath, sha); } getVersionedFileText(fileName: string, repoPath: string, sha: string) { Logger.log(`getVersionedFileText('${fileName}', ${repoPath}, ${sha})`); return Git.getVersionedFileText(fileName, repoPath, sha); } toggleCodeLens(editor: TextEditor) { Logger.log(`toggleCodeLens(${editor})`); if (this._config.codeLens.visibility !== CodeLensVisibility.OnDemand || (!this._config.codeLens.recentChange.enabled && !this._config.codeLens.authors.enabled)) return; if (this._codeLensProviderDisposable) { this._codeLensProviderDisposable.dispose(); if (editor.document.fileName === (this._codeLensProviderSelector && this._codeLensProviderSelector.pattern)) { this._codeLensProviderDisposable = null; return; } } const disposables: Disposable[] = []; this._codeLensProviderSelector = { scheme: DocumentSchemes.File, pattern: editor.document.fileName }; disposables.push(languages.registerCodeLensProvider(this._codeLensProviderSelector, new GitCodeLensProvider(this.context, this))); disposables.push(window.onDidChangeActiveTextEditor(e => { if (e.viewColumn && e.document !== editor.document) { this._codeLensProviderDisposable && this._codeLensProviderDisposable.dispose(); this._codeLensProviderDisposable = null; } })); this._codeLensProviderDisposable = Disposable.from(...disposables); } static isUncommitted(sha: string) { return Git.isUncommitted(sha); } static fromBlameUri(uri: Uri): IGitBlameUriData { if (uri.scheme !== DocumentSchemes.GitBlame) throw new Error(`fromGitUri(uri=${uri}) invalid scheme`); const data = GitProvider._fromGitUri(uri); const range = data.range as Position[]; data.range = new Range(range[0].line, range[0].character, range[1].line, range[1].character); return data; } static fromGitUri(uri: Uri) { if (uri.scheme !== DocumentSchemes.Git) throw new Error(`fromGitUri(uri=${uri}) invalid scheme`); return GitProvider._fromGitUri(uri); } private static _fromGitUri(uri: Uri): T { return JSON.parse(uri.query) as T; } 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)); } static toGitUri(commit: IGitCommit, index: number, commitCount: number, originalFileName?: string, decoration?: string) { return GitProvider._toGitUri(commit, DocumentSchemes.Git, commitCount, GitProvider._toGitUriData(commit, index, originalFileName, decoration)); } private static _toGitUri(commit: IGitCommit, scheme: DocumentSchemes, commitCount: number, data: IGitUriData | IGitBlameUriData) { const pad = (n: number) => ('0000000' + n).slice(-('' + commitCount).length); 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)}. ${commit.author}, ${moment(commit.date).format('MMM D, YYYY hh:MMa')} - ${uriPath}?${JSON.stringify(data)}`); return Uri.parse(`${scheme}:${pad(data.index)}. ${moment(commit.date).format('MMM D, YYYY hh:MMa')} - ${uriPath}?${JSON.stringify(data)}`); } private static _toGitUriData(commit: IGitCommit, index: number, originalFileName?: string, decoration?: string): T { const fileName = Git.normalizePath(path.join(commit.repoPath, commit.fileName)); const data = { repoPath: commit.repoPath, fileName: fileName, sha: commit.sha, index: index } as T; if (originalFileName) { data.originalFileName = Git.normalizePath(path.join(commit.repoPath, originalFileName)); } if (decoration) { data.decoration = decoration; } return data; } 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 IGitUriData { repoPath: string; fileName: string; originalFileName?: string; sha: string; index: number; decoration?: string; } export interface IGitBlameUriData extends IGitUriData { range: Range; }