/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ 'use strict'; import { Uri, Command, EventEmitter, Event, SourceControlResourceState, SourceControlResourceDecorations, Disposable, ProgressLocation, window, workspace, WorkspaceEdit } from 'vscode'; import { Git, Repository, Ref, Branch, Remote, Commit, GitErrorCodes, Stash } from './git'; import { anyEvent, eventToPromise, filterEvent, EmptyDisposable, combinedDisposable, dispose } from './util'; import { memoize, throttle, debounce } from './decorators'; import * as path from 'path'; import * as nls from 'vscode-nls'; import * as fs from 'fs'; const timeout = (millis: number) => new Promise(c => setTimeout(c, millis)); const localize = nls.loadMessageBundle(); const iconsRootPath = path.join(path.dirname(__dirname), 'resources', 'icons'); function getIconUri(iconName: string, theme: string): Uri { return Uri.file(path.join(iconsRootPath, theme, `${iconName}.svg`)); } export enum State { Uninitialized, Idle, NotAGitRepository } export enum Status { INDEX_MODIFIED, INDEX_ADDED, INDEX_DELETED, INDEX_RENAMED, INDEX_COPIED, MODIFIED, DELETED, UNTRACKED, IGNORED, ADDED_BY_US, ADDED_BY_THEM, DELETED_BY_US, DELETED_BY_THEM, BOTH_ADDED, BOTH_DELETED, BOTH_MODIFIED } export class Resource implements SourceControlResourceState { @memoize get resourceUri(): Uri { if (this.renameResourceUri && (this._type === Status.MODIFIED || this._type === Status.DELETED || this._type === Status.INDEX_RENAMED || this._type === Status.INDEX_COPIED)) { return this.renameResourceUri; } return this._resourceUri; } @memoize get command(): Command { return { command: 'git.openResource', title: localize('open', "Open"), arguments: [this] }; } get resourceGroup(): ResourceGroup { return this._resourceGroup; } get type(): Status { return this._type; } get original(): Uri { return this._resourceUri; } get renameResourceUri(): Uri | undefined { return this._renameResourceUri; } private static Icons = { light: { Modified: getIconUri('status-modified', 'light'), Added: getIconUri('status-added', 'light'), Deleted: getIconUri('status-deleted', 'light'), Renamed: getIconUri('status-renamed', 'light'), Copied: getIconUri('status-copied', 'light'), Untracked: getIconUri('status-untracked', 'light'), Ignored: getIconUri('status-ignored', 'light'), Conflict: getIconUri('status-conflict', 'light'), }, dark: { Modified: getIconUri('status-modified', 'dark'), Added: getIconUri('status-added', 'dark'), Deleted: getIconUri('status-deleted', 'dark'), Renamed: getIconUri('status-renamed', 'dark'), Copied: getIconUri('status-copied', 'dark'), Untracked: getIconUri('status-untracked', 'dark'), Ignored: getIconUri('status-ignored', 'dark'), Conflict: getIconUri('status-conflict', 'dark') } }; private getIconPath(theme: string): Uri | undefined { switch (this.type) { case Status.INDEX_MODIFIED: return Resource.Icons[theme].Modified; case Status.MODIFIED: return Resource.Icons[theme].Modified; case Status.INDEX_ADDED: return Resource.Icons[theme].Added; case Status.INDEX_DELETED: return Resource.Icons[theme].Deleted; case Status.DELETED: return Resource.Icons[theme].Deleted; case Status.INDEX_RENAMED: return Resource.Icons[theme].Renamed; case Status.INDEX_COPIED: return Resource.Icons[theme].Copied; case Status.UNTRACKED: return Resource.Icons[theme].Untracked; case Status.IGNORED: return Resource.Icons[theme].Ignored; case Status.BOTH_DELETED: return Resource.Icons[theme].Conflict; case Status.ADDED_BY_US: return Resource.Icons[theme].Conflict; case Status.DELETED_BY_THEM: return Resource.Icons[theme].Conflict; case Status.ADDED_BY_THEM: return Resource.Icons[theme].Conflict; case Status.DELETED_BY_US: return Resource.Icons[theme].Conflict; case Status.BOTH_ADDED: return Resource.Icons[theme].Conflict; case Status.BOTH_MODIFIED: return Resource.Icons[theme].Conflict; default: return void 0; } } private get strikeThrough(): boolean { switch (this.type) { case Status.DELETED: case Status.BOTH_DELETED: case Status.DELETED_BY_THEM: case Status.DELETED_BY_US: case Status.INDEX_DELETED: return true; default: return false; } } @memoize private get faded(): boolean { const workspaceRootPath = this.workspaceRoot.fsPath; return this.resourceUri.fsPath.substr(0, workspaceRootPath.length) !== workspaceRootPath; } get decorations(): SourceControlResourceDecorations { const light = { iconPath: this.getIconPath('light') }; const dark = { iconPath: this.getIconPath('dark') }; const strikeThrough = this.strikeThrough; const faded = this.faded; return { strikeThrough, faded, light, dark }; } constructor( private workspaceRoot: Uri, private _resourceGroup: ResourceGroup, private _resourceUri: Uri, private _type: Status, private _renameResourceUri?: Uri ) { } } export abstract class ResourceGroup { get id(): string { return this._id; } get contextKey(): string { return this._id; } get label(): string { return this._label; } get resources(): Resource[] { return this._resources; } constructor(private _id: string, private _label: string, private _resources: Resource[]) { } } export class MergeGroup extends ResourceGroup { static readonly ID = 'merge'; constructor(resources: Resource[] = []) { super(MergeGroup.ID, localize('merge changes', "Merge Changes"), resources); } } export class IndexGroup extends ResourceGroup { static readonly ID = 'index'; constructor(resources: Resource[] = []) { super(IndexGroup.ID, localize('staged changes', "Staged Changes"), resources); } } export class WorkingTreeGroup extends ResourceGroup { static readonly ID = 'workingTree'; constructor(resources: Resource[] = []) { super(WorkingTreeGroup.ID, localize('changes', "Changes"), resources); } } export enum Operation { Status = 1 << 0, Add = 1 << 1, RevertFiles = 1 << 2, Commit = 1 << 3, Clean = 1 << 4, Branch = 1 << 5, Checkout = 1 << 6, Reset = 1 << 7, Fetch = 1 << 8, Pull = 1 << 9, Push = 1 << 10, Sync = 1 << 11, Init = 1 << 12, Show = 1 << 13, Stage = 1 << 14, GetCommitTemplate = 1 << 15, DeleteBranch = 1 << 16, Merge = 1 << 17, Ignore = 1 << 18, Tag = 1 << 19, Stash = 1 << 20 } // function getOperationName(operation: Operation): string { // switch (operation) { // case Operation.Status: return 'Status'; // case Operation.Add: return 'Add'; // case Operation.RevertFiles: return 'RevertFiles'; // case Operation.Commit: return 'Commit'; // case Operation.Clean: return 'Clean'; // case Operation.Branch: return 'Branch'; // case Operation.Checkout: return 'Checkout'; // case Operation.Reset: return 'Reset'; // case Operation.Fetch: return 'Fetch'; // case Operation.Pull: return 'Pull'; // case Operation.Push: return 'Push'; // case Operation.Sync: return 'Sync'; // case Operation.Init: return 'Init'; // case Operation.Show: return 'Show'; // case Operation.Stage: return 'Stage'; // case Operation.GetCommitTemplate: return 'GetCommitTemplate'; // default: return 'unknown'; // } // } function isReadOnly(operation: Operation): boolean { switch (operation) { case Operation.Show: case Operation.GetCommitTemplate: return true; default: return false; } } function shouldShowProgress(operation: Operation): boolean { switch (operation) { case Operation.Fetch: return false; default: return true; } } export interface Operations { isIdle(): boolean; isRunning(operation: Operation): boolean; } class OperationsImpl implements Operations { constructor(private readonly operations: number = 0) { // noop } start(operation: Operation): OperationsImpl { return new OperationsImpl(this.operations | operation); } end(operation: Operation): OperationsImpl { return new OperationsImpl(this.operations & ~operation); } isRunning(operation: Operation): boolean { return (this.operations & operation) !== 0; } isIdle(): boolean { return this.operations === 0; } } export interface CommitOptions { all?: boolean; amend?: boolean; signoff?: boolean; signCommit?: boolean; } export class Model implements Disposable { private _onDidChangeRepository = new EventEmitter(); readonly onDidChangeRepository: Event = this._onDidChangeRepository.event; private _onDidChangeState = new EventEmitter(); readonly onDidChangeState: Event = this._onDidChangeState.event; private _onDidChangeResources = new EventEmitter(); readonly onDidChangeResources: Event = this._onDidChangeResources.event; @memoize get onDidChange(): Event { return anyEvent(this.onDidChangeState, this.onDidChangeResources); } private _onRunOperation = new EventEmitter(); readonly onRunOperation: Event = this._onRunOperation.event; private _onDidRunOperation = new EventEmitter(); readonly onDidRunOperation: Event = this._onDidRunOperation.event; @memoize get onDidChangeOperations(): Event { return anyEvent(this.onRunOperation as Event, this.onDidRunOperation as Event); } private _mergeGroup = new MergeGroup([]); get mergeGroup(): MergeGroup { return this._mergeGroup; } private _indexGroup = new IndexGroup([]); get indexGroup(): IndexGroup { return this._indexGroup; } private _workingTreeGroup = new WorkingTreeGroup([]); get workingTreeGroup(): WorkingTreeGroup { return this._workingTreeGroup; } private _HEAD: Branch | undefined; get HEAD(): Branch | undefined { return this._HEAD; } private _refs: Ref[] = []; get refs(): Ref[] { return this._refs; } private _remotes: Remote[] = []; get remotes(): Remote[] { return this._remotes; } private _operations = new OperationsImpl(); get operations(): Operations { return this._operations; } private repository: Repository; private _state = State.Uninitialized; get state(): State { return this._state; } set state(state: State) { this._state = state; this._onDidChangeState.fire(state); this._HEAD = undefined; this._refs = []; this._remotes = []; this._mergeGroup = new MergeGroup(); this._indexGroup = new IndexGroup(); this._workingTreeGroup = new WorkingTreeGroup(); this._onDidChangeResources.fire(); } private workspaceRoot: Uri; private onWorkspaceChange: Event; private isRepositoryHuge = false; private didWarnAboutLimit = false; private repositoryDisposable: Disposable = EmptyDisposable; private disposables: Disposable[] = []; constructor( private _git: Git, workspaceRootPath: string ) { this.workspaceRoot = Uri.file(workspaceRootPath); const fsWatcher = workspace.createFileSystemWatcher('**'); this.onWorkspaceChange = anyEvent(fsWatcher.onDidChange, fsWatcher.onDidCreate, fsWatcher.onDidDelete); this.disposables.push(fsWatcher); this.status(); } @throttle async init(): Promise { if (this.state !== State.NotAGitRepository) { return; } await this._git.init(this.workspaceRoot.fsPath); await this.status(); } @throttle async status(): Promise { await this.run(Operation.Status); } async add(...resources: Resource[]): Promise { await this.run(Operation.Add, () => this.repository.add(resources.map(r => r.resourceUri.fsPath))); } async stage(uri: Uri, contents: string): Promise { const relativePath = path.relative(this.repository.root, uri.fsPath).replace(/\\/g, '/'); await this.run(Operation.Stage, () => this.repository.stage(relativePath, contents)); } async revertFiles(...resources: Resource[]): Promise { await this.run(Operation.RevertFiles, () => this.repository.revertFiles('HEAD', resources.map(r => r.resourceUri.fsPath))); } async commit(message: string, opts: CommitOptions = Object.create(null)): Promise { await this.run(Operation.Commit, async () => { if (opts.all) { await this.repository.add([]); } await this.repository.commit(message, opts); }); } async clean(...resources: Resource[]): Promise { await this.run(Operation.Clean, async () => { const toClean: string[] = []; const toCheckout: string[] = []; resources.forEach(r => { switch (r.type) { case Status.UNTRACKED: case Status.IGNORED: toClean.push(r.resourceUri.fsPath); break; default: toCheckout.push(r.resourceUri.fsPath); break; } }); const promises: Promise[] = []; if (toClean.length > 0) { promises.push(this.repository.clean(toClean)); } if (toCheckout.length > 0) { promises.push(this.repository.checkout('', toCheckout)); } await Promise.all(promises); }); } async branch(name: string): Promise { await this.run(Operation.Branch, () => this.repository.branch(name, true)); } async deleteBranch(name: string, force?: boolean): Promise { await this.run(Operation.DeleteBranch, () => this.repository.deleteBranch(name, force)); } async merge(ref: string): Promise { await this.run(Operation.Merge, () => this.repository.merge(ref)); } async tag(name: string, message?: string): Promise { await this.run(Operation.Tag, () => this.repository.tag(name, message)); } async checkout(treeish: string): Promise { await this.run(Operation.Checkout, () => this.repository.checkout(treeish, [])); } async getCommit(ref: string): Promise { return await this.repository.getCommit(ref); } async reset(treeish: string, hard?: boolean): Promise { await this.run(Operation.Reset, () => this.repository.reset(treeish, hard)); } @throttle async fetch(): Promise { try { await this.run(Operation.Fetch, () => this.repository.fetch()); } catch (err) { // noop } } @throttle async pullWithRebase(): Promise { await this.run(Operation.Pull, () => this.repository.pull(true)); } @throttle async pull(rebase?: boolean, remote?: string, name?: string): Promise { await this.run(Operation.Pull, () => this.repository.pull(rebase, remote, name)); } @throttle async push(): Promise { await this.run(Operation.Push, () => this.repository.push()); } async pullFrom(rebase?: boolean, remote?: string, branch?: string): Promise { await this.run(Operation.Pull, () => this.repository.pull(rebase, remote, branch)); } async pushTo(remote?: string, name?: string, setUpstream: boolean = false): Promise { await this.run(Operation.Push, () => this.repository.push(remote, name, setUpstream)); } async pushTags(remote?: string): Promise { await this.run(Operation.Push, () => this.repository.push(remote, undefined, false, true)); } @throttle async sync(): Promise { await this.run(Operation.Sync, async () => { await this.repository.pull(); const shouldPush = this.HEAD && typeof this.HEAD.ahead === 'number' ? this.HEAD.ahead > 0 : true; if (shouldPush) { await this.repository.push(); } }); } async show(ref: string, filePath: string): Promise { return await this.run(Operation.Show, async () => { const relativePath = path.relative(this.repository.root, filePath).replace(/\\/g, '/'); const configFiles = workspace.getConfiguration('files'); const encoding = configFiles.get('encoding'); return await this.repository.buffer(`${ref}:${relativePath}`, encoding); }); } @throttle async stash(pop: boolean = false, index?: string): Promise { return await this.run(Operation.Stash, () => this.repository.stash(pop, index)); } async getCommitTemplate(): Promise { return await this.run(Operation.GetCommitTemplate, async () => this.repository.getCommitTemplate()); } async ignore(files: Uri[]): Promise { return await this.run(Operation.Ignore, async () => { const ignoreFile = `${this.repository.root}${path.sep}.gitignore`; const textToAppend = files .map(uri => path.relative(this.repository.root, uri.fsPath).replace(/\\/g, '/')) .join('\n'); const document = await new Promise(c => fs.exists(ignoreFile, c)) ? await workspace.openTextDocument(ignoreFile) : await workspace.openTextDocument(Uri.file(ignoreFile).with({ scheme: 'untitled' })); await window.showTextDocument(document); const edit = new WorkspaceEdit(); const lastLine = document.lineAt(document.lineCount - 1); const text = lastLine.isEmptyOrWhitespace ? `${textToAppend}\n` : `\n${textToAppend}\n`; edit.insert(document.uri, lastLine.range.end, text); workspace.applyEdit(edit); }); } async getStashes(): Promise { return await this.getStashes(); } private async run(operation: Operation, runOperation: () => Promise = () => Promise.resolve(null)): Promise { const run = async () => { this._operations = this._operations.start(operation); this._onRunOperation.fire(operation); try { await this.assertIdleState(); const result = await this.retryRun(runOperation); if (!isReadOnly(operation)) { await this.updateModelState(); } return result; } catch (err) { if (err.gitErrorCode === GitErrorCodes.NotAGitRepository) { this.repositoryDisposable.dispose(); const disposables: Disposable[] = []; this.onWorkspaceChange(this.onFSChange, this, disposables); this.repositoryDisposable = combinedDisposable(disposables); this.state = State.NotAGitRepository; } throw err; } finally { this._operations = this._operations.end(operation); this._onDidRunOperation.fire(operation); } }; return shouldShowProgress(operation) ? window.withProgress({ location: ProgressLocation.SourceControl }, run) : run(); } private async retryRun(runOperation: () => Promise = () => Promise.resolve(null)): Promise { let attempt = 0; while (true) { try { attempt++; return await runOperation(); } catch (err) { if (err.gitErrorCode === GitErrorCodes.RepositoryIsLocked && attempt <= 10) { // quatratic backoff await timeout(Math.pow(attempt, 2) * 50); } else { throw err; } } } } /* We use the native Node `watch` for faster, non debounced events. * That way we hopefully get the events during the operations we're * performing, thus sparing useless `git status` calls to refresh * the model's state. */ private async assertIdleState(): Promise { if (this.state === State.Idle) { return; } this.repositoryDisposable.dispose(); const disposables: Disposable[] = []; const repositoryRoot = await this._git.getRepositoryRoot(this.workspaceRoot.fsPath); this.repository = this._git.open(repositoryRoot); const onGitChange = filterEvent(this.onWorkspaceChange, uri => /\/\.git\//.test(uri.path)); const onRelevantGitChange = filterEvent(onGitChange, uri => !/\/\.git\/index\.lock$/.test(uri.path)); onRelevantGitChange(this.onFSChange, this, disposables); onRelevantGitChange(this._onDidChangeRepository.fire, this._onDidChangeRepository, disposables); const onNonGitChange = filterEvent(this.onWorkspaceChange, uri => !/\/\.git\//.test(uri.path)); onNonGitChange(this.onFSChange, this, disposables); this.repositoryDisposable = combinedDisposable(disposables); this.isRepositoryHuge = false; this.didWarnAboutLimit = false; this.state = State.Idle; } @throttle private async updateModelState(): Promise { const { status, didHitLimit } = await this.repository.getStatus(); const config = workspace.getConfiguration('git'); const shouldIgnore = config.get('ignoreLimitWarning') === true; this.isRepositoryHuge = didHitLimit; if (didHitLimit && !shouldIgnore && !this.didWarnAboutLimit) { const ok = { title: localize('ok', "OK"), isCloseAffordance: true }; const neverAgain = { title: localize('neveragain', "Never Show Again") }; window.showWarningMessage(localize('huge', "The git repository at '{0}' has too many active changes, only a subset of Git features will be enabled.", this.repository.root), ok, neverAgain).then(result => { if (result === neverAgain) { config.update('ignoreLimitWarning', true, false); } }); this.didWarnAboutLimit = true; } let HEAD: Branch | undefined; try { HEAD = await this.repository.getHEAD(); if (HEAD.name) { try { HEAD = await this.repository.getBranch(HEAD.name); } catch (err) { // noop } } } catch (err) { // noop } const [refs, remotes] = await Promise.all([this.repository.getRefs(), this.repository.getRemotes()]); this._HEAD = HEAD; this._refs = refs; this._remotes = remotes; const index: Resource[] = []; const workingTree: Resource[] = []; const merge: Resource[] = []; status.forEach(raw => { const uri = Uri.file(path.join(this.repository.root, raw.path)); const renameUri = raw.rename ? Uri.file(path.join(this.repository.root, raw.rename)) : undefined; switch (raw.x + raw.y) { case '??': return workingTree.push(new Resource(this.workspaceRoot, this.workingTreeGroup, uri, Status.UNTRACKED)); case '!!': return workingTree.push(new Resource(this.workspaceRoot, this.workingTreeGroup, uri, Status.IGNORED)); case 'DD': return merge.push(new Resource(this.workspaceRoot, this.mergeGroup, uri, Status.BOTH_DELETED)); case 'AU': return merge.push(new Resource(this.workspaceRoot, this.mergeGroup, uri, Status.ADDED_BY_US)); case 'UD': return merge.push(new Resource(this.workspaceRoot, this.mergeGroup, uri, Status.DELETED_BY_THEM)); case 'UA': return merge.push(new Resource(this.workspaceRoot, this.mergeGroup, uri, Status.ADDED_BY_THEM)); case 'DU': return merge.push(new Resource(this.workspaceRoot, this.mergeGroup, uri, Status.DELETED_BY_US)); case 'AA': return merge.push(new Resource(this.workspaceRoot, this.mergeGroup, uri, Status.BOTH_ADDED)); case 'UU': return merge.push(new Resource(this.workspaceRoot, this.mergeGroup, uri, Status.BOTH_MODIFIED)); } let isModifiedInIndex = false; switch (raw.x) { case 'M': index.push(new Resource(this.workspaceRoot, this.indexGroup, uri, Status.INDEX_MODIFIED)); isModifiedInIndex = true; break; case 'A': index.push(new Resource(this.workspaceRoot, this.indexGroup, uri, Status.INDEX_ADDED)); break; case 'D': index.push(new Resource(this.workspaceRoot, this.indexGroup, uri, Status.INDEX_DELETED)); break; case 'R': index.push(new Resource(this.workspaceRoot, this.indexGroup, uri, Status.INDEX_RENAMED, renameUri)); break; case 'C': index.push(new Resource(this.workspaceRoot, this.indexGroup, uri, Status.INDEX_COPIED, renameUri)); break; } switch (raw.y) { case 'M': workingTree.push(new Resource(this.workspaceRoot, this.workingTreeGroup, uri, Status.MODIFIED, renameUri)); break; case 'D': workingTree.push(new Resource(this.workspaceRoot, this.workingTreeGroup, uri, Status.DELETED, renameUri)); break; } }); this._mergeGroup = new MergeGroup(merge); this._indexGroup = new IndexGroup(index); this._workingTreeGroup = new WorkingTreeGroup(workingTree); this._onDidChangeResources.fire(); } private onFSChange(uri: Uri): void { const config = workspace.getConfiguration('git'); const autorefresh = config.get('autorefresh'); if (!autorefresh) { return; } if (this.isRepositoryHuge) { return; } if (!this.operations.isIdle()) { return; } this.eventuallyUpdateWhenIdleAndWait(); } @debounce(1000) private eventuallyUpdateWhenIdleAndWait(): void { this.updateWhenIdleAndWait(); } @throttle private async updateWhenIdleAndWait(): Promise { await this.whenIdle(); await this.status(); await timeout(5000); } private async whenIdle(): Promise { while (!this.operations.isIdle()) { await eventToPromise(this.onDidRunOperation); } } dispose(): void { this.repositoryDisposable.dispose(); this.disposables = dispose(this.disposables); } }