diff --git a/package.json b/package.json index 03e1682..d96768b 100644 --- a/package.json +++ b/package.json @@ -1054,6 +1054,11 @@ "command": "gitlens.gitExplorer.openChangedFileRevisions", "title": "Open Revisions", "category": "GitLens" + }, + { + "command": "gitlens.gitExplorer.applyChanges", + "title": "Apply Changes", + "category": "GitLens" } ], "menus": { @@ -1245,6 +1250,10 @@ { "command": "gitlens.gitExplorer.openChangedFileRevisions", "when": "false" + }, + { + "command": "gitlens.gitExplorer.applyChanges", + "when": "false" } ], "editor/context": [ @@ -1411,6 +1420,11 @@ "command": "gitlens.closeUnchangedFiles", "when": "gitlens:enabled", "group": "1_gitlens@2" + }, + { + "command": "gitlens.stashSave", + "when": "gitlens:enabled", + "group": "2_gitlens@1" } ], "scm/resourceState/context": [ @@ -1428,6 +1442,11 @@ "command": "gitlens.showQuickFileHistory", "when": "gitlens:enabled", "group": "1_gitlens_1@1" + }, + { + "command": "gitlens.stashSave", + "when": "gitlens:enabled", + "group": "2_gitlens@1" } ], "view/title": [ @@ -1519,14 +1538,19 @@ "group": "3_gitlens@2" }, { + "command": "gitlens.gitExplorer.applyChanges", + "when": "gitlens:enabled && view == gitlens.gitExplorer && viewItem == gitlens:commit-file", + "group": "4_gitlens@1" + }, + { "command": "gitlens.showQuickFileHistory", "when": "gitlens:isTracked && view == gitlens.gitExplorer && viewItem == gitlens:commit-file && gitlens:gitExplorer:view == repository", - "group": "4_gitlens@1" + "group": "5_gitlens@1" }, { "command": "gitlens.showQuickCommitFileDetails", "when": "gitlens:enabled && view == gitlens.gitExplorer && viewItem == gitlens:commit-file", - "group": "4_gitlens@2" + "group": "5_gitlens@2" }, { "command": "gitlens.stashApply", @@ -1579,9 +1603,14 @@ "group": "3_gitlens@1" }, { + "command": "gitlens.gitExplorer.applyChanges", + "when": "gitlens:enabled && view == gitlens.gitExplorer && viewItem == gitlens:stash-file", + "group": "4_gitlens@1" + }, + { "command": "gitlens.showQuickFileHistory", "when": "gitlens:isTracked && view == gitlens.gitExplorer && viewItem == gitlens:stash-file", - "group": "4_gitlens@1" + "group": "5_gitlens@1" } ] }, diff --git a/src/commands/stashSave.ts b/src/commands/stashSave.ts index cb78d6b..2623b7d 100644 --- a/src/commands/stashSave.ts +++ b/src/commands/stashSave.ts @@ -1,6 +1,7 @@ 'use strict'; -import { InputBoxOptions, window } from 'vscode'; +import { InputBoxOptions, Uri, window } from 'vscode'; import { GitService } from '../gitService'; +import { CommandContext } from '../commands'; import { Command, Commands } from './common'; import { Logger } from '../logger'; import { CommandQuickPickItem } from '../quickPicks'; @@ -8,6 +9,7 @@ import { CommandQuickPickItem } from '../quickPicks'; export interface StashSaveCommandArgs { message?: string; unstagedOnly?: boolean; + uris?: Uri[]; goBackCommand?: CommandQuickPickItem; } @@ -18,6 +20,22 @@ export class StashSaveCommand extends Command { super(Commands.StashSave); } + protected async preExecute(context: CommandContext, args: StashSaveCommandArgs = {}): Promise { + if (context.type === 'scm-states') { + args = { ...args }; + args.uris = context.scmResourceStates.map(s => s.resourceUri); + return this.execute(args); + } + + if (context.type === 'scm-groups') { + args = { ...args }; + args.uris = context.scmResourceGroups.reduce((a, b) => a.concat(b.resourceStates.map(s => s.resourceUri)), []); + return this.execute(args); + } + + return this.execute(args); + } + async execute(args: StashSaveCommandArgs = { unstagedOnly: false }) { if (!this.git.repoPath) return undefined; @@ -35,7 +53,7 @@ export class StashSaveCommand extends Command { if (args.message === undefined) return args.goBackCommand === undefined ? undefined : args.goBackCommand.execute(); } - return await this.git.stashSave(this.git.repoPath, args.message, args.unstagedOnly); + return await this.git.stashSave(this.git.repoPath, args.message, args.uris); } catch (ex) { Logger.error(ex, 'StashSaveCommand'); diff --git a/src/git/git.ts b/src/git/git.ts index 98ef823..0251662 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -177,6 +177,12 @@ export class Git { return gitCommand({ cwd: repoPath }, ...params); } + static checkout(repoPath: string, fileName: string, sha: string) { + const [file, root] = Git.splitPath(fileName, repoPath); + + return gitCommand({ cwd: root }, `checkout`, sha, `--`, file); + } + static async config_get(key: string, repoPath?: string) { try { return await gitCommand({ cwd: repoPath || '' }, `config`, `--get`, key); @@ -322,11 +328,18 @@ export class Git { return gitCommand({ cwd: repoPath }, ...defaultStashParams); } - static stash_save(repoPath: string, message?: string, unstagedOnly: boolean = false) { - const params = [`stash`, `save`, `--include-untracked`]; - if (unstagedOnly) { - params.push(`--keep-index`); + static stash_push(repoPath: string, pathspecs: string[], message?: string) { + const params = [`stash`, `push`]; + if (message) { + params.push(`-m`); + params.push(message); } + params.splice(params.length, 0, `--`, ...pathspecs); + return gitCommand({ cwd: repoPath }, ...params); + } + + static stash_save(repoPath: string, message?: string) { + const params = [`stash`, `save`, `--include-untracked`]; if (message) { params.push(message); } diff --git a/src/gitService.ts b/src/gitService.ts index f871b10..9252e07 100644 --- a/src/gitService.ts +++ b/src/gitService.ts @@ -1,1129 +1,1138 @@ -'use strict'; -import { Functions, Iterables, Objects } from './system'; -import { Disposable, Event, EventEmitter, FileSystemWatcher, Location, Position, Range, TextDocument, TextDocumentChangeEvent, TextEditor, Uri, workspace } from 'vscode'; -import { IConfig } from './configuration'; -import { DocumentSchemes, ExtensionKey, GlyphChars } from './constants'; -import { Git, GitAuthor, GitBlame, GitBlameCommit, GitBlameLine, GitBlameLines, GitBlameParser, GitBranch, GitCommit, GitDiff, GitDiffChunkLine, GitDiffParser, GitLog, GitLogCommit, GitLogParser, GitRemote, GitStash, GitStashParser, GitStatus, GitStatusFile, GitStatusParser, IGit, setDefaultEncoding } from './git/git'; -import { GitUri, IGitCommitInfo, IGitUriData } from './git/gitUri'; -import { Logger } from './logger'; -import * as fs from 'fs'; -import * as ignore from 'ignore'; -import * as moment from 'moment'; -import * as path from 'path'; - -export { GitUri, IGitCommitInfo }; -export * from './git/models/models'; -export * from './git/formatters/commit'; -export * from './git/formatters/status'; -export { getNameFromRemoteResource, RemoteResource, RemoteProvider } from './git/remotes/provider'; -export * from './git/gitContextTracker'; - -class UriCacheEntry { - - constructor(public uri: GitUri) { } -} - -class GitCacheEntry { - - private cache: Map = new Map(); - - constructor(public key: string) { } - - get hasErrors(): boolean { - return Iterables.every(this.cache.values(), _ => _.errorMessage !== undefined); - } - - get(key: string): T | undefined { - return this.cache.get(key) as T; - } - - set(key: string, value: T) { - this.cache.set(key, value); - } -} - -interface CachedItem { - item: Promise; - errorMessage?: string; -} - -interface CachedBlame extends CachedItem { } -interface CachedDiff extends CachedItem { } -interface CachedLog extends CachedItem { } - -enum RemoveCacheReason { - DocumentClosed, - DocumentSaved -} - -export type GitRepoSearchBy = 'author' | 'files' | 'message' | 'sha'; -export const GitRepoSearchBy = { - Author: 'author' as GitRepoSearchBy, - Files: 'files' as GitRepoSearchBy, - Message: 'message' as GitRepoSearchBy, - Sha: 'sha' as GitRepoSearchBy -}; - -type RepoChangedReasons = 'stash' | 'unknown'; - -export class GitService extends Disposable { - - private _onDidBlameFail = new EventEmitter(); - get onDidBlameFail(): Event { - return this._onDidBlameFail.event; - } - - private _onDidChangeGitCache = new EventEmitter(); - get onDidChangeGitCache(): Event { - return this._onDidChangeGitCache.event; - } - - private _onDidChangeRepo = new EventEmitter(); - get onDidChangeRepo(): Event { - return this._onDidChangeRepo.event; - } - - private _gitCache: Map; - private _remotesCache: Map; - private _cacheDisposable: Disposable | undefined; - private _uriCache: Map; - - config: IConfig; - private _disposable: Disposable | undefined; - private _gitignore: Promise; - private _repoWatcher: FileSystemWatcher | undefined; - private _stashWatcher: FileSystemWatcher | undefined; - - static EmptyPromise: Promise = Promise.resolve(undefined); - - constructor(public repoPath: string) { - super(() => this.dispose()); - - this._gitCache = new Map(); - this._remotesCache = new Map(); - this._uriCache = new Map(); - - this._onConfigurationChanged(); - - const subscriptions: Disposable[] = []; - - subscriptions.push(workspace.onDidChangeConfiguration(this._onConfigurationChanged, this)); - - this._disposable = Disposable.from(...subscriptions); - } - - dispose() { - this._disposable && this._disposable.dispose(); - - this._cacheDisposable && this._cacheDisposable.dispose(); - this._cacheDisposable = undefined; - - this._repoWatcher && this._repoWatcher.dispose(); - this._repoWatcher = undefined; - - this._stashWatcher && this._stashWatcher.dispose(); - this._stashWatcher = undefined; - - this._gitCache.clear(); - this._remotesCache.clear(); - this._uriCache.clear(); - } - - public get UseCaching() { - return this.config.advanced.caching.enabled; - } - - private _onConfigurationChanged() { - const encoding = workspace.getConfiguration('files').get('encoding', 'utf8'); - setDefaultEncoding(encoding); - - const cfg = workspace.getConfiguration().get(ExtensionKey)!; - - if (!Objects.areEquivalent(cfg.advanced, this.config && this.config.advanced)) { - if (cfg.advanced.caching.enabled) { - this._cacheDisposable && this._cacheDisposable.dispose(); - - this._repoWatcher = this._repoWatcher || workspace.createFileSystemWatcher('**/.git/index', true, false, true); - this._stashWatcher = this._stashWatcher || workspace.createFileSystemWatcher('**/.git/refs/stash', true, false, true); - - const disposables: Disposable[] = []; - - disposables.push(workspace.onDidCloseTextDocument(d => this._removeCachedEntry(d, RemoveCacheReason.DocumentClosed))); - disposables.push(workspace.onDidChangeTextDocument(this._onTextDocumentChanged, this)); - disposables.push(workspace.onDidSaveTextDocument(d => this._removeCachedEntry(d, RemoveCacheReason.DocumentSaved))); - disposables.push(this._repoWatcher.onDidChange(this._onRepoChanged, this)); - disposables.push(this._stashWatcher.onDidChange(this._onStashChanged, this)); - - this._cacheDisposable = Disposable.from(...disposables); - } - else { - this._cacheDisposable && this._cacheDisposable.dispose(); - this._cacheDisposable = undefined; - - this._repoWatcher && this._repoWatcher.dispose(); - this._repoWatcher = undefined; - - this._stashWatcher && this._stashWatcher.dispose(); - this._stashWatcher = undefined; - - this._gitCache.clear(); - this._remotesCache.clear(); - } - - this._gitignore = new Promise((resolve, reject) => { - if (!cfg.advanced.gitignore.enabled) { - resolve(undefined); - return; - } - - const gitignorePath = path.join(this.repoPath, '.gitignore'); - fs.exists(gitignorePath, e => { - if (e) { - fs.readFile(gitignorePath, 'utf8', (err, data) => { - if (!err) { - resolve(ignore().add(data)); - return; - } - resolve(undefined); - }); - return; - } - resolve(undefined); - }); - }); - } - - this.config = cfg; - } - - private _onTextDocumentChanged(e: TextDocumentChangeEvent) { - if (!this.UseCaching) return; - if (e.document.uri.scheme !== DocumentSchemes.File) return; - - // TODO: Rework this once https://github.com/Microsoft/vscode/issues/27231 is released in v1.13 - // We have to defer because isDirty is not reliable inside this event - setTimeout(() => { - // If the document is dirty all is fine, we'll just wait for the save before clearing our cache - if (e.document.isDirty) return; - - // If the document isn't dirty, it is very likely this event was triggered by an outside edit of this document - // Which means the document has been reloaded and we should clear our cache for it - this._removeCachedEntry(e.document, RemoveCacheReason.DocumentSaved); - }, 1); - } - - private _onRepoChanged() { - this._gitCache.clear(); - - this._fireRepoChange(); - this._fireGitCacheChange(); - } - - private _onStashChanged() { - this._fireRepoChange('stash'); - } - - private _fireGitCacheChangeDebounced: (() => void) | undefined = undefined; - - private _fireGitCacheChange() { - if (this._fireGitCacheChangeDebounced === undefined) { - this._fireGitCacheChangeDebounced = Functions.debounce(this._fireGitCacheChangeCore, 50); - } - - return this._fireGitCacheChangeDebounced(); - } - - private _fireGitCacheChangeCore() { - this._onDidChangeGitCache.fire(); - } - - private _fireRepoChangeDebounced: (() => void) | undefined = undefined; - private _repoChangedReasons: RepoChangedReasons[] = []; - - private _fireRepoChange(reason: RepoChangedReasons = 'unknown') { - if (this._fireRepoChangeDebounced === undefined) { - this._fireRepoChangeDebounced = Functions.debounce(this._fireRepoChangeCore, 50); - } - - this._repoChangedReasons.push(reason); - return this._fireRepoChangeDebounced(); - } - - private _fireRepoChangeCore() { - const reasons = this._repoChangedReasons; - this._repoChangedReasons = []; - - this._onDidChangeRepo.fire(reasons); - } - - private _removeCachedEntry(document: TextDocument, reason: RemoveCacheReason) { - if (!this.UseCaching) return; - if (document.uri.scheme !== DocumentSchemes.File) return; - - const cacheKey = this.getCacheEntryKey(document.uri); - - if (reason === RemoveCacheReason.DocumentSaved) { - // Don't remove broken blame on save (since otherwise we'll have to run the broken blame again) - const entry = this._gitCache.get(cacheKey); - if (entry && entry.hasErrors) return; - } - - if (this._gitCache.delete(cacheKey)) { - Logger.log(`Clear cache entry for '${cacheKey}', reason=${RemoveCacheReason[reason]}`); - - if (reason === RemoveCacheReason.DocumentSaved) { - this._fireGitCacheChange(); - } - } - } - - private async _fileExists(repoPath: string, fileName: string): Promise { - return await new Promise((resolve, reject) => fs.exists(path.resolve(repoPath, fileName), resolve)); - } - - async findNextCommit(repoPath: string, fileName: string, sha?: string): Promise { - let log = await this.getLogForFile(repoPath, fileName, sha, { maxCount: 1, reverse: true }); - let commit = log && Iterables.first(log.commits.values()); - if (commit) return commit; - - const nextFileName = await this.findNextFileName(repoPath, fileName, sha); - if (nextFileName) { - log = await this.getLogForFile(repoPath, nextFileName, sha, { maxCount: 1, reverse: true }); - commit = log && Iterables.first(log.commits.values()); - } - - return commit; - } - - async findNextFileName(repoPath: string | undefined, fileName: string, sha?: string): Promise { - [fileName, repoPath] = Git.splitPath(fileName, repoPath); - - return (await this._fileExists(repoPath, fileName)) - ? fileName - : await this._findNextFileName(repoPath, fileName, sha); - } - - async _findNextFileName(repoPath: string, fileName: string, sha?: string): Promise { - if (sha === undefined) { - // Get the most recent commit for this file name - const c = await this.getLogCommit(repoPath, fileName); - if (c === undefined) return undefined; - - sha = c.sha; - } - - // Get the full commit (so we can see if there are any matching renames in the file statuses) - const log = await this.getLogForRepo(repoPath, sha, 1); - if (log === undefined) return undefined; - - const c = Iterables.first(log.commits.values()); - const status = c.fileStatuses.find(_ => _.originalFileName === fileName); - if (status === undefined) return undefined; - - return status.fileName; - } - - async findWorkingFileName(commit: GitCommit): Promise; - async findWorkingFileName(repoPath: string | undefined, fileName: string): Promise; - async findWorkingFileName(commitOrRepoPath: GitCommit | string | undefined, fileName?: string): Promise { - let repoPath: string | undefined; - if (commitOrRepoPath === undefined || typeof commitOrRepoPath === 'string') { - repoPath = commitOrRepoPath; - if (fileName === undefined) throw new Error('Invalid fileName'); - - [fileName] = Git.splitPath(fileName, repoPath); - } - else { - const c = commitOrRepoPath; - repoPath = c.repoPath; - if (c.workingFileName && await this._fileExists(repoPath, c.workingFileName)) return c.workingFileName; - fileName = c.fileName; - } - - while (true) { - if (await this._fileExists(repoPath!, fileName)) return fileName; - - fileName = await this._findNextFileName(repoPath!, fileName); - if (fileName === undefined) return undefined; - } - } - - public async getBlameability(uri: GitUri): Promise { - if (!this.UseCaching) return await this.isTracked(uri); - - const cacheKey = this.getCacheEntryKey(uri); - const entry = this._gitCache.get(cacheKey); - if (entry === undefined) return await this.isTracked(uri); - - return !entry.hasErrors; - } - - async getBlameForFile(uri: GitUri): Promise { - let key = 'blame'; - if (uri.sha !== undefined) { - key += `:${uri.sha}`; - } - - let entry: GitCacheEntry | undefined; - if (this.UseCaching) { - const cacheKey = this.getCacheEntryKey(uri); - entry = this._gitCache.get(cacheKey); - - if (entry !== undefined) { - const cachedBlame = entry.get(key); - if (cachedBlame !== undefined) { - Logger.log(`Cached(${key}): getBlameForFile('${uri.repoPath}', '${uri.fsPath}', ${uri.sha})`); - return cachedBlame.item; - } - } - - Logger.log(`Not Cached(${key}): getBlameForFile('${uri.repoPath}', '${uri.fsPath}', ${uri.sha})`); - - if (entry === undefined) { - entry = new GitCacheEntry(cacheKey); - this._gitCache.set(entry.key, entry); - } - } - else { - Logger.log(`getBlameForFile('${uri.repoPath}', '${uri.fsPath}', ${uri.sha})`); - } - - const promise = this._getBlameForFile(uri, entry, key); - - if (entry) { - Logger.log(`Add blame cache for '${entry.key}:${key}'`); - - entry.set(key, { - item: promise - } as CachedBlame); - } - - return promise; - } - - private async _getBlameForFile(uri: GitUri, entry: GitCacheEntry | undefined, key: string): Promise { - const [file, root] = Git.splitPath(uri.fsPath, uri.repoPath, false); - - const ignore = await this._gitignore; - if (ignore && !ignore.filter([file]).length) { - Logger.log(`Skipping blame; '${uri.fsPath}' is gitignored`); - if (entry && entry.key) { - this._onDidBlameFail.fire(entry.key); - } - return await GitService.EmptyPromise as GitBlame; - } - - try { - const data = await Git.blame(root, file, uri.sha); - const blame = GitBlameParser.parse(data, root, file); - return blame; - } - 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}:${key}'`); - - entry.set(key, { - item: GitService.EmptyPromise, - errorMessage: msg - } as CachedBlame); - - this._onDidBlameFail.fire(entry.key); - return await GitService.EmptyPromise as GitBlame; - } - - return undefined; - } - } - - async getBlameForLine(uri: GitUri, line: number): Promise { - Logger.log(`getBlameForLine('${uri.repoPath}', '${uri.fsPath}', ${line}, ${uri.sha})`); - - if (this.UseCaching) { - const blame = await this.getBlameForFile(uri); - if (blame === undefined) return undefined; - - let blameLine = blame.lines[line]; - if (blameLine === undefined) { - if (blame.lines.length !== line) return undefined; - blameLine = blame.lines[line - 1]; - } - - const commit = blame.commits.get(blameLine.sha); - if (commit === undefined) return undefined; - - return { - author: Object.assign({}, blame.authors.get(commit.author), { lineCount: commit.lines.length }), - commit: commit, - line: blameLine - } as GitBlameLine; - } - - const fileName = uri.fsPath; - - try { - const data = await Git.blame(uri.repoPath, fileName, uri.sha, line + 1, line + 1); - const blame = GitBlameParser.parse(data, uri.repoPath, fileName); - if (blame === undefined) return undefined; - - const commit = Iterables.first(blame.commits.values()); - if (uri.repoPath) { - commit.repoPath = uri.repoPath; - } - return { - author: Iterables.first(blame.authors.values()), - commit: commit, - line: blame.lines[line] - } as GitBlameLine; - } - catch (ex) { - return undefined; - } - } - - async getBlameForRange(uri: GitUri, range: Range): Promise { - Logger.log(`getBlameForRange('${uri.repoPath}', '${uri.fsPath}', [${range.start.line}, ${range.end.line}], ${uri.sha})`); - - const blame = await this.getBlameForFile(uri); - if (blame === undefined) return undefined; - - return this.getBlameForRangeSync(blame, uri, range); - } - - getBlameForRangeSync(blame: GitBlame, uri: GitUri, range: Range): GitBlameLines | undefined { - Logger.log(`getBlameForRangeSync('${uri.repoPath}', '${uri.fsPath}', [${range.start.line}, ${range.end.line}], ${uri.sha})`); - - if (blame.lines.length === 0) return Object.assign({ allLines: blame.lines }, blame); - - if (range.start.line === 0 && range.end.line === blame.lines.length - 1) { - return Object.assign({ allLines: blame.lines }, blame); - } - - const lines = blame.lines.slice(range.start.line, range.end.line + 1); - const shas = new Set(lines.map(l => l.sha)); - - const authors: Map = new Map(); - const commits: Map = new Map(); - for (const c of blame.commits.values()) { - if (!shas.has(c.sha)) continue; - - const commit = new GitBlameCommit(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); - - let author = authors.get(commit.author); - if (author === undefined) { - author = { - name: commit.author, - lineCount: 0 - }; - authors.set(author.name, author); - } - - author.lineCount += commit.lines.length; - } - - const sortedAuthors = new Map([...authors.entries()].sort((a, b) => b[1].lineCount - a[1].lineCount)); - - return { - authors: sortedAuthors, - commits: commits, - lines: lines, - allLines: blame.lines - } as GitBlameLines; - } - - async getBlameLocations(uri: GitUri, range: Range, selectedSha?: string, line?: number): Promise { - Logger.log(`getBlameLocations('${uri.repoPath}', '${uri.fsPath}', [${range.start.line}, ${range.end.line}], ${uri.sha})`); - - const blame = await this.getBlameForRange(uri, range); - if (blame === undefined) return undefined; - - const commitCount = blame.commits.size; - const dateFormat = this.config.defaultDateFormat === null ? 'MMMM Do, YYYY h:MMa' : this.config.defaultDateFormat; - - const locations: Location[] = []; - Iterables.forEach(blame.commits.values(), (c, i) => { - if (c.isUncommitted) return; - - const decoration = `${GlyphChars.ArrowDropRight} ${c.author}, ${moment(c.date).format(dateFormat)}`; - const uri = GitService.toReferenceGitContentUri(c, i + 1, commitCount, c.originalFileName, decoration, dateFormat); - locations.push(new Location(uri, new Position(0, 0))); - if (c.sha === selectedSha) { - locations.push(new Location(uri, new Position((line || 0) + 1, 0))); - } - }); - - return locations; - } - - async getBranch(repoPath: string): Promise { - Logger.log(`getBranch('${repoPath}')`); - - const data = await Git.branch(repoPath, false); - const branches = data.split('\n').filter(_ => !!_).map(_ => new GitBranch(_)); - return branches.find(_ => _.current); - } - - async getBranches(repoPath: string): Promise { - Logger.log(`getBranches('${repoPath}')`); - - const data = await Git.branch(repoPath, true); - const branches = data.split('\n').filter(_ => !!_).map(_ => new GitBranch(_)); - return branches; - } - - getCacheEntryKey(fileName: string): string; - getCacheEntryKey(uri: Uri): string; - getCacheEntryKey(fileNameOrUri: string | Uri): string { - return Git.normalizePath(typeof fileNameOrUri === 'string' ? fileNameOrUri : fileNameOrUri.fsPath).toLowerCase(); - } - - async getConfig(key: string, repoPath?: string): Promise { - Logger.log(`getConfig('${key}', '${repoPath}')`); - - return await Git.config_get(key, repoPath); - } - - getGitUriForFile(uri: Uri) { - const cacheKey = this.getCacheEntryKey(uri); - const entry = this._uriCache.get(cacheKey); - return entry && entry.uri; - } - - async getDiffForFile(uri: GitUri, sha1?: string, sha2?: string): Promise { - if (sha1 !== undefined && sha2 === undefined && uri.sha !== undefined) { - sha2 = uri.sha; - } - - let key = 'diff'; - if (sha1 !== undefined) { - key += `:${sha1}`; - } - if (sha2 !== undefined) { - key += `:${sha2}`; - } - - let entry: GitCacheEntry | undefined; - if (this.UseCaching) { - const cacheKey = this.getCacheEntryKey(uri); - entry = this._gitCache.get(cacheKey); - - if (entry !== undefined) { - const cachedDiff = entry.get(key); - if (cachedDiff !== undefined) { - Logger.log(`Cached(${key}): getDiffForFile('${uri.repoPath}', '${uri.fsPath}', ${sha1}, ${sha2})`); - return cachedDiff.item; - } - } - - Logger.log(`Not Cached(${key}): getDiffForFile('${uri.repoPath}', '${uri.fsPath}', ${sha1}, ${sha2})`); - - if (entry === undefined) { - entry = new GitCacheEntry(cacheKey); - this._gitCache.set(entry.key, entry); - } - } - else { - Logger.log(`getDiffForFile('${uri.repoPath}', '${uri.fsPath}', ${sha1}, ${sha2})`); - } - - const promise = this._getDiffForFile(uri.repoPath, uri.fsPath, sha1, sha2, entry, key); - - if (entry) { - Logger.log(`Add log cache for '${entry.key}:${key}'`); - - entry.set(key, { - item: promise - } as CachedDiff); - } - - return promise; - } - - private async _getDiffForFile(repoPath: string | undefined, fileName: string, sha1: string | undefined, sha2: string | undefined, entry: GitCacheEntry | undefined, key: string): Promise { - const [file, root] = Git.splitPath(fileName, repoPath, false); - - try { - const data = await Git.diff(root, file, sha1, sha2); - const diff = GitDiffParser.parse(data); - return diff; - } - catch (ex) { - // Trap and cache expected diff errors - if (entry) { - const msg = ex && ex.toString(); - Logger.log(`Replace diff cache with empty promise for '${entry.key}:${key}'`); - - entry.set(key, { - item: GitService.EmptyPromise, - errorMessage: msg - } as CachedDiff); - - return await GitService.EmptyPromise as GitDiff; - } - - return undefined; - } - } - - async getDiffForLine(uri: GitUri, line: number, sha1?: string, sha2?: string): Promise { - try { - const diff = await this.getDiffForFile(uri, sha1, sha2); - if (diff === undefined) return undefined; - - const chunk = diff.chunks.find(_ => _.currentPosition.start <= line && _.currentPosition.end >= line); - if (chunk === undefined) return undefined; - - return chunk.lines[line - chunk.currentPosition.start + 1]; - } - catch (ex) { - return undefined; - } - } - - async getLogCommit(repoPath: string | undefined, fileName: string, options?: { firstIfMissing?: boolean, previous?: boolean }): Promise; - async getLogCommit(repoPath: string | undefined, fileName: string, sha: string | undefined, options?: { firstIfMissing?: boolean, previous?: boolean }): Promise; - async getLogCommit(repoPath: string | undefined, fileName: string, shaOrOptions?: string | undefined | { firstIfMissing?: boolean, previous?: boolean }, options?: { firstIfMissing?: boolean, previous?: boolean }): Promise { - let sha: string | undefined = undefined; - if (typeof shaOrOptions === 'string') { - sha = shaOrOptions; - } - else if (options === undefined) { - options = shaOrOptions; - } - - options = options || {}; - - const log = await this.getLogForFile(repoPath, fileName, sha, { maxCount: options.previous ? 2 : 1 }); - if (log === undefined) return undefined; - - const commit = sha && log.commits.get(sha); - if (commit === undefined && sha && !options.firstIfMissing) return undefined; - - return commit || Iterables.first(log.commits.values()); - } - - async getLogForRepo(repoPath: string, sha?: string, maxCount?: number, reverse: boolean = false): Promise { - Logger.log(`getLogForRepo('${repoPath}', ${sha}, ${maxCount})`); - - if (maxCount == null) { - maxCount = this.config.advanced.maxQuickHistory || 0; - } - - try { - const data = await Git.log(repoPath, sha, maxCount, reverse); - const log = GitLogParser.parse(data, 'branch', repoPath, undefined, sha, maxCount, reverse, undefined); - return log; - } - catch (ex) { - return undefined; - } - } - - async getLogForRepoSearch(repoPath: string, search: string, searchBy: GitRepoSearchBy, maxCount?: number): Promise { - Logger.log(`getLogForRepoSearch('${repoPath}', ${search}, ${searchBy}, ${maxCount})`); - - if (maxCount == null) { - maxCount = this.config.advanced.maxQuickHistory || 0; - } - - let searchArgs: string[] | undefined = undefined; - switch (searchBy) { - case GitRepoSearchBy.Author: - searchArgs = [`--author=${search}`]; - break; - case GitRepoSearchBy.Files: - searchArgs = [`--`, `${search}`]; - break; - case GitRepoSearchBy.Message: - searchArgs = [`--grep=${search}`]; - break; - case GitRepoSearchBy.Sha: - searchArgs = [search]; - maxCount = 1; - break; - } - - try { - const data = await Git.log_search(repoPath, searchArgs, maxCount); - const log = GitLogParser.parse(data, 'branch', repoPath, undefined, undefined, maxCount, false, undefined); - return log; - } - catch (ex) { - return undefined; - } - } - - async getLogForFile(repoPath: string | undefined, fileName: string, sha?: string, options: { maxCount?: number, range?: Range, reverse?: boolean, skipMerges?: boolean } = {}): Promise { - options = { ...{ reverse: false, skipMerges: false }, ...options }; - - let key = 'log'; - if (sha !== undefined) { - key += `:${sha}`; - } - if (options.maxCount !== undefined) { - key += `:n${options.maxCount}`; - } - - let entry: GitCacheEntry | undefined; - if (this.UseCaching && options.range === undefined && !options.reverse) { - const cacheKey = this.getCacheEntryKey(fileName); - entry = this._gitCache.get(cacheKey); - - if (entry !== undefined) { - const cachedLog = entry.get(key); - if (cachedLog !== undefined) { - Logger.log(`Cached(${key}): getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, undefined, false)`); - return cachedLog.item; - } - - if (key !== 'log') { - // Since we are looking for partial log, see if we have the log of the whole file - const cachedLog = entry.get('log'); - if (cachedLog !== undefined) { - if (sha === undefined) { - Logger.log(`Cached(~${key}): getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, undefined, false)`); - return cachedLog.item; - } - - Logger.log(`? Cache(${key}): getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, undefined, false)`); - const log = await cachedLog.item; - if (log !== undefined && log.commits.has(sha)) { - Logger.log(`Cached(${key}): getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, undefined, false)`); - return cachedLog.item; - } - } - } - } - - Logger.log(`Not Cached(${key}): getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, undefined, false)`); - - if (entry === undefined) { - entry = new GitCacheEntry(cacheKey); - this._gitCache.set(entry.key, entry); - } - } - else { - Logger.log(`getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, ${options.range && `[${options.range.start.line}, ${options.range.end.line}]`}, ${options.reverse})`); - } - - const promise = this._getLogForFile(repoPath, fileName, sha, options, entry, key); - - if (entry) { - Logger.log(`Add log cache for '${entry.key}:${key}'`); - - entry.set(key, { - item: promise - } as CachedLog); - } - - return promise; - } - - private async _getLogForFile(repoPath: string | undefined, fileName: string, sha: string | undefined, options: { maxCount?: number, range?: Range, reverse?: boolean, skipMerges?: boolean }, entry: GitCacheEntry | undefined, key: string): Promise { - const [file, root] = Git.splitPath(fileName, repoPath, false); - - const ignore = await this._gitignore; - if (ignore && !ignore.filter([file]).length) { - Logger.log(`Skipping log; '${fileName}' is gitignored`); - return await GitService.EmptyPromise as GitLog; - } - - try { - const { range, ...opts } = options; - const data = await Git.log_file(root, file, sha, { ...opts, ...{ startLine: range && range.start.line + 1, endLine: range && range.end.line + 1 } }); - const log = GitLogParser.parse(data, 'file', root, file, sha, options.maxCount, options.reverse!, range); - return log; - } - 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}:${key}'`); - - entry.set(key, { - item: GitService.EmptyPromise, - errorMessage: msg - } as CachedLog); - - return await GitService.EmptyPromise as GitLog; - } - - return undefined; - } - } - - async getLogLocations(uri: GitUri, selectedSha?: string, line?: number): Promise { - Logger.log(`getLogLocations('${uri.repoPath}', '${uri.fsPath}', ${uri.sha}, ${selectedSha}, ${line})`); - - const log = await this.getLogForFile(uri.repoPath, uri.fsPath, uri.sha); - if (log === undefined) return undefined; - - const commitCount = log.commits.size; - const dateFormat = this.config.defaultDateFormat === null ? 'MMMM Do, YYYY h:MMa' : this.config.defaultDateFormat; - - const locations: Location[] = []; - Iterables.forEach(log.commits.values(), (c, i) => { - if (c.isUncommitted) return; - - const decoration = `${GlyphChars.ArrowDropRight} ${c.author}, ${moment(c.date).format(dateFormat)}`; - const uri = GitService.toReferenceGitContentUri(c, i + 1, commitCount, c.originalFileName, decoration, dateFormat); - locations.push(new Location(uri, new Position(0, 0))); - if (c.sha === selectedSha) { - locations.push(new Location(uri, new Position((line || 0) + 1, 0))); - } - }); - - return locations; - } - - async getRemotes(repoPath: string): Promise { - if (!repoPath) return []; - - Logger.log(`getRemotes('${repoPath}')`); - - if (this.UseCaching) { - const remotes = this._remotesCache.get(repoPath); - if (remotes !== undefined) return remotes; - } - - const data = await Git.remote(repoPath); - const remotes = data.split('\n').filter(_ => !!_).map(_ => new GitRemote(_)); - if (this.UseCaching) { - this._remotesCache.set(repoPath, remotes); - } - return remotes; - } - - getRepoPath(cwd: string): Promise { - return GitService.getRepoPath(cwd); - } - - async getRepoPathFromFile(fileName: string): Promise { - const log = await this.getLogForFile(undefined, fileName, undefined, { maxCount: 1 }); - if (log === undefined) return undefined; - - return log.repoPath; - } - - async getRepoPathFromUri(uri: Uri | undefined): Promise { - if (!(uri instanceof Uri)) return this.repoPath; - - const repoPath = (await GitUri.fromUri(uri, this)).repoPath; - if (!repoPath) return this.repoPath; - - return repoPath; - } - - async getStashList(repoPath: string): Promise { - Logger.log(`getStash('${repoPath}')`); - - const data = await Git.stash_list(repoPath); - const stash = GitStashParser.parse(data, repoPath); - return stash; - } - - async getStatusForFile(repoPath: string, fileName: string): Promise { - Logger.log(`getStatusForFile('${repoPath}', '${fileName}')`); - - const porcelainVersion = Git.validateVersion(2, 11) ? 2 : 1; - - const data = await Git.status_file(repoPath, fileName, porcelainVersion); - const status = GitStatusParser.parse(data, repoPath, porcelainVersion); - if (status === undefined || !status.files.length) return undefined; - - return status.files[0]; - } - - async getStatusForRepo(repoPath: string): Promise { - Logger.log(`getStatusForRepo('${repoPath}')`); - - const porcelainVersion = Git.validateVersion(2, 11) ? 2 : 1; - - const data = await Git.status(repoPath, porcelainVersion); - const status = GitStatusParser.parse(data, repoPath, porcelainVersion); - return status; - } - - async getVersionedFile(repoPath: string | undefined, fileName: string, sha: string) { - Logger.log(`getVersionedFile('${repoPath}', '${fileName}', ${sha})`); - - const file = await Git.getVersionedFile(repoPath, fileName, sha); - const cacheKey = this.getCacheEntryKey(file); - const entry = new UriCacheEntry(new GitUri(Uri.file(fileName), { sha, repoPath: repoPath!, fileName })); - this._uriCache.set(cacheKey, entry); - return file; - } - - getVersionedFileText(repoPath: string, fileName: string, sha: string) { - Logger.log(`getVersionedFileText('${repoPath}', '${fileName}', ${sha})`); - - return Git.show(repoPath, fileName, sha); - } - - hasGitUriForFile(editor: TextEditor): boolean { - if (editor === undefined || editor.document === undefined || editor.document.uri === undefined) return false; - - const cacheKey = this.getCacheEntryKey(editor.document.uri); - return this._uriCache.has(cacheKey); - } - - isEditorBlameable(editor: TextEditor): boolean { - return (editor.viewColumn !== undefined || this.isTrackable(editor.document.uri) || this.hasGitUriForFile(editor)); - } - - async isFileUncommitted(uri: GitUri): Promise { - Logger.log(`isFileUncommitted('${uri.repoPath}', '${uri.fsPath}')`); - - const status = await this.getStatusForFile(uri.repoPath!, uri.fsPath); - return !!status; - } - - isTrackable(uri: Uri): boolean { - // Logger.log(`isTrackable('${uri.scheme}', '${uri.fsPath}')`); - - return uri.scheme === DocumentSchemes.File || uri.scheme === DocumentSchemes.Git || uri.scheme === DocumentSchemes.GitLensGit; - } - - async isTracked(uri: GitUri): Promise { - if (!this.isTrackable(uri)) return false; - - Logger.log(`isTracked('${uri.fsPath}', '${uri.repoPath}')`); - - const result = await Git.ls_files(uri.repoPath === undefined ? '' : uri.repoPath, uri.fsPath); - return !!result; - } - - openDirectoryDiff(repoPath: string, sha1: string, sha2?: string) { - Logger.log(`openDirectoryDiff('${repoPath}', ${sha1}, ${sha2})`); - - return Git.difftool_dirDiff(repoPath, sha1, sha2); - } - - stashApply(repoPath: string, stashName: string, deleteAfter: boolean = false) { - Logger.log(`stashApply('${repoPath}', ${stashName}, ${deleteAfter})`); - - return Git.stash_apply(repoPath, stashName, deleteAfter); - } - - stashDelete(repoPath: string, stashName: string) { - Logger.log(`stashDelete('${repoPath}', ${stashName}})`); - - return Git.stash_delete(repoPath, stashName); - } - - stashSave(repoPath: string, message?: string, unstagedOnly: boolean = false) { - Logger.log(`stashSave('${repoPath}', ${message}, ${unstagedOnly})`); - - return Git.stash_save(repoPath, message, unstagedOnly); - } - - static getGitPath(gitPath?: string): Promise { - return Git.getGitPath(gitPath); - } - - static getGitVersion(): string { - return Git.gitInfo().version; - } - - static async getRepoPath(cwd: string | undefined): Promise { - const repoPath = await Git.getRepoPath(cwd); - if (!repoPath) return ''; - - return repoPath; - } - - static fromGitContentUri(uri: Uri): IGitUriData { - if (uri.scheme !== DocumentSchemes.GitLensGit) throw new Error(`fromGitUri(uri=${uri}) invalid scheme`); - return GitService._fromGitContentUri(uri); - } - - private static _fromGitContentUri(uri: Uri): T { - return JSON.parse(uri.query) as T; - } - - static isSha(sha: string): boolean { - return Git.isSha(sha); - } - - static isUncommitted(sha: string): boolean { - return Git.isUncommitted(sha); - } - - static normalizePath(fileName: string, repoPath?: string): string { - return Git.normalizePath(fileName, repoPath); - } - - static toGitContentUri(sha: string, shortSha: string, fileName: string, repoPath: string, originalFileName?: string): Uri; - static toGitContentUri(commit: GitCommit): Uri; - static toGitContentUri(uri: GitUri): Uri; - static toGitContentUri(shaOrcommitOrUri: string | GitCommit | GitUri, shortSha?: string, fileName?: string, repoPath?: string, originalFileName?: string): Uri { - let data: IGitUriData; - if (typeof shaOrcommitOrUri === 'string') { - data = GitService._toGitUriData({ - sha: shaOrcommitOrUri, - fileName: fileName!, - repoPath: repoPath!, - originalFileName: originalFileName - }); - } - else if (shaOrcommitOrUri instanceof GitCommit) { - data = GitService._toGitUriData(shaOrcommitOrUri, undefined, shaOrcommitOrUri.originalFileName); - fileName = shaOrcommitOrUri.fileName; - shortSha = shaOrcommitOrUri.shortSha; - } - else { - data = GitService._toGitUriData({ - sha: shaOrcommitOrUri.sha!, - fileName: shaOrcommitOrUri.fsPath!, - repoPath: shaOrcommitOrUri.repoPath! - }); - fileName = shaOrcommitOrUri.fsPath; - shortSha = shaOrcommitOrUri.shortSha; - } - - const extension = path.extname(fileName!); - return Uri.parse(`${DocumentSchemes.GitLensGit}:${path.basename(fileName!, extension)}:${shortSha}${extension}?${JSON.stringify(data)}`); - } - - static toReferenceGitContentUri(commit: GitCommit, index: number, commitCount: number, originalFileName: string | undefined, decoration: string, dateFormat: string | null): Uri { - return GitService._toReferenceGitContentUri(commit, DocumentSchemes.GitLensGit, commitCount, GitService._toGitUriData(commit, index, originalFileName, decoration), dateFormat); - } - - private static _toReferenceGitContentUri(commit: GitCommit, scheme: DocumentSchemes, commitCount: number, data: IGitUriData, dateFormat: string | null) { - const pad = (n: number) => ('0000000' + n).slice(-('' + commitCount).length); - const ext = path.extname(data.fileName); - const uriPath = `${path.relative(commit.repoPath, data.fileName.slice(0, -ext.length))}/${commit.shortSha}${ext}`; - - let message = commit.message; - if (message.length > 50) { - message = message.substring(0, 49) + GlyphChars.Ellipsis; - } - - if (dateFormat === null) { - dateFormat = 'MMMM Do, YYYY h:MMa'; - } - - // NOTE: Need to specify an index here, since I can't control the sort order -- just alphabetic or by file location - return Uri.parse(`${scheme}:${pad(data.index || 0)} ${GlyphChars.Dot} ${encodeURIComponent(message)} ${GlyphChars.Dot} ${moment(commit.date).format(dateFormat)} ${GlyphChars.Dot} ${encodeURIComponent(uriPath)}?${JSON.stringify(data)}`); - } - - private static _toGitUriData(commit: IGitUriData, index?: number, originalFileName?: string, decoration?: string): T { - const fileName = Git.normalizePath(path.resolve(commit.repoPath, commit.fileName)); - const data = { repoPath: commit.repoPath, fileName: fileName, sha: commit.sha, index: index } as T; - if (originalFileName) { - data.originalFileName = Git.normalizePath(path.resolve(commit.repoPath, originalFileName)); - } - if (decoration) { - data.decoration = decoration; - } - return data; - } - - static validateGitVersion(major: number, minor: number): boolean { - const [gitMajor, gitMinor] = this.getGitVersion().split('.'); - return (parseInt(gitMajor, 10) >= major && parseInt(gitMinor, 10) >= minor); - } +'use strict'; +import { Functions, Iterables, Objects } from './system'; +import { Disposable, Event, EventEmitter, FileSystemWatcher, Location, Position, Range, TextDocument, TextDocumentChangeEvent, TextEditor, Uri, workspace } from 'vscode'; +import { IConfig } from './configuration'; +import { DocumentSchemes, ExtensionKey, GlyphChars } from './constants'; +import { Git, GitAuthor, GitBlame, GitBlameCommit, GitBlameLine, GitBlameLines, GitBlameParser, GitBranch, GitCommit, GitDiff, GitDiffChunkLine, GitDiffParser, GitLog, GitLogCommit, GitLogParser, GitRemote, GitStash, GitStashParser, GitStatus, GitStatusFile, GitStatusParser, IGit, setDefaultEncoding } from './git/git'; +import { GitUri, IGitCommitInfo, IGitUriData } from './git/gitUri'; +import { Logger } from './logger'; +import * as fs from 'fs'; +import * as ignore from 'ignore'; +import * as moment from 'moment'; +import * as path from 'path'; + +export { GitUri, IGitCommitInfo }; +export * from './git/models/models'; +export * from './git/formatters/commit'; +export * from './git/formatters/status'; +export { getNameFromRemoteResource, RemoteResource, RemoteProvider } from './git/remotes/provider'; +export * from './git/gitContextTracker'; + +class UriCacheEntry { + + constructor(public uri: GitUri) { } +} + +class GitCacheEntry { + + private cache: Map = new Map(); + + constructor(public key: string) { } + + get hasErrors(): boolean { + return Iterables.every(this.cache.values(), _ => _.errorMessage !== undefined); + } + + get(key: string): T | undefined { + return this.cache.get(key) as T; + } + + set(key: string, value: T) { + this.cache.set(key, value); + } +} + +interface CachedItem { + item: Promise; + errorMessage?: string; +} + +interface CachedBlame extends CachedItem { } +interface CachedDiff extends CachedItem { } +interface CachedLog extends CachedItem { } + +enum RemoveCacheReason { + DocumentClosed, + DocumentSaved +} + +export type GitRepoSearchBy = 'author' | 'files' | 'message' | 'sha'; +export const GitRepoSearchBy = { + Author: 'author' as GitRepoSearchBy, + Files: 'files' as GitRepoSearchBy, + Message: 'message' as GitRepoSearchBy, + Sha: 'sha' as GitRepoSearchBy +}; + +type RepoChangedReasons = 'stash' | 'unknown'; + +export class GitService extends Disposable { + + private _onDidBlameFail = new EventEmitter(); + get onDidBlameFail(): Event { + return this._onDidBlameFail.event; + } + + private _onDidChangeGitCache = new EventEmitter(); + get onDidChangeGitCache(): Event { + return this._onDidChangeGitCache.event; + } + + private _onDidChangeRepo = new EventEmitter(); + get onDidChangeRepo(): Event { + return this._onDidChangeRepo.event; + } + + private _gitCache: Map; + private _remotesCache: Map; + private _cacheDisposable: Disposable | undefined; + private _uriCache: Map; + + config: IConfig; + private _disposable: Disposable | undefined; + private _gitignore: Promise; + private _repoWatcher: FileSystemWatcher | undefined; + private _stashWatcher: FileSystemWatcher | undefined; + + static EmptyPromise: Promise = Promise.resolve(undefined); + + constructor(public repoPath: string) { + super(() => this.dispose()); + + this._gitCache = new Map(); + this._remotesCache = new Map(); + this._uriCache = new Map(); + + this._onConfigurationChanged(); + + const subscriptions: Disposable[] = []; + + subscriptions.push(workspace.onDidChangeConfiguration(this._onConfigurationChanged, this)); + + this._disposable = Disposable.from(...subscriptions); + } + + dispose() { + this._disposable && this._disposable.dispose(); + + this._cacheDisposable && this._cacheDisposable.dispose(); + this._cacheDisposable = undefined; + + this._repoWatcher && this._repoWatcher.dispose(); + this._repoWatcher = undefined; + + this._stashWatcher && this._stashWatcher.dispose(); + this._stashWatcher = undefined; + + this._gitCache.clear(); + this._remotesCache.clear(); + this._uriCache.clear(); + } + + public get UseCaching() { + return this.config.advanced.caching.enabled; + } + + private _onConfigurationChanged() { + const encoding = workspace.getConfiguration('files').get('encoding', 'utf8'); + setDefaultEncoding(encoding); + + const cfg = workspace.getConfiguration().get(ExtensionKey)!; + + if (!Objects.areEquivalent(cfg.advanced, this.config && this.config.advanced)) { + if (cfg.advanced.caching.enabled) { + this._cacheDisposable && this._cacheDisposable.dispose(); + + this._repoWatcher = this._repoWatcher || workspace.createFileSystemWatcher('**/.git/index', true, false, true); + this._stashWatcher = this._stashWatcher || workspace.createFileSystemWatcher('**/.git/refs/stash', true, false, true); + + const disposables: Disposable[] = []; + + disposables.push(workspace.onDidCloseTextDocument(d => this._removeCachedEntry(d, RemoveCacheReason.DocumentClosed))); + disposables.push(workspace.onDidChangeTextDocument(this._onTextDocumentChanged, this)); + disposables.push(workspace.onDidSaveTextDocument(d => this._removeCachedEntry(d, RemoveCacheReason.DocumentSaved))); + disposables.push(this._repoWatcher.onDidChange(this._onRepoChanged, this)); + disposables.push(this._stashWatcher.onDidChange(this._onStashChanged, this)); + + this._cacheDisposable = Disposable.from(...disposables); + } + else { + this._cacheDisposable && this._cacheDisposable.dispose(); + this._cacheDisposable = undefined; + + this._repoWatcher && this._repoWatcher.dispose(); + this._repoWatcher = undefined; + + this._stashWatcher && this._stashWatcher.dispose(); + this._stashWatcher = undefined; + + this._gitCache.clear(); + this._remotesCache.clear(); + } + + this._gitignore = new Promise((resolve, reject) => { + if (!cfg.advanced.gitignore.enabled) { + resolve(undefined); + return; + } + + const gitignorePath = path.join(this.repoPath, '.gitignore'); + fs.exists(gitignorePath, e => { + if (e) { + fs.readFile(gitignorePath, 'utf8', (err, data) => { + if (!err) { + resolve(ignore().add(data)); + return; + } + resolve(undefined); + }); + return; + } + resolve(undefined); + }); + }); + } + + this.config = cfg; + } + + private _onTextDocumentChanged(e: TextDocumentChangeEvent) { + if (!this.UseCaching) return; + if (e.document.uri.scheme !== DocumentSchemes.File) return; + + // TODO: Rework this once https://github.com/Microsoft/vscode/issues/27231 is released in v1.13 + // We have to defer because isDirty is not reliable inside this event + setTimeout(() => { + // If the document is dirty all is fine, we'll just wait for the save before clearing our cache + if (e.document.isDirty) return; + + // If the document isn't dirty, it is very likely this event was triggered by an outside edit of this document + // Which means the document has been reloaded and we should clear our cache for it + this._removeCachedEntry(e.document, RemoveCacheReason.DocumentSaved); + }, 1); + } + + private _onRepoChanged() { + this._gitCache.clear(); + + this._fireRepoChange(); + this._fireGitCacheChange(); + } + + private _onStashChanged() { + this._fireRepoChange('stash'); + } + + private _fireGitCacheChangeDebounced: (() => void) | undefined = undefined; + + private _fireGitCacheChange() { + if (this._fireGitCacheChangeDebounced === undefined) { + this._fireGitCacheChangeDebounced = Functions.debounce(this._fireGitCacheChangeCore, 50); + } + + return this._fireGitCacheChangeDebounced(); + } + + private _fireGitCacheChangeCore() { + this._onDidChangeGitCache.fire(); + } + + private _fireRepoChangeDebounced: (() => void) | undefined = undefined; + private _repoChangedReasons: RepoChangedReasons[] = []; + + private _fireRepoChange(reason: RepoChangedReasons = 'unknown') { + if (this._fireRepoChangeDebounced === undefined) { + this._fireRepoChangeDebounced = Functions.debounce(this._fireRepoChangeCore, 50); + } + + this._repoChangedReasons.push(reason); + return this._fireRepoChangeDebounced(); + } + + private _fireRepoChangeCore() { + const reasons = this._repoChangedReasons; + this._repoChangedReasons = []; + + this._onDidChangeRepo.fire(reasons); + } + + private _removeCachedEntry(document: TextDocument, reason: RemoveCacheReason) { + if (!this.UseCaching) return; + if (document.uri.scheme !== DocumentSchemes.File) return; + + const cacheKey = this.getCacheEntryKey(document.uri); + + if (reason === RemoveCacheReason.DocumentSaved) { + // Don't remove broken blame on save (since otherwise we'll have to run the broken blame again) + const entry = this._gitCache.get(cacheKey); + if (entry && entry.hasErrors) return; + } + + if (this._gitCache.delete(cacheKey)) { + Logger.log(`Clear cache entry for '${cacheKey}', reason=${RemoveCacheReason[reason]}`); + + if (reason === RemoveCacheReason.DocumentSaved) { + this._fireGitCacheChange(); + } + } + } + + checkoutFile(uri: GitUri, sha?: string) { + sha = sha || uri.sha; + Logger.log(`checkoutFile('${uri.repoPath}', '${uri.fsPath}', ${sha})`); + + return Git.checkout(uri.repoPath!, uri.fsPath, sha!); + } + + private async _fileExists(repoPath: string, fileName: string): Promise { + return await new Promise((resolve, reject) => fs.exists(path.resolve(repoPath, fileName), resolve)); + } + + async findNextCommit(repoPath: string, fileName: string, sha?: string): Promise { + let log = await this.getLogForFile(repoPath, fileName, sha, { maxCount: 1, reverse: true }); + let commit = log && Iterables.first(log.commits.values()); + if (commit) return commit; + + const nextFileName = await this.findNextFileName(repoPath, fileName, sha); + if (nextFileName) { + log = await this.getLogForFile(repoPath, nextFileName, sha, { maxCount: 1, reverse: true }); + commit = log && Iterables.first(log.commits.values()); + } + + return commit; + } + + async findNextFileName(repoPath: string | undefined, fileName: string, sha?: string): Promise { + [fileName, repoPath] = Git.splitPath(fileName, repoPath); + + return (await this._fileExists(repoPath, fileName)) + ? fileName + : await this._findNextFileName(repoPath, fileName, sha); + } + + async _findNextFileName(repoPath: string, fileName: string, sha?: string): Promise { + if (sha === undefined) { + // Get the most recent commit for this file name + const c = await this.getLogCommit(repoPath, fileName); + if (c === undefined) return undefined; + + sha = c.sha; + } + + // Get the full commit (so we can see if there are any matching renames in the file statuses) + const log = await this.getLogForRepo(repoPath, sha, 1); + if (log === undefined) return undefined; + + const c = Iterables.first(log.commits.values()); + const status = c.fileStatuses.find(_ => _.originalFileName === fileName); + if (status === undefined) return undefined; + + return status.fileName; + } + + async findWorkingFileName(commit: GitCommit): Promise; + async findWorkingFileName(repoPath: string | undefined, fileName: string): Promise; + async findWorkingFileName(commitOrRepoPath: GitCommit | string | undefined, fileName?: string): Promise { + let repoPath: string | undefined; + if (commitOrRepoPath === undefined || typeof commitOrRepoPath === 'string') { + repoPath = commitOrRepoPath; + if (fileName === undefined) throw new Error('Invalid fileName'); + + [fileName] = Git.splitPath(fileName, repoPath); + } + else { + const c = commitOrRepoPath; + repoPath = c.repoPath; + if (c.workingFileName && await this._fileExists(repoPath, c.workingFileName)) return c.workingFileName; + fileName = c.fileName; + } + + while (true) { + if (await this._fileExists(repoPath!, fileName)) return fileName; + + fileName = await this._findNextFileName(repoPath!, fileName); + if (fileName === undefined) return undefined; + } + } + + public async getBlameability(uri: GitUri): Promise { + if (!this.UseCaching) return await this.isTracked(uri); + + const cacheKey = this.getCacheEntryKey(uri); + const entry = this._gitCache.get(cacheKey); + if (entry === undefined) return await this.isTracked(uri); + + return !entry.hasErrors; + } + + async getBlameForFile(uri: GitUri): Promise { + let key = 'blame'; + if (uri.sha !== undefined) { + key += `:${uri.sha}`; + } + + let entry: GitCacheEntry | undefined; + if (this.UseCaching) { + const cacheKey = this.getCacheEntryKey(uri); + entry = this._gitCache.get(cacheKey); + + if (entry !== undefined) { + const cachedBlame = entry.get(key); + if (cachedBlame !== undefined) { + Logger.log(`Cached(${key}): getBlameForFile('${uri.repoPath}', '${uri.fsPath}', ${uri.sha})`); + return cachedBlame.item; + } + } + + Logger.log(`Not Cached(${key}): getBlameForFile('${uri.repoPath}', '${uri.fsPath}', ${uri.sha})`); + + if (entry === undefined) { + entry = new GitCacheEntry(cacheKey); + this._gitCache.set(entry.key, entry); + } + } + else { + Logger.log(`getBlameForFile('${uri.repoPath}', '${uri.fsPath}', ${uri.sha})`); + } + + const promise = this._getBlameForFile(uri, entry, key); + + if (entry) { + Logger.log(`Add blame cache for '${entry.key}:${key}'`); + + entry.set(key, { + item: promise + } as CachedBlame); + } + + return promise; + } + + private async _getBlameForFile(uri: GitUri, entry: GitCacheEntry | undefined, key: string): Promise { + const [file, root] = Git.splitPath(uri.fsPath, uri.repoPath, false); + + const ignore = await this._gitignore; + if (ignore && !ignore.filter([file]).length) { + Logger.log(`Skipping blame; '${uri.fsPath}' is gitignored`); + if (entry && entry.key) { + this._onDidBlameFail.fire(entry.key); + } + return await GitService.EmptyPromise as GitBlame; + } + + try { + const data = await Git.blame(root, file, uri.sha); + const blame = GitBlameParser.parse(data, root, file); + return blame; + } + 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}:${key}'`); + + entry.set(key, { + item: GitService.EmptyPromise, + errorMessage: msg + } as CachedBlame); + + this._onDidBlameFail.fire(entry.key); + return await GitService.EmptyPromise as GitBlame; + } + + return undefined; + } + } + + async getBlameForLine(uri: GitUri, line: number): Promise { + Logger.log(`getBlameForLine('${uri.repoPath}', '${uri.fsPath}', ${line}, ${uri.sha})`); + + if (this.UseCaching) { + const blame = await this.getBlameForFile(uri); + if (blame === undefined) return undefined; + + let blameLine = blame.lines[line]; + if (blameLine === undefined) { + if (blame.lines.length !== line) return undefined; + blameLine = blame.lines[line - 1]; + } + + const commit = blame.commits.get(blameLine.sha); + if (commit === undefined) return undefined; + + return { + author: Object.assign({}, blame.authors.get(commit.author), { lineCount: commit.lines.length }), + commit: commit, + line: blameLine + } as GitBlameLine; + } + + const fileName = uri.fsPath; + + try { + const data = await Git.blame(uri.repoPath, fileName, uri.sha, line + 1, line + 1); + const blame = GitBlameParser.parse(data, uri.repoPath, fileName); + if (blame === undefined) return undefined; + + const commit = Iterables.first(blame.commits.values()); + if (uri.repoPath) { + commit.repoPath = uri.repoPath; + } + return { + author: Iterables.first(blame.authors.values()), + commit: commit, + line: blame.lines[line] + } as GitBlameLine; + } + catch (ex) { + return undefined; + } + } + + async getBlameForRange(uri: GitUri, range: Range): Promise { + Logger.log(`getBlameForRange('${uri.repoPath}', '${uri.fsPath}', [${range.start.line}, ${range.end.line}], ${uri.sha})`); + + const blame = await this.getBlameForFile(uri); + if (blame === undefined) return undefined; + + return this.getBlameForRangeSync(blame, uri, range); + } + + getBlameForRangeSync(blame: GitBlame, uri: GitUri, range: Range): GitBlameLines | undefined { + Logger.log(`getBlameForRangeSync('${uri.repoPath}', '${uri.fsPath}', [${range.start.line}, ${range.end.line}], ${uri.sha})`); + + if (blame.lines.length === 0) return Object.assign({ allLines: blame.lines }, blame); + + if (range.start.line === 0 && range.end.line === blame.lines.length - 1) { + return Object.assign({ allLines: blame.lines }, blame); + } + + const lines = blame.lines.slice(range.start.line, range.end.line + 1); + const shas = new Set(lines.map(l => l.sha)); + + const authors: Map = new Map(); + const commits: Map = new Map(); + for (const c of blame.commits.values()) { + if (!shas.has(c.sha)) continue; + + const commit = new GitBlameCommit(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); + + let author = authors.get(commit.author); + if (author === undefined) { + author = { + name: commit.author, + lineCount: 0 + }; + authors.set(author.name, author); + } + + author.lineCount += commit.lines.length; + } + + const sortedAuthors = new Map([...authors.entries()].sort((a, b) => b[1].lineCount - a[1].lineCount)); + + return { + authors: sortedAuthors, + commits: commits, + lines: lines, + allLines: blame.lines + } as GitBlameLines; + } + + async getBlameLocations(uri: GitUri, range: Range, selectedSha?: string, line?: number): Promise { + Logger.log(`getBlameLocations('${uri.repoPath}', '${uri.fsPath}', [${range.start.line}, ${range.end.line}], ${uri.sha})`); + + const blame = await this.getBlameForRange(uri, range); + if (blame === undefined) return undefined; + + const commitCount = blame.commits.size; + const dateFormat = this.config.defaultDateFormat === null ? 'MMMM Do, YYYY h:MMa' : this.config.defaultDateFormat; + + const locations: Location[] = []; + Iterables.forEach(blame.commits.values(), (c, i) => { + if (c.isUncommitted) return; + + const decoration = `${GlyphChars.ArrowDropRight} ${c.author}, ${moment(c.date).format(dateFormat)}`; + const uri = GitService.toReferenceGitContentUri(c, i + 1, commitCount, c.originalFileName, decoration, dateFormat); + locations.push(new Location(uri, new Position(0, 0))); + if (c.sha === selectedSha) { + locations.push(new Location(uri, new Position((line || 0) + 1, 0))); + } + }); + + return locations; + } + + async getBranch(repoPath: string): Promise { + Logger.log(`getBranch('${repoPath}')`); + + const data = await Git.branch(repoPath, false); + const branches = data.split('\n').filter(_ => !!_).map(_ => new GitBranch(_)); + return branches.find(_ => _.current); + } + + async getBranches(repoPath: string): Promise { + Logger.log(`getBranches('${repoPath}')`); + + const data = await Git.branch(repoPath, true); + const branches = data.split('\n').filter(_ => !!_).map(_ => new GitBranch(_)); + return branches; + } + + getCacheEntryKey(fileName: string): string; + getCacheEntryKey(uri: Uri): string; + getCacheEntryKey(fileNameOrUri: string | Uri): string { + return Git.normalizePath(typeof fileNameOrUri === 'string' ? fileNameOrUri : fileNameOrUri.fsPath).toLowerCase(); + } + + async getConfig(key: string, repoPath?: string): Promise { + Logger.log(`getConfig('${key}', '${repoPath}')`); + + return await Git.config_get(key, repoPath); + } + + getGitUriForFile(uri: Uri) { + const cacheKey = this.getCacheEntryKey(uri); + const entry = this._uriCache.get(cacheKey); + return entry && entry.uri; + } + + async getDiffForFile(uri: GitUri, sha1?: string, sha2?: string): Promise { + if (sha1 !== undefined && sha2 === undefined && uri.sha !== undefined) { + sha2 = uri.sha; + } + + let key = 'diff'; + if (sha1 !== undefined) { + key += `:${sha1}`; + } + if (sha2 !== undefined) { + key += `:${sha2}`; + } + + let entry: GitCacheEntry | undefined; + if (this.UseCaching) { + const cacheKey = this.getCacheEntryKey(uri); + entry = this._gitCache.get(cacheKey); + + if (entry !== undefined) { + const cachedDiff = entry.get(key); + if (cachedDiff !== undefined) { + Logger.log(`Cached(${key}): getDiffForFile('${uri.repoPath}', '${uri.fsPath}', ${sha1}, ${sha2})`); + return cachedDiff.item; + } + } + + Logger.log(`Not Cached(${key}): getDiffForFile('${uri.repoPath}', '${uri.fsPath}', ${sha1}, ${sha2})`); + + if (entry === undefined) { + entry = new GitCacheEntry(cacheKey); + this._gitCache.set(entry.key, entry); + } + } + else { + Logger.log(`getDiffForFile('${uri.repoPath}', '${uri.fsPath}', ${sha1}, ${sha2})`); + } + + const promise = this._getDiffForFile(uri.repoPath, uri.fsPath, sha1, sha2, entry, key); + + if (entry) { + Logger.log(`Add log cache for '${entry.key}:${key}'`); + + entry.set(key, { + item: promise + } as CachedDiff); + } + + return promise; + } + + private async _getDiffForFile(repoPath: string | undefined, fileName: string, sha1: string | undefined, sha2: string | undefined, entry: GitCacheEntry | undefined, key: string): Promise { + const [file, root] = Git.splitPath(fileName, repoPath, false); + + try { + const data = await Git.diff(root, file, sha1, sha2); + const diff = GitDiffParser.parse(data); + return diff; + } + catch (ex) { + // Trap and cache expected diff errors + if (entry) { + const msg = ex && ex.toString(); + Logger.log(`Replace diff cache with empty promise for '${entry.key}:${key}'`); + + entry.set(key, { + item: GitService.EmptyPromise, + errorMessage: msg + } as CachedDiff); + + return await GitService.EmptyPromise as GitDiff; + } + + return undefined; + } + } + + async getDiffForLine(uri: GitUri, line: number, sha1?: string, sha2?: string): Promise { + try { + const diff = await this.getDiffForFile(uri, sha1, sha2); + if (diff === undefined) return undefined; + + const chunk = diff.chunks.find(_ => _.currentPosition.start <= line && _.currentPosition.end >= line); + if (chunk === undefined) return undefined; + + return chunk.lines[line - chunk.currentPosition.start + 1]; + } + catch (ex) { + return undefined; + } + } + + async getLogCommit(repoPath: string | undefined, fileName: string, options?: { firstIfMissing?: boolean, previous?: boolean }): Promise; + async getLogCommit(repoPath: string | undefined, fileName: string, sha: string | undefined, options?: { firstIfMissing?: boolean, previous?: boolean }): Promise; + async getLogCommit(repoPath: string | undefined, fileName: string, shaOrOptions?: string | undefined | { firstIfMissing?: boolean, previous?: boolean }, options?: { firstIfMissing?: boolean, previous?: boolean }): Promise { + let sha: string | undefined = undefined; + if (typeof shaOrOptions === 'string') { + sha = shaOrOptions; + } + else if (options === undefined) { + options = shaOrOptions; + } + + options = options || {}; + + const log = await this.getLogForFile(repoPath, fileName, sha, { maxCount: options.previous ? 2 : 1 }); + if (log === undefined) return undefined; + + const commit = sha && log.commits.get(sha); + if (commit === undefined && sha && !options.firstIfMissing) return undefined; + + return commit || Iterables.first(log.commits.values()); + } + + async getLogForRepo(repoPath: string, sha?: string, maxCount?: number, reverse: boolean = false): Promise { + Logger.log(`getLogForRepo('${repoPath}', ${sha}, ${maxCount})`); + + if (maxCount == null) { + maxCount = this.config.advanced.maxQuickHistory || 0; + } + + try { + const data = await Git.log(repoPath, sha, maxCount, reverse); + const log = GitLogParser.parse(data, 'branch', repoPath, undefined, sha, maxCount, reverse, undefined); + return log; + } + catch (ex) { + return undefined; + } + } + + async getLogForRepoSearch(repoPath: string, search: string, searchBy: GitRepoSearchBy, maxCount?: number): Promise { + Logger.log(`getLogForRepoSearch('${repoPath}', ${search}, ${searchBy}, ${maxCount})`); + + if (maxCount == null) { + maxCount = this.config.advanced.maxQuickHistory || 0; + } + + let searchArgs: string[] | undefined = undefined; + switch (searchBy) { + case GitRepoSearchBy.Author: + searchArgs = [`--author=${search}`]; + break; + case GitRepoSearchBy.Files: + searchArgs = [`--`, `${search}`]; + break; + case GitRepoSearchBy.Message: + searchArgs = [`--grep=${search}`]; + break; + case GitRepoSearchBy.Sha: + searchArgs = [search]; + maxCount = 1; + break; + } + + try { + const data = await Git.log_search(repoPath, searchArgs, maxCount); + const log = GitLogParser.parse(data, 'branch', repoPath, undefined, undefined, maxCount, false, undefined); + return log; + } + catch (ex) { + return undefined; + } + } + + async getLogForFile(repoPath: string | undefined, fileName: string, sha?: string, options: { maxCount?: number, range?: Range, reverse?: boolean, skipMerges?: boolean } = {}): Promise { + options = { ...{ reverse: false, skipMerges: false }, ...options }; + + let key = 'log'; + if (sha !== undefined) { + key += `:${sha}`; + } + if (options.maxCount !== undefined) { + key += `:n${options.maxCount}`; + } + + let entry: GitCacheEntry | undefined; + if (this.UseCaching && options.range === undefined && !options.reverse) { + const cacheKey = this.getCacheEntryKey(fileName); + entry = this._gitCache.get(cacheKey); + + if (entry !== undefined) { + const cachedLog = entry.get(key); + if (cachedLog !== undefined) { + Logger.log(`Cached(${key}): getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, undefined, false)`); + return cachedLog.item; + } + + if (key !== 'log') { + // Since we are looking for partial log, see if we have the log of the whole file + const cachedLog = entry.get('log'); + if (cachedLog !== undefined) { + if (sha === undefined) { + Logger.log(`Cached(~${key}): getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, undefined, false)`); + return cachedLog.item; + } + + Logger.log(`? Cache(${key}): getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, undefined, false)`); + const log = await cachedLog.item; + if (log !== undefined && log.commits.has(sha)) { + Logger.log(`Cached(${key}): getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, undefined, false)`); + return cachedLog.item; + } + } + } + } + + Logger.log(`Not Cached(${key}): getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, undefined, false)`); + + if (entry === undefined) { + entry = new GitCacheEntry(cacheKey); + this._gitCache.set(entry.key, entry); + } + } + else { + Logger.log(`getLogForFile('${repoPath}', '${fileName}', ${sha}, ${options.maxCount}, ${options.range && `[${options.range.start.line}, ${options.range.end.line}]`}, ${options.reverse})`); + } + + const promise = this._getLogForFile(repoPath, fileName, sha, options, entry, key); + + if (entry) { + Logger.log(`Add log cache for '${entry.key}:${key}'`); + + entry.set(key, { + item: promise + } as CachedLog); + } + + return promise; + } + + private async _getLogForFile(repoPath: string | undefined, fileName: string, sha: string | undefined, options: { maxCount?: number, range?: Range, reverse?: boolean, skipMerges?: boolean }, entry: GitCacheEntry | undefined, key: string): Promise { + const [file, root] = Git.splitPath(fileName, repoPath, false); + + const ignore = await this._gitignore; + if (ignore && !ignore.filter([file]).length) { + Logger.log(`Skipping log; '${fileName}' is gitignored`); + return await GitService.EmptyPromise as GitLog; + } + + try { + const { range, ...opts } = options; + const data = await Git.log_file(root, file, sha, { ...opts, ...{ startLine: range && range.start.line + 1, endLine: range && range.end.line + 1 } }); + const log = GitLogParser.parse(data, 'file', root, file, sha, options.maxCount, options.reverse!, range); + return log; + } + 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}:${key}'`); + + entry.set(key, { + item: GitService.EmptyPromise, + errorMessage: msg + } as CachedLog); + + return await GitService.EmptyPromise as GitLog; + } + + return undefined; + } + } + + async getLogLocations(uri: GitUri, selectedSha?: string, line?: number): Promise { + Logger.log(`getLogLocations('${uri.repoPath}', '${uri.fsPath}', ${uri.sha}, ${selectedSha}, ${line})`); + + const log = await this.getLogForFile(uri.repoPath, uri.fsPath, uri.sha); + if (log === undefined) return undefined; + + const commitCount = log.commits.size; + const dateFormat = this.config.defaultDateFormat === null ? 'MMMM Do, YYYY h:MMa' : this.config.defaultDateFormat; + + const locations: Location[] = []; + Iterables.forEach(log.commits.values(), (c, i) => { + if (c.isUncommitted) return; + + const decoration = `${GlyphChars.ArrowDropRight} ${c.author}, ${moment(c.date).format(dateFormat)}`; + const uri = GitService.toReferenceGitContentUri(c, i + 1, commitCount, c.originalFileName, decoration, dateFormat); + locations.push(new Location(uri, new Position(0, 0))); + if (c.sha === selectedSha) { + locations.push(new Location(uri, new Position((line || 0) + 1, 0))); + } + }); + + return locations; + } + + async getRemotes(repoPath: string): Promise { + if (!repoPath) return []; + + Logger.log(`getRemotes('${repoPath}')`); + + if (this.UseCaching) { + const remotes = this._remotesCache.get(repoPath); + if (remotes !== undefined) return remotes; + } + + const data = await Git.remote(repoPath); + const remotes = data.split('\n').filter(_ => !!_).map(_ => new GitRemote(_)); + if (this.UseCaching) { + this._remotesCache.set(repoPath, remotes); + } + return remotes; + } + + getRepoPath(cwd: string): Promise { + return GitService.getRepoPath(cwd); + } + + async getRepoPathFromFile(fileName: string): Promise { + const log = await this.getLogForFile(undefined, fileName, undefined, { maxCount: 1 }); + if (log === undefined) return undefined; + + return log.repoPath; + } + + async getRepoPathFromUri(uri: Uri | undefined): Promise { + if (!(uri instanceof Uri)) return this.repoPath; + + const repoPath = (await GitUri.fromUri(uri, this)).repoPath; + if (!repoPath) return this.repoPath; + + return repoPath; + } + + async getStashList(repoPath: string): Promise { + Logger.log(`getStash('${repoPath}')`); + + const data = await Git.stash_list(repoPath); + const stash = GitStashParser.parse(data, repoPath); + return stash; + } + + async getStatusForFile(repoPath: string, fileName: string): Promise { + Logger.log(`getStatusForFile('${repoPath}', '${fileName}')`); + + const porcelainVersion = Git.validateVersion(2, 11) ? 2 : 1; + + const data = await Git.status_file(repoPath, fileName, porcelainVersion); + const status = GitStatusParser.parse(data, repoPath, porcelainVersion); + if (status === undefined || !status.files.length) return undefined; + + return status.files[0]; + } + + async getStatusForRepo(repoPath: string): Promise { + Logger.log(`getStatusForRepo('${repoPath}')`); + + const porcelainVersion = Git.validateVersion(2, 11) ? 2 : 1; + + const data = await Git.status(repoPath, porcelainVersion); + const status = GitStatusParser.parse(data, repoPath, porcelainVersion); + return status; + } + + async getVersionedFile(repoPath: string | undefined, fileName: string, sha: string) { + Logger.log(`getVersionedFile('${repoPath}', '${fileName}', ${sha})`); + + const file = await Git.getVersionedFile(repoPath, fileName, sha); + const cacheKey = this.getCacheEntryKey(file); + const entry = new UriCacheEntry(new GitUri(Uri.file(fileName), { sha, repoPath: repoPath!, fileName })); + this._uriCache.set(cacheKey, entry); + return file; + } + + getVersionedFileText(repoPath: string, fileName: string, sha: string) { + Logger.log(`getVersionedFileText('${repoPath}', '${fileName}', ${sha})`); + + return Git.show(repoPath, fileName, sha); + } + + hasGitUriForFile(editor: TextEditor): boolean { + if (editor === undefined || editor.document === undefined || editor.document.uri === undefined) return false; + + const cacheKey = this.getCacheEntryKey(editor.document.uri); + return this._uriCache.has(cacheKey); + } + + isEditorBlameable(editor: TextEditor): boolean { + return (editor.viewColumn !== undefined || this.isTrackable(editor.document.uri) || this.hasGitUriForFile(editor)); + } + + async isFileUncommitted(uri: GitUri): Promise { + Logger.log(`isFileUncommitted('${uri.repoPath}', '${uri.fsPath}')`); + + const status = await this.getStatusForFile(uri.repoPath!, uri.fsPath); + return !!status; + } + + isTrackable(uri: Uri): boolean { + // Logger.log(`isTrackable('${uri.scheme}', '${uri.fsPath}')`); + + return uri.scheme === DocumentSchemes.File || uri.scheme === DocumentSchemes.Git || uri.scheme === DocumentSchemes.GitLensGit; + } + + async isTracked(uri: GitUri): Promise { + if (!this.isTrackable(uri)) return false; + + Logger.log(`isTracked('${uri.fsPath}', '${uri.repoPath}')`); + + const result = await Git.ls_files(uri.repoPath === undefined ? '' : uri.repoPath, uri.fsPath); + return !!result; + } + + openDirectoryDiff(repoPath: string, sha1: string, sha2?: string) { + Logger.log(`openDirectoryDiff('${repoPath}', ${sha1}, ${sha2})`); + + return Git.difftool_dirDiff(repoPath, sha1, sha2); + } + + stashApply(repoPath: string, stashName: string, deleteAfter: boolean = false) { + Logger.log(`stashApply('${repoPath}', ${stashName}, ${deleteAfter})`); + + return Git.stash_apply(repoPath, stashName, deleteAfter); + } + + stashDelete(repoPath: string, stashName: string) { + Logger.log(`stashDelete('${repoPath}', ${stashName}})`); + + return Git.stash_delete(repoPath, stashName); + } + + stashSave(repoPath: string, message?: string, uris?: Uri[]) { + Logger.log(`stashSave('${repoPath}', ${message}, ${uris})`); + + if (uris === undefined) return Git.stash_save(repoPath, message); + const pathspecs = uris.map(u => Git.splitPath(u.fsPath, repoPath)[0]); + return Git.stash_push(repoPath, pathspecs, message); + } + + static getGitPath(gitPath?: string): Promise { + return Git.getGitPath(gitPath); + } + + static getGitVersion(): string { + return Git.gitInfo().version; + } + + static async getRepoPath(cwd: string | undefined): Promise { + const repoPath = await Git.getRepoPath(cwd); + if (!repoPath) return ''; + + return repoPath; + } + + static fromGitContentUri(uri: Uri): IGitUriData { + if (uri.scheme !== DocumentSchemes.GitLensGit) throw new Error(`fromGitUri(uri=${uri}) invalid scheme`); + return GitService._fromGitContentUri(uri); + } + + private static _fromGitContentUri(uri: Uri): T { + return JSON.parse(uri.query) as T; + } + + static isSha(sha: string): boolean { + return Git.isSha(sha); + } + + static isUncommitted(sha: string): boolean { + return Git.isUncommitted(sha); + } + + static normalizePath(fileName: string, repoPath?: string): string { + return Git.normalizePath(fileName, repoPath); + } + + static toGitContentUri(sha: string, shortSha: string, fileName: string, repoPath: string, originalFileName?: string): Uri; + static toGitContentUri(commit: GitCommit): Uri; + static toGitContentUri(uri: GitUri): Uri; + static toGitContentUri(shaOrcommitOrUri: string | GitCommit | GitUri, shortSha?: string, fileName?: string, repoPath?: string, originalFileName?: string): Uri { + let data: IGitUriData; + if (typeof shaOrcommitOrUri === 'string') { + data = GitService._toGitUriData({ + sha: shaOrcommitOrUri, + fileName: fileName!, + repoPath: repoPath!, + originalFileName: originalFileName + }); + } + else if (shaOrcommitOrUri instanceof GitCommit) { + data = GitService._toGitUriData(shaOrcommitOrUri, undefined, shaOrcommitOrUri.originalFileName); + fileName = shaOrcommitOrUri.fileName; + shortSha = shaOrcommitOrUri.shortSha; + } + else { + data = GitService._toGitUriData({ + sha: shaOrcommitOrUri.sha!, + fileName: shaOrcommitOrUri.fsPath!, + repoPath: shaOrcommitOrUri.repoPath! + }); + fileName = shaOrcommitOrUri.fsPath; + shortSha = shaOrcommitOrUri.shortSha; + } + + const extension = path.extname(fileName!); + return Uri.parse(`${DocumentSchemes.GitLensGit}:${path.basename(fileName!, extension)}:${shortSha}${extension}?${JSON.stringify(data)}`); + } + + static toReferenceGitContentUri(commit: GitCommit, index: number, commitCount: number, originalFileName: string | undefined, decoration: string, dateFormat: string | null): Uri { + return GitService._toReferenceGitContentUri(commit, DocumentSchemes.GitLensGit, commitCount, GitService._toGitUriData(commit, index, originalFileName, decoration), dateFormat); + } + + private static _toReferenceGitContentUri(commit: GitCommit, scheme: DocumentSchemes, commitCount: number, data: IGitUriData, dateFormat: string | null) { + const pad = (n: number) => ('0000000' + n).slice(-('' + commitCount).length); + const ext = path.extname(data.fileName); + const uriPath = `${path.relative(commit.repoPath, data.fileName.slice(0, -ext.length))}/${commit.shortSha}${ext}`; + + let message = commit.message; + if (message.length > 50) { + message = message.substring(0, 49) + GlyphChars.Ellipsis; + } + + if (dateFormat === null) { + dateFormat = 'MMMM Do, YYYY h:MMa'; + } + + // NOTE: Need to specify an index here, since I can't control the sort order -- just alphabetic or by file location + return Uri.parse(`${scheme}:${pad(data.index || 0)} ${GlyphChars.Dot} ${encodeURIComponent(message)} ${GlyphChars.Dot} ${moment(commit.date).format(dateFormat)} ${GlyphChars.Dot} ${encodeURIComponent(uriPath)}?${JSON.stringify(data)}`); + } + + private static _toGitUriData(commit: IGitUriData, index?: number, originalFileName?: string, decoration?: string): T { + const fileName = Git.normalizePath(path.resolve(commit.repoPath, commit.fileName)); + const data = { repoPath: commit.repoPath, fileName: fileName, sha: commit.sha, index: index } as T; + if (originalFileName) { + data.originalFileName = Git.normalizePath(path.resolve(commit.repoPath, originalFileName)); + } + if (decoration) { + data.decoration = decoration; + } + return data; + } + + static validateGitVersion(major: number, minor: number): boolean { + const [gitMajor, gitMinor] = this.getGitVersion().split('.'); + return (parseInt(gitMajor, 10) >= major && parseInt(gitMinor, 10) >= minor); + } } \ No newline at end of file diff --git a/src/views/gitExplorer.ts b/src/views/gitExplorer.ts index f405471..8975805 100644 --- a/src/views/gitExplorer.ts +++ b/src/views/gitExplorer.ts @@ -43,6 +43,7 @@ export class GitExplorer implements TreeDataProvider { commands.registerCommand('gitlens.gitExplorer.openFileRevisionInRemote', this.openFileRevisionInRemote, this); commands.registerCommand('gitlens.gitExplorer.openChangedFiles', this.openChangedFiles, this); commands.registerCommand('gitlens.gitExplorer.openChangedFileRevisions', this.openChangedFileRevisions, this); + commands.registerCommand('gitlens.gitExplorer.applyChanges', this.applyChanges, this); const fn = Functions.debounce(this.onActiveEditorChanged, 500); context.subscriptions.push(window.onDidChangeActiveTextEditor(fn, this)); @@ -110,6 +111,11 @@ export class GitExplorer implements TreeDataProvider { this.refresh(); } + private async applyChanges(node: CommitNode | StashNode) { + await this.git.checkoutFile(node.uri); + return this.openFile(node); + } + private openChanges(node: CommitNode | StashNode) { const command = node.getCommand(); if (command === undefined || command.arguments === undefined) return;