Browse Source

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
main
Eric Amodio 8 years ago
parent
commit
fba6def3e4
7 changed files with 195 additions and 87 deletions
  1. +1
    -0
      package.json
  2. +10
    -3
      src/git.ts
  3. +3
    -2
      src/gitBlameCodeLensProvider.ts
  4. +1
    -1
      src/gitBlameContentProvider.ts
  5. +2
    -2
      src/gitBlameController.ts
  6. +168
    -79
      src/gitProvider.ts
  7. +10
    -0
      typings/ignore.d.ts

+ 1
- 0
package.json View File

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

+ 10
- 3
src/git.ts View File

@ -5,11 +5,18 @@ import * as tmp from 'tmp';
import {spawnPromise} from 'spawn-rx';
function gitCommand(cwd: string, ...args) {
console.log('[GitLens]', 'git', ...args);
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 => {
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;
});
}

+ 3
- 2
src/gitBlameCodeLensProvider.ts View File

@ -28,6 +28,9 @@ export default class GitBlameCodeLensProvider implements CodeLensProvider {
const sha = data.sha;
return this.git.getBlameForFile(fileName).then(blame => {
const lenses: CodeLens[] = [];
if (!blame) return lenses;
const commits = Array.from(blame.commits.values());
let index = commits.findIndex(c => c.sha === sha) + 1;
@ -36,8 +39,6 @@ export default class GitBlameCodeLensProvider implements CodeLensProvider {
previousCommit = commits[index];
}
const lenses: CodeLens[] = [];
// 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);
let lastLine = lines[0].originalLine;

+ 1
- 1
src/gitBlameContentProvider.ts View File

@ -90,7 +90,7 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi
clearInterval(handle);
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 => {
return {

+ 2
- 2
src/gitBlameController.ts View File

@ -153,7 +153,7 @@ class GitBlameEditorController extends Disposable {
applyBlame(sha?: string) {
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
this._toggleWhitespace = workspace.getConfiguration('editor').get('renderWhitespace') as boolean;
@ -212,7 +212,7 @@ class GitBlameEditorController extends Disposable {
applyHighlight(sha: string) {
return this._blame.then(blame => {
if (!blame.lines.length) return;
if (!blame || !blame.lines.length) return;
const highlightDecorationRanges = blame.lines
.filter(l => l.sha === sha)

+ 168
- 79
src/gitProvider.ts View File

@ -6,35 +6,70 @@ 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 * as ignore from 'ignore';
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;
interface IBlameCacheEntry {
//date: Date;
blame: Promise<IGitBlame>;
errorMessage?: string
}
enum RemoveCacheReason {
DocumentClosed,
DocumentSaved,
DocumentChanged
}
export default class GitProvider extends Disposable {
public repoPath: string;
private _blames: Map<string, Promise<IGitBlame>>;
private _blames: Map<string, IBlameCacheEntry>;
private _disposable: Disposable;
private _codeLensProviderSubscription: Disposable;
private _gitignore: Promise<ignore.Ignore>;
// 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) {
super(() => this.dispose());
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?
this._blames = new Map();
this._registerCodeLensProvider();
this._clearCacheFn = _.debounce(this._clearBlame.bind(this), 2500);
this._removeCachedBlameFn = _.debounce(this._removeCachedBlame.bind(this), 2500);
const subscriptions: Disposable[] = [];
subscriptions.push(workspace.onDidCloseTextDocument(d => this._clearBlame(d.fileName)));
subscriptions.push(workspace.onDidSaveTextDocument(d => this._clearCacheFn(d.fileName, true)));
subscriptions.push(workspace.onDidChangeTextDocument(e => this._clearCacheFn(e.document.fileName, false)));
// 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)));
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));
}
private _clearBlame(fileName: string, reset?: boolean) {
private _getBlameCacheKey(fileName: string) {
return fileName.toLowerCase();
}
private _removeCachedBlame(fileName: string, reason: RemoveCacheReason) {
fileName = Git.normalizePath(fileName, this.repoPath);
reset = !!reset;
if (this._blames.delete(fileName.toLowerCase())) {
console.log('[GitLens]', `Clear blame cache: fileName=${fileName}, reset=${reset})`);
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 (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();
}
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,84 +118,120 @@ export default class GitProvider extends Disposable {
getBlameForFile(fileName: string) {
fileName = Git.normalizePath(fileName, this.repoPath);
let blame = this._blames.get(fileName.toLowerCase());
if (blame !== undefined) return blame;
blame = Git.blamePorcelain(fileName, this.repoPath)
.then(data => {
const authors: Map<string, IGitAuthor> = new Map();
const commits: Map<string, IGitCommit> = new Map();
const lines: Array<IGitCommitLine> = [];
let m: Array<string>;
while ((m = blamePorcelainMatcher.exec(data)) != null) {
const sha = m[1].substring(0, 8);
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);
const cacheKey = this._getBlameCacheKey(fileName);
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)
.then(data => {
if (!data) return null;
const authors: Map<string, IGitAuthor> = new Map();
const commits: Map<string, IGitCommit> = new Map();
const lines: Array<IGitCommitLine> = [];
let m: Array<string>;
while ((m = blamePorcelainMatcher.exec(data)) != null) {
const sha = m[1].substring(0, 8);
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;
}
const previousSha = m[14];
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]
}
commit.lines.push(line);
lines.push(line);
}
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;
}
const previousSha = m[14];
if (previousSha) {
commit.previousSha = previousSha.substring(0, 8);
commit.previousFileName = m[15];
}
commits.set(sha, commit);
commits.forEach(c => authors.get(c.author).lineCount += c.lines.length);
const sortedAuthors: Map<string, IGitAuthor> = 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<string, IGitCommit> = 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
};
});
// 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 line: IGitCommitLine = {
sha,
line: parseInt(m[3], 10) - 1,
originalLine: parseInt(m[2], 10) - 1
//code: m[17]
const brokenBlame = this._blames.get(cacheKey);
if (brokenBlame) {
brokenBlame.errorMessage = msg;
this._blames.set(cacheKey, brokenBlame);
}
commit.lines.push(line);
lines.push(line);
}
commits.forEach(c => authors.get(c.author).lineCount += c.lines.length);
const sortedAuthors: Map<string, IGitAuthor> = 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<string, IGitCommit> = new Map();
Array.from(commits.values())
.sort((a, b) => b.date.getTime() - a.date.getTime())
.forEach(c => sortedCommits.set(c.sha, c));
throw ex;
});
}
return {
authors: sortedAuthors,
commits: sortedCommits,
lines: lines
};
this._blames.set(cacheKey, <IBlameCacheEntry> {
//date: new Date(),
blame: blame
});
this._blames.set(fileName.toLowerCase(), blame);
return blame;
return blame;
});
}
getBlameForLine(fileName: string, line: number): Promise<IGitBlameLine> {
return this.getBlameForFile(fileName).then(blame => {
const blameLine = blame.lines[line];
if (!blameLine) return undefined;
const blameLine = blame && blame.lines[line];
if (!blameLine) return null;
const commit = blame.commits.get(blameLine.sha);
return {
@ -163,6 +244,8 @@ export default class GitProvider extends Disposable {
getBlameForRange(fileName: string, range: Range): Promise<IGitBlameLines> {
return this.getBlameForFile(fileName).then(blame => {
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) {
@ -209,6 +292,8 @@ export default class GitProvider extends Disposable {
getBlameForShaRange(fileName: string, sha: string, range: Range): Promise<IGitBlameCommitLines> {
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);
let commit = blame.commits.get(sha);
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) {
return this.getBlameForRange(fileName, range).then(blame => {
if (!blame) return null;
const commitCount = blame.commits.size;
const locations: Array<Location> = [];
@ -240,6 +327,8 @@ export default class GitProvider extends Disposable {
// getHistoryLocations(fileName: string, range: Range) {
// return this.getBlameForRange(fileName, range).then(blame => {
// if (!blame) return null;
// const commitCount = blame.commits.size;
// const locations: Array<Location> = [];

+ 10
- 0
typings/ignore.d.ts 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;
}

Loading…
Cancel
Save