From ff01054f90f4222de6e3ae44659d25c7329957d7 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Mon, 19 Sep 2016 04:11:46 -0400 Subject: [PATCH] Fixes #1 Support blame on files outside repo Replaces blame regex parsing with more robust parser (also use s--incremental instead of --porcelain) Stops throwing on git blame errors (too many are common) Fixes issues with Diff with Previous command Fixes issues with blame explorer code lens -- with previous commits Fixes issues with compact blame annotations -- skips blank lines --- src/commands.ts | 6 +- src/git.ts | 102 ---------------- src/git/enrichers/blameParserEnricher.ts | 202 +++++++++++++++++++++++++++++++ src/git/enrichers/blameRegExpEnricher.ts | 92 ++++++++++++++ src/git/git.ts | 90 ++++++++++++++ src/git/gitEnrichment.ts | 82 +++++++++++++ src/gitBlameCodeLensProvider.ts | 31 ++--- src/gitBlameController.ts | 52 ++++---- src/gitEnrichment.ts | 171 -------------------------- src/gitProvider.ts | 94 ++++---------- 10 files changed, 528 insertions(+), 394 deletions(-) delete mode 100644 src/git.ts create mode 100644 src/git/enrichers/blameParserEnricher.ts create mode 100644 src/git/enrichers/blameRegExpEnricher.ts create mode 100644 src/git/git.ts create mode 100644 src/git/gitEnrichment.ts delete mode 100644 src/gitEnrichment.ts diff --git a/src/commands.ts b/src/commands.ts index 0b781c6..1cb77d8 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -62,9 +62,7 @@ export class DiffWithPreviousCommand extends EditorCommand { return window.showInformationMessage(`Commit ${sha} has no previous commit`); } - // TODO: Moving doesn't always seem to work -- or more accurately it seems like it moves down that number of lines from the current line - // which for a diff could be the first difference - return Promise.all([this.git.getVersionedFile(uri.fsPath, sha), this.git.getVersionedFile(uri.fsPath, compareWithSha)]) + return Promise.all([this.git.getVersionedFile(shaUri.fsPath, sha), this.git.getVersionedFile(compareWithUri.fsPath, compareWithSha)]) .catch(ex => console.error('[GitLens.DiffWithPreviousCommand]', 'getVersionedFile', ex)) .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'}))); @@ -91,8 +89,6 @@ export class DiffWithWorkingCommand extends EditorCommand { }); }; - // TODO: Moving doesn't always seem to work -- or more accurately it seems like it moves down that number of lines from the current line - // 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, `${path.basename(shaUri.fsPath)} (${sha}) ↔ ${path.basename(uri.fsPath)} (index)`) diff --git a/src/git.ts b/src/git.ts deleted file mode 100644 index 5d1bd22..0000000 --- a/src/git.ts +++ /dev/null @@ -1,102 +0,0 @@ -'use strict'; -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 => { - console.log('[GitLens]', 'git', ...args); - return s; - }) - .catch(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; - }); -} - -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) { - fileName = fileName.replace(/\\/g, '/'); - repoPath = repoPath.replace(/\\/g, '/'); - if (path.isAbsolute(fileName) && fileName.startsWith(repoPath)) { - fileName = path.relative(repoPath, fileName).replace(/\\/g, '/'); - } - return fileName; - } - - static repoPath(cwd: string) { - return gitCommand(cwd, 'rev-parse', '--show-toplevel').then(data => data.replace(/\r?\n|\r/g, '').replace(/\\/g, '/')); - } - - static blame(format: GitBlameFormat, fileName: string, repoPath: string, sha?: string) { - fileName = Git.normalizePath(fileName, repoPath); - - if (sha) { - return gitCommand(repoPath, 'blame', format, '--root', `${sha}^`, '--', 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 => { - const ext = path.extname(fileName); - tmp.file({ prefix: `${path.basename(fileName, ext)}-${sha}_`, postfix: ext }, (err, destination, fd, cleanupCallback) => { - if (err) { - reject(err); - return; - } - - //console.log(`getVersionedFile(${fileName}, ${sha}); destination=${destination}`); - fs.appendFile(destination, data, err => { - if (err) { - reject(err); - return; - } - resolve(destination); - }); - }); - }); - }); - } - - static getVersionedFileText(fileName: string, repoPath: string, sha: string) { - fileName = Git.normalizePath(fileName, repoPath); - sha = sha.replace('^', ''); - - return gitCommand(repoPath, 'show', `${sha}:./${fileName}`); - } - - // static getCommitMessage(sha: string, repoPath: string) { - // sha = sha.replace('^', ''); - - // return gitCommand(repoPath, 'show', '-s', '--format=%B', sha); - // // .then(s => { console.log(s); return s; }) - // // .catch(ex => console.error(ex)); - // } - - // static getCommitMessages(fileName: string, repoPath: string) { - // fileName = Git.normalizePath(fileName, repoPath); - - // // git log --format="%h (%aN %x09 %ai) %s" -- - // return gitCommand(repoPath, 'log', '--oneline', '--', fileName); - // // .then(s => { console.log(s); return s; }) - // // .catch(ex => console.error(ex)); - // } -} \ No newline at end of file diff --git a/src/git/enrichers/blameParserEnricher.ts b/src/git/enrichers/blameParserEnricher.ts new file mode 100644 index 0000000..f823376 --- /dev/null +++ b/src/git/enrichers/blameParserEnricher.ts @@ -0,0 +1,202 @@ +'use strict' +import {GitBlameFormat, GitCommit, IGitAuthor, IGitBlame, IGitCommit, IGitCommitLine, IGitEnricher} from './../git'; +import * as moment from 'moment'; +import * as path from 'path'; + +interface IBlameEntry { + sha: string; + line: number; + originalLine: number; + lineCount: number; + + author?: string; + authorEmail?: string; + authorDate?: string; + authorTimeZone?: string; + + committer?: string; + committerEmail?: string; + committerDate?: string; + committerTimeZone?: string; + + previousSha?: string; + previousFileName?: string; + + fileName?: string; + + summary?: string; +} + +export class GitBlameParserEnricher implements IGitEnricher { + constructor(public format: GitBlameFormat) { + if (format !== GitBlameFormat.incremental) { + throw new Error(`Invalid blame format=${format}`); + } + } + + private _parseEntries(data: string): IBlameEntry[] { + if (!data) return null; + + const lines = data.split('\n'); + if (!lines.length) return null; + + const entries: IBlameEntry[] = []; + + let entry: IBlameEntry; + let position = -1; + while (++position < lines.length) { + let lineParts = lines[position].split(" "); + if (lineParts.length < 2) { + continue; + } + + if (!entry) { + entry = { + sha: lineParts[0].substring(0, 8), + originalLine: parseInt(lineParts[1], 10) - 1, + line: parseInt(lineParts[2], 10) - 1, + lineCount: parseInt(lineParts[3], 10) + }; + + continue; + } + + switch (lineParts[0]) { + case "author": + entry.author = lineParts.slice(1).join(" ").trim(); + break; + + // case "author-mail": + // entry.authorEmail = lineParts[1].trim(); + // break; + + case "author-time": + entry.authorDate = lineParts[1]; + break; + + case "author-tz": + entry.authorTimeZone = lineParts[1]; + break; + + // case "committer": + // entry.committer = lineParts.slice(1).join(" ").trim(); + // break; + + // case "committer-mail": + // entry.committerEmail = lineParts[1].trim(); + // break; + + // case "committer-time": + // entry.committerDate = lineParts[1]; + // break; + + // case "committer-tz": + // entry.committerTimeZone = lineParts[1]; + // break; + + case "summary": + entry.summary = lineParts.slice(1).join(" ").trim(); + break; + + case "previous": + entry.previousSha = lineParts[1].substring(0, 8); + entry.previousFileName = lineParts.slice(2).join(" "); + break; + + case "filename": + entry.fileName = lineParts.slice(1).join(" "); + + entries.push(entry); + entry = null; + break; + + default: + break; + } + } + + return entries; + } + + enrich(data: string, fileName: string): IGitBlame { + const entries = this._parseEntries(data); + if (!entries) return null; + + const authors: Map = new Map(); + const commits: Map = new Map(); + const lines: Array = []; + + let repoPath: string; + let relativeFileName: string; + + for (let i = 0, len = entries.length; i < len; i++) { + const entry = entries[i]; + + if (i === 0) { + // Try to get the repoPath from the most recent commit + repoPath = fileName.replace(`/${entry.fileName}`, ''); + relativeFileName = path.relative(repoPath, fileName).replace(/\\/g, '/'); + } + + let commit = commits.get(entry.sha); + if (!commit) { + let author = authors.get(entry.author); + if (!author) { + author = { + name: entry.author, + lineCount: 0 + }; + authors.set(entry.author, author); + } + + commit = new GitCommit(repoPath, entry.sha, relativeFileName, entry.author, moment(`${entry.authorDate} ${entry.authorTimeZone}`, 'X Z').toDate(), entry.summary); + + if (relativeFileName !== entry.fileName) { + commit.originalFileName = entry.fileName; + } + + if (entry.previousSha) { + commit.previousSha = entry.previousSha; + commit.previousFileName = entry.previousFileName; + } + + commits.set(entry.sha, commit); + } + + for (let j = 0, len = entry.lineCount; j < len; j++) { + const line: IGitCommitLine = { + sha: entry.sha, + line: entry.line + j, + originalLine: entry.originalLine + j + } + + if (commit.previousSha) { + line.previousSha = commit.previousSha; + } + + commit.lines.push(line); + lines[line.line] = 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 { + repoPath: repoPath, + authors: sortedAuthors, + // commits: sortedCommits, + commits: commits, + lines: lines + }; + } +} \ No newline at end of file diff --git a/src/git/enrichers/blameRegExpEnricher.ts b/src/git/enrichers/blameRegExpEnricher.ts new file mode 100644 index 0000000..8543a92 --- /dev/null +++ b/src/git/enrichers/blameRegExpEnricher.ts @@ -0,0 +1,92 @@ +'use strict' +import {GitBlameFormat, GitCommit, IGitAuthor, IGitBlame, IGitCommit, IGitCommitLine, IGitEnricher} from './../git'; +import * as moment from 'moment'; + +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 class GitBlameRegExpEnricher 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 + }; + } +} \ No newline at end of file diff --git a/src/git/git.ts b/src/git/git.ts new file mode 100644 index 0000000..0429351 --- /dev/null +++ b/src/git/git.ts @@ -0,0 +1,90 @@ +'use strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as tmp from 'tmp'; +import {spawnPromise} from 'spawn-rx'; + +export * from './gitEnrichment'; +//export * from './enrichers/blameRegExpEnricher'; +export * from './enrichers/blameParserEnricher'; + +function gitCommand(cwd: string, ...args) { + return spawnPromise('git', args, { cwd: cwd }) + .then(s => { + console.log('[GitLens]', 'git', ...args); + return s; + }) + .catch(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; + }); +} + +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) { + return fileName.replace(/\\/g, '/'); + } + + static splitPath(fileName: string) { + // if (!path.isAbsolute(fileName)) { + // console.error('[GitLens]', `Git.splitPath(${fileName}) is not an absolute path!`); + // debugger; + // } + return [path.basename(fileName).replace(/\\/g, '/'), path.dirname(fileName).replace(/\\/g, '/')]; + } + + static repoPath(cwd: string) { + return gitCommand(cwd, 'rev-parse', '--show-toplevel').then(data => data.replace(/\r?\n|\r/g, '').replace(/\\/g, '/')); + } + + static blame(format: GitBlameFormat, fileName: string, sha?: string) { + const [file, root] = Git.splitPath(Git.normalizePath(fileName)); + + if (sha) { + return gitCommand(root, 'blame', format, '--root', `${sha}^`, '--', file); + } + return gitCommand(root, 'blame', format, '--root', '--', file); + } + + static getVersionedFile(fileName: string, sha: string) { + return new Promise((resolve, reject) => { + Git.getVersionedFileText(fileName, sha).then(data => { + const ext = path.extname(fileName); + tmp.file({ prefix: `${path.basename(fileName, ext)}-${sha}_`, postfix: ext }, (err, destination, fd, cleanupCallback) => { + if (err) { + reject(err); + return; + } + + //console.log(`getVersionedFile(${fileName}, ${sha}); destination=${destination}`); + fs.appendFile(destination, data, err => { + if (err) { + reject(err); + return; + } + resolve(destination); + }); + }); + }); + }); + } + + static getVersionedFileText(fileName: string, sha: string) { + const [file, root] = Git.splitPath(Git.normalizePath(fileName)); + sha = sha.replace('^', ''); + + return gitCommand(root, 'show', `${sha}:./${file}`); + } +} \ No newline at end of file diff --git a/src/git/gitEnrichment.ts b/src/git/gitEnrichment.ts new file mode 100644 index 0000000..24171f1 --- /dev/null +++ b/src/git/gitEnrichment.ts @@ -0,0 +1,82 @@ +'use strict' +import {Uri} from 'vscode'; +import * as path from 'path'; + +export interface IGitEnricher { + enrich(data: string, ...args): T; +} + +export interface IGitBlame { + repoPath: string; + 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 { + repoPath: string; + 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(public 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/gitBlameCodeLensProvider.ts b/src/gitBlameCodeLensProvider.ts index 2369dc6..81eb77c 100644 --- a/src/gitBlameCodeLensProvider.ts +++ b/src/gitBlameCodeLensProvider.ts @@ -6,13 +6,13 @@ 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) { + constructor(private git: GitProvider, public fileName: string, public commit: IGitCommit, range: Range) { super(range); } } export class GitDiffWithPreviousCodeLens extends CodeLens { - constructor(private git: GitProvider, public fileName: string, public sha: string, public compareWithSha: string, range: Range) { + constructor(private git: GitProvider, public fileName: string, public commit: IGitCommit, range: Range) { super(range); } } @@ -31,22 +31,17 @@ export default class GitBlameCodeLensProvider implements CodeLensProvider { const lenses: CodeLens[] = []; if (!blame) return lenses; - const commits = Array.from(blame.commits.values()); - let index = commits.findIndex(c => c.sha === sha) + 1; - - let previousCommit: IGitCommit; - if (index < commits.length) { - previousCommit = commits[index]; - } + const commit = blame.commits.get(sha); + const absoluteFileName = path.join(commit.repoPath, fileName); // 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; lines.forEach(l => { if (l.originalLine !== lastLine + 1) { - lenses.push(new GitDiffWithWorkingTreeCodeLens(this.git, fileName, sha, new Range(l.originalLine, 0, l.originalLine, 1))); - if (previousCommit) { - lenses.push(new GitDiffWithPreviousCodeLens(this.git, fileName, sha, previousCommit.sha, new Range(l.originalLine, 1, l.originalLine, 2))); + lenses.push(new GitDiffWithWorkingTreeCodeLens(this.git, absoluteFileName, commit, new Range(l.originalLine, 0, l.originalLine, 1))); + if (commit.previousSha) { + lenses.push(new GitDiffWithPreviousCodeLens(this.git, absoluteFileName, commit, new Range(l.originalLine, 1, l.originalLine, 2))); } } lastLine = l.originalLine; @@ -54,9 +49,9 @@ export default class GitBlameCodeLensProvider implements CodeLensProvider { // Check if we have a lens for the whole document -- if not add one if (!lenses.find(l => l.range.start.line === 0 && l.range.end.line === 0)) { - lenses.push(new GitDiffWithWorkingTreeCodeLens(this.git, fileName, sha, new Range(0, 0, 0, 1))); - if (previousCommit) { - lenses.push(new GitDiffWithPreviousCodeLens(this.git, fileName, sha, previousCommit.sha, new Range(0, 1, 0, 2))); + lenses.push(new GitDiffWithWorkingTreeCodeLens(this.git, absoluteFileName, commit, new Range(0, 0, 0, 1))); + if (commit.previousSha) { + lenses.push(new GitDiffWithPreviousCodeLens(this.git, absoluteFileName, commit, new Range(0, 1, 0, 2))); } } @@ -73,16 +68,16 @@ export default class GitBlameCodeLensProvider implements CodeLensProvider { lens.command = { title: `Compare with Working Tree`, command: Commands.DiffWithWorking, - arguments: [Uri.file(path.join(this.git.repoPath, lens.fileName)), lens.sha] + arguments: [Uri.file(lens.fileName), lens.commit.sha, lens.commit.uri, lens.range.start.line] }; return Promise.resolve(lens); } _resolveGitDiffWithPreviousCodeLens(lens: GitDiffWithPreviousCodeLens, token: CancellationToken): Thenable { lens.command = { - title: `Compare with Previous (${lens.compareWithSha})`, + title: `Compare with Previous (${lens.commit.previousSha})`, command: Commands.DiffWithPrevious, - arguments: [Uri.file(path.join(this.git.repoPath, lens.fileName)), lens.sha, lens.compareWithSha] + arguments: [Uri.file(lens.fileName), lens.commit.sha, lens.commit.uri, lens.commit.previousSha, lens.commit.previousUri, lens.range.start.line] }; return Promise.resolve(lens); } diff --git a/src/gitBlameController.ts b/src/gitBlameController.ts index 6478a4b..cd5c92a 100644 --- a/src/gitBlameController.ts +++ b/src/gitBlameController.ts @@ -1,5 +1,5 @@ 'use strict' -import {commands, DecorationInstanceRenderOptions, DecorationOptions, Diagnostic, DiagnosticCollection, DiagnosticSeverity, Disposable, ExtensionContext, languages, OverviewRulerLane, Position, Range, TextEditor, TextEditorDecorationType, Uri, window, workspace} from 'vscode'; +import {commands, DecorationInstanceRenderOptions, DecorationOptions, Diagnostic, DiagnosticCollection, DiagnosticSeverity, Disposable, ExtensionContext, languages, OverviewRulerLane, Position, Range, TextDocument, TextEditor, TextEditorDecorationType, Uri, window, workspace} from 'vscode'; import {BuiltInCommands, Commands, DocumentSchemes} from './constants'; import {BlameAnnotationStyle, IBlameConfig} from './configuration'; import GitProvider, {IGitBlame, IGitCommit} from './gitProvider'; @@ -96,13 +96,16 @@ class GitBlameEditorController extends Disposable { private _config: IBlameConfig; private _diagnostics: DiagnosticCollection; private _disposable: Disposable; + private _document: TextDocument; private _toggleWhitespace: boolean; constructor(private context: ExtensionContext, private git: GitProvider, public editor: TextEditor) { super(() => this.dispose()); - this.uri = this.editor.document.uri; + this._document = this.editor.document; + this.uri = this._document.uri; const fileName = this.uri.fsPath; + this._blame = this.git.getBlameForFile(fileName); this._config = workspace.getConfiguration('gitlens').get('blame'); @@ -205,24 +208,33 @@ class GitBlameEditorController extends Disposable { } let gutter = ''; - if (lastSha === l.sha) { - count++; - if (count === 1) { - gutter = `\\00a6\\00a0 ${this._getAuthor(commit, 17, true)}`; - } else if (count === 2) { - gutter = `\\00a6\\00a0 ${this._getDate(commit, true)}`; - } else { - gutter = '\\00a6\\00a0'; + if (lastSha !== l.sha) { + count = -1; + } + + const isEmptyOrWhitespace = this._document.lineAt(l.line).isEmptyOrWhitespace; + if (!isEmptyOrWhitespace) { + switch (++count) { + case 0: + gutter = commit.sha.substring(0, 8); + break; + case 1: + gutter = `\\00a6\\00a0 ${this._getAuthor(commit, 17, true)}`; + break; + case 2: + gutter = `\\00a6\\00a0 ${this._getDate(commit, true)}`; + break; + default: + gutter = '\\00a6\\00a0'; + break; } - } else { - count = 0; - gutter = commit.sha.substring(0, 8); } + lastSha = l.sha; return { range: this.editor.document.validateRange(new Range(l.line, 0, l.line, 0)), - hoverMessage: [commit.message, `${commit.author}, ${moment(commit.date).format('MMMM Do, YYYY hh:MM a')}`], + hoverMessage: [`_${l.sha}_: ${commit.message}`, `${commit.author}, ${moment(commit.date).format('MMMM Do, YYYY hh:MM a')}`], renderOptions: { before: { color: color, contentText: gutter, width: '11em' } } }; }); @@ -258,18 +270,6 @@ class GitBlameEditorController extends Disposable { if (l.sha.startsWith('00000000')) { color = 'rgba(0, 188, 242, 0.6)'; hoverMessage = ''; - // if (l.previousSha) { - // let previousCommit = blame.commits.get(l.previousSha); - // if (previousCommit) {//} && previousCommit.lines.find(_ => _.line === l.originalLine)) { - // commit = previousCommit; - // color = 'rgba(0, 188, 242, 0.6)'; - // } - // else { - // color = 'rgba(127, 186, 0, 0.6)'; - // } - // } else { - // color = 'rgba(127, 186, 0, 0.6)'; - // } } const gutter = this._getGutter(commit); diff --git a/src/gitEnrichment.ts b/src/gitEnrichment.ts deleted file mode 100644 index a1fa647..0000000 --- a/src/gitEnrichment.ts +++ /dev/null @@ -1,171 +0,0 @@ -'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 95884a6..9d7fc49 100644 --- a/src/gitProvider.ts +++ b/src/gitProvider.ts @@ -3,7 +3,7 @@ import {Disposable, ExtensionContext, languages, Location, Position, Range, Uri, import {DocumentSchemes, WorkspaceState} from './constants'; import {IConfig} from './configuration'; import GitCodeLensProvider from './gitCodeLensProvider'; -import Git, {GitBlameEnricher, GitBlameFormat, GitCommit, IGitAuthor, IGitBlame, IGitBlameCommitLines, IGitBlameLine, IGitBlameLines, IGitCommit} from './git'; +import Git, {GitBlameParserEnricher, GitBlameFormat, GitCommit, IGitAuthor, IGitBlame, IGitBlameCommitLines, IGitBlameLine, IGitBlameLines, IGitCommit} from './git/git'; import * as fs from 'fs' import * as ignore from 'ignore'; import * as _ from 'lodash'; @@ -11,7 +11,7 @@ import * as moment from 'moment'; import * as path from 'path'; export { Git }; -export * from './git'; +export * from './git/git'; interface IBlameCacheEntry { //date: Date; @@ -26,8 +26,6 @@ enum RemoveCacheReason { } export default class GitProvider extends Disposable { - public repoPath: string; - private _blameCache: Map; private _blameCacheDisposable: Disposable; @@ -37,17 +35,17 @@ export default class GitProvider extends Disposable { private _gitignore: Promise; static BlameEmptyPromise = Promise.resolve(null); - static BlameFormat = GitBlameFormat.porcelain; + static BlameFormat = GitBlameFormat.incremental; constructor(private context: ExtensionContext) { super(() => this.dispose()); - this.repoPath = context.workspaceState.get(WorkspaceState.RepoPath) as string; + const repoPath = context.workspaceState.get(WorkspaceState.RepoPath) as string; this._onConfigure(); this._gitignore = new Promise((resolve, reject) => { - const gitignorePath = path.join(this.repoPath, '.gitignore'); + const gitignorePath = path.join(repoPath, '.gitignore'); fs.exists(gitignorePath, e => { if (e) { fs.readFile(gitignorePath, 'utf8', (err, data) => { @@ -126,7 +124,7 @@ export default class GitProvider extends Disposable { private _removeCachedBlame(fileName: string, reason: RemoveCacheReason) { if (!this.UseCaching) return; - fileName = Git.normalizePath(fileName, this.repoPath); + fileName = Git.normalizePath(fileName); const cacheKey = this._getBlameCacheKey(fileName); if (reason === RemoveCacheReason.DocumentClosed) { @@ -150,7 +148,7 @@ export default class GitProvider extends Disposable { } getBlameForFile(fileName: string) { - fileName = Git.normalizePath(fileName, this.repoPath); + fileName = Git.normalizePath(fileName); const cacheKey = this._getBlameCacheKey(fileName); if (this.UseCaching) { @@ -164,15 +162,14 @@ export default class GitProvider extends Disposable { console.log('[GitLens]', `Skipping blame; ${fileName} is gitignored`); blame = GitProvider.BlameEmptyPromise; } else { - const enricher = new GitBlameEnricher(GitProvider.BlameFormat, this.repoPath); - blame = Git.blame(GitProvider.BlameFormat, fileName, this.repoPath) - .then(data => enricher.enrich(data, fileName)); - - 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'))) { + const enricher = new GitBlameParserEnricher(GitProvider.BlameFormat); + + blame = Git.blame(GitProvider.BlameFormat, fileName) + .then(data => enricher.enrich(data, fileName)) + .catch(ex => { + // Trap and cache expected blame errors + if (this.UseCaching) { + const msg = ex && ex.toString(); console.log('[GitLens]', `Replace blame cache: cacheKey=${cacheKey}`); this._blameCache.set(cacheKey, { //date: new Date(), @@ -181,16 +178,7 @@ export default class GitProvider extends Disposable { }); return GitProvider.BlameEmptyPromise; } - - const brokenBlame = this._blameCache.get(cacheKey); - if (brokenBlame) { - brokenBlame.errorMessage = msg; - this._blameCache.set(cacheKey, brokenBlame); - } - - throw ex; }); - } } if (this.UseCaching) { @@ -238,7 +226,7 @@ export default class GitProvider extends Disposable { blame.commits.forEach(c => { if (!shas.has(c.sha)) return; - const commit: IGitCommit = new GitCommit(this.repoPath, c.sha, c.fileName, c.author, c.date, c.message, + 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); @@ -274,7 +262,7 @@ export default class GitProvider extends Disposable { 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, + 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 }), @@ -304,50 +292,12 @@ 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 = []; - // Array.from(blame.commits.values()) - // .forEach((c, i) => { - // const uri = this.toBlameUri(c, i + 1, commitCount, range); - // c.lines.forEach(l => locations.push(new Location(c.originalFileName - // ? this.toBlameUri(c, i + 1, commitCount, range, c.originalFileName) - // : uri, - // new Position(l.originalLine, 0)))); - // }); - - // return locations; - // }); - // } - - // const commitMessageMatcher = /^([\^0-9a-fA-F]{7})\s(.*)$/gm; - - // getCommitMessage(sha: string) { - // return Git.getCommitMessage(sha, this.repoPath); - // } - - // getCommitMessages(fileName: string) { - // return Git.getCommitMessages(fileName, this.repoPath).then(data => { - // const commits: Map = new Map(); - // let m: Array; - // while ((m = commitMessageMatcher.exec(data)) != null) { - // commits.set(m[1], m[2]); - // } - - // return commits; - // }); - // } - getVersionedFile(fileName: string, sha: string) { - return Git.getVersionedFile(fileName, this.repoPath, sha); + return Git.getVersionedFile(fileName, sha); } getVersionedFileText(fileName: string, sha: string) { - return Git.getVersionedFileText(fileName, this.repoPath, sha); + return Git.getVersionedFileText(fileName, sha); } static fromBlameUri(uri: Uri): IGitBlameUriData { @@ -385,10 +335,10 @@ export default class GitProvider extends Disposable { } 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; + const fileName = Git.normalizePath(path.join(commit.repoPath, commit.fileName)); + const data = { fileName: fileName, sha: commit.sha, index: index } as T; if (originalFileName) { - data.originalFileName = originalFileName; + data.originalFileName = Git.normalizePath(path.join(commit.repoPath, originalFileName)); } return data; }