/*--------------------------------------------------------------------------------------------- * 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, EventEmitter, Event, SCMResource, SCMResourceDecorations, SCMResourceGroup, Disposable, window, workspace } from 'vscode'; import { Git, Repository, Ref, Branch, Remote, PushOptions, Commit, GitErrorCodes, GitError } from './git'; import { anyEvent, eventToPromise, filterEvent, mapEvent, EmptyDisposable, combinedDisposable, dispose } from './util'; import { memoize, throttle, debounce } from './decorators'; import { watch } from './watch'; import { Askpass } from './askpass'; import * as path from 'path'; import * as fs from 'fs'; import * as nls from 'vscode-nls'; const timeout = (millis: number) => new Promise(c => setTimeout(c, millis)); const exists = (path: string) => new Promise(c => fs.exists(path, c)); 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 SCMResource { get uri(): Uri { if (this.rename && (this._type === Status.MODIFIED || this._type === Status.DELETED || this._type === Status.INDEX_RENAMED)) { return this.rename; } return this._uri; } get type(): Status { return this._type; } get original(): Uri { return this._uri; } get rename(): Uri | undefined { return this._rename; } 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: return true; default: return false; } } get decorations(): SCMResourceDecorations { const light = { iconPath: this.getIconPath('light') }; const dark = { iconPath: this.getIconPath('dark') }; return { strikeThrough: this.strikeThrough, light, dark }; } constructor(private _uri: Uri, private _type: Status, private _rename?: Uri) { // console.log(this); } } export class ResourceGroup implements SCMResourceGroup { get id(): 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 } // 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; } } 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; } 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); } get git(): Git { return this._git; } 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; } get resources(): ResourceGroup[] { const result: ResourceGroup[] = []; if (this._mergeGroup.resources.length > 0) { result.push(this._mergeGroup); } if (this._indexGroup.resources.length > 0) { result.push(this._indexGroup); } result.push(this._workingTreeGroup); return result; } 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(this.resources); } private onWorkspaceChange: Event; private repositoryDisposable: Disposable = EmptyDisposable; private disposables: Disposable[] = []; constructor( private _git: Git, private workspaceRootPath: string, private askpass: Askpass ) { const fsWatcher = workspace.createFileSystemWatcher('**'); this.onWorkspaceChange = anyEvent(fsWatcher.onDidChange, fsWatcher.onDidCreate, fsWatcher.onDidDelete); this.disposables.push(fsWatcher); this.status(); } async whenIdle(): Promise { while (!this.operations.isIdle()) { await eventToPromise(this.onDidRunOperation); } } /** * Returns promise which resolves when there is no `.git/index.lock` file, * or when it has attempted way too many times. Back off mechanism. */ async whenUnlocked(): Promise { let millis = 100; let retries = 0; while (retries < 10 && await exists(path.join(this.repository.root, '.git', 'index.lock'))) { retries += 1; millis *= 1.4; await timeout(millis); } } @throttle async init(): Promise { if (this.state !== State.NotAGitRepository) { return; } await this.git.init(this.workspaceRootPath); await this.status(); } @throttle async status(): Promise { await this.run(Operation.Status); } @throttle async add(...resources: Resource[]): Promise { await this.run(Operation.Add, () => this.repository.add(resources.map(r => r.uri.fsPath))); } @throttle 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)); } @throttle async revertFiles(...resources: Resource[]): Promise { await this.run(Operation.RevertFiles, () => this.repository.revertFiles('HEAD', resources.map(r => r.uri.fsPath))); } @throttle 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); }); } @throttle 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.uri.fsPath); break; default: toCheckout.push(r.uri.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); }); } @throttle async branch(name: string): Promise { await this.run(Operation.Branch, () => this.repository.branch(name, true)); } @throttle async checkout(treeish: string): Promise { await this.run(Operation.Checkout, () => this.repository.checkout(treeish, [])); } @throttle async getCommit(ref: string): Promise { return await this.repository.getCommit(ref); } @throttle async reset(treeish: string, hard?: boolean): Promise { await this.run(Operation.Reset, () => this.repository.reset(treeish, hard)); } @throttle async fetch(): Promise { await this.run(Operation.Fetch, () => this.repository.fetch()); } @throttle async pull(rebase?: boolean): Promise { await this.run(Operation.Pull, () => this.repository.pull(rebase)); } @throttle async push(remote?: string, name?: string, options?: PushOptions): Promise { await this.run(Operation.Push, () => this.repository.push(remote, name, options)); } @throttle async sync(): Promise { await this.run(Operation.Sync, () => this.repository.sync()); } async show(ref: string, uri: Uri): Promise { // TODO@Joao: should we make this a general concept? await this.whenIdle(); return await this.run(Operation.Show, async () => { const relativePath = path.relative(this.repository.root, uri.fsPath).replace(/\\/g, '/'); const result = await this.repository.git.exec(this.repository.root, ['show', `${ref}:${relativePath}`]); if (result.exitCode !== 0) { throw new GitError({ message: localize('cantshow', "Could not show object"), exitCode: result.exitCode }); } return result.stdout; }); } async getCommitTemplate(): Promise { return await this.run(Operation.GetCommitTemplate, async () => this.repository.getCommitTemplate()); } private async run(operation: Operation, runOperation: () => Promise = () => Promise.resolve(null)): Promise { return window.withScmProgress(async () => { this._operations = this._operations.start(operation); this._onRunOperation.fire(operation); try { await this.assertIdleState(); await this.whenUnlocked(); const result = await runOperation(); if (!isReadOnly(operation)) { await this.update(); } return result; } catch (err) { if (err.gitErrorCode === GitErrorCodes.NotAGitRepository) { this.repositoryDisposable.dispose(); this.state = State.NotAGitRepository; } throw err; } finally { this._operations = this._operations.end(operation); this._onDidRunOperation.fire(operation); } }); } /* 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.workspaceRootPath); const askpassEnv = await this.askpass.getEnv(); this.repository = this.git.open(repositoryRoot, askpassEnv); const dotGitPath = path.join(repositoryRoot, '.git'); const { event: onRawGitChange, disposable: watcher } = watch(dotGitPath); disposables.push(watcher); const onGitChange = mapEvent(onRawGitChange, ({ filename }) => Uri.file(path.join(dotGitPath, filename))); const onRelevantGitChange = filterEvent(onGitChange, uri => !/\/\.git\/index\.lock$/.test(uri.fsPath)); onRelevantGitChange(this.onFSChange, this, disposables); onRelevantGitChange(this._onDidChangeRepository.fire, this._onDidChangeRepository, disposables); const onNonGitChange = filterEvent(this.onWorkspaceChange, uri => !/\/\.git\//.test(uri.fsPath)); onNonGitChange(this.onFSChange, this, disposables); this.repositoryDisposable = combinedDisposable(disposables); this.state = State.Idle; } @throttle private async update(): Promise { const status = await this.repository.getStatus(); 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(uri, Status.UNTRACKED)); case '!!': return workingTree.push(new Resource(uri, Status.IGNORED)); case 'DD': return merge.push(new Resource(uri, Status.BOTH_DELETED)); case 'AU': return merge.push(new Resource(uri, Status.ADDED_BY_US)); case 'UD': return merge.push(new Resource(uri, Status.DELETED_BY_THEM)); case 'UA': return merge.push(new Resource(uri, Status.ADDED_BY_THEM)); case 'DU': return merge.push(new Resource(uri, Status.DELETED_BY_US)); case 'AA': return merge.push(new Resource(uri, Status.BOTH_ADDED)); case 'UU': return merge.push(new Resource(uri, Status.BOTH_MODIFIED)); } let isModifiedInIndex = false; switch (raw.x) { case 'M': index.push(new Resource(uri, Status.INDEX_MODIFIED)); isModifiedInIndex = true; break; case 'A': index.push(new Resource(uri, Status.INDEX_ADDED)); break; case 'D': index.push(new Resource(uri, Status.INDEX_DELETED)); break; case 'R': index.push(new Resource(uri, Status.INDEX_RENAMED, renameUri)); break; case 'C': index.push(new Resource(uri, Status.INDEX_COPIED)); break; } switch (raw.y) { case 'M': workingTree.push(new Resource(uri, Status.MODIFIED, renameUri)); break; case 'D': workingTree.push(new Resource(uri, Status.DELETED, renameUri)); break; } }); this._mergeGroup = new MergeGroup(merge); this._indexGroup = new IndexGroup(index); this._workingTreeGroup = new WorkingTreeGroup(workingTree); this._onDidChangeResources.fire(this.resources); } private onFSChange(uri: Uri): void { const config = workspace.getConfiguration('git'); const autorefresh = config.get('autorefresh'); if (!autorefresh) { 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); } dispose(): void { this.repositoryDisposable.dispose(); this.disposables = dispose(this.disposables); } }