Adds .gitignore checks to reduce blame calls

Caches failed blames to reduce blame calls
Only clear failed blames from cache on change/save
Add better error messages and handling
This commit is contained in:
Eric Amodio
2016-09-14 13:30:14 -04:00
parent dfd17a8f17
commit fba6def3e4
7 changed files with 190 additions and 82 deletions

View File

@@ -83,6 +83,7 @@
"*" "*"
], ],
"dependencies": { "dependencies": {
"ignore": "^3.1.5",
"lodash": "^4.15.0", "lodash": "^4.15.0",
"moment": "^2.15.0", "moment": "^2.15.0",
"spawn-rx": "^2.0.1", "spawn-rx": "^2.0.1",

View File

@@ -5,11 +5,18 @@ import * as tmp from 'tmp';
import {spawnPromise} from 'spawn-rx'; import {spawnPromise} from 'spawn-rx';
function gitCommand(cwd: string, ...args) { function gitCommand(cwd: string, ...args) {
console.log('[GitLens]', 'git', ...args);
return spawnPromise('git', args, { cwd: cwd }) return spawnPromise('git', args, { cwd: cwd })
// .then(s => { console.log('[GitLens]', s); return s; }) .then(s => {
console.log('[GitLens]', 'git', ...args);
return s;
})
.catch(ex => { .catch(ex => {
console.error('[GitLens]', 'git', ...args, 'Failed:', ex); const msg = ex && ex.toString();
if (msg && (msg.includes('is outside repository') || msg.includes('no such path'))) {
console.warn('[GitLens]', 'git', ...args, msg && msg.replace(/\r?\n|\r/g, ' '));
} else {
console.error('[GitLens]', 'git', ...args, msg && msg.replace(/\r?\n|\r/g, ' '));
}
throw ex; throw ex;
}); });
} }

View File

