diff --git a/src/commands/closeUnchangedFiles.ts b/src/commands/closeUnchangedFiles.ts index 8c64b30..a5a8d61 100644 --- a/src/commands/closeUnchangedFiles.ts +++ b/src/commands/closeUnchangedFiles.ts @@ -23,10 +23,10 @@ export class CloseUnchangedFilesCommand extends ActiveEditorCommand { const repoPath = await this.git.getRepoPathFromUri(uri, this.repoPath); if (!repoPath) return window.showWarningMessage(`Unable to close unchanged files`); - const statuses = await this.git.getStatusesForRepo(repoPath); - if (!statuses) return window.showWarningMessage(`Unable to close unchanged files`); + const status = await this.git.getStatusForRepo(repoPath); + if (!status) return window.showWarningMessage(`Unable to close unchanged files`); - uris = statuses.map(_ => Uri.file(path.resolve(repoPath, _.fileName))); + uris = status.files.map(_ => Uri.file(path.resolve(repoPath, _.fileName))); } const editorTracker = new ActiveEditorTracker(); diff --git a/src/commands/openChangedFiles.ts b/src/commands/openChangedFiles.ts index 126bb28..5ff23e9 100644 --- a/src/commands/openChangedFiles.ts +++ b/src/commands/openChangedFiles.ts @@ -21,10 +21,10 @@ export class OpenChangedFilesCommand extends ActiveEditorCommand { const repoPath = await this.git.getRepoPathFromUri(uri, this.repoPath); if (!repoPath) return window.showWarningMessage(`Unable to open changed files`); - const statuses = await this.git.getStatusesForRepo(repoPath); - if (!statuses) return window.showWarningMessage(`Unable to open changed files`); + const status = await this.git.getStatusForRepo(repoPath); + if (!status) return window.showWarningMessage(`Unable to open changed files`); - uris = statuses.filter(_ => _.status !== 'D').map(_ => Uri.file(path.resolve(repoPath, _.fileName))); + uris = status.files.filter(_ => _.status !== 'D').map(_ => Uri.file(path.resolve(repoPath, _.fileName))); } for (const uri of uris) { diff --git a/src/commands/showQuickRepoStatus.ts b/src/commands/showQuickRepoStatus.ts index c561ee2..18a3fd4 100644 --- a/src/commands/showQuickRepoStatus.ts +++ b/src/commands/showQuickRepoStatus.ts @@ -20,10 +20,10 @@ export class ShowQuickRepoStatusCommand extends ActiveEditorCommand { const repoPath = await this.git.getRepoPathFromUri(uri, this.repoPath); if (!repoPath) return window.showWarningMessage(`Unable to show repository status`); - const statuses = await this.git.getStatusesForRepo(repoPath); - if (!statuses) return window.showWarningMessage(`Unable to show repository status`); + const status = await this.git.getStatusForRepo(repoPath); + if (!status) return window.showWarningMessage(`Unable to show repository status`); - const pick = await RepoStatusQuickPick.show(statuses, goBackCommand); + const pick = await RepoStatusQuickPick.show(status, goBackCommand); if (!pick) return undefined; if (pick instanceof CommandQuickPickItem) { diff --git a/src/git/git.ts b/src/git/git.ts index 72bb700..56441ff 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -6,13 +6,14 @@ import * as fs from 'fs'; import * as path from 'path'; import * as tmp from 'tmp'; -export * from './gitEnrichment'; -export * from './enrichers/blameParserEnricher'; -export * from './enrichers/logParserEnricher'; +export * from './models/models'; +export * from './parsers/blameParser'; +export * from './parsers/logParser'; +export * from './parsers/statusParser'; let git: IGit; -const DefaultLogParams = [`log`, `--name-status`, `--full-history`, `-M`, `--date=iso8601-strict`, `--format=%H -%nauthor %an%nauthor-date %ai%ncommitter %cn%ncommitter-date %ci%nparent %P%nsummary %B%nfilename ?`]; +const defaultLogParams = [`log`, `--name-status`, `--full-history`, `-M`, `--date=iso8601-strict`, `--format=%H -%nauthor %an%nauthor-date %ai%ncommitter %cn%ncommitter-date %ci%nparent %P%nsummary %B%nfilename ?`]; async function gitCommand(cwd: string, ...args: any[]) { try { @@ -32,17 +33,10 @@ async function gitCommand(cwd: string, ...args: any[]) { } } -export type GitBlameFormat = '--incremental' | '--line-porcelain' | '--porcelain'; -export const GitBlameFormat = { - incremental: '--incremental' as GitBlameFormat, - linePorcelain: '--line-porcelain' as GitBlameFormat, - porcelain: '--porcelain' as GitBlameFormat -}; - export class Git { - static ShaRegex = /^[0-9a-f]{40}( -)?$/; - static UncommittedRegex = /^[0]+$/; + static shaRegex = /^[0-9a-f]{40}( -)?$/; + static uncommittedRegex = /^[0]+$/; static gitInfo(): IGit { return git; @@ -84,11 +78,11 @@ export class Git { } static isSha(sha: string) { - return Git.ShaRegex.test(sha); + return Git.shaRegex.test(sha); } static isUncommitted(sha: string) { - return Git.UncommittedRegex.test(sha); + return Git.uncommittedRegex.test(sha); } static normalizePath(fileName: string, repoPath?: string) { @@ -111,10 +105,10 @@ export class Git { // Git commands - static blame(repoPath: string, fileName: string, format: GitBlameFormat, sha?: string, startLine?: number, endLine?: number) { + static blame(repoPath: string, fileName: string, sha?: string, startLine?: number, endLine?: number) { const [file, root]: [string, string] = Git.splitPath(Git.normalizePath(fileName), repoPath); - const params = [`blame`, `--root`, format]; + const params = [`blame`, `--root`, `--incremental`]; if (startLine != null && endLine != null) { params.push(`-L ${startLine},${endLine}`); @@ -127,8 +121,11 @@ export class Git { return gitCommand(root, ...params, `--`, file); } - static branch(repoPath: string) { - const params = [`branch`, `-a`]; + static branch(repoPath: string, all: boolean) { + const params = [`branch`]; + if (all) { + params.push(`-a`); + } return gitCommand(repoPath, ...params); } @@ -163,7 +160,7 @@ export class Git { } static log(repoPath: string, sha?: string, maxCount?: number, reverse: boolean = false) { - const params = [...DefaultLogParams]; + const params = [...defaultLogParams]; if (maxCount && !reverse) { params.push(`-n${maxCount}`); } @@ -183,7 +180,7 @@ export class Git { static log_file(repoPath: string, fileName: string, sha?: string, maxCount?: number, reverse: boolean = false, startLine?: number, endLine?: number) { const [file, root]: [string, string] = Git.splitPath(Git.normalizePath(fileName), repoPath); - const params = [...DefaultLogParams, `--no-merges`, `--follow`]; + const params = [...defaultLogParams, `--no-merges`, `--follow`]; if (maxCount && !reverse) { params.push(`-n${maxCount}`); } @@ -209,14 +206,14 @@ export class Git { } static status(repoPath: string): Promise { - const params = ['status', '--short']; + const params = ['status', '--porcelain=v2', '--branch']; return gitCommand(repoPath, ...params); } static status_file(repoPath: string, fileName: string): Promise { const [file, root]: [string, string] = Git.splitPath(Git.normalizePath(fileName), repoPath); - const params = ['status', file, '--short']; + const params = ['status', '--porcelain=v2', file]; return gitCommand(root, ...params); } } \ No newline at end of file diff --git a/src/git/gitEnrichment.ts b/src/git/gitEnrichment.ts deleted file mode 100644 index 981b67b..0000000 --- a/src/git/gitEnrichment.ts +++ /dev/null @@ -1,240 +0,0 @@ -'use strict'; -import { Uri } from 'vscode'; -import { Git } from './git'; -import * as path from 'path'; - -export interface IGitEnricher { - enrich(data: string, ...args: any[]): T; -} - -export interface IGitBlame { - repoPath: string; - authors: Map; - commits: Map; - lines: IGitCommitLine[]; -} - -export interface IGitBlameLine { - author: IGitAuthor; - commit: GitCommit; - line: IGitCommitLine; -} - -export interface IGitBlameLines extends IGitBlame { - allLines: IGitCommitLine[]; -} - -export interface IGitBlameCommitLines { - author: IGitAuthor; - commit: GitCommit; - lines: IGitCommitLine[]; -} - -export interface IGitAuthor { - name: string; - lineCount: number; -} - -interface IGitCommit { - repoPath: string; - sha: string; - fileName: string; - author: string; - date: Date; - message: string; - lines: IGitCommitLine[]; - originalFileName?: string; - previousSha?: string; - previousFileName?: string; - - readonly isUncommitted: boolean; - previousUri: Uri; - uri: Uri; -} - -export class GitCommit implements IGitCommit { - - lines: IGitCommitLine[]; - originalFileName?: string; - previousSha?: string; - previousFileName?: string; - private _isUncommitted: boolean | undefined; - - 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.fileName = this.fileName.replace(/, ?$/, ''); - - this.lines = lines || []; - this.originalFileName = originalFileName; - this.previousSha = previousSha; - this.previousFileName = previousFileName; - } - - get shortSha() { - return this.sha.substring(0, 8); - } - - get isUncommitted(): boolean { - if (this._isUncommitted === undefined) { - this._isUncommitted = Git.isUncommitted(this.sha); - } - return this._isUncommitted; - } - - get previousShortSha() { - return this.previousSha && this.previousSha.substring(0, 8); - } - - get previousUri(): Uri { - return this.previousFileName ? Uri.file(path.resolve(this.repoPath, this.previousFileName)) : this.uri; - } - - get uri(): Uri { - return Uri.file(path.resolve(this.repoPath, this.originalFileName || this.fileName)); - } - - getFormattedPath(separator: string = ' \u00a0\u2022\u00a0 '): string { - const directory = path.dirname(this.fileName); - return (!directory || directory === '.') - ? path.basename(this.fileName) - : `${path.basename(this.fileName)}${separator}${directory}`; - } -} - -export type GitLogType = 'file' | 'repo'; - -export class GitLogCommit extends GitCommit { - - fileNames: string; - fileStatuses: { status: GitFileStatus, fileName: string }[]; - nextSha?: string; - nextFileName?: string; - status: GitFileStatus; - - constructor( - public type: GitLogType, - repoPath: string, - sha: string, - fileName: string, - author: string, - date: Date, - message: string, - status?: GitFileStatus, - fileStatuses?: { status: GitFileStatus, fileName: string }[], - lines?: IGitCommitLine[], - originalFileName?: string, - previousSha?: string, - previousFileName?: string - ) { - super(repoPath, sha, fileName, author, date, message, lines, originalFileName, previousSha, previousFileName); - this.status = status; - - this.fileNames = this.fileName; - - if (fileStatuses) { - this.fileStatuses = fileStatuses.filter(_ => !!_.fileName); - this.fileName = this.fileStatuses[0].fileName; - } - else { - this.fileStatuses = [{ status: status, fileName: fileName }]; - } - } - - get nextShortSha() { - return this.nextSha && this.nextSha.substring(0, 8); - } - - get nextUri(): Uri { - return this.nextFileName ? Uri.file(path.resolve(this.repoPath, this.nextFileName)) : this.uri; - } -} - -export interface IGitCommitLine { - sha: string; - previousSha?: string; - line: number; - originalLine: number; - code?: string; -} - -export interface IGitLog { - repoPath: string; - authors: Map; - commits: Map; - - maxCount: number | undefined; - truncated: boolean; -} - -export declare type GitFileStatus = '?' | 'A' | 'C' | 'D' | 'M' | 'R' | 'U'; - -export class GitFileStatusItem { - - staged: boolean; - status: GitFileStatus; - fileName: string; - - constructor(public repoPath: string, status: string) { - this.fileName = status.substring(3); - this.parseStatus(status); - } - - private parseStatus(status: string) { - const indexStatus = status[0].trim(); - const workTreeStatus = status[1].trim(); - - this.staged = !!indexStatus; - this.status = (indexStatus || workTreeStatus || 'U') as GitFileStatus; - } -} - -const statusOcticonsMap = { - '?': '$(diff-ignored)', - A: '$(diff-added)', - C: '$(diff-added)', - D: '$(diff-removed)', - M: '$(diff-modified)', - R: '$(diff-renamed)', - U: '$(question)' -}; -export function getGitStatusIcon(status: GitFileStatus, missing: string = '\u00a0\u00a0\u00a0\u00a0'): string { - return statusOcticonsMap[status] || missing; -} - -export class GitBranch { - - current: boolean; - name: string; - remote: boolean; - - constructor(branch: string) { - branch = branch.trim(); - - if (branch.startsWith('* ')) { - branch = branch.substring(2); - this.current = true; - } - - if (branch.startsWith('remotes/')) { - branch = branch.substring(8); - this.remote = true; - } - - const index = branch.indexOf(' '); - if (index !== -1) { - branch = branch.substring(0, index); - } - - this.name = branch; - } -} \ No newline at end of file diff --git a/src/git/gitUri.ts b/src/git/gitUri.ts index 2cf2276..325e9ed 100644 --- a/src/git/gitUri.ts +++ b/src/git/gitUri.ts @@ -87,9 +87,9 @@ export class GitUri extends Uri { } export interface IGitCommitInfo { - sha: string; - repoPath: string; fileName: string; + repoPath: string; + sha?: string; originalFileName?: string; } diff --git a/src/git/models/blame.ts b/src/git/models/blame.ts new file mode 100644 index 0000000..ae07031 --- /dev/null +++ b/src/git/models/blame.ts @@ -0,0 +1,25 @@ +'use strict'; +import { GitCommit, IGitAuthor, IGitCommitLine } from './commit'; + +export interface IGitBlame { + repoPath: string; + authors: Map; + commits: Map; + lines: IGitCommitLine[]; +} + +export interface IGitBlameLine { + author: IGitAuthor; + commit: GitCommit; + line: IGitCommitLine; +} + +export interface IGitBlameLines extends IGitBlame { + allLines: IGitCommitLine[]; +} + +export interface IGitBlameCommitLines { + author: IGitAuthor; + commit: GitCommit; + lines: IGitCommitLine[]; +} \ No newline at end of file diff --git a/src/git/models/branch.ts b/src/git/models/branch.ts new file mode 100644 index 0000000..4cd889e --- /dev/null +++ b/src/git/models/branch.ts @@ -0,0 +1,29 @@ +'use strict'; + +export class GitBranch { + + current: boolean; + name: string; + remote: boolean; + + constructor(branch: string) { + branch = branch.trim(); + + if (branch.startsWith('* ')) { + branch = branch.substring(2); + this.current = true; + } + + if (branch.startsWith('remotes/')) { + branch = branch.substring(8); + this.remote = true; + } + + const index = branch.indexOf(' '); + if (index !== -1) { + branch = branch.substring(0, index); + } + + this.name = branch; + } +} \ No newline at end of file diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts new file mode 100644 index 0000000..0711abe --- /dev/null +++ b/src/git/models/commit.ts @@ -0,0 +1,94 @@ +'use strict'; +import { Uri } from 'vscode'; +import { Git } from '../git'; +import * as path from 'path'; + +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; + + readonly isUncommitted: boolean; + previousUri: Uri; + uri: Uri; +} + +export interface IGitCommitLine { + sha: string; + previousSha?: string; + line: number; + originalLine: number; + code?: string; +} + +export class GitCommit implements IGitCommit { + + lines: IGitCommitLine[]; + originalFileName?: string; + previousSha?: string; + previousFileName?: string; + workingFileName?: string; + private _isUncommitted: boolean | undefined; + + 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.fileName = this.fileName.replace(/, ?$/, ''); + + this.lines = lines || []; + this.originalFileName = originalFileName; + this.previousSha = previousSha; + this.previousFileName = previousFileName; + } + + get shortSha() { + return this.sha.substring(0, 8); + } + + get isUncommitted(): boolean { + if (this._isUncommitted === undefined) { + this._isUncommitted = Git.isUncommitted(this.sha); + } + return this._isUncommitted; + } + + get previousShortSha() { + return this.previousSha && this.previousSha.substring(0, 8); + } + + get previousUri(): Uri { + return this.previousFileName ? Uri.file(path.resolve(this.repoPath, this.previousFileName)) : this.uri; + } + + get uri(): Uri { + return Uri.file(path.resolve(this.repoPath, this.originalFileName || this.fileName)); + } + + getFormattedPath(separator: string = ' \u00a0\u2022\u00a0 '): string { + const directory = path.dirname(this.fileName); + return (!directory || directory === '.') + ? path.basename(this.fileName) + : `${path.basename(this.fileName)}${separator}${directory}`; + } +} \ No newline at end of file diff --git a/src/git/models/log.ts b/src/git/models/log.ts new file mode 100644 index 0000000..4ae13d5 --- /dev/null +++ b/src/git/models/log.ts @@ -0,0 +1,14 @@ +'use strict'; +import { Range } from 'vscode'; +import { IGitAuthor } from './commit'; +import { GitLogCommit } from './logCommit'; + +export interface IGitLog { + repoPath: string; + authors: Map; + commits: Map; + + maxCount: number | undefined; + range: Range; + truncated: boolean; +} \ No newline at end of file diff --git a/src/git/models/logCommit.ts b/src/git/models/logCommit.ts new file mode 100644 index 0000000..d5a88ff --- /dev/null +++ b/src/git/models/logCommit.ts @@ -0,0 +1,53 @@ +'use strict'; +import { Uri } from 'vscode'; +import { GitCommit, IGitCommitLine } from './commit'; +import { GitStatusFileStatus } from './status'; +import * as path from 'path'; + +export type GitLogType = 'file' | 'repo'; + +export class GitLogCommit extends GitCommit { + + fileNames: string; + fileStatuses: { status: GitStatusFileStatus, fileName: string, originalFileName?: string }[]; + nextSha?: string; + nextFileName?: string; + status: GitStatusFileStatus; + + constructor( + public type: GitLogType, + repoPath: string, + sha: string, + fileName: string, + author: string, + date: Date, + message: string, + status?: GitStatusFileStatus, + fileStatuses?: { status: GitStatusFileStatus, fileName: string, originalFileName?: string }[], + lines?: IGitCommitLine[], + originalFileName?: string, + previousSha?: string, + previousFileName?: string + ) { + super(repoPath, sha, fileName, author, date, message, lines, originalFileName, previousSha, previousFileName); + this.status = status; + + this.fileNames = this.fileName; + + if (fileStatuses) { + this.fileStatuses = fileStatuses.filter(_ => !!_.fileName); + this.fileName = this.fileStatuses[0].fileName; + } + else { + this.fileStatuses = [{ status: status, fileName: fileName }]; + } + } + + get nextShortSha() { + return this.nextSha && this.nextSha.substring(0, 8); + } + + get nextUri(): Uri { + return this.nextFileName ? Uri.file(path.resolve(this.repoPath, this.nextFileName)) : this.uri; + } +} \ No newline at end of file diff --git a/src/git/models/models.ts b/src/git/models/models.ts new file mode 100644 index 0000000..bc25b8b --- /dev/null +++ b/src/git/models/models.ts @@ -0,0 +1,7 @@ +'use strict'; +export * from './blame'; +export * from './branch'; +export * from './commit'; +export * from './log'; +export * from './logCommit'; +export * from './status'; \ No newline at end of file diff --git a/src/git/models/status.ts b/src/git/models/status.ts new file mode 100644 index 0000000..d360e58 --- /dev/null +++ b/src/git/models/status.ts @@ -0,0 +1,45 @@ +'use strict'; + +export interface IGitStatus { + + branch: string; + repoPath: string; + sha: string; + state: { + ahead: number; + behind: number; + }; + upstream?: string; + + files: GitStatusFile[]; +} + +export declare type GitStatusFileStatus = '!' | '?' | 'A' | 'C' | 'D' | 'M' | 'R' | 'U'; + +export class GitStatusFile { + + originalFileName?: string; + + constructor(public repoPath: string, public status: GitStatusFileStatus, public staged: boolean, public fileName: string, originalFileName?: string) { + this.originalFileName = originalFileName; + } + + getIcon() { + return getGitStatusIcon(this.status); + } +} + +const statusOcticonsMap = { + '!': '$(diff-ignored)', + '?': '$(diff-added)', + A: '$(diff-added)', + C: '$(diff-added)', + D: '$(diff-removed)', + M: '$(diff-modified)', + R: '$(diff-renamed)', + U: '$(question)' +}; + +export function getGitStatusIcon(status: GitStatusFileStatus, missing: string = '\u00a0\u00a0\u00a0\u00a0'): string { + return statusOcticonsMap[status] || missing; +} \ No newline at end of file diff --git a/src/git/enrichers/blameParserEnricher.ts b/src/git/parsers/blameParser.ts similarity index 92% rename from src/git/enrichers/blameParserEnricher.ts rename to src/git/parsers/blameParser.ts index ef5f6c0..6d83c8d 100644 --- a/src/git/enrichers/blameParserEnricher.ts +++ b/src/git/parsers/blameParser.ts @@ -1,5 +1,5 @@ 'use strict'; -import { Git, GitBlameFormat, GitCommit, IGitAuthor, IGitBlame, IGitCommitLine, IGitEnricher } from './../git'; +import { Git, GitCommit, IGitAuthor, IGitBlame, IGitCommitLine } from './../git'; import * as moment from 'moment'; import * as path from 'path'; @@ -28,15 +28,9 @@ interface IBlameEntry { summary?: string; } -export class GitBlameParserEnricher implements IGitEnricher { +export class GitBlameParser { - constructor(public format: GitBlameFormat) { - if (format !== GitBlameFormat.incremental) { - throw new Error(`Invalid blame format=${format}`); - } - } - - private _parseEntries(data: string): IBlameEntry[] { + private static _parseEntries(data: string): IBlameEntry[] { if (!data) return undefined; const lines = data.split('\n'); @@ -122,7 +116,7 @@ export class GitBlameParserEnricher implements IGitEnricher { return entries; } - enrich(data: string, fileName: string): IGitBlame { + static parse(data: string, fileName: string): IGitBlame { const entries = this._parseEntries(data); if (!entries) return undefined; diff --git a/src/git/enrichers/logParserEnricher.ts b/src/git/parsers/logParser.ts similarity index 91% rename from src/git/enrichers/logParserEnricher.ts rename to src/git/parsers/logParser.ts index 9224187..563f50a 100644 --- a/src/git/enrichers/logParserEnricher.ts +++ b/src/git/parsers/logParser.ts @@ -1,5 +1,6 @@ 'use strict'; -import { Git, GitFileStatus, GitLogCommit, GitLogType, IGitAuthor, IGitEnricher, IGitLog } from './../git'; +import { Range } from 'vscode'; +import { Git, GitStatusFileStatus, GitLogCommit, GitLogType, IGitAuthor, IGitLog } from './../git'; // import { Logger } from '../../logger'; import * as moment from 'moment'; import * as path from 'path'; @@ -17,16 +18,16 @@ interface ILogEntry { fileName?: string; originalFileName?: string; - fileStatuses?: { status: GitFileStatus, fileName: string, originalFileName: string }[]; + fileStatuses?: { status: GitStatusFileStatus, fileName: string, originalFileName: string }[]; - status?: GitFileStatus; + status?: GitStatusFileStatus; summary?: string; } -export class GitLogParserEnricher implements IGitEnricher { +export class GitLogParser { - private _parseEntries(data: string, isRepoPath: boolean): ILogEntry[] { + private static _parseEntries(data: string, isRepoPath: boolean): ILogEntry[] { if (!data) return undefined; const lines = data.split('\n'); @@ -43,7 +44,7 @@ export class GitLogParserEnricher implements IGitEnricher { } if (!entry) { - if (!Git.ShaRegex.test(lineParts[0])) continue; + if (!Git.shaRegex.test(lineParts[0])) continue; entry = { sha: lineParts[0] @@ -91,7 +92,7 @@ export class GitLogParserEnricher implements IGitEnricher { while (++position < lines.length) { lineParts = lines[position].split(' '); - if (Git.ShaRegex.test(lineParts[0])) { + if (Git.shaRegex.test(lineParts[0])) { position--; break; } @@ -113,7 +114,7 @@ export class GitLogParserEnricher implements IGitEnricher { } const status = { - status: lineParts[0][0] as GitFileStatus, + status: lineParts[0][0] as GitStatusFileStatus, fileName: lineParts[0].substring(1), originalFileName: undefined as string }; @@ -141,11 +142,11 @@ export class GitLogParserEnricher implements IGitEnricher { position += 2; lineParts = lines[position].split(' '); if (lineParts.length === 1) { - entry.status = lineParts[0][0] as GitFileStatus; + entry.status = lineParts[0][0] as GitStatusFileStatus; entry.fileName = lineParts[0].substring(1); } else { - entry.status = lineParts[3][0] as GitFileStatus; + entry.status = lineParts[3][0] as GitStatusFileStatus; entry.fileName = lineParts[0].substring(1); position += 4; } @@ -175,7 +176,7 @@ export class GitLogParserEnricher implements IGitEnricher { return entries; } - enrich(data: string, type: GitLogType, fileNameOrRepoPath: string, maxCount: number | undefined, isRepoPath: boolean, reverse: boolean): IGitLog { + static parse(data: string, type: GitLogType, fileNameOrRepoPath: string, maxCount: number | undefined, isRepoPath: boolean, reverse: boolean, range: Range): IGitLog { const entries = this._parseEntries(data, isRepoPath); if (!entries) return undefined; @@ -264,7 +265,8 @@ export class GitLogParserEnricher implements IGitEnricher { // commits: sortedCommits, commits: commits, maxCount: maxCount, - truncated: maxCount && entries.length >= maxCount + range: range, + truncated: !!(maxCount && entries.length >= maxCount) } as IGitLog; } } \ No newline at end of file diff --git a/src/git/parsers/statusParser.ts b/src/git/parsers/statusParser.ts new file mode 100644 index 0000000..d48715b --- /dev/null +++ b/src/git/parsers/statusParser.ts @@ -0,0 +1,89 @@ +'use strict'; +import { GitStatusFileStatus, GitStatusFile, IGitStatus } from './../git'; + +interface IFileStatusEntry { + staged: boolean; + status: GitStatusFileStatus; + fileName: string; + originalFileName: string; +} + +export class GitStatusParser { + + static parse(data: string, repoPath: string): IGitStatus { + if (!data) return undefined; + + const lines = data.split('\n').filter(_ => !!_); + if (!lines.length) return undefined; + + const status = { + repoPath: repoPath, + state: { + ahead: 0, + behind: 0 + }, + files: [] + } as IGitStatus; + + let position = -1; + while (++position < lines.length) { + const line = lines[position]; + // Headers + if (line.startsWith('#')) { + const lineParts = line.split(' '); + switch (lineParts[1]) { + case 'branch.oid': + status.sha = lineParts[2]; + break; + case 'branch.head': + status.branch = lineParts[2]; + break; + case 'branch.upstream': + status.upstream = lineParts[2]; + break; + case 'branch.ab': + status.state.ahead = +lineParts[2][1]; + status.state.behind = +lineParts[3][1]; + break; + } + } + else { + let lineParts = line.split(' '); + let entry: IFileStatusEntry; + switch (lineParts[0][0]) { + case '1': // normal + entry = this._parseFileEntry(lineParts[1], lineParts[8]); + break; + case '2': // rename + const file = lineParts[9].split('\t'); + entry = this._parseFileEntry(lineParts[1], file[0], file[1]); + break; + case 'u': // unmerged + entry = this._parseFileEntry(lineParts[1], lineParts[10]); + break; + case '?': // untracked + entry = this._parseFileEntry(' ?', lineParts[1]); + break; + } + + if (entry) { + status.files.push(new GitStatusFile(repoPath, entry.status, entry.staged, entry.fileName, entry.originalFileName)); + } + } + } + + return status; + } + + private static _parseFileEntry(rawStatus: string, fileName: string, originalFileName?: string): IFileStatusEntry { + const indexStatus = rawStatus[0] !== '.' ? rawStatus[0].trim() : undefined; + const workTreeStatus = rawStatus[1] !== '.' ? rawStatus[1].trim() : undefined; + + return { + status: (indexStatus || workTreeStatus || '?') as GitStatusFileStatus, + fileName: fileName, + originalFileName: originalFileName, + staged: !!indexStatus + }; + } +} \ No newline at end of file diff --git a/src/gitService.ts b/src/gitService.ts index 6d58b66..e9cd4a2 100644 --- a/src/gitService.ts +++ b/src/gitService.ts @@ -4,7 +4,7 @@ import { Disposable, Event, EventEmitter, ExtensionContext, FileSystemWatcher, l import { CommandContext, setCommandContext } from './commands'; import { CodeLensVisibility, IConfig } from './configuration'; import { DocumentSchemes, WorkspaceState } from './constants'; -import { Git, GitBlameParserEnricher, GitBlameFormat, GitBranch, GitCommit, GitFileStatusItem, GitLogParserEnricher, IGitAuthor, IGitBlame, IGitBlameLine, IGitBlameLines, IGitLog } from './git/git'; +import { Git, GitBlameParser, GitBranch, GitCommit, GitStatusFile, GitLogParser, GitStatusParser, IGitAuthor, IGitBlame, IGitBlameLine, IGitBlameLines, IGitLog, IGitStatus } from './git/git'; import { IGitUriData, GitUri } from './git/gitUri'; import GitCodeLensProvider from './gitCodeLensProvider'; import { Logger } from './logger'; @@ -13,7 +13,7 @@ import * as ignore from 'ignore'; import * as moment from 'moment'; import * as path from 'path'; -export { getGitStatusIcon } from './git/gitEnrichment'; +export { getGitStatusIcon } from './git/git'; export { Git, GitUri }; export * from './git/git'; @@ -30,6 +30,8 @@ class GitCacheEntry { get hasErrors() { return !!((this.blame && this.blame.errorMessage) || (this.log && this.log.errorMessage)); } + + constructor(public key: string) { } } interface ICachedItem { @@ -72,7 +74,6 @@ export class GitService extends Disposable { private _gitignore: Promise; static EmptyPromise: Promise = Promise.resolve(undefined); - static BlameFormat = GitBlameFormat.incremental; constructor(private context: ExtensionContext) { super(() => this.dispose()); @@ -256,69 +257,73 @@ export class GitService extends Disposable { return !(entry && entry.hasErrors); } - getBlameForFile(uri: GitUri): Promise { + async getBlameForFile(uri: GitUri): Promise { Logger.log(`getBlameForFile('${uri.repoPath}', '${uri.fsPath}', ${uri.sha})`); const fileName = Git.normalizePath(uri.fsPath, uri.repoPath); - const useCaching = this.UseGitCaching && !uri.sha; - let cacheKey: string | undefined; let entry: GitCacheEntry | undefined; - if (useCaching) { - cacheKey = this.getCacheEntryKey(fileName); + if (this.UseGitCaching && !uri.sha) { + const cacheKey = this.getCacheEntryKey(fileName); entry = this._gitCache.get(cacheKey); if (entry !== undefined && entry.blame !== undefined) return entry.blame.item; if (entry === undefined) { - entry = new GitCacheEntry(); + entry = new GitCacheEntry(cacheKey); } } - const promise = this._gitignore.then(ignore => { - if (ignore && !ignore.filter([fileName]).length) { - Logger.log(`Skipping blame; '${fileName}' is gitignored`); - if (cacheKey) { - this._onDidBlameFailEmitter.fire(cacheKey); - } - return GitService.EmptyPromise as Promise; - } + const promise = this._getBlameForFile(uri, fileName, entry); - return Git.blame(uri.repoPath, fileName, GitService.BlameFormat, uri.sha) - .then(data => new GitBlameParserEnricher(GitService.BlameFormat).enrich(data, fileName)) - .catch(ex => { - // Trap and cache expected blame errors - if (useCaching) { - const msg = ex && ex.toString(); - Logger.log(`Replace blame cache with empty promise for '${cacheKey}'`); - - entry.blame = { - //date: new Date(), - item: GitService.EmptyPromise, - errorMessage: msg - } as ICachedBlame; - - this._onDidBlameFailEmitter.fire(cacheKey); - this._gitCache.set(cacheKey, entry); - return GitService.EmptyPromise as Promise; - } - return undefined; - }); - }); - - if (useCaching) { - Logger.log(`Add blame cache for '${cacheKey}'`); + if (entry) { + Logger.log(`Add blame cache for '${entry.key}'`); entry.blame = { //date: new Date(), item: promise } as ICachedBlame; - this._gitCache.set(cacheKey, entry); + this._gitCache.set(entry.key, entry); } return promise; } + private async _getBlameForFile(uri: GitUri, fileName: string, entry: GitCacheEntry | undefined): Promise { + const ignore = await this._gitignore; + if (ignore && !ignore.filter([fileName]).length) { + Logger.log(`Skipping blame; '${fileName}' is gitignored`); + if (entry && entry.key) { + this._onDidBlameFailEmitter.fire(entry.key); + } + return await GitService.EmptyPromise as IGitBlame; + } + + try { + const data = await Git.blame(uri.repoPath, fileName, uri.sha); + return GitBlameParser.parse(data, fileName); + } + catch (ex) { + // Trap and cache expected blame errors + if (entry) { + const msg = ex && ex.toString(); + Logger.log(`Replace blame cache with empty promise for '${entry.key}'`); + + entry.blame = { + //date: new Date(), + item: GitService.EmptyPromise, + errorMessage: msg + } as ICachedBlame; + + this._onDidBlameFailEmitter.fire(entry.key); + this._gitCache.set(entry.key, entry); + return await GitService.EmptyPromise as IGitBlame; + } + + return undefined; + } + }; + async getBlameForLine(uri: GitUri, line: number): Promise { Logger.log(`getBlameForLine('${uri.repoPath}', '${uri.fsPath}', ${line}, ${uri.sha})`); @@ -338,8 +343,8 @@ export class GitService extends Disposable { const fileName = Git.normalizePath(uri.fsPath, uri.repoPath); try { - const data = await Git.blame(uri.repoPath, fileName, GitService.BlameFormat, uri.sha, line + 1, line + 1); - const blame = new GitBlameParserEnricher(GitService.BlameFormat).enrich(data, fileName); + const data = await Git.blame(uri.repoPath, fileName, uri.sha, line + 1, line + 1); + const blame = GitBlameParser.parse(data, fileName); if (!blame) return undefined; const commit = Iterables.first(blame.commits.values()); @@ -439,7 +444,7 @@ export class GitService extends Disposable { async getBranches(repoPath: string): Promise { Logger.log(`getBranches('${repoPath}')`); - const data = await Git.branch(repoPath); + const data = await Git.branch(repoPath, true); const branches = data.split('\n').filter(_ => !!_).map(_ => new GitBranch(_)); return branches; } @@ -455,7 +460,7 @@ export class GitService extends Disposable { } async getLogForRepo(repoPath: string, sha?: string, maxCount?: number, reverse: boolean = false): Promise { - Logger.log(`getLogForRepo('${repoPath}', ${maxCount})`); + Logger.log(`getLogForRepo('${repoPath}', ${sha}, ${maxCount})`); if (maxCount == null) { maxCount = this.config.advanced.maxQuickHistory || 0; @@ -463,7 +468,7 @@ export class GitService extends Disposable { try { const data = await Git.log(repoPath, sha, maxCount, reverse); - return new GitLogParserEnricher().enrich(data, 'repo', repoPath, maxCount, true, reverse); + return GitLogParser.parse(data, 'repo', repoPath, maxCount, true, reverse, undefined); } catch (ex) { return undefined; @@ -474,61 +479,65 @@ export class GitService extends Disposable { Logger.log(`getLogForFile('${repoPath}', '${fileName}', ${sha}, ${range && `[${range.start.line}, ${range.end.line}]`}, ${maxCount}, ${reverse})`); fileName = Git.normalizePath(fileName); - const useCaching = this.UseGitCaching && !sha && !range && !maxCount; - let cacheKey: string; - let entry: GitCacheEntry; - if (useCaching) { - cacheKey = this.getCacheEntryKey(fileName); + let entry: GitCacheEntry | undefined; + if (this.UseGitCaching && !sha && !range && !maxCount) { + const cacheKey = this.getCacheEntryKey(fileName); entry = this._gitCache.get(cacheKey); if (entry !== undefined && entry.log !== undefined) return entry.log.item; if (entry === undefined) { - entry = new GitCacheEntry(); + entry = new GitCacheEntry(cacheKey); } } - const promise = this._gitignore.then(ignore => { - if (ignore && !ignore.filter([fileName]).length) { - Logger.log(`Skipping log; '${fileName}' is gitignored`); - return GitService.EmptyPromise as Promise; - } + const promise = this._getLogForFile(repoPath, fileName, sha, range, maxCount, reverse, entry); - return Git.log_file(repoPath, fileName, sha, maxCount, reverse, range && range.start.line + 1, range && range.end.line + 1) - .then(data => new GitLogParserEnricher().enrich(data, 'file', repoPath || fileName, maxCount, !!repoPath, reverse)) - .catch(ex => { - // Trap and cache expected log errors - if (useCaching) { - const msg = ex && ex.toString(); - Logger.log(`Replace log cache with empty promise for '${cacheKey}'`); - - entry.log = { - //date: new Date(), - item: GitService.EmptyPromise, - errorMessage: msg - } as ICachedLog; - - this._gitCache.set(cacheKey, entry); - return GitService.EmptyPromise as Promise; - } - return undefined; - }); - }); - - if (useCaching) { - Logger.log(`Add log cache for '${cacheKey}'`); + if (entry) { + Logger.log(`Add log cache for '${entry.key}'`); entry.log = { //date: new Date(), item: promise } as ICachedLog; - this._gitCache.set(cacheKey, entry); + this._gitCache.set(entry.key, entry); } return promise; } + private async _getLogForFile(repoPath: string, fileName: string, sha: string, range: Range, maxCount: number, reverse: boolean, entry: GitCacheEntry | undefined): Promise { + const ignore = await this._gitignore; + if (ignore && !ignore.filter([fileName]).length) { + Logger.log(`Skipping log; '${fileName}' is gitignored`); + return await GitService.EmptyPromise as IGitLog; + } + + try { + const data = await Git.log_file(repoPath, fileName, sha, maxCount, reverse, range && range.start.line + 1, range && range.end.line + 1); + return GitLogParser.parse(data, 'file', repoPath || fileName, maxCount, !!repoPath, reverse, range); + } + catch (ex) { + // Trap and cache expected log errors + if (entry) { + const msg = ex && ex.toString(); + Logger.log(`Replace log cache with empty promise for '${entry.key}'`); + + entry.log = { + //date: new Date(), + item: GitService.EmptyPromise, + errorMessage: msg + } as ICachedLog; + + this._gitCache.set(entry.key, entry); + return await GitService.EmptyPromise as IGitLog; + } + + return undefined; + } + }; + async getLogLocations(uri: GitUri, selectedSha?: string, line?: number): Promise { Logger.log(`getLogLocations('${uri.repoPath}', '${uri.fsPath}', ${uri.sha}, ${selectedSha}, ${line})`); @@ -570,18 +579,19 @@ export class GitService extends Disposable { return (await this.getRepoPathFromFile(gitUri.fsPath)) || fallbackRepoPath; } - async getStatusForFile(repoPath: string, fileName: string): Promise { + async getStatusForFile(repoPath: string, fileName: string): Promise { Logger.log(`getStatusForFile('${repoPath}', '${fileName}')`); - const status = await Git.status_file(repoPath, fileName); - return status && status.trim().length && new GitFileStatusItem(repoPath, status); + const data = await Git.status_file(repoPath, fileName); + const status = GitStatusParser.parse(data, repoPath); + return status && status.files.length && status.files[0]; } - async getStatusesForRepo(repoPath: string): Promise { + async getStatusForRepo(repoPath: string): Promise { Logger.log(`getStatusForRepo('${repoPath}')`); - const statuses = (await Git.status(repoPath)).split('\n').filter(_ => !!_); - return statuses.map(_ => new GitFileStatusItem(repoPath, _)); + const data = await Git.status(repoPath); + return GitStatusParser.parse(data, repoPath); } async isFileUncommitted(uri: GitUri): Promise { diff --git a/src/quickPicks/gitQuickPicks.ts b/src/quickPicks/gitQuickPicks.ts index 40936f0..44dd597 100644 --- a/src/quickPicks/gitQuickPicks.ts +++ b/src/quickPicks/gitQuickPicks.ts @@ -1,6 +1,6 @@ 'use strict'; import { QuickPickItem, Uri } from 'vscode'; -import { getGitStatusIcon, GitCommit, GitFileStatus, GitService, GitUri } from '../gitService'; +import { getGitStatusIcon, GitCommit, GitStatusFileStatus, GitService, GitUri } from '../gitService'; import { OpenFileCommandQuickPickItem } from './quickPicks'; import * as moment from 'moment'; import * as path from 'path'; @@ -24,9 +24,9 @@ export class CommitWithFileStatusQuickPickItem extends OpenFileCommandQuickPickI gitUri: GitUri; sha: string; shortSha: string; - status: GitFileStatus; + status: GitStatusFileStatus; - constructor(commit: GitCommit, fileName: string, status: GitFileStatus) { + constructor(commit: GitCommit, fileName: string, status: GitStatusFileStatus) { const icon = getGitStatusIcon(status); let directory = path.dirname(fileName); diff --git a/src/quickPicks/repoStatus.ts b/src/quickPicks/repoStatus.ts index a8d8d91..409fab1 100644 --- a/src/quickPicks/repoStatus.ts +++ b/src/quickPicks/repoStatus.ts @@ -2,15 +2,15 @@ import { Iterables } from '../system'; import { QuickPickItem, QuickPickOptions, Uri, window } from 'vscode'; import { Commands, Keyboard } from '../commands'; -import { getGitStatusIcon, GitFileStatusItem } from '../gitService'; +import { GitStatusFile, IGitStatus } from '../gitService'; import { CommandQuickPickItem, getQuickPickIgnoreFocusOut, OpenFileCommandQuickPickItem } from './quickPicks'; import * as path from 'path'; export class OpenStatusFileCommandQuickPickItem extends OpenFileCommandQuickPickItem { - constructor(status: GitFileStatusItem, item?: QuickPickItem) { + constructor(status: GitStatusFile, item?: QuickPickItem) { const uri = Uri.file(path.resolve(status.repoPath, status.fileName)); - const icon = getGitStatusIcon(status.status); + const icon = status.getIcon(); let directory = path.dirname(status.fileName); if (!directory || directory === '.') { @@ -26,7 +26,7 @@ export class OpenStatusFileCommandQuickPickItem extends OpenFileCommandQuickPick export class OpenStatusFilesCommandQuickPickItem extends CommandQuickPickItem { - constructor(statuses: GitFileStatusItem[], item?: QuickPickItem) { + constructor(statuses: GitStatusFile[], item?: QuickPickItem) { const repoPath = statuses.length && statuses[0].repoPath; const uris = statuses.map(_ => Uri.file(path.resolve(repoPath, _.fileName))); @@ -40,50 +40,71 @@ export class OpenStatusFilesCommandQuickPickItem extends CommandQuickPickItem { export class RepoStatusQuickPick { - static async show(statuses: GitFileStatusItem[], goBackCommand?: CommandQuickPickItem): Promise { + static async show(status: IGitStatus, goBackCommand?: CommandQuickPickItem): Promise { // Sort the status by staged and then filename - statuses.sort((a, b) => (a.staged ? -1 : 1) - (b.staged ? -1 : 1) || a.fileName.localeCompare(b.fileName)); + const files = status.files; + files.sort((a, b) => (a.staged ? -1 : 1) - (b.staged ? -1 : 1) || a.fileName.localeCompare(b.fileName)); - const items = Array.from(Iterables.map(statuses, s => new OpenStatusFileCommandQuickPickItem(s))) as (OpenStatusFileCommandQuickPickItem | OpenStatusFilesCommandQuickPickItem | CommandQuickPickItem)[]; + const added = files.filter(_ => _.status === 'A' || _.status === '?'); + const deleted = files.filter(_ => _.status === 'D'); + const changed = files.filter(_ => _.status !== 'A' && _.status !== '?' && _.status !== 'D'); - if (statuses.some(_ => _.staged)) { + const hasStaged = files.some(_ => _.staged); + + let stagedStatus = ''; + let unstagedStatus = ''; + if (hasStaged) { + const stagedAdded = added.filter(_ => _.staged).length; + const stagedChanged = changed.filter(_ => _.staged).length; + const stagedDeleted = deleted.filter(_ => _.staged).length; + + stagedStatus = `+${stagedAdded} ~${stagedChanged} -${stagedDeleted}`; + unstagedStatus = `+${added.length - stagedAdded} ~${changed.length - stagedChanged} -${deleted.length - stagedDeleted}`; + } + else { + unstagedStatus = `+${added.length} ~${changed.length} -${deleted.length}`; + } + + const items = Array.from(Iterables.map(files, s => new OpenStatusFileCommandQuickPickItem(s))) as (OpenStatusFileCommandQuickPickItem | OpenStatusFilesCommandQuickPickItem | CommandQuickPickItem)[]; + + if (hasStaged) { let index = 0; - const unstagedIndex = statuses.findIndex(_ => !_.staged); + const unstagedIndex = files.findIndex(_ => !_.staged); if (unstagedIndex > -1) { items.splice(unstagedIndex, 0, new CommandQuickPickItem({ label: `Unstaged Files`, - description: undefined + description: unstagedStatus }, Commands.ShowQuickRepoStatus, [goBackCommand])); - items.splice(index++, 0, new OpenStatusFilesCommandQuickPickItem(statuses.filter(_ => _.status !== 'D' && _.staged), { - label: `$(file-symlink-file) Open Staged Files`, + items.splice(unstagedIndex, 0, new OpenStatusFilesCommandQuickPickItem(files.filter(_ => _.status !== 'D' && _.staged), { + label: `\u00a0\u00a0\u00a0\u00a0 $(file-symlink-file) Open Staged Files`, description: undefined })); - items.splice(index++, 0, new OpenStatusFilesCommandQuickPickItem(statuses.filter(_ => _.status !== 'D' && !_.staged), { - label: `$(file-symlink-file) Open Unstaged Files`, + items.push(new OpenStatusFilesCommandQuickPickItem(files.filter(_ => _.status !== 'D' && !_.staged), { + label: `\u00a0\u00a0\u00a0\u00a0 $(file-symlink-file) Open Unstaged Files`, description: undefined })); } items.splice(index++, 0, new CommandQuickPickItem({ label: `Staged Files`, - description: undefined + description: stagedStatus }, Commands.ShowQuickRepoStatus, [goBackCommand])); } - else if (statuses.some(_ => !_.staged)) { + else if (files.some(_ => !_.staged)) { items.splice(0, 0, new CommandQuickPickItem({ label: `Unstaged Files`, - description: undefined + description: unstagedStatus }, Commands.ShowQuickRepoStatus, [goBackCommand])); } - if (statuses.length) { + if (files.length) { items.splice(0, 0, new CommandQuickPickItem({ label: '$(x) Close Unchanged Files', description: null }, Commands.CloseUnchangedFiles)); - items.splice(0, 0, new OpenStatusFilesCommandQuickPickItem(statuses.filter(_ => _.status !== 'D'))); + items.splice(0, 0, new OpenStatusFilesCommandQuickPickItem(files.filter(_ => _.status !== 'D'))); } if (goBackCommand) { @@ -92,9 +113,18 @@ export class RepoStatusQuickPick { const scope = await Keyboard.instance.beginScope({ left: goBackCommand }); + let syncStatus = ''; + if (status.upstream) { + syncStatus = status.state.ahead || status.state.behind + ? `..${status.upstream} ${status.state.behind}\u2193 ${status.state.ahead}\u2191` + : `..${status.upstream} \u27F3`; + } + else { + } + const pick = await window.showQuickPick(items, { matchOnDescription: true, - placeHolder: statuses.length ? 'Repository has changes' : 'Repository has no changes', + placeHolder: `${status.branch}${syncStatus}`, ignoreFocusOut: getQuickPickIgnoreFocusOut(), onDidSelectItem: (item: QuickPickItem) => { scope.setKeyCommand('right', item);