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
This commit is contained in:
Eric Amodio
2016-09-19 04:11:46 -04:00
parent c69a160ea5
commit ff01054f90
8 changed files with 368 additions and 234 deletions

View File

@@ -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<IGitBlame> {
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<string, IGitAuthor> = new Map();
const commits: Map<string, IGitCommit> = new Map();
const lines: Array<IGitCommitLine> = [];
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<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 <IGitBlame>{
repoPath: repoPath,
authors: sortedAuthors,
// commits: sortedCommits,
commits: commits,
lines: lines
};
}
}

View File

@@ -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<IGitBlame> {
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<string, IGitAuthor> = new Map();
const commits: Map<string, IGitCommit> = new Map();
const lines: Array<IGitCommitLine> = [];
let m: Array<string>;
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<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 <IGitBlame>{
authors: sortedAuthors,
commits: sortedCommits,
lines: lines
};
}
}

90
src/git/git.ts Normal file
View File

@@ -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<string>((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}`);
}
}

82
src/git/gitEnrichment.ts Normal file
View File

@@ -0,0 +1,82 @@
'use strict'
import {Uri} from 'vscode';
import * as path from 'path';
export interface IGitEnricher<T> {
enrich(data: string, ...args): T;
}
export interface IGitBlame {
repoPath: string;
authors: Map<string, IGitAuthor>;
commits: Map<string, IGitCommit>;
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;
}