@@ -28,6 +28,9 @@ export default class GitBlameCodeLensProvider implements CodeLensProvider {
const sha = data.sha; const sha = data.sha;
return this.git.getBlameForFile(fileName).then(blame => { return this.git.getBlameForFile(fileName).then(blame => {
const lenses: CodeLens[] = [];
if (!blame) return lenses;
const commits = Array.from(blame.commits.values()); const commits = Array.from(blame.commits.values());
let index = commits.findIndex(c => c.sha === sha) + 1; let index = commits.findIndex(c => c.sha === sha) + 1;
@@ -36,8 +39,6 @@ export default class GitBlameCodeLensProvider implements CodeLensProvider {
previousCommit = commits[index]; previousCommit = commits[index];
} }
const lenses: CodeLens[] = [];
// Add codelens to each "group" of blame lines // Add codelens to each "group" of blame lines
const lines = blame.lines.filter(l => l.sha === sha && l.originalLine >= data.range.start.line && l.originalLine <= data.range.end.line); const lines = blame.lines.filter(l => l.sha === sha && l.originalLine >= data.range.start.line && l.originalLine <= data.range.end.line);
let lastLine = lines[0].originalLine; let lastLine = lines[0].originalLine;

View File

@@ -90,7 +90,7 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi
clearInterval(handle); clearInterval(handle);
this.git.getBlameForShaRange(data.fileName, data.sha, data.range).then(blame => { this.git.getBlameForShaRange(data.fileName, data.sha, data.range).then(blame => {
if (!blame.lines.length) return; if (!blame || !blame.lines.length) return;
editor.setDecorations(this._blameDecoration, blame.lines.map(l => { editor.setDecorations(this._blameDecoration, blame.lines.map(l => {
return { return {

View File

@@ -153,7 +153,7 @@ class GitBlameEditorController extends Disposable {
applyBlame(sha?: string) { applyBlame(sha?: string) {
return this._blame.then(blame => { return this._blame.then(blame => {
if (!blame.lines.length) return; if (!blame || !blame.lines.length) return;
// HACK: Until https://github.com/Microsoft/vscode/issues/11485 is fixed -- toggle whitespace off // HACK: Until https://github.com/Microsoft/vscode/issues/11485 is fixed -- toggle whitespace off
this._toggleWhitespace = workspace.getConfiguration('editor').get('renderWhitespace') as boolean; this._toggleWhitespace = workspace.getConfiguration('editor').get('renderWhitespace') as boolean;
@@ -212,7 +212,7 @@ class GitBlameEditorController extends Disposable {
applyHighlight(sha: string) { applyHighlight(sha: string) {
return this._blame.then(blame => { return this._blame.then(blame => {
if (!blame.lines.length) return; if (!blame || !blame.lines.length) return;
const highlightDecorationRanges = blame.lines const highlightDecorationRanges = blame.lines
.filter(l => l.sha === sha) .filter(l => l.sha === sha)

View File

@@ -6,35 +6,70 @@ import Git from './git';
import {basename, dirname, extname, join} from 'path'; import {basename, dirname, extname, join} from 'path';
import * as moment from 'moment'; import * as moment from 'moment';
import * as _ from 'lodash'; import * as _ from 'lodash';
import {exists, readFile} from 'fs'
import * as ignore from 'ignore';
const commitMessageMatcher = /^([\^0-9a-fA-F]{7})\s(.*)$/gm; 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 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;
interface IBlameCacheEntry {
//date: Date;
blame: Promise<IGitBlame>;
errorMessage?: string
}
enum RemoveCacheReason {
DocumentClosed,
DocumentSaved,
DocumentChanged
}
export default class GitProvider extends Disposable { export default class GitProvider extends Disposable {
public repoPath: string; public repoPath: string;
private _blames: Map<string, Promise<IGitBlame>>; private _blames: Map<string, IBlameCacheEntry>;
private _disposable: Disposable; private _disposable: Disposable;
private _codeLensProviderSubscription: Disposable; private _codeLensProviderSubscription: Disposable;
private _gitignore: Promise<ignore.Ignore>;
// TODO: Needs to be a Map so it can debounce per file // TODO: Needs to be a Map so it can debounce per file
private _clearCacheFn: ((string, boolean) => void) & _.Cancelable; private _removeCachedBlameFn: ((string, boolean) => void) & _.Cancelable;
static BlameEmptyPromise = Promise.resolve(<IGitBlame>null);
constructor(private context: ExtensionContext) { constructor(private context: ExtensionContext) {
super(() => this.dispose()); super(() => this.dispose());
this.repoPath = context.workspaceState.get(WorkspaceState.RepoPath) as string; this.repoPath = context.workspaceState.get(WorkspaceState.RepoPath) as string;
this._gitignore = new Promise<ignore.Ignore>((resolve, reject) => {
const gitignorePath = join(this.repoPath, '.gitignore');
exists(gitignorePath, e => {
if (e) {
readFile(gitignorePath, 'utf8', (err, data) => {
if (!err) {
resolve(ignore().add(data));
return;
}
resolve(null);
});
return;
}
resolve(null);
});
});
// TODO: Cache needs to be cleared on file changes -- createFileSystemWatcher or timeout? // TODO: Cache needs to be cleared on file changes -- createFileSystemWatcher or timeout?
this._blames = new Map(); this._blames = new Map();
this._registerCodeLensProvider(); this._registerCodeLensProvider();
this._clearCacheFn = _.debounce(this._clearBlame.bind(this), 2500); this._removeCachedBlameFn = _.debounce(this._removeCachedBlame.bind(this), 2500);
const subscriptions: Disposable[] = []; const subscriptions: Disposable[] = [];
subscriptions.push(workspace.onDidCloseTextDocument(d => this._clearBlame(d.fileName))); // TODO: Maybe stop clearing on close and instead limit to a certain number of recent blames
subscriptions.push(workspace.onDidSaveTextDocument(d => this._clearCacheFn(d.fileName, true))); subscriptions.push(workspace.onDidCloseTextDocument(d => this._removeCachedBlame(d.fileName, RemoveCacheReason.DocumentClosed)));
subscriptions.push(workspace.onDidChangeTextDocument(e => this._clearCacheFn(e.document.fileName, false))); subscriptions.push(workspace.onDidSaveTextDocument(d => this._removeCachedBlameFn(d.fileName, RemoveCacheReason.DocumentSaved)));
subscriptions.push(workspace.onDidChangeTextDocument(e => this._removeCachedBlameFn(e.document.fileName, RemoveCacheReason.DocumentChanged)));
this._disposable = Disposable.from(...subscriptions); this._disposable = Disposable.from(...subscriptions);
} }
@@ -52,17 +87,27 @@ export default class GitProvider extends Disposable {
this._codeLensProviderSubscription = languages.registerCodeLensProvider(GitCodeLensProvider.selector, new GitCodeLensProvider(this.context, this)); this._codeLensProviderSubscription = languages.registerCodeLensProvider(GitCodeLensProvider.selector, new GitCodeLensProvider(this.context, this));
} }
private _clearBlame(fileName: string, reset?: boolean) { private _getBlameCacheKey(fileName: string) {
fileName = Git.normalizePath(fileName, this.repoPath); return fileName.toLowerCase();
reset = !!reset;
if (this._blames.delete(fileName.toLowerCase())) {
console.log('[GitLens]', `Clear blame cache: fileName=${fileName}, reset=${reset})`);
if (reset) {
// 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();
} }
private _removeCachedBlame(fileName: string, reason: RemoveCacheReason) {
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);
if (entry && entry.errorMessage) return;
}
if (this._blames.delete(cacheKey)) {
console.log('[GitLens]', `Clear blame cache: fileName=${fileName}, 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();
// }
} }
} }
@@ -73,11 +118,20 @@ export default class GitProvider extends Disposable {
getBlameForFile(fileName: string) { getBlameForFile(fileName: string) {
fileName = Git.normalizePath(fileName, this.repoPath); fileName = Git.normalizePath(fileName, this.repoPath);
let blame = this._blames.get(fileName.toLowerCase()); const cacheKey = this._getBlameCacheKey(fileName);
if (blame !== undefined) return blame; let entry = this._blames.get(cacheKey);
if (entry !== undefined) return entry.blame;
return this._gitignore.then(ignore => {
let blame: Promise<IGitBlame>;
if (ignore && !ignore.filter([fileName]).length) {
console.log('[GitLens]', `Skipping blame; ${fileName} is gitignored`);
blame = GitProvider.BlameEmptyPromise;
} else {
blame = Git.blamePorcelain(fileName, this.repoPath) blame = Git.blamePorcelain(fileName, this.repoPath)
.then(data => { .then(data => {
if (!data) return null;
const authors: Map<string, IGitAuthor> = new Map(); const authors: Map<string, IGitAuthor> = new Map();
const commits: Map<string, IGitCommit> = new Map(); const commits: Map<string, IGitCommit> = new Map();
const lines: Array<IGitCommitLine> = []; const lines: Array<IGitCommitLine> = [];
@@ -143,14 +197,41 @@ export default class GitProvider extends Disposable {
}; };
}); });
this._blames.set(fileName.toLowerCase(), blame); // 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'))) {
this._blames.set(cacheKey, <IBlameCacheEntry>{
//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;
});
}
this._blames.set(cacheKey, <IBlameCacheEntry> {
//date: new Date(),
blame: blame
});
return blame; return blame;
});
} }
getBlameForLine(fileName: string, line: number): Promise<IGitBlameLine> { getBlameForLine(fileName: string, line: number): Promise<IGitBlameLine> {
return this.getBlameForFile(fileName).then(blame => { return this.getBlameForFile(fileName).then(blame => {
const blameLine = blame.lines[line]; const blameLine = blame && blame.lines[line];
if (!blameLine) return undefined; if (!blameLine) return null;
const commit = blame.commits.get(blameLine.sha); const commit = blame.commits.get(blameLine.sha);
return { return {
@@ -163,6 +244,8 @@ export default class GitProvider extends Disposable {
getBlameForRange(fileName: string, range: Range): Promise<IGitBlameLines> { getBlameForRange(fileName: string, range: Range): Promise<IGitBlameLines> {
return this.getBlameForFile(fileName).then(blame => { return this.getBlameForFile(fileName).then(blame => {
if (!blame) return null;
if (!blame.lines.length) return Object.assign({ allLines: blame.lines }, blame); if (!blame.lines.length) return Object.assign({ allLines: blame.lines }, blame);
if (range.start.line === 0 && range.end.line === blame.lines.length - 1) { if (range.start.line === 0 && range.end.line === blame.lines.length - 1) {
@@ -209,6 +292,8 @@ export default class GitProvider extends Disposable {
getBlameForShaRange(fileName: string, sha: string, range: Range): Promise<IGitBlameCommitLines> { getBlameForShaRange(fileName: string, sha: string, range: Range): Promise<IGitBlameCommitLines> {
return this.getBlameForFile(fileName).then(blame => { return this.getBlameForFile(fileName).then(blame => {
if (!blame) return null;
const lines = blame.lines.slice(range.start.line, range.end.line + 1).filter(l => l.sha === sha); const lines = blame.lines.slice(range.start.line, range.end.line + 1).filter(l => l.sha === sha);
let commit = blame.commits.get(sha); let commit = blame.commits.get(sha);
commit = new GitCommit(this.repoPath, commit.sha, commit.fileName, commit.author, commit.date, commit.message, lines); commit = new GitCommit(this.repoPath, commit.sha, commit.fileName, commit.author, commit.date, commit.message, lines);
@@ -222,6 +307,8 @@ export default class GitProvider extends Disposable {
getBlameLocations(fileName: string, range: Range) { getBlameLocations(fileName: string, range: Range) {
return this.getBlameForRange(fileName, range).then(blame => { return this.getBlameForRange(fileName, range).then(blame => {
if (!blame) return null;
const commitCount = blame.commits.size; const commitCount = blame.commits.size;
const locations: Array<Location> = []; const locations: Array<Location> = [];
@@ -240,6 +327,8 @@ export default class GitProvider extends Disposable {
// getHistoryLocations(fileName: string, range: Range) { // getHistoryLocations(fileName: string, range: Range) {
// return this.getBlameForRange(fileName, range).then(blame => { // return this.getBlameForRange(fileName, range).then(blame => {
// if (!blame) return null;
// const commitCount = blame.commits.size; // const commitCount = blame.commits.size;
// const locations: Array<Location> = []; // const locations: Array<Location> = [];

10
typings/ignore.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
declare module "ignore" {
namespace ignore {
interface Ignore {
add(patterns: string | Array<string> | Ignore): Ignore;
filter(paths: Array<string>): Array<string>;
}
}
function ignore(): ignore.Ignore;
export = ignore;
}