diff --git a/package.json b/package.json index 56371cb..bdd25be 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "*" ], "dependencies": { + "ignore": "^3.1.5", "lodash": "^4.15.0", "moment": "^2.15.0", "spawn-rx": "^2.0.1", diff --git a/src/git.ts b/src/git.ts index 9d86729..2b56c9a 100644 --- a/src/git.ts +++ b/src/git.ts @@ -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; }); } diff --git a/src/gitBlameCodeLensProvider.ts b/src/gitBlameCodeLensProvider.ts index c008f11..7e91e09 100644 --- a/src/gitBlameCodeLensProvider.ts +++ b/src/gitBlameCodeLensProvider.ts @@ -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; diff --git a/src/gitBlameContentProvider.ts b/src/gitBlameContentProvider.ts index 2dfff1c..c61840e 100644 --- a/src/gitBlameContentProvider.ts +++ b/src/gitBlameContentProvider.ts @@ -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 { diff --git a/src/gitBlameController.ts b/src/gitBlameController.ts index bc1a22d..2d96f07 100644 --- a/src/gitBlameController.ts +++ b/src/gitBlameController.ts @@ -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) diff --git a/src/gitProvider.ts b/src/gitProvider.ts index 053f69f..0161f6a 100644 --- a/src/gitProvider.ts +++ b/src/gitProvider.ts @@ -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; + errorMessage?: string +} + +enum RemoveCacheReason { + DocumentClosed, + DocumentSaved, + DocumentChanged +} + export default class GitProvider extends Disposable { public repoPath: string; - private _blames: Map>; + private _blames: Map; private _disposable: Disposable; private _codeLensProviderSubscription: Disposable; + private _gitignore: Promise; // 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(null); constructor(private context: ExtensionContext) { super(() => this.dispose()); this.repoPath = context.workspaceState.get(WorkspaceState.RepoPath) as string; + this._gitignore = new Promise((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; + const cacheKey = this._getBlameCacheKey(fileName); + let entry = this._blames.get(cacheKey); + if (entry !== undefined) return entry.blame; - blame = Git.blamePorcelain(fileName, this.repoPath) - .then(data => { - const authors: Map = new Map(); - const commits: Map = new Map(); - const lines: Array = []; + return this._gitignore.then(ignore => { + let blame: Promise; + 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; - let m: Array; - 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 authors: Map = new Map(); + const commits: Map = new Map(); + const lines: Array = []; + + let m: Array; + 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]); + commits.forEach(c => authors.get(c.author).lineCount += c.lines.length); - const originalFileName = m[16]; - if (!fileName.toLowerCase().endsWith(originalFileName.toLowerCase())) { - commit.originalFileName = originalFileName; - } + 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 previousSha = m[14]; - if (previousSha) { - commit.previousSha = previousSha.substring(0, 8); - commit.previousFileName = m[15]; - } + 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)); - commits.set(sha, commit); + 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, { + //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); - } + throw ex; + }); + } - 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 - }; + this._blames.set(cacheKey, { + //date: new Date(), + blame: blame }); - this._blames.set(fileName.toLowerCase(), blame); - return blame; + return blame; + }); } getBlameForLine(fileName: string, line: number): Promise { 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 { 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 { 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 = []; @@ -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 = []; diff --git a/typings/ignore.d.ts b/typings/ignore.d.ts new file mode 100644 index 0000000..791415a --- /dev/null +++ b/typings/ignore.d.ts @@ -0,0 +1,10 @@ +declare module "ignore" { + namespace ignore { + interface Ignore { + add(patterns: string | Array | Ignore): Ignore; + filter(paths: Array): Array; + } + } + function ignore(): ignore.Ignore; + export = ignore; +}