Merge branch 'master' into pr/99324

This commit is contained in:
João Moreno
2020-11-09 16:17:36 +01:00
2681 changed files with 157406 additions and 100881 deletions

View File

@@ -5,10 +5,12 @@
import { Model } from '../model';
import { Repository as BaseRepository, Resource } from '../repository';
import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, GitExtension, RefType, RemoteSourceProvider, CredentialsProvider, BranchQuery } from './git';
import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, RemoteSourceProvider, CredentialsProvider, BranchQuery, PushErrorHandler } from './git';
import { Event, SourceControlInputBox, Uri, SourceControl, Disposable, commands } from 'vscode';
import { mapEvent } from '../util';
import { toGitUri } from '../uri';
import { pickRemoteSource, PickRemoteSourceOptions } from '../remoteSource';
import { GitExtensionImpl } from './extension';
class ApiInputBox implements InputBox {
set value(value: string) { this._inputBox.value = value; }
@@ -271,6 +273,10 @@ export class ApiImpl implements API {
return this._model.registerCredentialsProvider(provider);
}
registerPushErrorHandler(handler: PushErrorHandler): Disposable {
return this._model.registerPushErrorHandler(handler);
}
constructor(private _model: Model) { }
}
@@ -308,41 +314,51 @@ function getStatus(status: Status): string {
return 'UNKNOWN';
}
export function registerAPICommands(extension: GitExtension): Disposable {
return Disposable.from(
commands.registerCommand('git.api.getRepositories', () => {
const api = extension.getAPI(1);
return api.repositories.map(r => r.rootUri.toString());
}),
export function registerAPICommands(extension: GitExtensionImpl): Disposable {
const disposables: Disposable[] = [];
commands.registerCommand('git.api.getRepositoryState', (uri: string) => {
const api = extension.getAPI(1);
const repository = api.getRepository(Uri.parse(uri));
disposables.push(commands.registerCommand('git.api.getRepositories', () => {
const api = extension.getAPI(1);
return api.repositories.map(r => r.rootUri.toString());
}));
if (!repository) {
return null;
}
disposables.push(commands.registerCommand('git.api.getRepositoryState', (uri: string) => {
const api = extension.getAPI(1);
const repository = api.getRepository(Uri.parse(uri));
const state = repository.state;
if (!repository) {
return null;
}
const ref = (ref: Ref | undefined) => (ref && { ...ref, type: getRefType(ref.type) });
const change = (change: Change) => ({
uri: change.uri.toString(),
originalUri: change.originalUri.toString(),
renameUri: change.renameUri?.toString(),
status: getStatus(change.status)
});
const state = repository.state;
return {
HEAD: ref(state.HEAD),
refs: state.refs.map(ref),
remotes: state.remotes,
submodules: state.submodules,
rebaseCommit: state.rebaseCommit,
mergeChanges: state.mergeChanges.map(change),
indexChanges: state.indexChanges.map(change),
workingTreeChanges: state.workingTreeChanges.map(change)
};
})
);
const ref = (ref: Ref | undefined) => (ref && { ...ref, type: getRefType(ref.type) });
const change = (change: Change) => ({
uri: change.uri.toString(),
originalUri: change.originalUri.toString(),
renameUri: change.renameUri?.toString(),
status: getStatus(change.status)
});
return {
HEAD: ref(state.HEAD),
refs: state.refs.map(ref),
remotes: state.remotes,
submodules: state.submodules,
rebaseCommit: state.rebaseCommit,
mergeChanges: state.mergeChanges.map(change),
indexChanges: state.indexChanges.map(change),
workingTreeChanges: state.workingTreeChanges.map(change)
};
}));
disposables.push(commands.registerCommand('git.api.getRemoteSources', (opts?: PickRemoteSourceOptions) => {
if (!extension.model) {
return;
}
return pickRemoteSource(extension.model, opts);
}));
return Disposable.from(...disposables);
}

View File

@@ -7,7 +7,6 @@ import { Model } from '../model';
import { GitExtension, Repository, API } from './git';
import { ApiRepository, ApiImpl } from './api1';
import { Event, EventEmitter } from 'vscode';
import { latchEvent } from '../util';
export function deprecated(_target: any, key: string, descriptor: any): void {
if (typeof descriptor.value !== 'function') {
@@ -26,17 +25,27 @@ export class GitExtensionImpl implements GitExtension {
enabled: boolean = false;
private _onDidChangeEnablement = new EventEmitter<boolean>();
readonly onDidChangeEnablement: Event<boolean> = latchEvent(this._onDidChangeEnablement.event);
readonly onDidChangeEnablement: Event<boolean> = this._onDidChangeEnablement.event;
private _model: Model | undefined = undefined;
set model(model: Model | undefined) {
this._model = model;
this.enabled = !!model;
const enabled = !!model;
if (this.enabled === enabled) {
return;
}
this.enabled = enabled;
this._onDidChangeEnablement.fire(this.enabled);
}
get model(): Model | undefined {
return this._model;
}
constructor(model?: Model) {
if (model) {
this.enabled = true;
@@ -73,4 +82,4 @@ export class GitExtensionImpl implements GitExtension {
return new ApiImpl(this._model);
}
}
}

View File

@@ -130,10 +130,13 @@ export interface CommitOptions {
signoff?: boolean;
signCommit?: boolean;
empty?: boolean;
noVerify?: boolean;
}
export interface BranchQuery {
readonly remote?: boolean;
readonly pattern?: string;
readonly count?: number;
readonly contains?: string;
}
@@ -221,6 +224,10 @@ export interface CredentialsProvider {
getCredentials(host: Uri): ProviderResult<Credentials>;
}
export interface PushErrorHandler {
handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise<boolean>;
}
export type APIState = 'uninitialized' | 'initialized';
export interface API {
@@ -237,6 +244,7 @@ export interface API {
registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable;
registerCredentialsProvider(provider: CredentialsProvider): Disposable;
registerPushErrorHandler(handler: PushErrorHandler): Disposable;
}
export interface GitExtension {
@@ -274,6 +282,7 @@ export const enum GitErrorCodes {
CantOpenResource = 'CantOpenResource',
GitNotFound = 'GitNotFound',
CantCreatePipe = 'CantCreatePipe',
PermissionDenied = 'PermissionDenied',
CantAccessRemote = 'CantAccessRemote',
RepositoryNotFound = 'RepositoryNotFound',
RepositoryIsLocked = 'RepositoryIsLocked',

View File

@@ -6,10 +6,10 @@
import { lstat, Stats } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { commands, Disposable, LineChange, MessageOptions, OutputChannel, Position, ProgressLocation, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, QuickPick } from 'vscode';
import { commands, Disposable, LineChange, MessageOptions, OutputChannel, Position, ProgressLocation, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider } from 'vscode';
import TelemetryReporter from 'vscode-extension-telemetry';
import * as nls from 'vscode-nls';
import { Branch, GitErrorCodes, Ref, RefType, Status, CommitOptions, RemoteSourceProvider, RemoteSource } from './api/git';
import { Branch, GitErrorCodes, Ref, RefType, Status, CommitOptions, RemoteSourceProvider } from './api/git';
import { ForcePushMode, Git, Stash } from './git';
import { Model } from './model';
import { Repository, Resource, ResourceGroupType } from './repository';
@@ -18,8 +18,8 @@ import { fromGitUri, toGitUri, isGitUri } from './uri';
import { grep, isDescendant, pathEquals } from './util';
import { Log, LogLevel } from './log';
import { GitTimelineItem } from './timelineProvider';
import { throttle, debounce } from './decorators';
import { ApiRepository } from './api/api1';
import { pickRemoteSource } from './remoteSource';
const localize = nls.loadMessageBundle();
@@ -31,14 +31,14 @@ class CheckoutItem implements QuickPickItem {
constructor(protected ref: Ref) { }
async run(repository: Repository): Promise<void> {
async run(repository: Repository, opts?: { detached?: boolean }): Promise<void> {
const ref = this.ref.name;
if (!ref) {
return;
}
await repository.checkout(ref);
await repository.checkout(ref, opts);
}
}
@@ -99,32 +99,36 @@ class MergeItem implements QuickPickItem {
}
}
class CreateBranchItem implements QuickPickItem {
class RebaseItem implements QuickPickItem {
constructor(private cc: CommandCenter) { }
get label(): string { return this.ref.name || ''; }
description: string = '';
get label(): string { return '$(plus) ' + localize('create branch', 'Create new branch...'); }
get description(): string { return ''; }
get alwaysShow(): boolean { return true; }
constructor(readonly ref: Ref) { }
async run(repository: Repository): Promise<void> {
await this.cc.branch(repository);
if (this.ref?.name) {
await repository.rebase(this.ref.name);
}
}
}
class CreateBranchItem implements QuickPickItem {
get label(): string { return '$(plus) ' + localize('create branch', 'Create new branch...'); }
get description(): string { return ''; }
get alwaysShow(): boolean { return true; }
}
class CreateBranchFromItem implements QuickPickItem {
constructor(private cc: CommandCenter) { }
get label(): string { return '$(plus) ' + localize('create branch from', 'Create new branch from...'); }
get description(): string { return ''; }
get alwaysShow(): boolean { return true; }
}
async run(repository: Repository): Promise<void> {
await this.cc.branch(repository);
}
class CheckoutDetachedItem implements QuickPickItem {
get label(): string { return '$(debug-disconnect) ' + localize('checkout detached', 'Checkout detached...'); }
get description(): string { return ''; }
get alwaysShow(): boolean { return true; }
}
class HEADItem implements QuickPickItem {
@@ -203,18 +207,53 @@ async function categorizeResourceByResolution(resources: Resource[]): Promise<{
function createCheckoutItems(repository: Repository): CheckoutItem[] {
const config = workspace.getConfiguration('git');
const checkoutType = config.get<string>('checkoutType') || 'all';
const includeTags = checkoutType === 'all' || checkoutType === 'tags';
const includeRemotes = checkoutType === 'all' || checkoutType === 'remote';
const checkoutTypeConfig = config.get<string | string[]>('checkoutType');
let checkoutTypes: string[];
const heads = repository.refs.filter(ref => ref.type === RefType.Head)
.map(ref => new CheckoutItem(ref));
const tags = (includeTags ? repository.refs.filter(ref => ref.type === RefType.Tag) : [])
.map(ref => new CheckoutTagItem(ref));
const remoteHeads = (includeRemotes ? repository.refs.filter(ref => ref.type === RefType.RemoteHead) : [])
.map(ref => new CheckoutRemoteHeadItem(ref));
if (checkoutTypeConfig === 'all' || !checkoutTypeConfig || checkoutTypeConfig.length === 0) {
checkoutTypes = ['local', 'remote', 'tags'];
} else if (typeof checkoutTypeConfig === 'string') {
checkoutTypes = [checkoutTypeConfig];
} else {
checkoutTypes = checkoutTypeConfig;
}
return [...heads, ...tags, ...remoteHeads];
const processors = checkoutTypes.map(getCheckoutProcessor)
.filter(p => !!p) as CheckoutProcessor[];
for (const ref of repository.refs) {
for (const processor of processors) {
processor.onRef(ref);
}
}
return processors.reduce<CheckoutItem[]>((r, p) => r.concat(...p.items), []);
}
class CheckoutProcessor {
private refs: Ref[] = [];
get items(): CheckoutItem[] { return this.refs.map(r => new this.ctor(r)); }
constructor(private type: RefType, private ctor: { new(ref: Ref): CheckoutItem }) { }
onRef(ref: Ref): void {
if (ref.type === this.type) {
this.refs.push(ref);
}
}
}
function getCheckoutProcessor(type: string): CheckoutProcessor | undefined {
switch (type) {
case 'local':
return new CheckoutProcessor(RefType.Head, CheckoutItem);
case 'remote':
return new CheckoutProcessor(RefType.RemoteHead, CheckoutRemoteHeadItem);
case 'tags':
return new CheckoutProcessor(RefType.Tag, CheckoutTagItem);
}
return undefined;
}
function sanitizeRemoteName(name: string) {
@@ -232,6 +271,7 @@ enum PushType {
Push,
PushTo,
PushFollowTags,
PushTags
}
interface PushOptions {
@@ -240,75 +280,27 @@ interface PushOptions {
silent?: boolean;
}
async function getQuickPickResult<T extends QuickPickItem>(quickpick: QuickPick<T>): Promise<T | undefined> {
const result = await new Promise<T | undefined>(c => {
quickpick.onDidAccept(() => c(quickpick.selectedItems[0]));
quickpick.onDidHide(() => c(undefined));
quickpick.show();
});
class CommandErrorOutputTextDocumentContentProvider implements TextDocumentContentProvider {
quickpick.hide();
return result;
}
private items = new Map<string, string>();
class RemoteSourceProviderQuickPick {
private quickpick: QuickPick<QuickPickItem & { remoteSource?: RemoteSource }>;
constructor(private provider: RemoteSourceProvider) {
this.quickpick = window.createQuickPick();
this.quickpick.ignoreFocusOut = true;
if (provider.supportsQuery) {
this.quickpick.placeholder = localize('type to search', "Repository name (type to search)");
this.quickpick.onDidChangeValue(this.onDidChangeValue, this);
} else {
this.quickpick.placeholder = localize('type to filter', "Repository name");
}
set(uri: Uri, contents: string): void {
this.items.set(uri.path, contents);
}
@debounce(300)
onDidChangeValue(): void {
this.query();
delete(uri: Uri): void {
this.items.delete(uri.path);
}
@throttle
async query(): Promise<void> {
this.quickpick.busy = true;
try {
const remoteSources = await this.provider.getRemoteSources(this.quickpick.value) || [];
if (remoteSources.length === 0) {
this.quickpick.items = [{
label: localize('none found', "No remote repositories found."),
alwaysShow: true
}];
} else {
this.quickpick.items = remoteSources.map(remoteSource => ({
label: remoteSource.name,
description: remoteSource.description || (typeof remoteSource.url === 'string' ? remoteSource.url : remoteSource.url[0]),
remoteSource
}));
}
} catch (err) {
this.quickpick.items = [{ label: localize('error', "$(error) Error: {0}", err.message), alwaysShow: true }];
console.error(err);
} finally {
this.quickpick.busy = false;
}
}
async pick(): Promise<RemoteSource | undefined> {
this.query();
const result = await getQuickPickResult(this.quickpick);
return result?.remoteSource;
provideTextDocumentContent(uri: Uri): string | undefined {
return this.items.get(uri.path);
}
}
export class CommandCenter {
private disposables: Disposable[];
private commandErrors = new CommandErrorOutputTextDocumentContentProvider();
constructor(
private git: Git,
@@ -325,6 +317,8 @@ export class CommandCenter {
return commands.registerCommand(commandId, command);
}
});
this.disposables.push(workspace.registerTextDocumentContentProvider('git-output', this.commandErrors));
}
@command('git.setLogLevel')
@@ -399,7 +393,9 @@ export class CommandCenter {
right = toGitUri(resource.resourceUri, resource.resourceGroupType === ResourceGroupType.Index ? 'index' : 'wt', { submoduleOf: repository.root });
}
} else {
if (resource.type !== Status.DELETED_BY_THEM) {
if (resource.type === Status.DELETED_BY_US || resource.type === Status.DELETED_BY_THEM) {
left = toGitUri(resource.resourceUri, '~1');
} else {
left = this.getLeftResource(resource);
}
@@ -524,54 +520,12 @@ export class CommandCenter {
}
}
@command('git.clone')
async clone(url?: string, parentPath?: string): Promise<void> {
if (!url) {
const quickpick = window.createQuickPick<(QuickPickItem & { provider?: RemoteSourceProvider, url?: string })>();
quickpick.ignoreFocusOut = true;
const providers = this.model.getRemoteProviders()
.map(provider => ({ label: (provider.icon ? `$(${provider.icon}) ` : '') + localize('clonefrom', "Clone from {0}", provider.name), alwaysShow: true, provider }));
quickpick.placeholder = providers.length === 0
? localize('provide url', "Provide repository URL")
: localize('provide url or pick', "Provide repository URL or pick a repository source.");
const updatePicks = (value?: string) => {
if (value) {
quickpick.items = [{
label: localize('repourl', "Clone from URL"),
description: value,
alwaysShow: true,
url: value
},
...providers];
} else {
quickpick.items = providers;
}
};
quickpick.onDidChangeValue(updatePicks);
updatePicks();
const result = await getQuickPickResult(quickpick);
if (result) {
if (result.url) {
url = result.url;
} else if (result.provider) {
const quickpick = new RemoteSourceProviderQuickPick(result.provider);
const remote = await quickpick.pick();
if (remote) {
if (typeof remote.url === 'string') {
url = remote.url;
} else if (remote.url.length > 0) {
url = await window.showQuickPick(remote.url, { ignoreFocusOut: true, placeHolder: localize('pick url', "Choose a URL to clone from.") });
}
}
}
}
async cloneRepository(url?: string, parentPath?: string, options: { recursive?: boolean } = {}): Promise<void> {
if (!url || typeof url !== 'string') {
url = await pickRemoteSource(this.model, {
providerLabel: provider => localize('clonefrom', "Clone from {0}", provider.name),
urlLabel: localize('repourl', "Clone from URL")
});
}
if (!url) {
@@ -622,39 +576,58 @@ export class CommandCenter {
const repositoryPath = await window.withProgress(
opts,
(progress, token) => this.git.clone(url!, parentPath!, progress, token)
(progress, token) => this.git.clone(url!, { parentPath: parentPath!, progress, recursive: options.recursive }, token)
);
let message = localize('proposeopen', "Would you like to open the cloned repository?");
const open = localize('openrepo', "Open");
const openNewWindow = localize('openreponew', "Open in New Window");
const choices = [open, openNewWindow];
const config = workspace.getConfiguration('git');
const openAfterClone = config.get<'always' | 'alwaysNewWindow' | 'whenNoFolderOpen' | 'prompt'>('openAfterClone');
const addToWorkspace = localize('add', "Add to Workspace");
if (workspace.workspaceFolders) {
message = localize('proposeopen2', "Would you like to open the cloned repository, or add it to the current workspace?");
choices.push(addToWorkspace);
enum PostCloneAction { Open, OpenNewWindow, AddToWorkspace }
let action: PostCloneAction | undefined = undefined;
if (openAfterClone === 'always') {
action = PostCloneAction.Open;
} else if (openAfterClone === 'alwaysNewWindow') {
action = PostCloneAction.OpenNewWindow;
} else if (openAfterClone === 'whenNoFolderOpen' && !workspace.workspaceFolders) {
action = PostCloneAction.Open;
}
const result = await window.showInformationMessage(message, ...choices);
if (action === undefined) {
let message = localize('proposeopen', "Would you like to open the cloned repository?");
const open = localize('openrepo', "Open");
const openNewWindow = localize('openreponew', "Open in New Window");
const choices = [open, openNewWindow];
const addToWorkspace = localize('add', "Add to Workspace");
if (workspace.workspaceFolders) {
message = localize('proposeopen2', "Would you like to open the cloned repository, or add it to the current workspace?");
choices.push(addToWorkspace);
}
const result = await window.showInformationMessage(message, ...choices);
action = result === open ? PostCloneAction.Open
: result === openNewWindow ? PostCloneAction.OpenNewWindow
: result === addToWorkspace ? PostCloneAction.AddToWorkspace : undefined;
}
const openFolder = result === open;
/* __GDPR__
"clone" : {
"outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
"openFolder": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }
}
*/
this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'success' }, { openFolder: openFolder ? 1 : 0 });
this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'success' }, { openFolder: action === PostCloneAction.Open || action === PostCloneAction.OpenNewWindow ? 1 : 0 });
const uri = Uri.file(repositoryPath);
if (openFolder) {
commands.executeCommand('vscode.openFolder', uri);
} else if (result === addToWorkspace) {
if (action === PostCloneAction.Open) {
commands.executeCommand('vscode.openFolder', uri, { forceReuseWindow: true });
} else if (action === PostCloneAction.AddToWorkspace) {
workspace.updateWorkspaceFolders(workspace.workspaceFolders!.length, 0, { uri });
} else if (result === openNewWindow) {
commands.executeCommand('vscode.openFolder', uri, true);
} else if (action === PostCloneAction.OpenNewWindow) {
commands.executeCommand('vscode.openFolder', uri, { forceNewWindow: true });
}
} catch (err) {
if (/already exists and is not an empty directory/.test(err && err.stderr || '')) {
@@ -679,6 +652,16 @@ export class CommandCenter {
}
}
@command('git.clone')
async clone(url?: string, parentPath?: string): Promise<void> {
this.cloneRepository(url, parentPath);
}
@command('git.cloneRecursive')
async cloneRecursive(url?: string, parentPath?: string): Promise<void> {
this.cloneRepository(url, parentPath, { recursive: true });
}
@command('git.init')
async init(skipFolderPrompt = false): Promise<void> {
let repositoryPath: string | undefined = undefined;
@@ -845,7 +828,7 @@ export class CommandCenter {
try {
document = await workspace.openTextDocument(uri);
} catch (error) {
await commands.executeCommand<void>('vscode.open', uri, opts);
await commands.executeCommand('vscode.open', uri, opts);
continue;
}
@@ -858,7 +841,7 @@ export class CommandCenter {
const editor = await window.showTextDocument(document, opts);
editor.revealRange(previousVisibleRanges[0]);
} else {
await window.showTextDocument(document, opts);
await commands.executeCommand('vscode.open', uri, opts);
}
}
}
@@ -937,6 +920,27 @@ export class CommandCenter {
}
}
@command('git.rename', { repository: true })
async rename(repository: Repository, fromUri: Uri): Promise<void> {
if (!fromUri) {
return;
}
const from = path.relative(repository.root, fromUri.path);
let to = await window.showInputBox({
value: from,
valueSelection: [from.length - path.basename(from).length, from.length]
});
to = to?.trim();
if (!to) {
return;
}
await repository.move(from, to);
}
@command('git.stage')
async stage(...resourceStates: SourceControlResourceState[]): Promise<void> {
this.outputChannel.appendLine(`git.stage ${resourceStates.length}`);
@@ -1109,6 +1113,9 @@ export class CommandCenter {
}
await this._stageChanges(textEditor, [changes[index]]);
const firstStagedLine = changes[index].modifiedStartLineNumber - 1;
textEditor.selections = [new Selection(firstStagedLine, 0, firstStagedLine, 0)];
}
@command('git.stageSelectedRanges', { diff: true })
@@ -1156,6 +1163,9 @@ export class CommandCenter {
}
await this._revertChanges(textEditor, [...changes.slice(0, index), ...changes.slice(index + 1)]);
const firstStagedLine = changes[index].modifiedStartLineNumber - 1;
textEditor.selections = [new Selection(firstStagedLine, 0, firstStagedLine, 0)];
}
@command('git.revertSelectedRanges', { diff: true })
@@ -1177,7 +1187,9 @@ export class CommandCenter {
return;
}
const selectionsBeforeRevert = textEditor.selections;
await this._revertChanges(textEditor, selectedChanges);
textEditor.selections = selectionsBeforeRevert;
}
private async _revertChanges(textEditor: TextEditor, changes: LineChange[]): Promise<void> {
@@ -1190,7 +1202,6 @@ export class CommandCenter {
const originalUri = toGitUri(modifiedUri, '~');
const originalDocument = await workspace.openTextDocument(originalUri);
const selectionsBeforeRevert = textEditor.selections;
const visibleRangesBeforeRevert = textEditor.visibleRanges;
const result = applyLineChanges(originalDocument, modifiedDocument, changes);
@@ -1200,7 +1211,6 @@ export class CommandCenter {
await modifiedDocument.save();
textEditor.selections = selectionsBeforeRevert;
textEditor.revealRange(visibleRangesBeforeRevert[0]);
}
@@ -1299,7 +1309,7 @@ export class CommandCenter {
if (scmResources.length === 1) {
if (untrackedCount > 0) {
message = localize('confirm delete', "Are you sure you want to DELETE {0}?\nThis is IRREVERSIBLE!\nThis file will be FOREVER LOST.", path.basename(scmResources[0].resourceUri.fsPath));
message = localize('confirm delete', "Are you sure you want to DELETE {0}?\nThis is IRREVERSIBLE!\nThis file will be FOREVER LOST if you proceed.", path.basename(scmResources[0].resourceUri.fsPath));
yes = localize('delete file', "Delete file");
} else {
if (scmResources[0].type === Status.DELETED) {
@@ -1404,7 +1414,7 @@ export class CommandCenter {
private async _cleanTrackedChanges(repository: Repository, resources: Resource[]): Promise<void> {
const message = resources.length === 1
? localize('confirm discard all single', "Are you sure you want to discard changes in {0}?", path.basename(resources[0].resourceUri.fsPath))
: localize('confirm discard all', "Are you sure you want to discard ALL changes in {0} files?\nThis is IRREVERSIBLE!\nYour current working set will be FOREVER LOST.", resources.length);
: localize('confirm discard all', "Are you sure you want to discard ALL changes in {0} files?\nThis is IRREVERSIBLE!\nYour current working set will be FOREVER LOST if you proceed.", resources.length);
const yes = resources.length === 1
? localize('discardAll multiple', "Discard 1 File")
: localize('discardAll', "Discard All {0} Files", resources.length);
@@ -1418,7 +1428,7 @@ export class CommandCenter {
}
private async _cleanUntrackedChange(repository: Repository, resource: Resource): Promise<void> {
const message = localize('confirm delete', "Are you sure you want to DELETE {0}?\nThis is IRREVERSIBLE!\nThis file will be FOREVER LOST.", path.basename(resource.resourceUri.fsPath));
const message = localize('confirm delete', "Are you sure you want to DELETE {0}?\nThis is IRREVERSIBLE!\nThis file will be FOREVER LOST if you proceed.", path.basename(resource.resourceUri.fsPath));
const yes = localize('delete file', "Delete file");
const pick = await window.showWarningMessage(message, { modal: true }, yes);
@@ -1430,7 +1440,7 @@ export class CommandCenter {
}
private async _cleanUntrackedChanges(repository: Repository, resources: Resource[]): Promise<void> {
const message = localize('confirm delete multiple', "Are you sure you want to DELETE {0} files?", resources.length);
const message = localize('confirm delete multiple', "Are you sure you want to DELETE {0} files?\nThis is IRREVERSIBLE!\nThese files will be FOREVER LOST if you proceed.", resources.length);
const yes = localize('delete files', "Delete Files");
const pick = await window.showWarningMessage(message, { modal: true }, yes);
@@ -1496,7 +1506,7 @@ export class CommandCenter {
}
// prompt the user if we want to commit all or not
const message = localize('no staged changes', "There are no staged changes to commit.\n\nWould you like to automatically stage all your changes and commit them directly?");
const message = localize('no staged changes', "There are no staged changes to commit.\n\nWould you like to stage all your changes and commit them directly?");
const yes = localize('yes', "Yes");
const always = localize('always', "Always");
const never = localize('never', "Never");
@@ -1536,10 +1546,38 @@ export class CommandCenter {
// no staged changes and no tracked unstaged changes
|| (noStagedChanges && smartCommitChanges === 'tracked' && repository.workingTreeGroup.resourceStates.every(r => r.type === Status.UNTRACKED))
)
// amend allows changing only the commit message
&& !opts.amend
&& !opts.empty
) {
window.showInformationMessage(localize('no changes', "There are no changes to commit."));
return false;
const commitAnyway = localize('commit anyway', "Create Empty Commit");
const answer = await window.showInformationMessage(localize('no changes', "There are no changes to commit."), commitAnyway);
if (answer !== commitAnyway) {
return false;
}
opts.empty = true;
}
if (opts.noVerify) {
if (!config.get<boolean>('allowNoVerifyCommit')) {
await window.showErrorMessage(localize('no verify commit not allowed', "Commits without verification are not allowed, please enable them with the 'git.allowNoVerifyCommit' setting."));
return false;
}
if (config.get<boolean>('confirmNoVerifyCommit')) {
const message = localize('confirm no verify commit', "You are about to commit your changes without verification, this skips pre-commit hooks and can be undesirable.\n\nAre you sure to continue?");
const yes = localize('ok', "OK");
const neverAgain = localize('never ask again', "OK, Don't Ask Again");
const pick = await window.showWarningMessage(message, { modal: true }, yes, neverAgain);
if (pick === neverAgain) {
config.update('confirmNoVerifyCommit', false, true);
} else if (pick !== yes) {
return false;
}
}
}
const message = await getCommitMessage();
@@ -1646,8 +1684,7 @@ export class CommandCenter {
await this.commitWithAnyInput(repository, { all: true, amend: true });
}
@command('git.commitEmpty', { repository: true })
async commitEmpty(repository: Repository): Promise<void> {
private async _commitEmpty(repository: Repository, noVerify?: boolean): Promise<void> {
const root = Uri.file(repository.root);
const config = workspace.getConfiguration('git', root);
const shouldPrompt = config.get<boolean>('confirmEmptyCommits') === true;
@@ -1665,7 +1702,52 @@ export class CommandCenter {
}
}
await this.commitWithAnyInput(repository, { empty: true });
await this.commitWithAnyInput(repository, { empty: true, noVerify });
}
@command('git.commitEmpty', { repository: true })
async commitEmpty(repository: Repository): Promise<void> {
await this._commitEmpty(repository);
}
@command('git.commitNoVerify', { repository: true })
async commitNoVerify(repository: Repository): Promise<void> {
await this.commitWithAnyInput(repository, { noVerify: true });
}
@command('git.commitStagedNoVerify', { repository: true })
async commitStagedNoVerify(repository: Repository): Promise<void> {
await this.commitWithAnyInput(repository, { all: false, noVerify: true });
}
@command('git.commitStagedSignedNoVerify', { repository: true })
async commitStagedSignedNoVerify(repository: Repository): Promise<void> {
await this.commitWithAnyInput(repository, { all: false, signoff: true, noVerify: true });
}
@command('git.commitStagedAmendNoVerify', { repository: true })
async commitStagedAmendNoVerify(repository: Repository): Promise<void> {
await this.commitWithAnyInput(repository, { all: false, amend: true, noVerify: true });
}
@command('git.commitAllNoVerify', { repository: true })
async commitAllNoVerify(repository: Repository): Promise<void> {
await this.commitWithAnyInput(repository, { all: true, noVerify: true });
}
@command('git.commitAllSignedNoVerify', { repository: true })
async commitAllSignedNoVerify(repository: Repository): Promise<void> {
await this.commitWithAnyInput(repository, { all: true, signoff: true, noVerify: true });
}
@command('git.commitAllAmendNoVerify', { repository: true })
async commitAllAmendNoVerify(repository: Repository): Promise<void> {
await this.commitWithAnyInput(repository, { all: true, amend: true, noVerify: true });
}
@command('git.commitEmptyNoVerify', { repository: true })
async commitEmptyNoVerify(repository: Repository): Promise<void> {
await this._commitEmpty(repository, true);
}
@command('git.restoreCommitTemplate', { repository: true })
@@ -1704,20 +1786,38 @@ export class CommandCenter {
}
@command('git.checkout', { repository: true })
async checkout(repository: Repository, treeish: string): Promise<boolean> {
if (typeof treeish === 'string') {
await repository.checkout(treeish);
async checkout(repository: Repository, treeish?: string): Promise<boolean> {
return this._checkout(repository, { treeish });
}
@command('git.checkoutDetached', { repository: true })
async checkoutDetached(repository: Repository, treeish?: string): Promise<boolean> {
return this._checkout(repository, { detached: true, treeish });
}
private async _checkout(repository: Repository, opts?: { detached?: boolean, treeish?: string }): Promise<boolean> {
if (typeof opts?.treeish === 'string') {
await repository.checkout(opts?.treeish, opts);
return true;
}
const createBranch = new CreateBranchItem(this);
const createBranchFrom = new CreateBranchFromItem(this);
const picks = [createBranch, createBranchFrom, ...createCheckoutItems(repository)];
const placeHolder = localize('select a ref to checkout', 'Select a ref to checkout');
const createBranch = new CreateBranchItem();
const createBranchFrom = new CreateBranchFromItem();
const checkoutDetached = new CheckoutDetachedItem();
const picks: QuickPickItem[] = [];
if (!opts?.detached) {
picks.push(createBranch, createBranchFrom, checkoutDetached);
}
picks.push(...createCheckoutItems(repository));
const quickpick = window.createQuickPick();
quickpick.items = picks;
quickpick.placeholder = placeHolder;
quickpick.placeholder = opts?.detached
? localize('select a ref to checkout detached', 'Select a ref to checkout in detached mode')
: localize('select a ref to checkout', 'Select a ref to checkout');
quickpick.show();
const choice = await new Promise<QuickPickItem | undefined>(c => quickpick.onDidAccept(() => c(quickpick.activeItems[0])));
@@ -1731,8 +1831,31 @@ export class CommandCenter {
await this._branch(repository, quickpick.value);
} else if (choice === createBranchFrom) {
await this._branch(repository, quickpick.value, true);
} else if (choice === checkoutDetached) {
return this._checkout(repository, { detached: true });
} else {
await (choice as CheckoutItem).run(repository);
const item = choice as CheckoutItem;
try {
await item.run(repository, opts);
} catch (err) {
if (err.gitErrorCode !== GitErrorCodes.DirtyWorkTree) {
throw err;
}
const force = localize('force', "Force Checkout");
const stash = localize('stashcheckout', "Stash & Checkout");
const choice = await window.showWarningMessage(localize('local changes', "Your local changes would be overwritten by checkout."), { modal: true }, force, stash);
if (choice === force) {
await this.cleanAll(repository);
await item.run(repository, opts);
} else if (choice === stash) {
await this.stash(repository);
await item.run(repository, opts);
await this.stashPopLatest(repository);
}
}
}
return true;
@@ -1885,6 +2008,44 @@ export class CommandCenter {
await choice.run(repository);
}
@command('git.rebase', { repository: true })
async rebase(repository: Repository): Promise<void> {
const config = workspace.getConfiguration('git');
const checkoutType = config.get<string>('checkoutType') || 'all';
const includeRemotes = checkoutType === 'all' || checkoutType === 'remote';
const heads = repository.refs.filter(ref => ref.type === RefType.Head)
.filter(ref => ref.name !== repository.HEAD?.name)
.filter(ref => ref.name || ref.commit);
const remoteHeads = (includeRemotes ? repository.refs.filter(ref => ref.type === RefType.RemoteHead) : [])
.filter(ref => ref.name || ref.commit);
const picks = [...heads, ...remoteHeads]
.map(ref => new RebaseItem(ref));
// set upstream branch as first
if (repository.HEAD?.upstream) {
const upstreamName = `${repository.HEAD?.upstream.remote}/${repository.HEAD?.upstream.name}`;
const index = picks.findIndex(e => e.ref.name === upstreamName);
if (index > -1) {
const [ref] = picks.splice(index, 1);
ref.description = '(upstream)';
picks.unshift(ref);
}
}
const placeHolder = localize('select a branch to rebase onto', 'Select a branch to rebase onto');
const choice = await window.showQuickPick<RebaseItem>(picks, { placeHolder });
if (!choice) {
return;
}
await choice.run(repository);
}
@command('git.createTag', { repository: true })
async createTag(repository: Repository): Promise<void> {
const inputTagName = await window.showInputBox({
@@ -1904,8 +2065,7 @@ export class CommandCenter {
});
const name = inputTagName.replace(/^\.|\/\.|\.\.|~|\^|:|\/$|\.lock$|\.lock\/|\\|\*|\s|^\s*$|\.$/g, '-');
const message = inputMessage || name;
await repository.tag(name, message);
await repository.tag(name, inputMessage);
}
@command('git.deleteTag', { repository: true })
@@ -2063,6 +2223,10 @@ export class CommandCenter {
return;
}
if (pushOptions.pushType === PushType.PushTags) {
await repository.pushTags(undefined, forcePushMode);
}
if (!repository.HEAD || !repository.HEAD.name) {
if (!pushOptions.silent) {
window.showWarningMessage(localize('nobranch', "Please check out a branch to push to a remote."));
@@ -2144,54 +2308,17 @@ export class CommandCenter {
await this._push(repository, { pushType: PushType.PushTo, forcePush: true });
}
@command('git.pushTags', { repository: true })
async pushTags(repository: Repository): Promise<void> {
await this._push(repository, { pushType: PushType.PushTags });
}
@command('git.addRemote', { repository: true })
async addRemote(repository: Repository): Promise<string | undefined> {
const quickpick = window.createQuickPick<(QuickPickItem & { provider?: RemoteSourceProvider, url?: string })>();
quickpick.ignoreFocusOut = true;
const providers = this.model.getRemoteProviders()
.map(provider => ({ label: (provider.icon ? `$(${provider.icon}) ` : '') + localize('addfrom', "Add remote from {0}", provider.name), alwaysShow: true, provider }));
quickpick.placeholder = providers.length === 0
? localize('provide url', "Provide repository URL")
: localize('provide url or pick', "Provide repository URL or pick a repository source.");
const updatePicks = (value?: string) => {
if (value) {
quickpick.items = [{
label: localize('addFrom', "Add remote from URL"),
description: value,
alwaysShow: true,
url: value
},
...providers];
} else {
quickpick.items = providers;
}
};
quickpick.onDidChangeValue(updatePicks);
updatePicks();
const result = await getQuickPickResult(quickpick);
let url: string | undefined;
if (result) {
if (result.url) {
url = result.url;
} else if (result.provider) {
const quickpick = new RemoteSourceProviderQuickPick(result.provider);
const remote = await quickpick.pick();
if (remote) {
if (typeof remote.url === 'string') {
url = remote.url;
} else if (remote.url.length > 0) {
url = await window.showQuickPick(remote.url, { ignoreFocusOut: true, placeHolder: localize('pick url', "Choose a URL to clone from.") });
}
}
}
}
const url = await pickRemoteSource(this.model, {
providerLabel: provider => localize('addfrom', "Add remote from {0}", provider.name),
urlLabel: localize('addFrom', "Add remote from URL")
});
if (!url) {
return;
@@ -2219,6 +2346,7 @@ export class CommandCenter {
}
await repository.addRemote(name, url);
await repository.fetch(name);
return name;
}
@@ -2432,6 +2560,34 @@ export class CommandCenter {
return;
}
const config = workspace.getConfiguration('git', Uri.file(repository.root));
const promptToSaveFilesBeforeStashing = config.get<'always' | 'staged' | 'never'>('promptToSaveFilesBeforeStash');
if (promptToSaveFilesBeforeStashing !== 'never') {
let documents = workspace.textDocuments
.filter(d => !d.isUntitled && d.isDirty && isDescendant(repository.root, d.uri.fsPath));
if (promptToSaveFilesBeforeStashing === 'staged' || repository.indexGroup.resourceStates.length > 0) {
documents = documents
.filter(d => repository.indexGroup.resourceStates.some(s => pathEquals(s.resourceUri.fsPath, d.uri.fsPath)));
}
if (documents.length > 0) {
const message = documents.length === 1
? localize('unsaved stash files single', "The following file has unsaved changes which won't be included in the stash if you proceed: {0}.\n\nWould you like to save it before stashing?", path.basename(documents[0].uri.fsPath))
: localize('unsaved stash files', "There are {0} unsaved files.\n\nWould you like to save them before stashing?", documents.length);
const saveAndStash = localize('save and stash', "Save All & Stash");
const stash = localize('stash', "Stash Anyway");
const pick = await window.showWarningMessage(message, { modal: true }, saveAndStash, stash);
if (pick === saveAndStash) {
await Promise.all(documents.map(d => d.save()));
} else if (pick !== stash) {
return; // do not stash on cancel
}
}
}
const message = await this.getStashMessage();
if (typeof message === 'undefined') {
@@ -2515,6 +2671,16 @@ export class CommandCenter {
return;
}
// request confirmation for the operation
const yes = localize('yes', "Yes");
const result = await window.showWarningMessage(
localize('sure drop', "Are you sure you want to drop the stash: {0}?", stash.description),
yes
);
if (result !== yes) {
return;
}
await repository.dropStash(stash.index);
}
@@ -2533,8 +2699,7 @@ export class CommandCenter {
@command('git.timeline.openDiff', { repository: false })
async timelineOpenDiff(item: TimelineItem, uri: Uri | undefined, _source: string) {
// eslint-disable-next-line eqeqeq
if (uri == null || !GitTimelineItem.is(item)) {
if (uri === undefined || uri === null || !GitTimelineItem.is(item)) {
return undefined;
}
@@ -2579,7 +2744,11 @@ export class CommandCenter {
@command('git.rebaseAbort', { repository: true })
async rebaseAbort(repository: Repository): Promise<void> {
await repository.rebaseAbort();
if (repository.rebaseCommit) {
await repository.rebaseAbort();
} else {
await window.showInformationMessage(localize('no rebase', "No rebase in progress."));
}
}
private createCommand(id: string, key: string, method: Function, options: CommandOptions): (...args: any[]) => any {
@@ -2630,6 +2799,31 @@ export class CommandCenter {
const outputChannel = this.outputChannel as OutputChannel;
choices.set(openOutputChannelChoice, () => outputChannel.show());
const showCommandOutputChoice = localize('show command output', "Show Command Output");
if (err.stderr) {
choices.set(showCommandOutputChoice, async () => {
const timestamp = new Date().getTime();
const uri = Uri.parse(`git-output:/git-error-${timestamp}`);
let command = 'git';
if (err.gitArgs) {
command = `${command} ${err.gitArgs.join(' ')}`;
} else if (err.gitCommand) {
command = `${command} ${err.gitCommand}`;
}
this.commandErrors.set(uri, `> ${command}\n${err.stderr}`);
try {
const doc = await workspace.openTextDocument(uri);
await window.showTextDocument(doc);
} finally {
this.commandErrors.delete(uri);
}
});
}
switch (err.gitErrorCode) {
case GitErrorCodes.DirtyWorkTree:
message = localize('clean repo', "Please clean your repository working tree before checkout.");

View File

@@ -3,25 +3,25 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { window, workspace, Uri, Disposable, Event, EventEmitter, Decoration, DecorationProvider, ThemeColor } from 'vscode';
import { window, workspace, Uri, Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, ThemeColor } from 'vscode';
import * as path from 'path';
import { Repository, GitResourceGroup } from './repository';
import { Model } from './model';
import { debounce } from './decorators';
import { filterEvent, dispose, anyEvent, fireEvent } from './util';
import { filterEvent, dispose, anyEvent, fireEvent, PromiseSource } from './util';
import { GitErrorCodes, Status } from './api/git';
type Callback = { resolve: (status: boolean) => void, reject: (err: any) => void };
class GitIgnoreDecorationProvider implements FileDecorationProvider {
class GitIgnoreDecorationProvider implements DecorationProvider {
private static Decoration: FileDecoration = { color: new ThemeColor('gitDecoration.ignoredResourceForeground') };
readonly onDidChangeDecorations: Event<Uri[]>;
private queue = new Map<string, { repository: Repository; queue: Map<string, Callback>; }>();
readonly onDidChange: Event<Uri[]>;
private queue = new Map<string, { repository: Repository; queue: Map<string, PromiseSource<FileDecoration | undefined>>; }>();
private disposables: Disposable[] = [];
constructor(private model: Model) {
this.onDidChangeDecorations = fireEvent(anyEvent<any>(
filterEvent(workspace.onDidSaveTextDocument, e => e.fileName.endsWith('.gitignore')),
this.onDidChange = fireEvent(anyEvent<any>(
filterEvent(workspace.onDidSaveTextDocument, e => /\.gitignore$|\.git\/info\/exclude$/.test(e.uri.path)),
model.onDidOpenRepository,
model.onDidCloseRepository
));
@@ -29,32 +29,29 @@ class GitIgnoreDecorationProvider implements DecorationProvider {
this.disposables.push(window.registerDecorationProvider(this));
}
provideDecoration(uri: Uri): Promise<Decoration | undefined> {
async provideFileDecoration(uri: Uri): Promise<FileDecoration | undefined> {
const repository = this.model.getRepository(uri);
if (!repository) {
return Promise.resolve(undefined);
return;
}
let queueItem = this.queue.get(repository.root);
if (!queueItem) {
queueItem = { repository, queue: new Map<string, Callback>() };
queueItem = { repository, queue: new Map<string, PromiseSource<FileDecoration | undefined>>() };
this.queue.set(repository.root, queueItem);
}
return new Promise<boolean>((resolve, reject) => {
queueItem!.queue.set(uri.fsPath, { resolve, reject });
let promiseSource = queueItem.queue.get(uri.fsPath);
if (!promiseSource) {
promiseSource = new PromiseSource();
queueItem!.queue.set(uri.fsPath, promiseSource);
this.checkIgnoreSoon();
}).then(ignored => {
if (ignored) {
return <Decoration>{
priority: 3,
color: new ThemeColor('gitDecoration.ignoredResourceForeground')
};
}
return undefined;
});
}
return await promiseSource.promise;
}
@debounce(500)
@@ -66,16 +63,16 @@ class GitIgnoreDecorationProvider implements DecorationProvider {
const paths = [...item.queue.keys()];
item.repository.checkIgnore(paths).then(ignoreSet => {
for (const [key, value] of item.queue.entries()) {
value.resolve(ignoreSet.has(key));
for (const [path, promiseSource] of item.queue.entries()) {
promiseSource.resolve(ignoreSet.has(path) ? GitIgnoreDecorationProvider.Decoration : undefined);
}
}, err => {
if (err.gitErrorCode !== GitErrorCodes.IsInSubmodule) {
console.error(err);
}
for (const [, value] of item.queue.entries()) {
value.reject(err);
for (const [, promiseSource] of item.queue.entries()) {
promiseSource.reject(err);
}
});
}
@@ -87,19 +84,19 @@ class GitIgnoreDecorationProvider implements DecorationProvider {
}
}
class GitDecorationProvider implements DecorationProvider {
class GitDecorationProvider implements FileDecorationProvider {
private static SubmoduleDecorationData: Decoration = {
title: 'Submodule',
letter: 'S',
private static SubmoduleDecorationData: FileDecoration = {
tooltip: 'Submodule',
badge: 'S',
color: new ThemeColor('gitDecoration.submoduleResourceForeground')
};
private readonly _onDidChangeDecorations = new EventEmitter<Uri[]>();
readonly onDidChangeDecorations: Event<Uri[]> = this._onDidChangeDecorations.event;
readonly onDidChange: Event<Uri[]> = this._onDidChangeDecorations.event;
private disposables: Disposable[] = [];
private decorations = new Map<string, Decoration>();
private decorations = new Map<string, FileDecoration>();
constructor(private repository: Repository) {
this.disposables.push(
@@ -109,7 +106,7 @@ class GitDecorationProvider implements DecorationProvider {
}
private onDidRunGitStatus(): void {
let newDecorations = new Map<string, Decoration>();
let newDecorations = new Map<string, FileDecoration>();
this.collectSubmoduleDecorationData(newDecorations);
this.collectDecorationData(this.repository.indexGroup, newDecorations);
@@ -122,7 +119,7 @@ class GitDecorationProvider implements DecorationProvider {
this._onDidChangeDecorations.fire([...uris.values()].map(value => Uri.parse(value, true)));
}
private collectDecorationData(group: GitResourceGroup, bucket: Map<string, Decoration>): void {
private collectDecorationData(group: GitResourceGroup, bucket: Map<string, FileDecoration>): void {
for (const r of group.resourceStates) {
const decoration = r.resourceDecoration;
@@ -137,13 +134,13 @@ class GitDecorationProvider implements DecorationProvider {
}
}
private collectSubmoduleDecorationData(bucket: Map<string, Decoration>): void {
private collectSubmoduleDecorationData(bucket: Map<string, FileDecoration>): void {
for (const submodule of this.repository.submodules) {
bucket.set(Uri.file(path.join(this.repository.root, submodule.path)).toString(), GitDecorationProvider.SubmoduleDecorationData);
}
}
provideDecoration(uri: Uri): Decoration | undefined {
provideFileDecoration(uri: Uri): FileDecoration | undefined {
return this.decorations.get(uri.toString());
}

View File

@@ -0,0 +1,39 @@
/*---------------------------------------------------------------------------------------------
* 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 { workspace, Uri } from 'vscode';
import { getExtensionContext } from './main';
import { TextDecoder } from 'util';
const emojiRegex = /:([-+_a-z0-9]+):/g;
let emojiMap: Record<string, string> | undefined;
let emojiMapPromise: Promise<void> | undefined;
export async function ensureEmojis() {
if (emojiMap === undefined) {
if (emojiMapPromise === undefined) {
emojiMapPromise = loadEmojiMap();
}
await emojiMapPromise;
}
}
async function loadEmojiMap() {
const context = getExtensionContext();
const uri = (Uri as any).joinPath(context.extensionUri, 'resources', 'emojis.json');
emojiMap = JSON.parse(new TextDecoder('utf8').decode(await workspace.fs.readFile(uri)));
}
export function emojify(message: string) {
if (emojiMap === undefined) {
return message;
}
return message.replace(emojiRegex, (s, code) => {
return emojiMap?.[code] || s;
});
}

View File

@@ -133,6 +133,8 @@ export class GitFileSystemProvider implements FileSystemProvider {
}
async stat(uri: Uri): Promise<FileStat> {
await this.model.isInitialized;
const { submoduleOf, path, ref } = fromGitUri(uri);
const repository = submoduleOf ? this.model.getRepository(submoduleOf) : this.model.getRepository(uri);
if (!repository) {
@@ -158,6 +160,8 @@ export class GitFileSystemProvider implements FileSystemProvider {
}
async readFile(uri: Uri): Promise<Uint8Array> {
await this.model.isInitialized;
const { path, ref, submoduleOf } = fromGitUri(uri);
if (submoduleOf) {

View File

@@ -9,11 +9,10 @@ import * as os from 'os';
import * as cp from 'child_process';
import * as which from 'which';
import { EventEmitter } from 'events';
import iconv = require('iconv-lite');
import * as iconv from 'iconv-lite-umd';
import * as filetype from 'file-type';
import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter } from './util';
import { CancellationToken, Progress, Uri } from 'vscode';
import { URI } from 'vscode-uri';
import { detectEncoding } from './encoding';
import { Ref, RefType, Branch, Remote, GitErrorCodes, LogOptions, Change, Status, CommitOptions, BranchQuery } from './api/git';
import * as byline from 'byline';
@@ -139,18 +138,28 @@ function findGitWin32(onLookup: (path: string) => void): Promise<IGit> {
.then(undefined, () => findGitWin32InPath(onLookup));
}
export function findGit(hint: string | undefined, onLookup: (path: string) => void): Promise<IGit> {
const first = hint ? findSpecificGit(hint, onLookup) : Promise.reject<IGit>(null);
export async function findGit(hint: string | string[] | undefined, onLookup: (path: string) => void): Promise<IGit> {
const hints = Array.isArray(hint) ? hint : hint ? [hint] : [];
return first
.then(undefined, () => {
switch (process.platform) {
case 'darwin': return findGitDarwin(onLookup);
case 'win32': return findGitWin32(onLookup);
default: return findSpecificGit('git', onLookup);
}
})
.then(null, () => Promise.reject(new Error('Git installation not found.')));
for (const hint of hints) {
try {
return await findSpecificGit(hint, onLookup);
} catch {
// noop
}
}
try {
switch (process.platform) {
case 'darwin': return await findGitDarwin(onLookup);
case 'win32': return await findGitWin32(onLookup);
default: return await findSpecificGit('git', onLookup);
}
} catch {
// noop
}
throw new Error('Git installation not found.');
}
export interface IExecutionResult<T extends string | Buffer> {
@@ -251,6 +260,7 @@ export interface IGitErrorData {
exitCode?: number;
gitErrorCode?: string;
gitCommand?: string;
gitArgs?: string[];
}
export class GitError {
@@ -262,6 +272,7 @@ export class GitError {
exitCode?: number;
gitErrorCode?: string;
gitCommand?: string;
gitArgs?: string[];
constructor(data: IGitErrorData) {
if (data.error) {
@@ -278,6 +289,7 @@ export class GitError {
this.exitCode = data.exitCode;
this.gitErrorCode = data.gitErrorCode;
this.gitCommand = data.gitCommand;
this.gitArgs = data.gitArgs;
}
toString(): string {
@@ -341,6 +353,12 @@ function sanitizePath(path: string): string {
const COMMIT_FORMAT = '%H%n%aN%n%aE%n%at%n%ct%n%P%n%B';
export interface ICloneOptions {
readonly parentPath: string;
readonly progress: Progress<{ increment: number }>;
readonly recursive?: boolean;
}
export class Git {
readonly path: string;
@@ -363,18 +381,18 @@ export class Git {
return;
}
async clone(url: string, parentPath: string, progress: Progress<{ increment: number }>, cancellationToken?: CancellationToken): Promise<string> {
async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise<string> {
let baseFolderName = decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository';
let folderName = baseFolderName;
let folderPath = path.join(parentPath, folderName);
let folderPath = path.join(options.parentPath, folderName);
let count = 1;
while (count < 20 && await new Promise(c => exists(folderPath, c))) {
folderName = `${baseFolderName}-${count++}`;
folderPath = path.join(parentPath, folderName);
folderPath = path.join(options.parentPath, folderName);
}
await mkdirp(parentPath);
await mkdirp(options.parentPath);
const onSpawn = (child: cp.ChildProcess) => {
const decoder = new StringDecoder('utf8');
@@ -398,14 +416,18 @@ export class Git {
}
if (totalProgress !== previousProgress) {
progress.report({ increment: totalProgress - previousProgress });
options.progress.report({ increment: totalProgress - previousProgress });
previousProgress = totalProgress;
}
});
};
try {
await this.exec(parentPath, ['clone', url.includes(' ') ? encodeURI(url) : url, folderPath, '--progress'], { cancellationToken, onSpawn });
let command = ['clone', url.includes(' ') ? encodeURI(url) : url, folderPath, '--progress'];
if (options.recursive) {
command.push('--recursive');
}
await this.exec(options.parentPath, command, { cancellationToken, onSpawn });
} catch (err) {
if (err.stderr) {
err.stderr = err.stderr.replace(/^Cloning.+$/m, '').trim();
@@ -419,10 +441,10 @@ export class Git {
}
async getRepositoryRoot(repositoryPath: string): Promise<string> {
const result = await this.exec(repositoryPath, ['rev-parse', '--show-toplevel']);
const result = await this.exec(repositoryPath, ['rev-parse', '--show-toplevel'], { log: false });
// Keep trailing spaces which are part of the directory name
const repoPath = path.normalize(result.stdout.trimLeft().replace(/(\r\n|\r|\n)+$/, ''));
const repoPath = path.normalize(result.stdout.trimLeft().replace(/[\r\n]+$/, ''));
if (isWindows) {
// On Git 2.25+ if you call `rev-parse --show-toplevel` on a mapped drive, instead of getting the mapped drive path back, you get the UNC path for the mapped drive.
@@ -435,10 +457,9 @@ export class Git {
const [, letter] = match;
try {
const networkPath = await new Promise<string>(resolve =>
const networkPath = await new Promise<string | undefined>(resolve =>
realpath.native(`${letter}:`, { encoding: 'utf8' }, (err, resolvedPath) =>
// eslint-disable-next-line eqeqeq
resolve(err != null ? undefined : resolvedPath),
resolve(err !== null ? undefined : resolvedPath),
),
);
if (networkPath !== undefined) {
@@ -517,7 +538,8 @@ export class Git {
stderr: result.stderr,
exitCode: result.exitCode,
gitErrorCode: getGitErrorCode(result.stderr),
gitCommand: args[0]
gitCommand: args[0],
gitArgs: args
}));
}
@@ -670,7 +692,7 @@ export function parseGitmodules(raw: string): Submodule[] {
return;
}
const propertyMatch = /^\s*(\w+)\s+=\s+(.*)$/.exec(line);
const propertyMatch = /^\s*(\w+)\s*=\s*(.*)$/.exec(line);
if (!propertyMatch) {
return;
@@ -905,7 +927,7 @@ export class Repository {
}
async buffer(object: string): Promise<Buffer> {
const child = this.stream(['show', object]);
const child = this.stream(['show', '--textconv', object]);
if (!child.stdout) {
return Promise.reject<Buffer>('Can\'t open file from git');
@@ -978,7 +1000,7 @@ export class Repository {
}
async detectObjectType(object: string): Promise<{ mimetype: string, encoding?: string }> {
const child = await this.stream(['show', object]);
const child = await this.stream(['show', '--textconv', object]);
const buffer = await readBytes(child.stdout!, 4100);
try {
@@ -1146,7 +1168,7 @@ export class Repository {
break;
}
const originalUri = URI.file(path.isAbsolute(resourcePath) ? resourcePath : path.join(this.repositoryRoot, resourcePath));
const originalUri = Uri.file(path.isAbsolute(resourcePath) ? resourcePath : path.join(this.repositoryRoot, resourcePath));
let status: Status = Status.UNTRACKED;
// Copy or Rename status comes with a number, e.g. 'R100'. We don't need the number, so we use only first character of the status.
@@ -1174,7 +1196,7 @@ export class Repository {
break;
}
const uri = URI.file(path.isAbsolute(newPath) ? newPath : path.join(this.repositoryRoot, newPath));
const uri = Uri.file(path.isAbsolute(newPath) ? newPath : path.join(this.repositoryRoot, newPath));
result.push({
uri,
renameUri: uri,
@@ -1224,7 +1246,7 @@ export class Repository {
}
if (paths && paths.length) {
for (const chunk of splitInChunks(paths, MAX_CLI_LENGTH)) {
for (const chunk of splitInChunks(paths.map(sanitizePath), MAX_CLI_LENGTH)) {
await this.run([...args, '--', ...chunk]);
}
} else {
@@ -1277,13 +1299,17 @@ export class Repository {
await this.run(['update-index', add, '--cacheinfo', mode, hash, path]);
}
async checkout(treeish: string, paths: string[], opts: { track?: boolean } = Object.create(null)): Promise<void> {
async checkout(treeish: string, paths: string[], opts: { track?: boolean, detached?: boolean } = Object.create(null)): Promise<void> {
const args = ['checkout', '-q'];
if (opts.track) {
args.push('--track');
}
if (opts.detached) {
args.push('--detach');
}
if (treeish) {
args.push(treeish);
}
@@ -1299,6 +1325,7 @@ export class Repository {
} catch (err) {
if (/Please,? commit your changes or stash them/.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.DirtyWorkTree;
err.gitTreeish = treeish;
}
throw err;
@@ -1323,10 +1350,15 @@ export class Repository {
if (opts.signCommit) {
args.push('-S');
}
if (opts.empty) {
args.push('--allow-empty');
}
if (opts.noVerify) {
args.push('--no-verify');
}
try {
await this.run(args, { input: message || '' });
} catch (commitErr) {
@@ -1391,6 +1423,11 @@ export class Repository {
await this.run(args);
}
async move(from: string, to: string): Promise<void> {
const args = ['mv', from, to];
await this.run(args);
}
async setBranchUpstream(name: string, upstream: string): Promise<void> {
const args = ['branch', '--set-upstream-to', upstream, name];
await this.run(args);
@@ -1441,7 +1478,7 @@ export class Repository {
const args = ['clean', '-f', '-q'];
for (const paths of groups) {
for (const chunk of splitInChunks(paths, MAX_CLI_LENGTH)) {
for (const chunk of splitInChunks(paths.map(sanitizePath), MAX_CLI_LENGTH)) {
promises.push(limiter.queue(() => this.run([...args, '--', ...chunk])));
}
}
@@ -1481,7 +1518,7 @@ export class Repository {
try {
if (paths && paths.length > 0) {
for (const chunk of splitInChunks(paths, MAX_CLI_LENGTH)) {
for (const chunk of splitInChunks(paths.map(sanitizePath), MAX_CLI_LENGTH)) {
await this.run([...args, '--', ...chunk]);
}
} else {
@@ -1596,7 +1633,25 @@ export class Repository {
}
}
async push(remote?: string, name?: string, setUpstream: boolean = false, tags = false, forcePushMode?: ForcePushMode): Promise<void> {
async rebase(branch: string, options: PullOptions = {}): Promise<void> {
const args = ['rebase'];
args.push(branch);
try {
await this.run(args, options);
} catch (err) {
if (/^CONFLICT \([^)]+\): \b/m.test(err.stdout || '')) {
err.gitErrorCode = GitErrorCodes.Conflict;
} else if (/cannot rebase onto multiple branches/i.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.CantRebaseMultipleBranches;
}
throw err;
}
}
async push(remote?: string, name?: string, setUpstream: boolean = false, followTags = false, forcePushMode?: ForcePushMode, tags = false): Promise<void> {
const args = ['push'];
if (forcePushMode === ForcePushMode.ForceWithLease) {
@@ -1609,10 +1664,14 @@ export class Repository {
args.push('-u');
}
if (tags) {
if (followTags) {
args.push('--follow-tags');
}
if (tags) {
args.push('--tags');
}
if (remote) {
args.push(remote);
}
@@ -1630,6 +1689,8 @@ export class Repository {
err.gitErrorCode = GitErrorCodes.RemoteConnectionError;
} else if (/^fatal: The current branch .* has no upstream branch/.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.NoUpstreamBranch;
} else if (/Permission.*denied/.test(err.stderr || '')) {
err.gitErrorCode = GitErrorCodes.PermissionDenied;
}
throw err;
@@ -1720,11 +1781,17 @@ export class Repository {
}
}
getStatus(limit = 5000): Promise<{ status: IFileStatus[]; didHitLimit: boolean; }> {
getStatus(opts?: { limit?: number, ignoreSubmodules?: boolean }): Promise<{ status: IFileStatus[]; didHitLimit: boolean; }> {
return new Promise<{ status: IFileStatus[]; didHitLimit: boolean; }>((c, e) => {
const parser = new GitStatusParser();
const env = { GIT_OPTIONAL_LOCKS: '0' };
const child = this.stream(['status', '-z', '-u'], { env });
const args = ['status', '-z', '-u'];
if (opts?.ignoreSubmodules) {
args.push('--ignore-submodules');
}
const child = this.stream(args, { env });
const onExit = (exitCode: number) => {
if (exitCode !== 0) {
@@ -1734,13 +1801,15 @@ export class Repository {
stderr,
exitCode,
gitErrorCode: getGitErrorCode(stderr),
gitCommand: 'status'
gitCommand: 'status',
gitArgs: args
}));
}
c({ status: parser.status, didHitLimit: false });
};
const limit = opts?.limit ?? 5000;
const onStdoutData = (raw: string) => {
parser.update(raw);
@@ -1793,13 +1862,23 @@ export class Repository {
.map(([ref]) => ({ name: ref, type: RefType.Head } as Branch));
}
async getRefs(opts?: { sort?: 'alphabetically' | 'committerdate', contains?: string }): Promise<Ref[]> {
const args = ['for-each-ref', '--format', '%(refname) %(objectname)'];
async getRefs(opts?: { sort?: 'alphabetically' | 'committerdate', contains?: string, pattern?: string, count?: number }): Promise<Ref[]> {
const args = ['for-each-ref'];
if (opts?.count) {
args.push(`--count=${opts.count}`);
}
if (opts && opts.sort && opts.sort !== 'alphabetically') {
args.push('--sort', `-${opts.sort}`);
}
args.push('--format', '%(refname) %(objectname) %(*objectname)');
if (opts?.pattern) {
args.push(opts.pattern);
}
if (opts?.contains) {
args.push('--contains', opts.contains);
}
@@ -1809,12 +1888,12 @@ export class Repository {
const fn = (line: string): Ref | null => {
let match: RegExpExecArray | null;
if (match = /^refs\/heads\/([^ ]+) ([0-9a-f]{40})$/.exec(line)) {
if (match = /^refs\/heads\/([^ ]+) ([0-9a-f]{40}) ([0-9a-f]{40})?$/.exec(line)) {
return { name: match[1], commit: match[2], type: RefType.Head };
} else if (match = /^refs\/remotes\/([^/]+)\/([^ ]+) ([0-9a-f]{40})$/.exec(line)) {
} else if (match = /^refs\/remotes\/([^/]+)\/([^ ]+) ([0-9a-f]{40}) ([0-9a-f]{40})?$/.exec(line)) {
return { name: `${match[1]}/${match[2]}`, commit: match[3], type: RefType.RemoteHead, remote: match[1] };
} else if (match = /^refs\/tags\/([^ ]+) ([0-9a-f]{40})$/.exec(line)) {
return { name: match[1], commit: match[2], type: RefType.Tag };
} else if (match = /^refs\/tags\/([^ ]+) ([0-9a-f]{40}) ([0-9a-f]{40})?$/.exec(line)) {
return { name: match[1], commit: match[3] ?? match[2], type: RefType.Tag };
}
return null;
@@ -1863,7 +1942,7 @@ export class Repository {
remote.pushUrl = url;
}
// https://github.com/Microsoft/vscode/issues/45271
// https://github.com/microsoft/vscode/issues/45271
remote.isReadOnly = remote.pushUrl === undefined || remote.pushUrl === 'no_push';
}
@@ -1922,7 +2001,7 @@ export class Repository {
}
async getBranches(query: BranchQuery): Promise<Ref[]> {
const refs = await this.getRefs({ contains: query.contains });
const refs = await this.getRefs({ contains: query.contains, pattern: query.pattern ? `refs/${query.pattern}` : undefined, count: query.count });
return refs.filter(value => (value.type !== RefType.Tag) && (query.remote || !value.remote));
}
@@ -1931,6 +2010,17 @@ export class Repository {
return message.replace(/^\s*#.*$\n?/gm, '').trim();
}
async getSquashMessage(): Promise<string | undefined> {
const squashMsgPath = path.join(this.repositoryRoot, '.git', 'SQUASH_MSG');
try {
const raw = await fs.readFile(squashMsgPath, 'utf8');
return this.stripCommitMessageComments(raw);
} catch {
return undefined;
}
}
async getMergeMessage(): Promise<string | undefined> {
const mergeMsgPath = path.join(this.repositoryRoot, '.git', 'MERGE_MSG');

View File

@@ -33,7 +33,7 @@ export async function deactivate(): Promise<any> {
}
async function createModel(context: ExtensionContext, outputChannel: OutputChannel, telemetryReporter: TelemetryReporter, disposables: Disposable[]): Promise<Model> {
const pathHint = workspace.getConfiguration('git').get<string>('path');
const pathHint = workspace.getConfiguration('git').get<string | string[]>('path');
const info = await findGit(pathHint, path => outputChannel.appendLine(localize('looking', "Looking for git in: {0}", path)));
const askpass = await Askpass.create(outputChannel, context.storagePath);
@@ -74,7 +74,7 @@ async function createModel(context: ExtensionContext, outputChannel: OutputChann
new GitTimelineProvider(model)
);
await checkGitVersion(info);
checkGitVersion(info);
return model;
}
@@ -127,7 +127,7 @@ async function warnAboutMissingGit(): Promise<void> {
}
}
export async function _activate(context: ExtensionContext): Promise<GitExtension> {
export async function _activate(context: ExtensionContext): Promise<GitExtensionImpl> {
const disposables: Disposable[] = [];
context.subscriptions.push(new Disposable(() => Disposable.from(...disposables).dispose()));
@@ -169,13 +169,20 @@ export async function _activate(context: ExtensionContext): Promise<GitExtension
}
}
let _context: ExtensionContext;
export function getExtensionContext(): ExtensionContext {
return _context;
}
export async function activate(context: ExtensionContext): Promise<GitExtension> {
_context = context;
const result = await _activate(context);
context.subscriptions.push(registerAPICommands(result));
return result;
}
async function checkGitVersion(info: IGit): Promise<void> {
async function checkGitv1(info: IGit): Promise<void> {
const config = workspace.getConfiguration('git');
const shouldIgnore = config.get<boolean>('ignoreLegacyWarning') === true;
@@ -202,3 +209,38 @@ async function checkGitVersion(info: IGit): Promise<void> {
await config.update('ignoreLegacyWarning', true, true);
}
}
async function checkGitWindows(info: IGit): Promise<void> {
if (!/^2\.(25|26)\./.test(info.version)) {
return;
}
const config = workspace.getConfiguration('git');
const shouldIgnore = config.get<boolean>('ignoreWindowsGit27Warning') === true;
if (shouldIgnore) {
return;
}
const update = localize('updateGit', "Update Git");
const neverShowAgain = localize('neverShowAgain', "Don't Show Again");
const choice = await window.showWarningMessage(
localize('git2526', "There are known issues with the installed Git {0}. Please update to Git >= 2.27 for the git features to work correctly.", info.version),
update,
neverShowAgain
);
if (choice === update) {
commands.executeCommand('vscode.open', Uri.parse('https://git-scm.com/'));
} else if (choice === neverShowAgain) {
await config.update('ignoreWindowsGit27Warning', true, true);
}
}
async function checkGitVersion(info: IGit): Promise<void> {
await checkGitv1(info);
if (process.platform === 'win32') {
await checkGitWindows(info);
}
}

View File

@@ -6,15 +6,16 @@
import { workspace, WorkspaceFoldersChangeEvent, Uri, window, Event, EventEmitter, QuickPickItem, Disposable, SourceControl, SourceControlResourceGroup, TextEditor, Memento, OutputChannel, commands } from 'vscode';
import { Repository, RepositoryState } from './repository';
import { memoize, sequentialize, debounce } from './decorators';
import { dispose, anyEvent, filterEvent, isDescendant, firstIndex, pathEquals, toDisposable } from './util';
import { dispose, anyEvent, filterEvent, isDescendant, pathEquals, toDisposable, eventToPromise } from './util';
import { Git } from './git';
import * as path from 'path';
import * as fs from 'fs';
import * as nls from 'vscode-nls';
import { fromGitUri } from './uri';
import { APIState as State, RemoteSourceProvider, CredentialsProvider } from './api/git';
import { APIState as State, RemoteSourceProvider, CredentialsProvider, PushErrorHandler } from './api/git';
import { Askpass } from './askpass';
import { IRemoteSourceProviderRegistry } from './remoteProvider';
import { IPushErrorHandlerRegistry } from './pushError';
const localize = nls.loadMessageBundle();
@@ -46,7 +47,7 @@ interface OpenRepository extends Disposable {
repository: Repository;
}
export class Model implements IRemoteSourceProviderRegistry {
export class Model implements IRemoteSourceProviderRegistry, IPushErrorHandlerRegistry {
private _onDidOpenRepository = new EventEmitter<Repository>();
readonly onDidOpenRepository: Event<Repository> = this._onDidOpenRepository.event;
@@ -77,6 +78,15 @@ export class Model implements IRemoteSourceProviderRegistry {
commands.executeCommand('setContext', 'git.state', state);
}
@memoize
get isInitialized(): Promise<void> {
if (this._state === 'initialized') {
return Promise.resolve();
}
return eventToPromise(filterEvent(this.onDidChangeState, s => s === 'initialized')) as Promise<any>;
}
private remoteSourceProviders = new Set<RemoteSourceProvider>();
private _onDidAddRemoteSourceProvider = new EventEmitter<RemoteSourceProvider>();
@@ -85,6 +95,8 @@ export class Model implements IRemoteSourceProviderRegistry {
private _onDidRemoveRemoteSourceProvider = new EventEmitter<RemoteSourceProvider>();
readonly onDidRemoveRemoteSourceProvider = this._onDidRemoveRemoteSourceProvider.event;
private pushErrorHandlers = new Set<PushErrorHandler>();
private disposables: Disposable[] = [];
constructor(readonly git: Git, private readonly askpass: Askpass, private globalState: Memento, private outputChannel: OutputChannel) {
@@ -148,6 +160,13 @@ export class Model implements IRemoteSourceProviderRegistry {
}
private onPossibleGitRepositoryChange(uri: Uri): void {
const config = workspace.getConfiguration('git');
const autoRepositoryDetection = config.get<boolean | 'subFolders' | 'openEditors'>('autoRepositoryDetection');
if (autoRepositoryDetection === false) {
return;
}
this.eventuallyScanPossibleGitRepository(uri.fsPath.replace(/\.git.*$/, ''));
}
@@ -241,7 +260,7 @@ export class Model implements IRemoteSourceProviderRegistry {
// This can happen whenever `path` has the wrong case sensitivity in
// case insensitive file systems
// https://github.com/Microsoft/vscode/issues/33498
// https://github.com/microsoft/vscode/issues/33498
const repositoryRoot = Uri.file(rawRoot).fsPath;
if (this.getRepository(repositoryRoot)) {
@@ -253,7 +272,7 @@ export class Model implements IRemoteSourceProviderRegistry {
}
const dotGit = await this.git.getRepositoryDotGit(repositoryRoot);
const repository = new Repository(this.git.open(repositoryRoot, dotGit), this, this.globalState, this.outputChannel);
const repository = new Repository(this.git.open(repositoryRoot, dotGit), this, this, this.globalState, this.outputChannel);
this.open(repository);
await repository.status();
@@ -353,7 +372,7 @@ export class Model implements IRemoteSourceProviderRegistry {
const picks = this.openRepositories.map((e, index) => new RepositoryPick(e.repository, index));
const active = window.activeTextEditor;
const repository = active && this.getRepository(active.document.fileName);
const index = firstIndex(picks, pick => pick.repository === repository);
const index = picks.findIndex(pick => pick.repository === repository);
// Move repository pick containing the active text editor to appear first
if (index > -1) {
@@ -469,6 +488,15 @@ export class Model implements IRemoteSourceProviderRegistry {
return [...this.remoteSourceProviders.values()];
}
registerPushErrorHandler(handler: PushErrorHandler): Disposable {
this.pushErrorHandlers.add(handler);
return toDisposable(() => this.pushErrorHandlers.delete(handler));
}
getPushErrorHandlers(): PushErrorHandler[] {
return [...this.pushErrorHandlers];
}
dispose(): void {
const openRepositories = [...this.openRepositories];
openRepositories.forEach(r => r.dispose());

View File

@@ -34,4 +34,4 @@ export class GitProtocolHandler implements UriHandler {
dispose(): void {
this.disposables = dispose(this.disposables);
}
}
}

View File

@@ -0,0 +1,12 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from 'vscode';
import { PushErrorHandler } from './api/git';
export interface IPushErrorHandlerRegistry {
registerPushErrorHandler(provider: PushErrorHandler): Disposable;
getPushErrorHandlers(): PushErrorHandler[];
}

View File

@@ -0,0 +1,133 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { QuickPickItem, window, QuickPick } from 'vscode';
import * as nls from 'vscode-nls';
import { RemoteSourceProvider, RemoteSource } from './api/git';
import { Model } from './model';
import { throttle, debounce } from './decorators';
const localize = nls.loadMessageBundle();
async function getQuickPickResult<T extends QuickPickItem>(quickpick: QuickPick<T>): Promise<T | undefined> {
const result = await new Promise<T | undefined>(c => {
quickpick.onDidAccept(() => c(quickpick.selectedItems[0]));
quickpick.onDidHide(() => c(undefined));
quickpick.show();
});
quickpick.hide();
return result;
}
class RemoteSourceProviderQuickPick {
private quickpick: QuickPick<QuickPickItem & { remoteSource?: RemoteSource }>;
constructor(private provider: RemoteSourceProvider) {
this.quickpick = window.createQuickPick();
this.quickpick.ignoreFocusOut = true;
if (provider.supportsQuery) {
this.quickpick.placeholder = localize('type to search', "Repository name (type to search)");
this.quickpick.onDidChangeValue(this.onDidChangeValue, this);
} else {
this.quickpick.placeholder = localize('type to filter', "Repository name");
}
}
@debounce(300)
private onDidChangeValue(): void {
this.query();
}
@throttle
private async query(): Promise<void> {
this.quickpick.busy = true;
try {
const remoteSources = await this.provider.getRemoteSources(this.quickpick.value) || [];
if (remoteSources.length === 0) {
this.quickpick.items = [{
label: localize('none found', "No remote repositories found."),
alwaysShow: true
}];
} else {
this.quickpick.items = remoteSources.map(remoteSource => ({
label: remoteSource.name,
description: remoteSource.description || (typeof remoteSource.url === 'string' ? remoteSource.url : remoteSource.url[0]),
remoteSource
}));
}
} catch (err) {
this.quickpick.items = [{ label: localize('error', "$(error) Error: {0}", err.message), alwaysShow: true }];
console.error(err);
} finally {
this.quickpick.busy = false;
}
}
async pick(): Promise<RemoteSource | undefined> {
this.query();
const result = await getQuickPickResult(this.quickpick);
return result?.remoteSource;
}
}
export interface PickRemoteSourceOptions {
readonly providerLabel?: (provider: RemoteSourceProvider) => string;
readonly urlLabel?: string;
}
export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions = {}): Promise<string | undefined> {
const quickpick = window.createQuickPick<(QuickPickItem & { provider?: RemoteSourceProvider, url?: string })>();
quickpick.ignoreFocusOut = true;
const providers = model.getRemoteProviders()
.map(provider => ({ label: (provider.icon ? `$(${provider.icon}) ` : '') + (options.providerLabel ? options.providerLabel(provider) : provider.name), alwaysShow: true, provider }));
quickpick.placeholder = providers.length === 0
? localize('provide url', "Provide repository URL")
: localize('provide url or pick', "Provide repository URL or pick a repository source.");
const updatePicks = (value?: string) => {
if (value) {
quickpick.items = [{
label: options.urlLabel ?? localize('url', "URL"),
description: value,
alwaysShow: true,
url: value
},
...providers];
} else {
quickpick.items = providers;
}
};
quickpick.onDidChangeValue(updatePicks);
updatePicks();
const result = await getQuickPickResult(quickpick);
if (result) {
if (result.url) {
return result.url;
} else if (result.provider) {
const quickpick = new RemoteSourceProviderQuickPick(result.provider);
const remote = await quickpick.pick();
if (remote) {
if (typeof remote.url === 'string') {
return remote.url;
} else if (remote.url.length > 0) {
return await window.showQuickPick(remote.url, { ignoreFocusOut: true, placeHolder: localize('pick url', "Choose a URL to clone from.") });
}
}
}
}
return undefined;
}

View File

@@ -5,7 +5,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { CancellationToken, Command, Disposable, Event, EventEmitter, Memento, OutputChannel, ProgressLocation, ProgressOptions, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, ThemeColor, Uri, window, workspace, WorkspaceEdit, Decoration } from 'vscode';
import { CancellationToken, Command, Disposable, Event, EventEmitter, Memento, OutputChannel, ProgressLocation, ProgressOptions, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, ThemeColor, Uri, window, workspace, WorkspaceEdit, FileDecoration, commands } from 'vscode';
import * as nls from 'vscode-nls';
import { Branch, Change, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status, CommitOptions, BranchQuery } from './api/git';
import { AutoFetcher } from './autofetch';
@@ -17,6 +17,8 @@ import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable,
import { IFileWatcher, watch } from './watch';
import { Log, LogLevel } from './log';
import { IRemoteSourceProviderRegistry } from './remoteProvider';
import { IPushErrorHandlerRegistry } from './pushError';
import { ApiRepository } from './api/api1';
const timeout = (millis: number) => new Promise(c => setTimeout(c, millis));
@@ -203,9 +205,11 @@ export class Resource implements SourceControlResourceState {
get color(): ThemeColor {
switch (this.type) {
case Status.INDEX_MODIFIED:
return new ThemeColor('gitDecoration.stageModifiedResourceForeground');
case Status.MODIFIED:
return new ThemeColor('gitDecoration.modifiedResourceForeground');
case Status.INDEX_DELETED:
return new ThemeColor('gitDecoration.stageDeletedResourceForeground');
case Status.DELETED:
return new ThemeColor('gitDecoration.deletedResourceForeground');
case Status.INDEX_ADDED:
@@ -251,14 +255,10 @@ export class Resource implements SourceControlResourceState {
}
}
get resourceDecoration(): Decoration {
return {
bubble: this.type !== Status.DELETED && this.type !== Status.INDEX_DELETED,
title: this.tooltip,
letter: this.letter,
color: this.color,
priority: this.priority
};
get resourceDecoration(): FileDecoration {
const res = new FileDecoration(this.letter, this.tooltip, this.color);
res.propagate = this.type !== Status.DELETED && this.type !== Status.INDEX_DELETED;
return res;
}
constructor(
@@ -300,6 +300,7 @@ export const enum Operation {
RenameBranch = 'RenameBranch',
DeleteRef = 'DeleteRef',
Merge = 'Merge',
Rebase = 'Rebase',
Ignore = 'Ignore',
Tag = 'Tag',
DeleteTag = 'DeleteTag',
@@ -314,6 +315,8 @@ export const enum Operation {
Blame = 'Blame',
Log = 'Log',
LogFile = 'LogFile',
Move = 'Move'
}
function isReadOnly(operation: Operation): boolean {
@@ -537,7 +540,7 @@ class DotGitWatcher implements IFileWatcher {
upstreamWatcher.event(this.emitter.fire, this.emitter, this.transientDisposables);
} catch (err) {
if (Log.logLevel <= LogLevel.Error) {
this.outputChannel.appendLine(`Failed to watch ref '${upstreamPath}', is most likely packed.\n${err.stack || err}`);
this.outputChannel.appendLine(`Warning: Failed to watch ref '${upstreamPath}', is most likely packed.`);
}
}
}
@@ -642,6 +645,7 @@ export class Repository implements Disposable {
}
this._rebaseCommit = rebaseCommit;
commands.executeCommand('setContext', 'gitRebaseInProgress', !!this._rebaseCommit);
}
get rebaseCommit(): Commit | undefined {
@@ -683,6 +687,7 @@ export class Repository implements Disposable {
constructor(
private readonly repository: BaseRepository,
remoteSourceProviderRegistry: IRemoteSourceProviderRegistry,
private pushErrorHandlerRegistry: IPushErrorHandlerRegistry,
globalState: Memento,
outputChannel: OutputChannel
) {
@@ -729,10 +734,10 @@ export class Repository implements Disposable {
this.updateInputBoxPlaceholder();
this.disposables.push(this.onDidRunGitStatus(() => this.updateInputBoxPlaceholder()));
this._mergeGroup = this._sourceControl.createResourceGroup('merge', localize('merge changes', "MERGE CHANGES"));
this._indexGroup = this._sourceControl.createResourceGroup('index', localize('staged changes', "STAGED CHANGES"));
this._workingTreeGroup = this._sourceControl.createResourceGroup('workingTree', localize('changes', "CHANGES"));
this._untrackedGroup = this._sourceControl.createResourceGroup('untracked', localize('untracked changes', "UNTRACKED CHANGES"));
this._mergeGroup = this._sourceControl.createResourceGroup('merge', localize('merge changes', "Merge Changes"));
this._indexGroup = this._sourceControl.createResourceGroup('index', localize('staged changes', "Staged Changes"));
this._workingTreeGroup = this._sourceControl.createResourceGroup('workingTree', localize('changes', "Changes"));
this._untrackedGroup = this._sourceControl.createResourceGroup('untracked', localize('untracked changes', "Untracked Changes"));
const updateIndexGroupVisibility = () => {
const config = workspace.getConfiguration('git', root);
@@ -743,11 +748,11 @@ export class Repository implements Disposable {
onConfigListener(updateIndexGroupVisibility, this, this.disposables);
updateIndexGroupVisibility();
const onConfigListenerForBranchSortOrder = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git.branchSortOrder', root));
onConfigListenerForBranchSortOrder(this.updateModelState, this, this.disposables);
const onConfigListenerForUntracked = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git.untrackedChanges', root));
onConfigListenerForUntracked(this.updateModelState, this, this.disposables);
filterEvent(workspace.onDidChangeConfiguration, e =>
e.affectsConfiguration('git.branchSortOrder', root)
|| e.affectsConfiguration('git.untrackedChanges', root)
|| e.affectsConfiguration('git.ignoreSubmodules', root)
)(this.updateModelState, this, this.disposables);
const updateInputBoxVisibility = () => {
const config = workspace.getConfiguration('git', root);
@@ -768,7 +773,7 @@ export class Repository implements Disposable {
this.disposables.push(new AutoFetcher(this, globalState));
// https://github.com/Microsoft/vscode/issues/39039
// https://github.com/microsoft/vscode/issues/39039
const onSuccessfulPush = filterEvent(this.onDidRunOperation, e => e.operation === Operation.Push && !e.error);
onSuccessfulPush(() => {
const gitConfig = workspace.getConfiguration('git');
@@ -861,14 +866,20 @@ export class Repository implements Disposable {
return;
}
const path = uri.path;
if (this.mergeGroup.resourceStates.some(r => r.resourceUri.path === path)) {
return undefined;
}
return toGitUri(uri, '', { replaceFileExtension: true });
}
async getInputTemplate(): Promise<string> {
const mergeMessage = await this.repository.getMergeMessage();
const commitMessage = (await Promise.all([this.repository.getMergeMessage(), this.repository.getSquashMessage()])).find(msg => !!msg);
if (mergeMessage) {
return mergeMessage;
if (commitMessage) {
return commitMessage;
}
return await this.repository.getCommitTemplate();
@@ -1050,6 +1061,10 @@ export class Repository implements Disposable {
await this.run(Operation.RenameBranch, () => this.repository.renameBranch(name));
}
async move(from: string, to: string): Promise<void> {
await this.run(Operation.Move, () => this.repository.move(from, to));
}
async getBranch(name: string): Promise<Branch> {
return await this.run(Operation.GetBranch, () => this.repository.getBranch(name));
}
@@ -1066,6 +1081,10 @@ export class Repository implements Disposable {
await this.run(Operation.Merge, () => this.repository.merge(ref));
}
async rebase(branch: string): Promise<void> {
await this.run(Operation.Rebase, () => this.repository.rebase(branch));
}
async tag(name: string, message?: string): Promise<void> {
await this.run(Operation.Tag, () => this.repository.tag(name, message));
}
@@ -1074,8 +1093,8 @@ export class Repository implements Disposable {
await this.run(Operation.DeleteTag, () => this.repository.deleteTag(name));
}
async checkout(treeish: string): Promise<void> {
await this.run(Operation.Checkout, () => this.repository.checkout(treeish, []));
async checkout(treeish: string, opts?: { detached?: boolean }): Promise<void> {
await this.run(Operation.Checkout, () => this.repository.checkout(treeish, [], opts));
}
async checkoutTracking(treeish: string): Promise<void> {
@@ -1112,21 +1131,31 @@ export class Repository implements Disposable {
@throttle
async fetchDefault(options: { silent?: boolean } = {}): Promise<void> {
await this.run(Operation.Fetch, () => this.repository.fetch(options));
await this._fetch({ silent: options.silent });
}
@throttle
async fetchPrune(): Promise<void> {
await this.run(Operation.Fetch, () => this.repository.fetch({ prune: true }));
await this._fetch({ prune: true });
}
@throttle
async fetchAll(): Promise<void> {
await this.run(Operation.Fetch, () => this.repository.fetch({ all: true }));
await this._fetch({ all: true });
}
async fetch(remote?: string, ref?: string, depth?: number): Promise<void> {
await this.run(Operation.Fetch, () => this.repository.fetch({ remote, ref, depth }));
await this._fetch({ remote, ref, depth });
}
private async _fetch(options: { remote?: string, ref?: string, all?: boolean, prune?: boolean, depth?: number, silent?: boolean } = {}): Promise<void> {
if (!options.prune) {
const config = workspace.getConfiguration('git', Uri.file(this.root));
const prune = config.get<boolean>('pruneOnFetch');
options.prune = prune;
}
await this.run(Operation.Fetch, async () => this.repository.fetch(options));
}
@throttle
@@ -1182,15 +1211,19 @@ export class Repository implements Disposable {
branch = `${head.name}:${head.upstream.name}`;
}
await this.run(Operation.Push, () => this.repository.push(remote, branch, undefined, undefined, forcePushMode));
await this.run(Operation.Push, () => this._push(remote, branch, undefined, undefined, forcePushMode));
}
async pushTo(remote?: string, name?: string, setUpstream: boolean = false, forcePushMode?: ForcePushMode): Promise<void> {
await this.run(Operation.Push, () => this.repository.push(remote, name, setUpstream, undefined, forcePushMode));
await this.run(Operation.Push, () => this._push(remote, name, setUpstream, undefined, forcePushMode));
}
async pushFollowTags(remote?: string, forcePushMode?: ForcePushMode): Promise<void> {
await this.run(Operation.Push, () => this.repository.push(remote, undefined, false, true, forcePushMode));
await this.run(Operation.Push, () => this._push(remote, undefined, false, true, forcePushMode));
}
async pushTags(remote?: string, forcePushMode?: ForcePushMode): Promise<void> {
await this.run(Operation.Push, () => this._push(remote, undefined, false, false, forcePushMode, true));
}
async blame(path: string): Promise<string> {
@@ -1223,6 +1256,7 @@ export class Repository implements Disposable {
const config = workspace.getConfiguration('git', Uri.file(this.root));
const fetchOnPull = config.get<boolean>('fetchOnPull');
const tags = config.get<boolean>('pullTags');
const followTags = config.get<boolean>('followTagsWhenSync');
const supportCancellation = config.get<boolean>('supportCancellation');
const fn = async (cancellationToken?: CancellationToken) => {
@@ -1257,7 +1291,7 @@ export class Repository implements Disposable {
const shouldPush = this.HEAD && (typeof this.HEAD.ahead === 'number' ? this.HEAD.ahead > 0 : true);
if (shouldPush) {
await this.repository.push(remoteName, pushBranch);
await this._push(remoteName, pushBranch, false, followTags);
}
});
});
@@ -1419,6 +1453,31 @@ export class Repository implements Disposable {
return ignored;
}
private async _push(remote?: string, refspec?: string, setUpstream: boolean = false, followTags = false, forcePushMode?: ForcePushMode, tags = false): Promise<void> {
try {
await this.repository.push(remote, refspec, setUpstream, followTags, forcePushMode, tags);
} catch (err) {
if (!remote || !refspec) {
throw err;
}
const repository = new ApiRepository(this);
const remoteObj = repository.state.remotes.find(r => r.name === remote);
if (!remoteObj) {
throw err;
}
for (const handler of this.pushErrorHandlerRegistry.getPushErrorHandlers()) {
if (await handler.handlePushError(repository, remoteObj, refspec, err)) {
return;
}
}
throw err;
}
}
private async run<T>(operation: Operation, runOperation: () => Promise<T> = () => Promise.resolve<any>(null)): Promise<T> {
if (this.state !== RepositoryState.Idle) {
throw new Error('Repository not initialized');
@@ -1494,9 +1553,12 @@ export class Repository implements Disposable {
@throttle
private async updateModelState(): Promise<void> {
const { status, didHitLimit } = await this.repository.getStatus();
const config = workspace.getConfiguration('git');
const scopedConfig = workspace.getConfiguration('git', Uri.file(this.repository.root));
const ignoreSubmodules = scopedConfig.get<boolean>('ignoreSubmodules');
const { status, didHitLimit } = await this.repository.getStatus({ ignoreSubmodules });
const config = workspace.getConfiguration('git');
const shouldIgnore = config.get<boolean>('ignoreLimitWarning') === true;
const useIcons = !config.get<boolean>('decorations.enabled', true);
this.isRepositoryHuge = didHitLimit;
@@ -1768,6 +1830,28 @@ export class Repository implements Disposable {
return `${this.HEAD.behind}${this.HEAD.ahead}`;
}
get syncTooltip(): string {
if (!this.HEAD
|| !this.HEAD.name
|| !this.HEAD.commit
|| !this.HEAD.upstream
|| !(this.HEAD.ahead || this.HEAD.behind)
) {
return localize('sync changes', "Synchronize Changes");
}
const remoteName = this.HEAD && this.HEAD.remote || this.HEAD.upstream.remote;
const remote = this.remotes.find(r => r.name === remoteName);
if ((remote && remote.isReadOnly) || !this.HEAD.ahead) {
return localize('pull n', "Pull {0} commits from {1}/{2}", this.HEAD.behind, this.HEAD.upstream.remote, this.HEAD.upstream.name);
} else if (!this.HEAD.behind) {
return localize('push n', "Push {0} commits to {1}/{2}", this.HEAD.ahead, this.HEAD.upstream.remote, this.HEAD.upstream.name);
} else {
return localize('pull push n', "Pull {0} and push {1} commits between {2}/{3}", this.HEAD.behind, this.HEAD.ahead, this.HEAD.upstream.remote, this.HEAD.upstream.name);
}
}
private updateInputBoxPlaceholder(): void {
const branchName = this.headShortName;

View File

@@ -18,8 +18,8 @@ export function applyLineChanges(original: TextDocument, modified: TextDocument,
// if this is a deletion at the very end of the document,then we need to account
// for a newline at the end of the last line which may have been deleted
// https://github.com/Microsoft/vscode/issues/59670
if (isDeletion && diff.originalStartLineNumber === original.lineCount) {
// https://github.com/microsoft/vscode/issues/59670
if (isDeletion && diff.originalEndLineNumber === original.lineCount) {
endLine -= 1;
endCharacter = original.lineAt(endLine).range.end.character;
}

View File

@@ -28,7 +28,7 @@ class CheckoutStatusBar {
return {
command: 'git.checkout',
tooltip: `${this.repository.headLabel}`,
tooltip: localize('checkout', "Checkout branch/tag..."),
title,
arguments: [this.repository.sourceControl]
};
@@ -61,6 +61,15 @@ class SyncStatusBar {
}
constructor(private repository: Repository, private remoteSourceProviderRegistry: IRemoteSourceProviderRegistry) {
this._state = {
enabled: true,
isSyncRunning: false,
hasRemotes: false,
HEAD: undefined,
remoteSourceProviders: this.remoteSourceProviderRegistry.getRemoteProviders()
.filter(p => !!p.publishRepository)
};
repository.onDidRunGitStatus(this.onDidRunGitStatus, this, this.disposables);
repository.onDidChangeOperations(this.onDidChangeOperations, this, this.disposables);
@@ -70,15 +79,6 @@ class SyncStatusBar {
const onEnablementChange = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git.enableStatusBarSync'));
onEnablementChange(this.updateEnablement, this, this.disposables);
this.updateEnablement();
this._state = {
enabled: true,
isSyncRunning: false,
hasRemotes: false,
HEAD: undefined,
remoteSourceProviders: this.remoteSourceProviderRegistry.getRemoteProviders()
.filter(p => !!p.publishRepository)
};
}
private updateEnablement(): void {
@@ -150,7 +150,7 @@ class SyncStatusBar {
const rebaseWhenSync = config.get<string>('rebaseWhenSync');
command = rebaseWhenSync ? 'git.syncRebase' : 'git.sync';
tooltip = localize('sync changes', "Synchronize Changes");
tooltip = this.repository.syncTooltip;
} else {
icon = '$(cloud-upload)';
command = 'git.publish';

View File

@@ -184,6 +184,17 @@ suite('git', () => {
{ name: 'deps/spdlog', path: 'deps/spdlog', url: 'https://github.com/gabime/spdlog.git' }
]);
});
test('whitespace again #108371', () => {
const sample = `[submodule "deps/spdlog"]
path= deps/spdlog
url=https://github.com/gabime/spdlog.git
`;
assert.deepEqual(parseGitmodules(sample), [
{ name: 'deps/spdlog', path: 'deps/spdlog', url: 'https://github.com/gabime/spdlog.git' }
]);
});
});
suite('parseGitCommit', () => {

View File

@@ -6,21 +6,31 @@
const path = require('path');
const testRunner = require('vscode/lib/testrunner');
const suite = 'Integration Git Tests';
const options: any = {
ui: 'tdd',
useColors: (!process.env.BUILD_ARTIFACTSTAGINGDIRECTORY && process.platform !== 'win32'),
timeout: 60000
};
// These integration tests is being run in multiple environments (electron, web, remote)
// so we need to set the suite name based on the environment as the suite name is used
// for the test results file name
let suite = '';
if (process.env.VSCODE_BROWSER) {
suite = `${process.env.VSCODE_BROWSER} Browser Integration Git Tests`;
} else if (process.env.REMOTE_VSCODE) {
suite = 'Remote Integration Git Tests';
} else {
suite = 'Integration Git Tests';
}
if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) {
options.reporter = 'mocha-multi-reporters';
options.reporterOptions = {
reporterEnabled: 'spec, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
testsuitesTitle: `${suite} ${process.platform}`,
mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`)
mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`)
}
};
}

View File

@@ -4,10 +4,11 @@
*--------------------------------------------------------------------------------------------*/
import * as nls from 'vscode-nls';
import { CancellationToken, Disposable, env, Event, EventEmitter, ThemeIcon, Timeline, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvider, Uri, workspace } from 'vscode';
import { CancellationToken, ConfigurationChangeEvent, Disposable, env, Event, EventEmitter, ThemeIcon, Timeline, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvider, Uri, workspace } from 'vscode';
import { Model } from './model';
import { Repository, Resource } from './repository';
import { debounce } from './decorators';
import { emojify, ensureEmojis } from './emoji';
const localize = nls.loadMessageBundle();
@@ -65,27 +66,33 @@ export class GitTimelineProvider implements TimelineProvider {
readonly id = 'git-history';
readonly label = localize('git.timeline.source', 'Git History');
private disposable: Disposable;
private readonly disposable: Disposable;
private providerDisposable: Disposable | undefined;
private repo: Repository | undefined;
private repoDisposable: Disposable | undefined;
private repoStatusDate: Date | undefined;
constructor(private readonly _model: Model) {
constructor(private readonly model: Model) {
this.disposable = Disposable.from(
_model.onDidOpenRepository(this.onRepositoriesChanged, this),
workspace.registerTimelineProvider(['file', 'git', 'vscode-remote', 'gitlens-git'], this),
model.onDidOpenRepository(this.onRepositoriesChanged, this),
workspace.onDidChangeConfiguration(this.onConfigurationChanged, this)
);
if (model.repositories.length) {
this.ensureProviderRegistration();
}
}
dispose() {
this.providerDisposable?.dispose();
this.disposable.dispose();
}
async provideTimeline(uri: Uri, options: TimelineOptions, _token: CancellationToken): Promise<Timeline> {
// console.log(`GitTimelineProvider.provideTimeline: uri=${uri} state=${this._model.state}`);
const repo = this._model.getRepository(uri);
const repo = this.model.getRepository(uri);
if (!repo) {
this.repoDisposable?.dispose();
this.repoStatusDate = undefined;
@@ -105,12 +112,14 @@ export class GitTimelineProvider implements TimelineProvider {
);
}
const config = workspace.getConfiguration('git.timeline');
// TODO@eamodio: Ensure that the uri is a file -- if not we could get the history of the repo?
let limit: number | undefined;
if (options.limit !== undefined && typeof options.limit !== 'number') {
try {
const result = await this._model.git.exec(repo.root, ['rev-list', '--count', `${options.limit.id}..`, '--', uri.fsPath]);
const result = await this.model.git.exec(repo.root, ['rev-list', '--count', `${options.limit.id}..`, '--', uri.fsPath]);
if (!result.exitCode) {
// Ask for 2 more (1 for the limit commit and 1 for the next commit) than so we can determine if there are more commits
limit = Number(result.stdout) + 2;
@@ -124,6 +133,8 @@ export class GitTimelineProvider implements TimelineProvider {
limit = options.limit === undefined ? undefined : options.limit + 1;
}
await ensureEmojis();
const commits = await repo.logFile(uri, {
maxEntries: limit,
hash: options.cursor,
@@ -141,13 +152,20 @@ export class GitTimelineProvider implements TimelineProvider {
const dateFormatter = new Intl.DateTimeFormat(env.language, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
const items = commits.map<GitTimelineItem>((c, i) => {
const date = c.commitDate; // c.authorDate
const dateType = config.get<'committed' | 'authored'>('date');
const showAuthor = config.get<boolean>('showAuthor');
const item = new GitTimelineItem(c.hash, commits[i + 1]?.hash ?? `${c.hash}^`, c.message, date?.getTime() ?? 0, c.hash, 'git:file:commit');
const items = commits.map<GitTimelineItem>((c, i) => {
const date = dateType === 'authored' ? c.authorDate : c.commitDate;
const message = emojify(c.message);
const item = new GitTimelineItem(c.hash, commits[i + 1]?.hash ?? `${c.hash}^`, message, date?.getTime() ?? 0, c.hash, 'git:file:commit');
item.iconPath = new (ThemeIcon as any)('git-commit');
item.description = c.authorName;
item.detail = `${c.authorName} (${c.authorEmail}) \u2014 ${c.hash.substr(0, 8)}\n${dateFormatter.format(date)}\n\n${c.message}`;
if (showAuthor) {
item.description = c.authorName;
}
item.detail = `${c.authorName} (${c.authorEmail}) — ${c.hash.substr(0, 8)}\n${dateFormatter.format(date)}\n\n${message}`;
item.command = {
title: 'Open Comparison',
command: 'git.timeline.openDiff',
@@ -168,7 +186,7 @@ export class GitTimelineProvider implements TimelineProvider {
// TODO@eamodio: Replace with a better icon -- reflecting its status maybe?
item.iconPath = new (ThemeIcon as any)('git-commit');
item.description = '';
item.detail = localize('git.timeline.detail', '{0} \u2014 {1}\n{2}\n\n{3}', you, localize('git.index', 'Index'), dateFormatter.format(date), Resource.getStatusText(index.type));
item.detail = localize('git.timeline.detail', '{0} {1}\n{2}\n\n{3}', you, localize('git.index', 'Index'), dateFormatter.format(date), Resource.getStatusText(index.type));
item.command = {
title: 'Open Comparison',
command: 'git.timeline.openDiff',
@@ -186,7 +204,7 @@ export class GitTimelineProvider implements TimelineProvider {
// TODO@eamodio: Replace with a better icon -- reflecting its status maybe?
item.iconPath = new (ThemeIcon as any)('git-commit');
item.description = '';
item.detail = localize('git.timeline.detail', '{0} \u2014 {1}\n{2}\n\n{3}', you, localize('git.workingTree', 'Working Tree'), dateFormatter.format(date), Resource.getStatusText(working.type));
item.detail = localize('git.timeline.detail', '{0} {1}\n{2}\n\n{3}', you, localize('git.workingTree', 'Working Tree'), dateFormatter.format(date), Resource.getStatusText(working.type));
item.command = {
title: 'Open Comparison',
command: 'git.timeline.openDiff',
@@ -203,9 +221,23 @@ export class GitTimelineProvider implements TimelineProvider {
};
}
private ensureProviderRegistration() {
if (this.providerDisposable === undefined) {
this.providerDisposable = workspace.registerTimelineProvider(['file', 'git', 'vscode-remote', 'gitlens-git'], this);
}
}
private onConfigurationChanged(e: ConfigurationChangeEvent) {
if (e.affectsConfiguration('git.timeline.date') || e.affectsConfiguration('git.timeline.showAuthor')) {
this.fireChanged();
}
}
private onRepositoriesChanged(_repo: Repository) {
// console.log(`GitTimelineProvider.onRepositoriesChanged`);
this.ensureProviderRegistration();
// TODO@eamodio: Being naive for now and just always refreshing each time there is a new repository
this.fireChanged();
}
@@ -219,7 +251,7 @@ export class GitTimelineProvider implements TimelineProvider {
private onRepositoryStatusChanged(_repo: Repository) {
// console.log(`GitTimelineProvider.onRepositoryStatusChanged`);
// This is crappy, but for now just save the last time a status was run and use that as the timestamp for staged items
// This is less than ideal, but for now just save the last time a status was run and use that as the timestamp for staged items
this.repoStatusDate = new Date();
this.fireChanged();

View File

@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Event, Disposable } from 'vscode';
import { Event, Disposable, EventEmitter } from 'vscode';
import { dirname, sep } from 'path';
import { Readable } from 'stream';
import { promises as fs, createReadStream } from 'fs';
@@ -44,18 +44,6 @@ export function filterEvent<T>(event: Event<T>, filter: (e: T) => boolean): Even
return (listener: (e: T) => any, thisArgs?: any, disposables?: Disposable[]) => event(e => filter(e) && listener.call(thisArgs, e), null, disposables);
}
export function latchEvent<T>(event: Event<T>): Event<T> {
let firstCall = true;
let cache: T;
return filterEvent(event, value => {
let shouldEmit = firstCall || value !== cache;
firstCall = false;
cache = value;
return shouldEmit;
});
}
export function anyEvent<T>(...events: Event<T>[]): Event<T> {
return (listener: (e: T) => any, thisArgs?: any, disposables?: Disposable[]) => {
const result = combinedDisposable(events.map(event => event(i => listener.call(thisArgs, i))));
@@ -194,16 +182,6 @@ export function uniqueFilter<T>(keyFn: (t: T) => string): (t: T) => boolean {
};
}
export function firstIndex<T>(array: T[], fn: (t: T) => boolean): number {
for (let i = 0; i < array.length; i++) {
if (fn(array[i])) {
return i;
}
}
return -1;
}
export function find<T>(array: T[], fn: (t: T) => boolean): T | undefined {
let result: T | undefined = undefined;
@@ -358,7 +336,7 @@ export function* splitInChunks(array: string[], maxChunkLength: number): Iterabl
interface ILimitedTaskFactory<T> {
factory: () => Promise<T>;
c: (value?: T | Promise<T>) => void;
c: (value: T | Promise<T>) => void;
e: (error?: any) => void;
}
@@ -400,3 +378,39 @@ export class Limiter<T> {
}
}
}
type Completion<T> = { success: true, value: T } | { success: false, err: any };
export class PromiseSource<T> {
private _onDidComplete = new EventEmitter<Completion<T>>();
private _promise: Promise<T> | undefined;
get promise(): Promise<T> {
if (this._promise) {
return this._promise;
}
return eventToPromise(this._onDidComplete.event).then(completion => {
if (completion.success) {
return completion.value;
} else {
throw completion.err;
}
});
}
resolve(value: T): void {
if (!this._promise) {
this._promise = Promise.resolve(value);
this._onDidComplete.fire({ success: true, value });
}
}
reject(err: any): void {
if (!this._promise) {
this._promise = Promise.reject(err);
this._onDidComplete.fire({ success: false, err });
}
}
}