819 lines
22 KiB

'use strict';
import * as paths from 'path';
import {
commands,
ConfigurationChangeEvent,
Disposable,
Event,
EventEmitter,
ProgressLocation,
RelativePattern,
Uri,
window,
workspace,
WorkspaceFolder,
} from 'vscode';
import { configuration } from '../../configuration';
import { StarredRepositories, WorkspaceState } from '../../constants';
import { Container } from '../../container';
import { GitBranch, GitContributor, GitDiffShortStat, GitRemote, GitStash, GitStatus, GitTag } from '../git';
import { GitService } from '../gitService';
import { GitUri } from '../gitUri';
import { Logger } from '../../logger';
import { Messages } from '../../messages';
import { GitBranchReference, GitReference, GitTagReference } from './models';
import { RemoteProviderFactory, RemoteProviders, RemoteProviderWithApi } from '../remotes/factory';
import { Arrays, Functions, gate, Iterables, log, logName } from '../../system';
import { runGitCommandInTerminal } from '../../terminal';
const ignoreGitRegex = /\.git(?:\/|\\|$)/;
const refsRegex = /\.git\/refs\/(heads|remotes|tags)/;
export enum RepositoryChange {
Config = 'config',
Closed = 'closed',
// FileSystem = 'file-system',
Heads = 'heads',
Index = 'index',
Ignores = 'ignores',
Remotes = 'remotes',
Stash = 'stash',
Tags = 'tags',
Unknown = 'unknown',
}
export class RepositoryChangeEvent {
constructor(public readonly repository?: Repository, public readonly changes: RepositoryChange[] = []) {}
changed(change: RepositoryChange, only: boolean = false) {
if (only) return this.changes.length === 1 && this.changes[0] === change;
return this.changes.includes(change);
// const changed = this.changes.includes(change);
// if (changed) return true;
// if (change === RepositoryChange.Repository) {
// return this.changes.includes(RepositoryChange.Stashes);
// }
// return false;
}
}
export interface RepositoryFileSystemChangeEvent {
readonly repository?: Repository;
readonly uris: Uri[];
}
@logName<Repository>((r, name) => `${name}(${r.id})`)
export class Repository implements Disposable {
static sort(repositories: Repository[]) {
return repositories.sort((a, b) => (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || a.index - b.index);
}
private _onDidChange = new EventEmitter<RepositoryChangeEvent>();
get onDidChange(): Event<RepositoryChangeEvent> {
return this._onDidChange.event;
}
private _onDidChangeFileSystem = new EventEmitter<RepositoryFileSystemChangeEvent>();
get onDidChangeFileSystem(): Event<RepositoryFileSystemChangeEvent> {
return this._onDidChangeFileSystem.event;
}
readonly formattedName: string;
readonly id: string;
readonly index: number;
readonly name: string;
readonly normalizedPath: string;
readonly supportsChangeEvents: boolean = true;
private _branch: Promise<GitBranch | undefined> | undefined;
private readonly _disposable: Disposable;
private _fireChangeDebounced: ((e: RepositoryChangeEvent) => void) | undefined = undefined;
private _fireFileSystemChangeDebounced: ((e: RepositoryFileSystemChangeEvent) => void) | undefined = undefined;
private _fsWatchCounter = 0;
private _fsWatcherDisposable: Disposable | undefined;
private _pendingChanges: { repo?: RepositoryChangeEvent; fs?: RepositoryFileSystemChangeEvent } = {};
private _providers: RemoteProviders | undefined;
private _remotes: Promise<GitRemote[]> | undefined;
private _remotesDisposable: Disposable | undefined;
private _suspended: boolean;
constructor(
public readonly folder: WorkspaceFolder,
public readonly path: string,
public readonly root: boolean,
private readonly onAnyRepositoryChanged: (repo: Repository, e: RepositoryChangeEvent) => void,
suspended: boolean,
closed: boolean = false,
) {
const relativePath = paths.relative(folder.uri.fsPath, path);
if (root) {
// Check if the repository is not contained by a workspace folder
const repoFolder = workspace.getWorkspaceFolder(GitUri.fromRepoPath(path));
if (repoFolder == null) {
// If it isn't within a workspace folder we can't get change events, see: https://github.com/Microsoft/vscode/issues/3025
this.supportsChangeEvents = false;
this.formattedName = this.name = paths.basename(path);
} else {
this.formattedName = this.name = folder.name;
}
} else {
this.formattedName = relativePath ? `${folder.name} (${relativePath})` : folder.name;
this.name = folder.name;
}
this.index = folder.index;
this.normalizedPath = (path.endsWith('/') ? path : `${path}/`).toLowerCase();
this.id = this.normalizedPath;
this._suspended = suspended;
this._closed = closed;
// TODO: createFileSystemWatcher doesn't work unless the folder is part of the workspaceFolders
// https://github.com/Microsoft/vscode/issues/3025
const watcher = workspace.createFileSystemWatcher(
new RelativePattern(
folder,
'{\
**/.git/config,\
**/.git/index,\
**/.git/HEAD,\
**/.git/refs/stash,\
**/.git/refs/heads/**,\
**/.git/refs/remotes/**,\
**/.git/refs/tags/**,\
**/.gitignore\
}',
),
);
this._disposable = Disposable.from(
watcher,
watcher.onDidChange(this.onRepositoryChanged, this),
watcher.onDidCreate(this.onRepositoryChanged, this),
watcher.onDidDelete(this.onRepositoryChanged, this),
configuration.onDidChange(this.onConfigurationChanged, this),
);
this.onConfigurationChanged(configuration.initializingChangeEvent);
}
dispose() {
this.stopWatchingFileSystem();
// // Clean up any disposables in storage
// for (const item of this.storage.values()) {
// if (item != null && typeof item.dispose === 'function') {
// item.dispose();
// }
// }
this._remotesDisposable?.dispose();
this._disposable?.dispose();
}
private onConfigurationChanged(e: ConfigurationChangeEvent) {
if (configuration.changed(e, 'remotes', this.folder.uri)) {
this._providers = RemoteProviderFactory.loadProviders(configuration.get('remotes', this.folder.uri));
if (!configuration.initializing(e)) {
this.resetRemotesCache();
this.fireChange(RepositoryChange.Remotes);
}
}
}
private onFileSystemChanged(uri: Uri) {
// Ignore .git changes
if (ignoreGitRegex.test(uri.fsPath)) return;
this.fireFileSystemChange(uri);
}
private onRepositoryChanged(uri: Uri | undefined) {
if (uri == null) {
this.fireChange(RepositoryChange.Unknown);
return;
}
if (uri.path.endsWith('.git/config')) {
this._branch = undefined;
this.resetRemotesCache();
this.fireChange(RepositoryChange.Config, RepositoryChange.Remotes);
return;
}
if (uri.path.endsWith('.git/index')) {
this.fireChange(RepositoryChange.Index);
return;
}
if (uri.path.endsWith('.git/HEAD') || uri.path.endsWith('.git/ORIG_HEAD')) {
this._branch = undefined;
this.fireChange(RepositoryChange.Heads);
return;
}
if (uri.path.endsWith('.git/refs/stash')) {
this.fireChange(RepositoryChange.Stash);
return;
}
if (uri.path.endsWith('/.gitignore')) {
this.fireChange(RepositoryChange.Ignores);
return;
}
const match = refsRegex.exec(uri.path);
if (match != null) {
switch (match[1]) {
case 'heads':
this._branch = undefined;
this.fireChange(RepositoryChange.Heads);
return;
case 'remotes':
this._branch = undefined;
this.resetRemotesCache();
this.fireChange(RepositoryChange.Remotes);
return;
case 'tags':
this.fireChange(RepositoryChange.Tags);
return;
}
}
this.fireChange(RepositoryChange.Unknown);
}
private _closed: boolean = false;
get closed(): boolean {
return this._closed;
}
set closed(value: boolean) {
const changed = this._closed !== value;
this._closed = value;
if (changed) {
this.fireChange(RepositoryChange.Closed);
}
}
@gate()
@log()
branch(...args: string[]) {
this.runTerminalCommand('branch', ...args);
}
@gate()
@log()
branchDelete(
branches: GitBranchReference | GitBranchReference[],
{ force, remote }: { force?: boolean; remote?: boolean } = {},
) {
if (!Array.isArray(branches)) {
branches = [branches];
}
const localBranches = branches.filter(b => !b.remote);
if (localBranches.length !== 0) {
const args = ['--delete'];
if (force) {
args.push('--force');
}
this.runTerminalCommand('branch', ...args, ...branches.map(b => b.ref));
if (remote) {
const trackingBranches = localBranches.filter(b => b.tracking != null);
if (trackingBranches.length !== 0) {
const branchesByOrigin = Arrays.groupByMap(trackingBranches, b => GitBranch.getRemote(b.tracking!));
for (const [remote, branches] of branchesByOrigin.entries()) {
this.runTerminalCommand(
'push',
'-d',
remote,
...branches.map(b => GitBranch.getNameWithoutRemote(b.tracking!)),
);
}
}
}
}
const remoteBranches = branches.filter(b => b.remote);
if (remoteBranches.length !== 0) {
const branchesByOrigin = Arrays.groupByMap(remoteBranches, b => GitBranch.getRemote(b.name));
for (const [remote, branches] of branchesByOrigin.entries()) {
this.runTerminalCommand(
'push',
'-d',
remote,
...branches.map(b => GitReference.getNameWithoutRemote(b)),
);
}
}
}
@gate(() => '')
@log()
cherryPick(...args: string[]) {
this.runTerminalCommand('cherry-pick', ...args);
}
containsUri(uri: Uri) {
if (GitUri.is(uri)) {
uri = uri.repoPath != null ? GitUri.file(uri.repoPath) : uri.documentUri();
}
return this.folder === workspace.getWorkspaceFolder(uri);
}
@gate()
@log()
async fetch(
options: {
all?: boolean;
branch?: GitBranchReference;
progress?: boolean;
prune?: boolean;
remote?: string;
} = {},
) {
const { progress, ...opts } = { progress: true, ...options };
if (!progress) return this.fetchCore(opts);
return void (await window.withProgress(
{
location: ProgressLocation.Notification,
title: opts.branch
? `Pulling ${opts.branch.name}...`
: `Fetching ${opts.remote ? `${opts.remote} of ` : ''}${this.formattedName}...`,
},
() => this.fetchCore(opts),
));
}
private async fetchCore(
options: { all?: boolean; branch?: GitBranchReference; prune?: boolean; remote?: string } = {},
) {
try {
void (await Container.git.fetch(this.path, options));
this.fireChange(RepositoryChange.Unknown);
} catch (ex) {
Logger.error(ex);
void Messages.showGenericErrorMessage('Unable to fetch repository');
}
}
async getBranch(name?: string): Promise<GitBranch | undefined> {
if (name) {
const [branch] = await this.getBranches({ filter: b => b.name === name });
return branch;
}
if (this._branch == null || !this.supportsChangeEvents) {
this._branch = Container.git.getBranch(this.path);
}
return this._branch;
}
getBranches(
options: { filter?: (b: GitBranch) => boolean; sort?: boolean | { current: boolean } } = {},
): Promise<GitBranch[]> {
return Container.git.getBranches(this.path, options);
}
getBranchesAndOrTags(
options: {
filterBranches?: (b: GitBranch) => boolean;
filterTags?: (t: GitTag) => boolean;
include?: 'all' | 'branches' | 'tags';
sort?: boolean | { current: boolean };
} = {},
) {
return Container.git.getBranchesAndOrTags(this.path, options);
}
getChangedFilesCount(sha?: string): Promise<GitDiffShortStat | undefined> {
return Container.git.getChangedFilesCount(this.path, sha);
}
getContributors(): Promise<GitContributor[]> {
return Container.git.getContributors(this.path);
}
async getLastFetched(): Promise<number> {
const hasRemotes = await this.hasRemotes();
if (!hasRemotes || Container.vsls.isMaybeGuest) return 0;
try {
const stat = await workspace.fs.stat(Uri.file(paths.join(this.path, '.git/FETCH_HEAD')));
return stat.mtime;
} catch {
return 0;
}
}
getRemotes(_options: { sort?: boolean } = {}): Promise<GitRemote[]> {
if (this._remotes == null || !this.supportsChangeEvents) {
if (this._providers == null) {
const remotesCfg = configuration.get('remotes', this.folder.uri);
this._providers = RemoteProviderFactory.loadProviders(remotesCfg);
}
// Since we are caching the results, always sort
this._remotes = Container.git.getRemotesCore(this.path, this._providers, { sort: true });
void this.subscribeToRemotes(this._remotes);
}
return this._remotes;
}
private resetRemotesCache() {
this._remotes = undefined;
this._remotesDisposable?.dispose();
this._remotesDisposable = undefined;
}
private async subscribeToRemotes(remotes: Promise<GitRemote[]>) {
this._remotesDisposable?.dispose();
this._remotesDisposable = undefined;
this._remotesDisposable = Disposable.from(
...Iterables.filterMap(await remotes, r => {
if (!(r.provider instanceof RemoteProviderWithApi)) return undefined;
return r.provider.onDidChange(() => this.fireChange(RepositoryChange.Remotes));
}),
);
}
getStash(): Promise<GitStash | undefined> {
return Container.git.getStash(this.path);
}
getStatus(): Promise<GitStatus | undefined> {
return Container.git.getStatusForRepo(this.path);
}
getTags(options?: { filter?: (t: GitTag) => boolean; sort?: boolean }): Promise<GitTag[]> {
return Container.git.getTags(this.path, options);
}
async hasRemotes(): Promise<boolean> {
const remotes = await this.getRemotes();
return remotes?.length > 0;
}
async hasTrackingBranch(): Promise<boolean> {
const branch = await this.getBranch();
return branch?.tracking != null;
}
@gate(() => '')
@log()
merge(...args: string[]) {
this.runTerminalCommand('merge', ...args);
}
@gate()
@log()
async pull(options: { progress?: boolean; rebase?: boolean } = {}) {
const { progress, ...opts } = { progress: true, ...options };
if (!progress) return this.pullCore();
return void (await window.withProgress(
{
location: ProgressLocation.Notification,
title: `Pulling ${this.formattedName}...`,
},
() => this.pullCore(opts),
));
}
private async pullCore(options: { rebase?: boolean } = {}) {
try {
const tracking = await this.hasTrackingBranch();
if (tracking) {
void (await commands.executeCommand(options.rebase ? 'git.pullRebase' : 'git.pull', this.path));
} else if (configuration.getAny<boolean>('git.fetchOnPull', Uri.file(this.path))) {
void (await Container.git.fetch(this.path));
}
this.fireChange(RepositoryChange.Unknown);
} catch (ex) {
Logger.error(ex);
void Messages.showGenericErrorMessage('Unable to pull repository');
}
}
@gate()
@log()
async push(
options: {
force?: boolean;
progress?: boolean;
reference?: GitReference;
publish?: {
remote: string;
};
} = {},
) {
const { progress, ...opts } = { progress: true, ...options };
if (!progress) return this.pushCore(opts);
return void (await window.withProgress(
{
location: ProgressLocation.Notification,
title: GitReference.isBranch(opts.reference)
? `${opts.publish ? 'Publishing ' : 'Pushing '}${opts.reference.name}...`
: `Pushing ${this.formattedName}...`,
},
() => this.pushCore(opts),
));
}
private async pushCore(
options: {
force?: boolean;
reference?: GitReference;
publish?: {
remote: string;
};
} = {},
) {
try {
if (GitReference.isBranch(options.reference)) {
const repo = await GitService.getBuiltInGitRepository(this.path);
if (repo == null) return;
if (options.publish != null) {
await repo?.push(options.publish.remote, options.reference.name, true);
} else {
const branch = await this.getBranch(options.reference.name);
if (branch == null) return;
await repo?.push(branch.getRemoteName(), branch.name);
}
} else if (options.reference != null) {
const repo = await GitService.getBuiltInGitRepository(this.path);
if (repo == null) return;
const branch = await this.getBranch();
if (branch == null) return;
await repo?.push(branch.getRemoteName(), `${options.reference.ref}:${branch.getNameWithoutRemote()}`);
} else {
void (await commands.executeCommand(options.force ? 'git.pushForce' : 'git.push', this.path));
}
this.fireChange(RepositoryChange.Unknown);
} catch (ex) {
Logger.error(ex);
void Messages.showGenericErrorMessage('Unable to push repository');
}
}
@gate(() => '')
@log()
rebase(...args: string[]) {
this.runTerminalCommand('rebase', ...args);
}
@gate(() => '')
@log()
reset(...args: string[]) {
this.runTerminalCommand('reset', ...args);
}
resume() {
if (!this._suspended) return;
this._suspended = false;
// If we've come back into focus and we are dirty, fire the change events
if (this._pendingChanges.repo != null) {
this._fireChangeDebounced!(this._pendingChanges.repo);
}
if (this._pendingChanges.fs != null) {
this._fireFileSystemChangeDebounced!(this._pendingChanges.fs);
}
}
@gate()
@log()
revert(...args: string[]) {
this.runTerminalCommand('revert', ...args);
}
get starred() {
const starred = Container.context.workspaceState.get<StarredRepositories>(WorkspaceState.StarredRepositories);
return starred != null && starred[this.id] === true;
}
star() {
return this.updateStarred(true);
}
@gate(() => '')
@log()
async stashApply(stashName: string, options: { deleteAfter?: boolean } = {}) {
void (await Container.git.stashApply(this.path, stashName, options));
if (!this.supportsChangeEvents) {
this.fireChange(RepositoryChange.Stash);
}
}
@gate(() => '')
@log()
async stashDelete(stashName: string, ref?: string) {
void (await Container.git.stashDelete(this.path, stashName, ref));
if (!this.supportsChangeEvents) {
this.fireChange(RepositoryChange.Stash);
}
}
@gate(() => '')
@log()
async stashSave(message?: string, uris?: Uri[], options: { includeUntracked?: boolean; keepIndex?: boolean } = {}) {
void (await Container.git.stashSave(this.path, message, uris, options));
if (!this.supportsChangeEvents) {
this.fireChange(RepositoryChange.Stash);
}
}
@gate()
@log()
async switch(ref: string, options: { createBranch?: string | undefined; progress?: boolean } = {}) {
const { progress, ...opts } = { progress: true, ...options };
if (!progress) return this.switchCore(ref, opts);
return void (await window.withProgress(
{
location: ProgressLocation.Notification,
title: `Switching ${this.formattedName} to ${ref}...`,
cancellable: false,
},
() => this.switchCore(ref, opts),
));
}
private async switchCore(ref: string, options: { createBranch?: string } = {}) {
try {
void (await Container.git.checkout(this.path, ref, options));
this.fireChange(RepositoryChange.Unknown);
} catch (ex) {
Logger.error(ex);
void Messages.showGenericErrorMessage('Unable to switch to reference');
}
}
toAbsoluteUri(path: string, options?: { validate?: boolean }): Uri | undefined {
const uri = Uri.joinPath(GitUri.file(this.path), path);
return !(options?.validate ?? true) || this.containsUri(uri) ? uri : undefined;
}
unstar() {
return this.updateStarred(false);
}
private async updateStarred(star: boolean) {
let starred = Container.context.workspaceState.get<StarredRepositories>(WorkspaceState.StarredRepositories);
if (starred == null) {
starred = Object.create(null) as StarredRepositories;
}
if (star) {
starred[this.id] = true;
} else {
const { [this.id]: _, ...rest } = starred;
starred = rest;
}
await Container.context.workspaceState.update(WorkspaceState.StarredRepositories, starred);
}
startWatchingFileSystem() {
this._fsWatchCounter++;
if (this._fsWatcherDisposable != null) return;
// TODO: createFileSystemWatcher doesn't work unless the folder is part of the workspaceFolders
// https://github.com/Microsoft/vscode/issues/3025
const watcher = workspace.createFileSystemWatcher(new RelativePattern(this.folder, '**'));
this._fsWatcherDisposable = Disposable.from(
watcher,
watcher.onDidChange(this.onFileSystemChanged, this),
watcher.onDidCreate(this.onFileSystemChanged, this),
watcher.onDidDelete(this.onFileSystemChanged, this),
);
}
stopWatchingFileSystem() {
if (this._fsWatcherDisposable == null) return;
if (--this._fsWatchCounter > 0) return;
this._fsWatcherDisposable.dispose();
this._fsWatcherDisposable = undefined;
}
suspend() {
this._suspended = true;
}
@gate()
@log()
tag(...args: string[]) {
this.runTerminalCommand('tag', ...args);
}
@gate()
@log()
tagDelete(tags: GitTagReference | GitTagReference[]) {
if (!Array.isArray(tags)) {
tags = [tags];
}
const args = ['--delete'];
this.runTerminalCommand('tag', ...args, ...tags.map(t => t.ref));
}
private fireChange(...changes: RepositoryChange[]) {
this.onAnyRepositoryChanged(this, new RepositoryChangeEvent(this, changes));
if (this._fireChangeDebounced == null) {
this._fireChangeDebounced = Functions.debounce(this.fireChangeCore.bind(this), 250);
}
if (this._pendingChanges.repo == null) {
this._pendingChanges.repo = new RepositoryChangeEvent(this);
}
const e = this._pendingChanges.repo;
for (const reason of changes) {
if (!e.changes.includes(reason)) {
e.changes.push(reason);
}
}
if (this._suspended) return;
this._fireChangeDebounced(e);
}
private fireChangeCore(e: RepositoryChangeEvent) {
this._pendingChanges.repo = undefined;
this._onDidChange.fire(e);
}
private fireFileSystemChange(uri: Uri) {
if (this._fireFileSystemChangeDebounced == null) {
this._fireFileSystemChangeDebounced = Functions.debounce(this.fireFileSystemChangeCore.bind(this), 2500);
}
if (this._pendingChanges.fs == null) {
this._pendingChanges.fs = { repository: this, uris: [] };
}
const e = this._pendingChanges.fs;
e.uris.push(uri);
if (this._suspended) return;
this._fireFileSystemChangeDebounced(e);
}
private async fireFileSystemChangeCore(e: RepositoryFileSystemChangeEvent) {
this._pendingChanges.fs = undefined;
const uris = await Container.git.excludeIgnoredUris(this.path, e.uris);
if (uris.length === 0) return;
if (uris.length !== e.uris.length) {
e = { ...e, uris: uris };
}
this._onDidChangeFileSystem.fire(e);
}
private runTerminalCommand(command: string, ...args: string[]) {
const parsedArgs = args.map(arg => (arg.startsWith('#') ? `"${arg}"` : arg));
runGitCommandInTerminal(command, parsedArgs.join(' '), this.path, true);
if (!this.supportsChangeEvents) {
this.fireChange(RepositoryChange.Unknown);
}
}
}