commit 933ad74eabbf0a110b1d86ddd71c8cca018b2887 Author: upstream Date: Tue Oct 13 23:05:12 2026 +0200 Upstream microsoft/vscode state of the touched files before 50c02cad13 diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts new file mode 100644 index 0000000..52a752a --- /dev/null +++ b/extensions/git/src/commands.ts @@ -0,0 +1,5788 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as os from 'os'; +import * as path from 'path'; +import { Command, commands, Disposable, MessageOptions, Position, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider, InputBoxValidationSeverity, TabInputText, TabInputTextMerge, QuickPickItemKind, TextDocument, LogOutputChannel, l10n, Memento, UIKind, QuickInputButton, ThemeIcon, SourceControlHistoryItem, SourceControl, InputBoxValidationMessage, Tab, TabInputNotebook, TabInputNotebookDiff, QuickInputButtonLocation, languages, SourceControlArtifact, ProgressLocation } from 'vscode'; +import TelemetryReporter from '@vscode/extension-telemetry'; +import type { CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref } from './api/git'; +import { ForcePushMode, GitErrorCodes, RefType, Status } from './api/git.constants'; +import { Git, GitError, Repository as GitRepository, Stash, Worktree } from './git'; +import { Model } from './model'; +import { GitResourceGroup, Repository, Resource, ResourceGroupType } from './repository'; +import { DiffEditorSelectionHunkToolbarContext, LineChange, applyLineChanges, getIndexDiffInformation, getModifiedRange, getWorkingTreeDiffInformation, intersectDiffWithRange, invertLineChange, toLineChanges, toLineRanges, compareLineChanges } from './staging'; +import { fromGitUri, toGitUri, isGitUri, toMergeUris, toMultiFileDiffEditorUris } from './uri'; +import { coalesce, DiagnosticSeverityConfig, dispose, fromNow, getHistoryItemDisplayName, getStashDescription, grep, isDefined, isDescendant, isLinuxSnap, isRemote, isWindows, pathEquals, relativePath, subject, toDiagnosticSeverity, truncate } from './util'; +import { GitTimelineItem } from './timelineProvider'; +import { ApiRepository } from './api/api1'; +import { getRemoteSourceActions, pickRemoteSource } from './remoteSource'; +import { RemoteSourceAction } from './typings/git-base'; +import { CloneManager } from './cloneManager'; +import { getSafeNotificationMessage } from './notification'; + +abstract class CheckoutCommandItem implements QuickPickItem { + abstract get label(): string; + get description(): string { return ''; } + get alwaysShow(): boolean { return true; } +} + +class CreateBranchItem extends CheckoutCommandItem { + get label(): string { return l10n.t('{0} Create new branch...', '$(plus)'); } +} + +class CreateBranchFromItem extends CheckoutCommandItem { + get label(): string { return l10n.t('{0} Create new branch from...', '$(plus)'); } +} + +class CheckoutDetachedItem extends CheckoutCommandItem { + get label(): string { return l10n.t('{0} Checkout detached...', '$(debug-disconnect)'); } +} + +class RefItemSeparator implements QuickPickItem { + get kind(): QuickPickItemKind { return QuickPickItemKind.Separator; } + + get label(): string { + switch (this.refType) { + case RefType.Head: + return l10n.t('branches'); + case RefType.RemoteHead: + return l10n.t('remote branches'); + case RefType.Tag: + return l10n.t('tags'); + default: + return ''; + } + } + + constructor(private readonly refType: RefType) { } +} + +class RefItem implements QuickPickItem { + + get label(): string { + switch (this.ref.type) { + case RefType.Head: + return `$(git-branch) ${this.ref.name ?? this.shortCommit}`; + case RefType.RemoteHead: + return `$(cloud) ${this.ref.name ?? this.shortCommit}`; + case RefType.Tag: + return `$(tag) ${this.ref.name ?? this.shortCommit}`; + default: + return ''; + } + } + + get description(): string { + if (this.ref.commitDetails?.commitDate) { + return fromNow(this.ref.commitDetails.commitDate, true, true); + } + + switch (this.ref.type) { + case RefType.Head: + return this.shortCommit; + case RefType.RemoteHead: + return l10n.t('Remote branch at {0}', this.shortCommit); + case RefType.Tag: + return l10n.t('Tag at {0}', this.shortCommit); + default: + return ''; + } + } + + get detail(): string | undefined { + if (this.ref.commitDetails?.authorName && this.ref.commitDetails?.message) { + return `${this.ref.commitDetails.authorName}$(circle-small-filled)${this.shortCommit}$(circle-small-filled)${this.ref.commitDetails.message}`; + } + + return undefined; + } + + get refId(): string { + switch (this.ref.type) { + case RefType.Head: + return `refs/heads/${this.ref.name}`; + case RefType.RemoteHead: + return `refs/remotes/${this.ref.name}`; + case RefType.Tag: + return `refs/tags/${this.ref.name}`; + default: + throw new Error('Unknown ref type'); + } + } + get refName(): string | undefined { return this.ref.name; } + get refRemote(): string | undefined { return this.ref.remote; } + get shortCommit(): string { return (this.ref.commit || '').substring(0, this.shortCommitLength); } + get commitMessage(): string | undefined { return this.ref.commitDetails?.message; } + + private _buttons?: QuickInputButton[]; + get buttons(): QuickInputButton[] | undefined { return this._buttons; } + set buttons(newButtons: QuickInputButton[] | undefined) { this._buttons = newButtons; } + + constructor(protected readonly ref: Ref, private readonly shortCommitLength: number) { } +} + +class BranchItem extends RefItem { + override get description(): string { + const description: string[] = []; + + if (typeof this.ref.behind === 'number' && typeof this.ref.ahead === 'number') { + description.push(`${this.ref.behind}↓ ${this.ref.ahead}↑`); + } + if (this.ref.commitDetails?.commitDate) { + description.push(fromNow(this.ref.commitDetails.commitDate, true, true)); + } + + return description.length > 0 ? description.join('$(circle-small-filled)') : this.shortCommit; + } + + constructor(override readonly ref: Branch, shortCommitLength: number) { + super(ref, shortCommitLength); + } +} + +class CheckoutItem extends BranchItem { + async run(repository: Repository, opts?: { detached?: boolean }): Promise { + if (!this.ref.name) { + return; + } + + const config = workspace.getConfiguration('git', Uri.file(repository.root)); + const pullBeforeCheckout = config.get('pullBeforeCheckout', false) === true; + + const treeish = opts?.detached ? this.ref.commit ?? this.ref.name : this.ref.name; + await repository.checkout(treeish, { ...opts, pullBeforeCheckout }); + } +} + +class CheckoutProtectedItem extends CheckoutItem { + + override get label(): string { + return `$(lock) ${this.ref.name ?? this.shortCommit}`; + } +} + +class CheckoutRemoteHeadItem extends RefItem { + + async run(repository: Repository, opts?: { detached?: boolean }): Promise { + if (!this.ref.name) { + return; + } + + if (opts?.detached) { + await repository.checkout(this.ref.commit ?? this.ref.name, opts); + return; + } + + const branches = await repository.findTrackingBranches(this.ref.name); + + if (branches.length > 0) { + await repository.checkout(branches[0].name!, opts); + } else { + await repository.checkoutTracking(this.ref.name, opts); + } + } +} + +class CheckoutTagItem extends RefItem { + + async run(repository: Repository, opts?: { detached?: boolean }): Promise { + if (!this.ref.name) { + return; + } + + await repository.checkout(this.ref.name, opts); + } +} + +class BranchDeleteItem extends BranchItem { + + async run(repository: Repository, force?: boolean): Promise { + if (this.ref.type === RefType.Head && this.refName) { + await repository.deleteBranch(this.refName, force); + } else if (this.ref.type === RefType.RemoteHead && this.refRemote && this.refName) { + const refName = this.refName.substring(this.refRemote.length + 1); + await repository.deleteRemoteRef(this.refRemote, refName, { force }); + } + } +} + +class TagDeleteItem extends RefItem { + + async run(repository: Repository): Promise { + if (this.ref.name) { + await repository.deleteTag(this.ref.name); + } + } +} + +class RemoteTagDeleteItem extends RefItem { + + override get description(): string { + return l10n.t('Remote tag at {0}', this.shortCommit); + } + + async run(repository: Repository, remote: string): Promise { + if (this.ref.name) { + await repository.deleteRemoteRef(remote, this.ref.name); + } + } +} + +class WorktreeItem implements QuickPickItem { + + get label(): string { + return `$(list-tree) ${this.worktree.name}`; + } + + get description(): string | undefined { + return this.worktree.path; + } + + constructor(readonly worktree: Worktree) { } +} + +class WorktreeDeleteItem extends WorktreeItem { + override get description(): string | undefined { + if (!this.worktree.commitDetails) { + return undefined; + } + + return coalesce([ + this.worktree.detached ? l10n.t('detached') : this.worktree.ref.substring(11), + this.worktree.commitDetails.hash.substring(0, this.shortCommitLength), + this.worktree.commitDetails.message.split('\n')[0] + ]).join(' \u2022 '); + } + + get detail(): string { + return this.worktree.path; + } + + constructor(worktree: Worktree, private readonly shortCommitLength: number) { + super(worktree); + } + + async run(mainRepository: Repository): Promise { + if (!this.worktree.path) { + return; + } + + await mainRepository.deleteWorktree(this.worktree.path); + } +} + + +class MergeItem extends BranchItem { + + async run(repository: Repository): Promise { + if (this.ref.name || this.ref.commit) { + await repository.merge(this.ref.name ?? this.ref.commit!); + } + } +} + +class RebaseItem extends BranchItem { + + async run(repository: Repository): Promise { + if (this.ref?.name) { + await repository.rebase(this.ref.name); + } + } +} + +class RebaseUpstreamItem extends RebaseItem { + + override get description(): string { + return '(upstream)'; + } +} + +class HEADItem implements QuickPickItem { + + constructor(private repository: Repository, private readonly shortCommitLength: number) { } + + get label(): string { return 'HEAD'; } + get description(): string { return (this.repository.HEAD?.commit ?? '').substring(0, this.shortCommitLength); } + get alwaysShow(): boolean { return true; } + get refName(): string { return 'HEAD'; } +} + +class AddRemoteItem implements QuickPickItem { + + constructor(private cc: CommandCenter) { } + + get label(): string { return '$(plus) ' + l10n.t('Add a new remote...'); } + get description(): string { return ''; } + + get alwaysShow(): boolean { return true; } + + async run(repository: Repository): Promise { + await this.cc.addRemote(repository); + } +} + +class RemoteItem implements QuickPickItem { + get label() { return `$(cloud) ${this.remote.name}`; } + get description(): string | undefined { return this.remote.fetchUrl; } + get remoteName(): string { return this.remote.name; } + + constructor(private readonly repository: Repository, private readonly remote: Remote) { } + + async run(): Promise { + await this.repository.fetch({ remote: this.remote.name }); + } +} + +class FetchAllRemotesItem implements QuickPickItem { + get label(): string { return l10n.t('{0} Fetch all remotes', '$(cloud-download)'); } + + constructor(private readonly repository: Repository) { } + + async run(): Promise { + await this.repository.fetch({ all: true }); + } +} + +class RepositoryItem implements QuickPickItem { + get label(): string { return `$(repo) ${getRepositoryLabel(this.path)}`; } + + get description(): string { return this.path; } + + constructor(public readonly path: string) { } +} + +class StashItem implements QuickPickItem { + get label(): string { return `#${this.stash.index}: ${this.stash.description}`; } + + get description(): string | undefined { return getStashDescription(this.stash); } + + constructor(readonly stash: Stash) { } +} + +interface ScmCommandOptions { + repository?: boolean; + repositoryFilter?: ('repository' | 'submodule' | 'worktree')[]; +} + +interface ScmCommand { + commandId: string; + key: string; + method: Function; + options: ScmCommandOptions; +} + +const Commands: ScmCommand[] = []; + +function command(commandId: string, options: ScmCommandOptions = {}): MethodDecorator { + return (_target: any, key: string | symbol, descriptor: PropertyDescriptor): void => { + if (typeof descriptor.value !== 'function') { + throw new Error('not supported'); + } + Commands.push({ commandId, key: String(key), method: descriptor.value, options }); + }; +} + +// const ImageMimetypes = [ +// 'image/png', +// 'image/gif', +// 'image/jpeg', +// 'image/webp', +// 'image/tiff', +// 'image/bmp' +// ]; + +async function categorizeResourceByResolution(resources: Resource[]): Promise<{ merge: Resource[]; resolved: Resource[]; unresolved: Resource[]; deletionConflicts: Resource[] }> { + const selection = resources.filter(s => s instanceof Resource) as Resource[]; + const merge = selection.filter(s => s.resourceGroupType === ResourceGroupType.Merge); + const isBothAddedOrModified = (s: Resource) => s.type === Status.BOTH_MODIFIED || s.type === Status.BOTH_ADDED; + const isAnyDeleted = (s: Resource) => s.type === Status.DELETED_BY_THEM || s.type === Status.DELETED_BY_US; + const possibleUnresolved = merge.filter(isBothAddedOrModified); + const promises = possibleUnresolved.map(s => grep(s.resourceUri.fsPath, /^<{7}\s|^={7}$|^>{7}\s/)); + const unresolvedBothModified = await Promise.all(promises); + const resolved = possibleUnresolved.filter((_s, i) => !unresolvedBothModified[i]); + const deletionConflicts = merge.filter(s => isAnyDeleted(s)); + const unresolved = [ + ...merge.filter(s => !isBothAddedOrModified(s) && !isAnyDeleted(s)), + ...possibleUnresolved.filter((_s, i) => unresolvedBothModified[i]) + ]; + + return { merge, resolved, unresolved, deletionConflicts }; +} + +async function createCheckoutItems(repository: Repository, detached = false): Promise { + const config = workspace.getConfiguration('git'); + const checkoutTypeConfig = config.get('checkoutType'); + const showRefDetails = config.get('showReferenceDetails') === true; + + let checkoutTypes: string[]; + + if (checkoutTypeConfig === 'all' || !checkoutTypeConfig || checkoutTypeConfig.length === 0) { + checkoutTypes = ['local', 'remote', 'tags']; + } else if (typeof checkoutTypeConfig === 'string') { + checkoutTypes = [checkoutTypeConfig]; + } else { + checkoutTypes = checkoutTypeConfig; + } + + if (detached) { + // Remove tags when in detached mode + checkoutTypes = checkoutTypes.filter(t => t !== 'tags'); + } + + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); + const refProcessors = checkoutTypes.map(type => getCheckoutRefProcessor(repository, type)) + .filter(p => !!p) as RefProcessor[]; + + const buttons = await getRemoteRefItemButtons(repository); + const itemsProcessor = new CheckoutItemsProcessor(repository, refProcessors, buttons, detached); + + return itemsProcessor.processRefs(refs); +} + +type RemoteSourceActionButton = { + iconPath: ThemeIcon; + tooltip: string; + actual: RemoteSourceAction; +}; + +async function getRemoteRefItemButtons(repository: Repository) { + // Compute actions for all known remotes + const remoteUrlsToActions = new Map(); + + const getButtons = async (remoteUrl: string) => (await getRemoteSourceActions(remoteUrl)).map((action) => ({ iconPath: new ThemeIcon(action.icon), tooltip: action.label, actual: action })); + + for (const remote of repository.remotes) { + if (remote.fetchUrl) { + const actions = remoteUrlsToActions.get(remote.fetchUrl) ?? []; + actions.push(...await getButtons(remote.fetchUrl)); + remoteUrlsToActions.set(remote.fetchUrl, actions); + } + if (remote.pushUrl && remote.pushUrl !== remote.fetchUrl) { + const actions = remoteUrlsToActions.get(remote.pushUrl) ?? []; + actions.push(...await getButtons(remote.pushUrl)); + remoteUrlsToActions.set(remote.pushUrl, actions); + } + } + + return remoteUrlsToActions; +} + +class RefProcessor { + protected readonly refs: Ref[] = []; + + constructor(protected readonly type: RefType, protected readonly ctor: { new(ref: Ref, shortCommitLength: number): QuickPickItem } = RefItem) { } + + processRef(ref: Ref): boolean { + if (!ref.name && !ref.commit) { + return false; + } + if (ref.type !== this.type) { + return false; + } + + this.refs.push(ref); + return true; + } + + getItems(shortCommitLength: number): QuickPickItem[] { + const items = this.refs.map(r => new this.ctor(r, shortCommitLength)); + return items.length === 0 ? items : [new RefItemSeparator(this.type), ...items]; + } +} + +class RefItemsProcessor { + protected readonly shortCommitLength: number; + + constructor( + protected readonly repository: Repository, + protected readonly processors: RefProcessor[], + protected readonly options: { + skipCurrentBranch?: boolean; + skipCurrentBranchRemote?: boolean; + } = {} + ) { + const config = workspace.getConfiguration('git', Uri.file(repository.root)); + this.shortCommitLength = config.get('commitShortHashLength', 7); + } + + processRefs(refs: Ref[]): QuickPickItem[] { + const refsToSkip = this.getRefsToSkip(); + + for (const ref of refs) { + if (ref.name && refsToSkip.includes(ref.name)) { + continue; + } + for (const processor of this.processors) { + if (processor.processRef(ref)) { + break; + } + } + } + + const result: QuickPickItem[] = []; + for (const processor of this.processors) { + result.push(...processor.getItems(this.shortCommitLength)); + } + + return result; + } + + protected getRefsToSkip(): string[] { + const refsToSkip = ['origin/HEAD']; + + if (this.options.skipCurrentBranch && this.repository.HEAD?.name) { + refsToSkip.push(this.repository.HEAD.name); + } + + if (this.options.skipCurrentBranchRemote && this.repository.HEAD?.upstream) { + refsToSkip.push(`${this.repository.HEAD.upstream.remote}/${this.repository.HEAD.upstream.name}`); + } + + return refsToSkip; + } +} + +class CheckoutRefProcessor extends RefProcessor { + + constructor(private readonly repository: Repository) { + super(RefType.Head); + } + + override getItems(shortCommitLength: number): QuickPickItem[] { + const items = this.refs.map(ref => { + return this.repository.isBranchProtected(ref) ? + new CheckoutProtectedItem(ref, shortCommitLength) : + new CheckoutItem(ref, shortCommitLength); + }); + + return items.length === 0 ? items : [new RefItemSeparator(this.type), ...items]; + } +} + +class CheckoutItemsProcessor extends RefItemsProcessor { + + private defaultButtons: RemoteSourceActionButton[] | undefined; + + constructor( + repository: Repository, + processors: RefProcessor[], + private readonly buttons: Map, + private readonly detached = false) { + super(repository, processors); + + // Default button(s) + const remote = repository.remotes.find(r => r.pushUrl === repository.HEAD?.remote || r.fetchUrl === repository.HEAD?.remote) ?? repository.remotes[0]; + const remoteUrl = remote?.pushUrl ?? remote?.fetchUrl; + if (remoteUrl) { + this.defaultButtons = buttons.get(remoteUrl); + } + } + + override processRefs(refs: Ref[]): QuickPickItem[] { + for (const ref of refs) { + if (!this.detached && ref.name === 'origin/HEAD') { + continue; + } + + for (const processor of this.processors) { + if (processor.processRef(ref)) { + break; + } + } + } + + const result: QuickPickItem[] = []; + for (const processor of this.processors) { + for (const item of processor.getItems(this.shortCommitLength)) { + if (!(item instanceof RefItem)) { + result.push(item); + continue; + } + + // Button(s) + if (item.refRemote) { + const matchingRemote = this.repository.remotes.find((remote) => remote.name === item.refRemote); + const buttons = []; + if (matchingRemote?.pushUrl) { + buttons.push(...this.buttons.get(matchingRemote.pushUrl) ?? []); + } + if (matchingRemote?.fetchUrl && matchingRemote.fetchUrl !== matchingRemote.pushUrl) { + buttons.push(...this.buttons.get(matchingRemote.fetchUrl) ?? []); + } + if (buttons.length) { + item.buttons = buttons; + } + } else { + item.buttons = this.defaultButtons; + } + + result.push(item); + } + } + + return result; + } +} + +function getCheckoutRefProcessor(repository: Repository, type: string): RefProcessor | undefined { + switch (type) { + case 'local': + return new CheckoutRefProcessor(repository); + case 'remote': + return new RefProcessor(RefType.RemoteHead, CheckoutRemoteHeadItem); + case 'tags': + return new RefProcessor(RefType.Tag, CheckoutTagItem); + default: + return undefined; + } +} + +function getRepositoryLabel(repositoryRoot: string): string { + const workspaceFolder = workspace.getWorkspaceFolder(Uri.file(repositoryRoot)); + return workspaceFolder?.uri.toString() === repositoryRoot ? workspaceFolder.name : path.basename(repositoryRoot); +} + +function compareRepositoryLabel(repositoryRoot1: string, repositoryRoot2: string): number { + return getRepositoryLabel(repositoryRoot1).localeCompare(getRepositoryLabel(repositoryRoot2)); +} + +function sanitizeBranchName(name: string, whitespaceChar: string): string { + return name ? name.trim().replace(/^-+/, '').replace(/^\.|\/\.|\.\.|~|\^|:|\/$|\.lock$|\.lock\/|\\|\*|\s|^\s*$|\.$|\[|\]$/g, whitespaceChar) : name; +} + +function sanitizeRemoteName(name: string) { + name = name.trim(); + return name && name.replace(/^\.|\/\.|\.\.|~|\^|:|\/$|\.lock$|\.lock\/|\\|\*|\s|^\s*$|\.$|\[|\]$/g, '-'); +} + +enum PushType { + Push, + PushTo, + PushFollowTags, + PushTags +} + +interface PushOptions { + pushType: PushType; + forcePush?: boolean; + silent?: boolean; + + pushTo?: { + remote?: string; + refspec?: string; + setUpstream?: boolean; + }; +} + +class CommandErrorOutputTextDocumentContentProvider implements TextDocumentContentProvider { + + private items = new Map(); + + set(uri: Uri, contents: string): void { + this.items.set(uri.path, contents); + } + + delete(uri: Uri): void { + this.items.delete(uri.path); + } + + provideTextDocumentContent(uri: Uri): string | undefined { + return this.items.get(uri.path); + } +} + +async function evaluateDiagnosticsCommitHook(repository: Repository, options: CommitOptions, logger: LogOutputChannel): Promise { + const config = workspace.getConfiguration('git', Uri.file(repository.root)); + const enabled = config.get('diagnosticsCommitHook.enabled', false) === true; + const sourceSeverity = config.get>('diagnosticsCommitHook.sources', { '*': 'error' }); + logger.trace(`[CommandCenter][evaluateDiagnosticsCommitHook] Diagnostics Commit Hook: enabled=${enabled}, sources=${JSON.stringify(sourceSeverity)}`); + + if (!enabled) { + return true; + } + + const resources: Uri[] = []; + if (repository.indexGroup.resourceStates.length > 0) { + // Staged files + resources.push(...repository.indexGroup.resourceStates.map(r => r.resourceUri)); + } else if (options.all === 'tracked') { + // Tracked files + resources.push(...repository.workingTreeGroup.resourceStates + .filter(r => r.type !== Status.UNTRACKED && r.type !== Status.IGNORED) + .map(r => r.resourceUri)); + } else { + // All files + resources.push(...repository.workingTreeGroup.resourceStates.map(r => r.resourceUri)); + resources.push(...repository.untrackedGroup.resourceStates.map(r => r.resourceUri)); + } + + const diagnostics: Map = new Map(); + + for (const resource of resources) { + const unresolvedDiagnostics = languages.getDiagnostics(resource) + .filter(d => { + logger.trace(`[CommandCenter][evaluateDiagnosticsCommitHook] Evaluating diagnostic for ${resource.fsPath}: source='${d.source}', severity='${d.severity}'`); + + // No source or ignored source + if (!d.source || (Object.keys(sourceSeverity).includes(d.source) && sourceSeverity[d.source] === 'none')) { + logger.trace(`[CommandCenter][evaluateDiagnosticsCommitHook] Ignoring diagnostic for ${resource.fsPath}: source='${d.source}', severity='${d.severity}'`); + return false; + } + + // Source severity + if (Object.keys(sourceSeverity).includes(d.source) && d.severity <= toDiagnosticSeverity(sourceSeverity[d.source])) { + logger.trace(`[CommandCenter][evaluateDiagnosticsCommitHook] Found unresolved diagnostic for ${resource.fsPath}: source='${d.source}', severity='${d.severity}'`); + return true; + } + + // Wildcard severity + if (Object.keys(sourceSeverity).includes('*') && d.severity <= toDiagnosticSeverity(sourceSeverity['*'])) { + logger.trace(`[CommandCenter][evaluateDiagnosticsCommitHook] Found unresolved diagnostic for ${resource.fsPath}: source='${d.source}', severity='${d.severity}'`); + return true; + } + + logger.trace(`[CommandCenter][evaluateDiagnosticsCommitHook] Ignoring diagnostic for ${resource.fsPath}: source='${d.source}', severity='${d.severity}'`); + return false; + }); + + if (unresolvedDiagnostics.length > 0) { + diagnostics.set(resource, unresolvedDiagnostics.length); + } + } + + if (diagnostics.size === 0) { + return true; + } + + // Show dialog + const commit = l10n.t('Commit Anyway'); + const view = l10n.t('View Problems'); + + const message = diagnostics.size === 1 + ? l10n.t('The following file has unresolved diagnostics: \'{0}\'.\n\nHow would you like to proceed?', path.basename(diagnostics.keys().next().value!.fsPath)) + : l10n.t('There are {0} files that have unresolved diagnostics.\n\nHow would you like to proceed?', diagnostics.size); + + const choice = await window.showWarningMessage(message, { modal: true }, commit, view); + + // Commit Anyway + if (choice === commit) { + return true; + } + + // View Problems + if (choice === view) { + commands.executeCommand('workbench.panel.markers.view.focus'); + } + + return false; +} + +export class CommandCenter { + + private disposables: Disposable[]; + private commandErrors = new CommandErrorOutputTextDocumentContentProvider(); + + constructor( + private git: Git, + private model: Model, + private globalState: Memento, + private logger: LogOutputChannel, + private telemetryReporter: TelemetryReporter, + private cloneManager: CloneManager + ) { + this.disposables = Commands.map(({ commandId, key, method, options }) => { + const command = this.createCommand(commandId, key, method, options); + return commands.registerCommand(commandId, command); + }); + + this.disposables.push(workspace.registerTextDocumentContentProvider('git-output', this.commandErrors)); + } + + @command('git.showOutput') + showOutput(): void { + this.logger.show(); + } + + @command('git.refresh', { repository: true }) + async refresh(repository: Repository): Promise { + await repository.refresh(); + } + + @command('git.openResource') + async openResource(resource: Resource): Promise { + const repository = this.model.getRepository(resource.resourceUri); + + if (!repository) { + return; + } + + await resource.open(); + } + + @command('git.openAllChanges', { repository: true }) + async openChanges(repository: Repository): Promise { + for (const resource of [...repository.workingTreeGroup.resourceStates, ...repository.untrackedGroup.resourceStates]) { + if ( + resource.type === Status.DELETED || resource.type === Status.DELETED_BY_THEM || + resource.type === Status.DELETED_BY_US || resource.type === Status.BOTH_DELETED + ) { + continue; + } + + void commands.executeCommand( + 'vscode.open', + resource.resourceUri, + { background: true, preview: false, } + ); + } + } + + @command('git.openMergeEditor') + async openMergeEditor(uri: unknown) { + if (uri === undefined) { + // fallback to active editor... + if (window.tabGroups.activeTabGroup.activeTab?.input instanceof TabInputText) { + uri = window.tabGroups.activeTabGroup.activeTab.input.uri; + } + } + if (!(uri instanceof Uri)) { + return; + } + const repo = this.model.getRepository(uri); + if (!repo) { + return; + } + + const isRebasing = Boolean(repo.rebaseCommit); + + type InputData = { uri: Uri; title?: string; detail?: string; description?: string }; + const mergeUris = toMergeUris(uri); + + let isStashConflict = false; + try { + // Look at the conflict markers to check if this is a stash conflict + const document = await workspace.openTextDocument(uri); + const firstConflictInfo = findFirstConflictMarker(document); + isStashConflict = firstConflictInfo?.incomingChangeLabel === 'Stashed changes'; + } catch (error) { + console.error(error); + } + + const current: InputData = { uri: mergeUris.ours, title: l10n.t('Current') }; + const incoming: InputData = { uri: mergeUris.theirs, title: l10n.t('Incoming') }; + + if (isStashConflict) { + incoming.title = l10n.t('Stashed Changes'); + } + + try { + const [head, rebaseOrMergeHead, oursDiff, theirsDiff] = await Promise.all([ + repo.getCommit('HEAD'), + isRebasing ? repo.getCommit('REBASE_HEAD') : repo.getCommit('MERGE_HEAD'), + await repo.diffBetween(isRebasing ? 'REBASE_HEAD' : 'MERGE_HEAD', 'HEAD'), + await repo.diffBetween('HEAD', isRebasing ? 'REBASE_HEAD' : 'MERGE_HEAD') + ]); + + const oursDiffFile = oursDiff?.find(diff => diff.uri.fsPath === uri.fsPath); + const theirsDiffFile = theirsDiff?.find(diff => diff.uri.fsPath === uri.fsPath); + + // ours (current branch and commit) + current.detail = head.refNames.map(s => s.replace(/^HEAD ->/, '')).join(', '); + current.description = '$(git-commit) ' + head.hash.substring(0, 7); + if (theirsDiffFile) { + // use the original uri in case the file was renamed by theirs + current.uri = toGitUri(theirsDiffFile.originalUri, head.hash); + } else { + current.uri = toGitUri(uri, head.hash); + } + + // theirs + incoming.detail = rebaseOrMergeHead.refNames.join(', '); + incoming.description = '$(git-commit) ' + rebaseOrMergeHead.hash.substring(0, 7); + if (oursDiffFile) { + // use the original uri in case the file was renamed by ours + incoming.uri = toGitUri(oursDiffFile.originalUri, rebaseOrMergeHead.hash); + } else { + incoming.uri = toGitUri(uri, rebaseOrMergeHead.hash); + } + + } catch (error) { + // not so bad, can continue with just uris + console.error('FAILED to read HEAD, MERGE_HEAD commits'); + console.error(error); + } + + const options = { + base: mergeUris.base, + input1: isRebasing ? current : incoming, + input2: isRebasing ? incoming : current, + output: uri + }; + + await commands.executeCommand( + '_open.mergeEditor', + options + ); + + function findFirstConflictMarker(doc: TextDocument): { currentChangeLabel: string; incomingChangeLabel: string } | undefined { + const conflictMarkerStart = '<<<<<<<'; + const conflictMarkerEnd = '>>>>>>>'; + let inConflict = false; + let currentChangeLabel: string = ''; + let incomingChangeLabel: string = ''; + let hasConflict = false; + + for (let lineIdx = 0; lineIdx < doc.lineCount; lineIdx++) { + const lineStr = doc.lineAt(lineIdx).text; + if (!inConflict) { + if (lineStr.startsWith(conflictMarkerStart)) { + currentChangeLabel = lineStr.substring(conflictMarkerStart.length).trim(); + inConflict = true; + hasConflict = true; + } + } else { + if (lineStr.startsWith(conflictMarkerEnd)) { + incomingChangeLabel = lineStr.substring(conflictMarkerStart.length).trim(); + inConflict = false; + break; + } + } + } + if (hasConflict) { + return { + currentChangeLabel, + incomingChangeLabel + }; + } + return undefined; + } + } + + private getRepositoriesWithRemote(repositories: Repository[]) { + return repositories.reduce<(QuickPickItem & { repository: Repository })[]>((items, repository) => { + const remote = repository.remotes.find((r) => r.name === repository.HEAD?.upstream?.remote); + if (remote?.pushUrl) { + items.push({ repository: repository, label: remote.pushUrl }); + } + return items; + }, []); + } + + @command('git.continueInLocalClone') + async continueInLocalClone(): Promise { + if (this.model.repositories.length === 0) { return; } + + // Pick a single repository to continue working on in a local clone if there's more than one + let items = this.getRepositoriesWithRemote(this.model.repositories); + + // We have a repository but there is no remote URL (e.g. git init) + if (items.length === 0) { + const pick = this.model.repositories.length === 1 + ? { repository: this.model.repositories[0] } + : await window.showQuickPick(this.model.repositories.map((i) => ({ repository: i, label: i.root })), { canPickMany: false, placeHolder: l10n.t('Choose which repository to publish') }); + if (!pick) { return; } + + await this.publish(pick.repository); + + items = this.getRepositoriesWithRemote([pick.repository]); + if (items.length === 0) { + return; + } + } + + let selection = items[0]; + if (items.length > 1) { + const pick = await window.showQuickPick(items, { canPickMany: false, placeHolder: l10n.t('Choose which repository to clone') }); + if (pick === undefined) { return; } + selection = pick; + } + + const uri = selection.label; + const ref = selection.repository.HEAD?.upstream?.name; + + if (uri !== undefined) { + let target = `${env.uriScheme}://vscode.git/clone?url=${encodeURIComponent(uri)}`; + const isWeb = env.uiKind === UIKind.Web; + const isRemote = env.remoteName !== undefined; + + if (isWeb || isRemote) { + if (ref !== undefined) { + target += `&ref=${encodeURIComponent(ref)}`; + } + + if (isWeb) { + // Launch desktop client if currently in web + return Uri.parse(target); + } + + if (isRemote) { + // If already in desktop client but in a remote window, we need to force a new window + // so that the git extension can access the local filesystem for cloning + target += `&windowId=_blank`; + return Uri.parse(target); + } + } + + // Otherwise, directly clone + void this.clone(uri, undefined, { ref: ref }); + } + } + + @command('git.clone') + async clone(url?: string, parentPath?: string, options?: { ref?: string; postCloneAction?: 'none'; returnRepositoryPath?: boolean }): Promise { + return this.cloneManager.clone(url, { parentPath, ...options }); + } + + @command('git.cloneRecursive') + async cloneRecursive(url?: string, parentPath?: string): Promise { + await this.cloneManager.clone(url, { parentPath, recursive: true }); + } + + @command('_git.cloneRepository') + async cloneRepository(url: string, localPath: string, ref?: string): Promise { + const opts = { + location: ProgressLocation.Notification, + title: l10n.t('Cloning git repository "{0}"...', url), + cancellable: true + }; + + const parentPath = path.dirname(localPath); + const targetName = path.basename(localPath); + + await window.withProgress( + opts, + (progress, token) => this.model.git.clone(url, { parentPath, targetName, progress, ref }, token) + ); + } + + @command('_git.checkout') + async checkoutRepository(repositoryPath: string, treeish: string, detached?: boolean): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + await repo.checkout(treeish, [], detached ? { detached: true } : {}); + } + + @command('_git.pull') + async pullRepository(repositoryPath: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + return repo.pull(); + } + + @command('_git.fetchRepository') + async fetchRepository(repositoryPath: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + await repo.fetch(); + } + + @command('_git.revParse') + async revParse(repositoryPath: string, ref: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + const result = await repo.exec(['rev-parse', ref]); + return result.stdout.trim(); + } + + @command('_git.revListCount') + async revListCount(repositoryPath: string, fromRef: string, toRef: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + const result = await repo.exec(['rev-list', '--count', `${fromRef}..${toRef}`]); + return Number(result.stdout.trim()) || 0; + } + + @command('_git.revParseAbbrevRef') + async revParseAbbrevRef(repositoryPath: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + const result = await repo.exec(['rev-parse', '--abbrev-ref', 'HEAD']); + return result.stdout.trim(); + } + + @command('_git.mergeBranch') + async mergeBranch(repositoryPath: string, branch: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + const result = await repo.exec(['merge', branch, '--no-edit']); + return result.stdout.trim(); + } + + @command('git.init') + async init(skipFolderPrompt = false): Promise { + let repositoryPath: string | undefined = undefined; + let askToOpen = true; + + if (workspace.workspaceFolders) { + if (skipFolderPrompt && workspace.workspaceFolders.length === 1) { + repositoryPath = workspace.workspaceFolders[0].uri.fsPath; + askToOpen = false; + } else { + const placeHolder = l10n.t('Pick workspace folder to initialize git repo in'); + const pick = { label: l10n.t('Choose Folder...') }; + const items: { label: string; folder?: WorkspaceFolder }[] = [ + ...workspace.workspaceFolders.map(folder => ({ label: folder.name, description: folder.uri.fsPath, folder })), + pick + ]; + const item = await window.showQuickPick(items, { placeHolder, ignoreFocusOut: true }); + + if (!item) { + return; + } else if (item.folder) { + repositoryPath = item.folder.uri.fsPath; + askToOpen = false; + } + } + } + + if (!repositoryPath) { + const homeUri = Uri.file(os.homedir()); + const defaultUri = workspace.workspaceFolders && workspace.workspaceFolders.length > 0 + ? Uri.file(workspace.workspaceFolders[0].uri.fsPath) + : homeUri; + + const result = await window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri, + openLabel: l10n.t('Initialize Repository') + }); + + if (!result || result.length === 0) { + return; + } + + const uri = result[0]; + + if (homeUri.toString().startsWith(uri.toString())) { + const yes = l10n.t('Initialize Repository'); + const message = getSafeNotificationMessage( + l10n.t('This will create a Git repository in "{0}". Are you sure you want to continue?', uri.fsPath), + l10n.t('This will create a Git repository in the selected folder. Are you sure you want to continue?'), + ); + const answer = await window.showWarningMessage(message, yes); + + if (answer !== yes) { + return; + } + } + + repositoryPath = uri.fsPath; + + if (workspace.workspaceFolders && workspace.workspaceFolders.some(w => w.uri.toString() === uri.toString())) { + askToOpen = false; + } + } + + const config = workspace.getConfiguration('git'); + const defaultBranchName = config.get('defaultBranchName', 'main'); + const branchWhitespaceChar = config.get('branchWhitespaceChar', '-'); + + await this.git.init(repositoryPath, { defaultBranch: sanitizeBranchName(defaultBranchName, branchWhitespaceChar) }); + + let message = l10n.t('Would you like to open the initialized repository?'); + const open = l10n.t('Open'); + const openNewWindow = l10n.t('Open in New Window'); + const choices = [open, openNewWindow]; + + if (!askToOpen) { + await this.model.openRepository(repositoryPath); + return; + } + + const addToWorkspace = l10n.t('Add to Workspace'); + if (workspace.workspaceFolders) { + message = l10n.t('Would you like to open the initialized repository, or add it to the current workspace?'); + choices.push(addToWorkspace); + } + + const result = await window.showInformationMessage(message, ...choices); + const uri = Uri.file(repositoryPath); + + if (result === open) { + commands.executeCommand('vscode.openFolder', uri); + } else if (result === addToWorkspace) { + workspace.updateWorkspaceFolders(workspace.workspaceFolders!.length, 0, { uri }); + } else if (result === openNewWindow) { + commands.executeCommand('vscode.openFolder', uri, true); + } else { + await this.model.openRepository(repositoryPath); + } + } + + @command('git.openRepository', { repository: false }) + async openRepository(path?: string): Promise { + if (!path) { + const result = await window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri: Uri.file(os.homedir()), + openLabel: l10n.t('Open Repository') + }); + + if (!result || result.length === 0) { + return; + } + + path = result[0].fsPath; + } + + await this.model.openRepository(path, true, true); + } + + @command('git.reopenClosedRepositories', { repository: false }) + async reopenClosedRepositories(): Promise { + if (this.model.closedRepositories.length === 0) { + return; + } + + const closedRepositories: string[] = []; + + const title = l10n.t('Reopen Closed Repositories'); + const placeHolder = l10n.t('Pick a repository to reopen'); + + const allRepositoriesLabel = l10n.t('All Repositories'); + const allRepositoriesQuickPickItem: QuickPickItem = { label: allRepositoriesLabel }; + const repositoriesQuickPickItems: QuickPickItem[] = this.model.closedRepositories + .sort(compareRepositoryLabel).map(r => new RepositoryItem(r)); + + const items = this.model.closedRepositories.length === 1 ? [...repositoriesQuickPickItems] : + [...repositoriesQuickPickItems, { label: '', kind: QuickPickItemKind.Separator }, allRepositoriesQuickPickItem]; + + const repositoryItem = await window.showQuickPick(items, { title, placeHolder }); + if (!repositoryItem) { + return; + } + + if (repositoryItem === allRepositoriesQuickPickItem) { + // All Repositories + closedRepositories.push(...this.model.closedRepositories.values()); + } else { + // One Repository + closedRepositories.push((repositoryItem as RepositoryItem).path); + } + + for (const repository of closedRepositories) { + await this.model.openRepository(repository, true, true); + } + } + + @command('git.close', { repository: true }) + async close(repository: Repository, ...args: SourceControl[]): Promise { + const otherRepositories = args + .map(sourceControl => this.model.getRepository(sourceControl)) + .filter(isDefined); + + for (const r of [repository, ...otherRepositories]) { + this.model.close(r); + } + } + + @command('git.closeOtherRepositories', { repository: true }) + async closeOtherRepositories(repository: Repository, ...args: SourceControl[]): Promise { + const otherRepositories = args + .map(sourceControl => this.model.getRepository(sourceControl)) + .filter(isDefined); + + const selectedRepositories = [repository, ...otherRepositories]; + for (const r of this.model.repositories) { + if (selectedRepositories.includes(r)) { + continue; + } + this.model.close(r); + } + } + + @command('git.openFile') + async openFile(arg?: Resource | Uri, ...resourceStates: SourceControlResourceState[]): Promise { + const preserveFocus = arg instanceof Resource; + + let uris: Uri[] | undefined; + + if (arg instanceof Uri) { + if (isGitUri(arg)) { + uris = [Uri.file(fromGitUri(arg).path)]; + } else if (arg.scheme === 'file') { + uris = [arg]; + } + } else { + let resource = arg; + + if (!(resource instanceof Resource)) { + // can happen when called from a keybinding + resource = this.getSCMResource(); + } + + if (resource) { + uris = ([resource, ...resourceStates] as Resource[]) + .filter(r => r.type !== Status.DELETED && r.type !== Status.INDEX_DELETED) + .map(r => r.resourceUri); + } else if (window.activeTextEditor) { + uris = [window.activeTextEditor.document.uri]; + } + } + + if (!uris) { + return; + } + + const activeTextEditor = window.activeTextEditor; + // Must extract these now because opening a new document will change the activeTextEditor reference + const previousVisibleRanges = activeTextEditor?.visibleRanges; + const previousURI = activeTextEditor?.document.uri; + const previousSelection = activeTextEditor?.selection; + + for (const uri of uris) { + const opts: TextDocumentShowOptions = { + preserveFocus, + preview: false, + viewColumn: ViewColumn.Active + }; + + await commands.executeCommand('vscode.open', uri, { + ...opts, + override: arg instanceof Resource && arg.type === Status.BOTH_MODIFIED ? false : undefined + }); + + const document = window.activeTextEditor?.document; + + // If the document doesn't match what we opened then don't attempt to select the range + // Additionally if there was no previous document we don't have information to select a range + if (document?.uri.toString() !== uri.toString() || !activeTextEditor || !previousURI || !previousSelection) { + continue; + } + + // Check if active text editor has same path as other editor. we cannot compare via + // URI.toString() here because the schemas can be different. Instead we just go by path. + if (previousURI.path === uri.path && document) { + // preserve not only selection but also visible range + opts.selection = previousSelection; + const editor = await window.showTextDocument(document, opts); + // This should always be defined but just in case + if (previousVisibleRanges && previousVisibleRanges.length > 0) { + let rangeToReveal = previousVisibleRanges[0]; + if (previousSelection && previousVisibleRanges.length > 1) { + // In case of multiple visible ranges, find the one that intersects with the selection + rangeToReveal = previousVisibleRanges.find(r => r.intersection(previousSelection)) ?? rangeToReveal; + } + editor.revealRange(rangeToReveal); + } + } + } + } + + @command('git.openFile2') + async openFile2(arg?: Resource | Uri, ...resourceStates: SourceControlResourceState[]): Promise { + this.openFile(arg, ...resourceStates); + } + + @command('git.openHEADFile') + async openHEADFile(arg?: Resource | Uri): Promise { + let resource: Resource | undefined = undefined; + const preview = !(arg instanceof Resource); + + if (arg instanceof Resource) { + resource = arg; + } else if (arg instanceof Uri) { + resource = this.getSCMResource(arg); + } else { + resource = this.getSCMResource(); + } + + if (!resource) { + return; + } + + const HEAD = resource.leftUri; + const basename = path.basename(resource.resourceUri.fsPath); + const title = `${basename} (HEAD)`; + + if (!HEAD) { + window.showWarningMessage(getSafeNotificationMessage( + l10n.t('HEAD version of "{0}" is not available.', basename), + l10n.t('HEAD version of this file is not available.'), + )); + return; + } + + const opts: TextDocumentShowOptions = { + preview + }; + + return await commands.executeCommand('vscode.open', HEAD, opts, title); + } + + @command('git.openChange') + async openChange(arg?: Resource | Uri, ...resourceStates: SourceControlResourceState[]): Promise { + let resources: Resource[] | undefined = undefined; + + if (arg instanceof Uri) { + const resource = this.getSCMResource(arg); + if (resource !== undefined) { + resources = [resource]; + } + } else { + let resource: Resource | undefined = undefined; + + if (arg instanceof Resource) { + resource = arg; + } else { + resource = this.getSCMResource(); + } + + if (resource) { + resources = [...resourceStates as Resource[], resource]; + } + } + + if (!resources) { + return; + } + + for (const resource of resources) { + await resource.openChange(); + } + } + + @command('git.compareWithWorkspace') + async compareWithWorkspace(resource?: Resource): Promise { + if (!resource) { + return; + } + + await resource.compareWithWorkspace(); + } + + @command('git.rename', { repository: true }) + async rename(repository: Repository, fromUri: Uri | undefined): Promise { + fromUri = fromUri ?? window.activeTextEditor?.document.uri; + + if (!fromUri) { + return; + } + + const from = relativePath(repository.root, fromUri.fsPath); + 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); + + // Close active editor and open the renamed file + await commands.executeCommand('workbench.action.closeActiveEditor'); + await commands.executeCommand('vscode.open', Uri.file(path.join(repository.root, to)), { viewColumn: ViewColumn.Active }); + } + + @command('git.delete') + async delete(uri: Uri | undefined): Promise { + const activeDocument = window.activeTextEditor?.document; + uri = uri ?? activeDocument?.uri; + if (!uri) { + return; + } + + const repository = this.model.getRepository(uri); + if (!repository) { + return; + } + + const allChangedResources = [ + ...repository.workingTreeGroup.resourceStates, + ...repository.indexGroup.resourceStates, + ...repository.mergeGroup.resourceStates, + ...repository.untrackedGroup.resourceStates + ]; + + // Check if file has uncommitted changes + const uriString = uri.toString(); + if (allChangedResources.some(o => pathEquals(o.resourceUri.toString(), uriString))) { + window.showInformationMessage(l10n.t('Git: Delete can only be performed on committed files without uncommitted changes.')); + return; + } + + await repository.rm([uri]); + + // Close the active editor if it's not dirty + if (activeDocument && !activeDocument.isDirty && pathEquals(activeDocument.uri.toString(), uriString)) { + await commands.executeCommand('workbench.action.closeActiveEditor'); + } + } + + @command('git.stage') + async stage(...resourceStates: SourceControlResourceState[]): Promise { + this.logger.debug(`[CommandCenter][stage] git.stage ${resourceStates.length} `); + + resourceStates = resourceStates.filter(s => !!s); + + if (resourceStates.length === 0 || (resourceStates[0] && !(resourceStates[0].resourceUri instanceof Uri))) { + const resource = this.getSCMResource(); + + this.logger.debug(`[CommandCenter][stage] git.stage.getSCMResource ${resource ? resource.resourceUri.toString() : null} `); + + if (!resource) { + return; + } + + resourceStates = [resource]; + } + + const selection = resourceStates.filter(s => s instanceof Resource) as Resource[]; + const { resolved, unresolved, deletionConflicts } = await categorizeResourceByResolution(selection); + + if (unresolved.length > 0) { + const message = unresolved.length > 1 + ? l10n.t('Are you sure you want to stage {0} files with merge conflicts?', unresolved.length) + : l10n.t('Are you sure you want to stage {0} with merge conflicts?', path.basename(unresolved[0].resourceUri.fsPath)); + + const yes = l10n.t('Yes'); + const pick = await window.showWarningMessage(message, { modal: true }, yes); + + if (pick !== yes) { + return; + } + } + + try { + await this.runByRepository(deletionConflicts.map(r => r.resourceUri), async (repository, resources) => { + for (const resource of resources) { + await this._stageDeletionConflict(repository, resource); + } + }); + } catch (err) { + if (/Cancelled/.test(err.message)) { + return; + } + + throw err; + } + + const workingTree = selection.filter(s => s.resourceGroupType === ResourceGroupType.WorkingTree); + const untracked = selection.filter(s => s.resourceGroupType === ResourceGroupType.Untracked); + const scmResources = [...workingTree, ...untracked, ...resolved, ...unresolved]; + + this.logger.debug(`[CommandCenter][stage] git.stage.scmResources ${scmResources.length} `); + if (!scmResources.length) { + return; + } + + const resources = scmResources.map(r => r.resourceUri); + await this.runByRepository(resources, async (repository, resources) => repository.add(resources)); + } + + @command('git.stageAll', { repository: true }) + async stageAll(repository: Repository): Promise { + const resources = [...repository.workingTreeGroup.resourceStates, ...repository.untrackedGroup.resourceStates]; + const uris = resources.map(r => r.resourceUri); + + if (uris.length > 0) { + const config = workspace.getConfiguration('git', Uri.file(repository.root)); + const untrackedChanges = config.get<'mixed' | 'separate' | 'hidden'>('untrackedChanges'); + await repository.add(uris, untrackedChanges === 'mixed' ? undefined : { update: true }); + } + } + + private async _stageDeletionConflict(repository: Repository, uri: Uri): Promise { + const uriString = uri.toString(); + const resource = repository.mergeGroup.resourceStates.filter(r => r.resourceUri.toString() === uriString)[0]; + + if (!resource) { + return; + } + + if (resource.type === Status.DELETED_BY_THEM) { + const keepIt = l10n.t('Keep Our Version'); + const deleteIt = l10n.t('Delete File'); + const result = await window.showInformationMessage(l10n.t('File "{0}" was deleted by them and modified by us.\n\nWhat would you like to do?', path.basename(uri.fsPath)), { modal: true }, keepIt, deleteIt); + + if (result === keepIt) { + await repository.add([uri]); + } else if (result === deleteIt) { + await repository.rm([uri]); + } else { + throw new Error('Cancelled'); + } + } else if (resource.type === Status.DELETED_BY_US) { + const keepIt = l10n.t('Keep Their Version'); + const deleteIt = l10n.t('Delete File'); + const result = await window.showInformationMessage(l10n.t('File "{0}" was deleted by us and modified by them.\n\nWhat would you like to do?', path.basename(uri.fsPath)), { modal: true }, keepIt, deleteIt); + + if (result === keepIt) { + await repository.add([uri]); + } else if (result === deleteIt) { + await repository.rm([uri]); + } else { + throw new Error('Cancelled'); + } + } + } + + @command('git.stageAllTracked', { repository: true }) + async stageAllTracked(repository: Repository): Promise { + const resources = repository.workingTreeGroup.resourceStates + .filter(r => r.type !== Status.UNTRACKED && r.type !== Status.IGNORED); + const uris = resources.map(r => r.resourceUri); + + await repository.add(uris); + } + + @command('git.stageAllUntracked', { repository: true }) + async stageAllUntracked(repository: Repository): Promise { + const resources = [...repository.workingTreeGroup.resourceStates, ...repository.untrackedGroup.resourceStates] + .filter(r => r.type === Status.UNTRACKED || r.type === Status.IGNORED); + const uris = resources.map(r => r.resourceUri); + + await repository.add(uris); + } + + @command('git.stageAllMerge', { repository: true }) + async stageAllMerge(repository: Repository): Promise { + const resources = repository.mergeGroup.resourceStates.filter(s => s instanceof Resource) as Resource[]; + const { merge, unresolved, deletionConflicts } = await categorizeResourceByResolution(resources); + + try { + for (const deletionConflict of deletionConflicts) { + await this._stageDeletionConflict(repository, deletionConflict.resourceUri); + } + } catch (err) { + if (/Cancelled/.test(err.message)) { + return; + } + + throw err; + } + + if (unresolved.length > 0) { + const message = unresolved.length > 1 + ? l10n.t('Are you sure you want to stage {0} files with merge conflicts?', merge.length) + : l10n.t('Are you sure you want to stage {0} with merge conflicts?', path.basename(merge[0].resourceUri.fsPath)); + + const yes = l10n.t('Yes'); + const pick = await window.showWarningMessage(message, { modal: true }, yes); + + if (pick !== yes) { + return; + } + } + + const uris = resources.map(r => r.resourceUri); + + if (uris.length > 0) { + await repository.add(uris); + } + } + + @command('git.stageChange') + async stageChange(uri: Uri, changes: LineChange[], index: number): Promise { + if (!uri) { + return; + } + + const textEditor = window.visibleTextEditors.filter(e => e.document.uri.toString() === uri.toString())[0]; + + if (!textEditor) { + return; + } + + await this._stageChanges(textEditor, [changes[index]]); + + const firstStagedLine = changes[index].modifiedStartLineNumber; + textEditor.selections = [new Selection(firstStagedLine, 0, firstStagedLine, 0)]; + } + + @command('git.diff.stageHunk') + async diffStageHunk(changes: DiffEditorSelectionHunkToolbarContext | undefined): Promise { + if (changes) { + this.diffStageHunkOrSelection(changes); + } else { + await this.stageHunkAtCursor(); + } + } + + @command('git.diff.stageSelection') + async diffStageSelection(changes: DiffEditorSelectionHunkToolbarContext | undefined): Promise { + this.diffStageHunkOrSelection(changes); + } + + async diffStageHunkOrSelection(changes: DiffEditorSelectionHunkToolbarContext | undefined): Promise { + if (!changes) { + return; + } + + let modifiedUri = changes.modifiedUri; + let modifiedDocument: TextDocument | undefined; + + if (!modifiedUri) { + const textEditor = window.activeTextEditor; + if (!textEditor) { + return; + } + modifiedDocument = textEditor.document; + modifiedUri = modifiedDocument.uri; + } + + if (modifiedUri.scheme !== 'file') { + return; + } + + if (!modifiedDocument) { + modifiedDocument = await workspace.openTextDocument(modifiedUri); + } + + const result = changes.originalWithModifiedChanges; + await this.runByRepository(modifiedUri, async (repository, resource) => + await repository.stage(resource, result, modifiedDocument.encoding)); + } + + private async stageHunkAtCursor(): Promise { + const textEditor = window.activeTextEditor; + + if (!textEditor) { + return; + } + + const workingTreeDiffInformation = getWorkingTreeDiffInformation(textEditor); + if (!workingTreeDiffInformation) { + return; + } + + const workingTreeLineChanges = toLineChanges(workingTreeDiffInformation); + const modifiedDocument = textEditor.document; + const cursorPosition = textEditor.selection.active; + + // Find the hunk that contains the cursor position + const hunkAtCursor = workingTreeLineChanges.find(change => { + const hunkRange = getModifiedRange(modifiedDocument, change); + return hunkRange.contains(cursorPosition); + }); + + if (!hunkAtCursor) { + window.showInformationMessage(l10n.t('No hunk found at cursor position.')); + return; + } + + await this._stageChanges(textEditor, [hunkAtCursor]); + } + + @command('git.stageSelectedRanges') + async stageSelectedChanges(): Promise { + const textEditor = window.activeTextEditor; + + if (!textEditor) { + return; + } + + const workingTreeDiffInformation = getWorkingTreeDiffInformation(textEditor); + if (!workingTreeDiffInformation) { + return; + } + + const workingTreeLineChanges = toLineChanges(workingTreeDiffInformation); + + this.logger.trace(`[CommandCenter][stageSelectedChanges] diffInformation: ${JSON.stringify(workingTreeDiffInformation)}`); + this.logger.trace(`[CommandCenter][stageSelectedChanges] diffInformation changes: ${JSON.stringify(workingTreeLineChanges)}`); + + const modifiedDocument = textEditor.document; + const selectedLines = toLineRanges(textEditor.selections, modifiedDocument); + const selectedChanges = workingTreeLineChanges + .map(change => selectedLines.reduce((result, range) => result || intersectDiffWithRange(modifiedDocument, change, range), null)) + .filter(d => !!d) as LineChange[]; + + this.logger.trace(`[CommandCenter][stageSelectedChanges] selectedChanges: ${JSON.stringify(selectedChanges)}`); + + if (!selectedChanges.length) { + window.showInformationMessage(l10n.t('The selection range does not contain any changes.')); + return; + } + + await this._stageChanges(textEditor, selectedChanges); + } + + @command('git.stageFile') + async stageFile(uri: Uri): Promise { + uri = uri ?? window.activeTextEditor?.document.uri; + + if (!uri) { + return; + } + + const repository = this.model.getRepository(uri); + if (!repository) { + return; + } + + const resources = [ + ...repository.workingTreeGroup.resourceStates, + ...repository.untrackedGroup.resourceStates] + .filter(r => r.multiFileDiffEditorModifiedUri?.toString() === uri.toString() || r.multiDiffEditorOriginalUri?.toString() === uri.toString()) + .map(r => r.resourceUri); + + if (resources.length === 0) { + return; + } + + await repository.add(resources); + } + + @command('git.acceptMerge') + async acceptMerge(_uri: Uri | unknown): Promise { + const { activeTab } = window.tabGroups.activeTabGroup; + if (!activeTab) { + return; + } + + if (!(activeTab.input instanceof TabInputTextMerge)) { + return; + } + + const uri = activeTab.input.result; + + const repository = this.model.getRepository(uri); + if (!repository) { + console.log(`FAILED to complete merge because uri ${uri.toString()} doesn't belong to any repository`); + return; + } + + const result = await commands.executeCommand('mergeEditor.acceptMerge') as { successful: boolean }; + if (result.successful) { + await repository.add([uri]); + await commands.executeCommand('workbench.view.scm'); + } + + /* + if (!(uri instanceof Uri)) { + return; + } + + + + + // make sure to save the merged document + const doc = workspace.textDocuments.find(doc => doc.uri.toString() === uri.toString()); + if (!doc) { + console.log(`FAILED to complete merge because uri ${uri.toString()} doesn't match a document`); + return; + } + if (doc.isDirty) { + await doc.save(); + } + + // find the merge editor tabs for the resource in question and close them all + let didCloseTab = false; + const mergeEditorTabs = window.tabGroups.all.map(group => group.tabs.filter(tab => tab.input instanceof TabInputTextMerge && tab.input.result.toString() === uri.toString())).flat(); + if (mergeEditorTabs.includes(activeTab)) { + didCloseTab = await window.tabGroups.close(mergeEditorTabs, true); + } + + // Only stage if the merge editor has been successfully closed. That means all conflicts have been + // handled or unhandled conflicts are OK by the user. + if (didCloseTab) { + await repository.add([uri]); + await commands.executeCommand('workbench.view.scm'); + }*/ + } + + @command('git.runGitMerge') + async runGitMergeNoDiff3(): Promise { + await this.runGitMerge(false); + } + + @command('git.runGitMergeDiff3') + async runGitMergeDiff3(): Promise { + await this.runGitMerge(true); + } + + private async runGitMerge(diff3: boolean): Promise { + const { activeTab } = window.tabGroups.activeTabGroup; + if (!activeTab) { + return; + } + + const input = activeTab.input; + if (!(input instanceof TabInputTextMerge)) { + return; + } + + const result = await this.git.mergeFile({ + basePath: input.base.fsPath, + input1Path: input.input1.fsPath, + input2Path: input.input2.fsPath, + diff3, + }); + + const doc = workspace.textDocuments.find(doc => doc.uri.toString() === input.result.toString()); + if (!doc) { + return; + } + const e = new WorkspaceEdit(); + + e.replace( + input.result, + new Range( + new Position(0, 0), + new Position(doc.lineCount, 0), + ), + result + ); + await workspace.applyEdit(e); + } + + private async _stageChanges(textEditor: TextEditor, changes: LineChange[]): Promise { + const modifiedDocument = textEditor.document; + const modifiedUri = modifiedDocument.uri; + + if (modifiedUri.scheme !== 'file') { + return; + } + + const originalUri = toGitUri(modifiedUri, '~'); + const originalDocument = await workspace.openTextDocument(originalUri); + const result = applyLineChanges(originalDocument, modifiedDocument, changes); + + await this.runByRepository(modifiedUri, async (repository, resource) => + await repository.stage(resource, result, modifiedDocument.encoding)); + } + + @command('git.revertChange') + async revertChange(uri: Uri, changes: LineChange[], index: number): Promise { + if (!uri) { + return; + } + + const textEditor = window.visibleTextEditors.filter(e => e.document.uri.toString() === uri.toString())[0]; + + if (!textEditor) { + return; + } + + await this._revertChanges(textEditor, [...changes.slice(0, index), ...changes.slice(index + 1)]); + + const firstStagedLine = changes[index].modifiedStartLineNumber; + textEditor.selections = [new Selection(firstStagedLine, 0, firstStagedLine, 0)]; + } + + @command('git.revertSelectedRanges') + async revertSelectedRanges(): Promise { + const textEditor = window.activeTextEditor; + + if (!textEditor) { + return; + } + + const workingTreeDiffInformation = getWorkingTreeDiffInformation(textEditor); + if (!workingTreeDiffInformation) { + return; + } + + const workingTreeLineChanges = toLineChanges(workingTreeDiffInformation); + + this.logger.trace(`[CommandCenter][revertSelectedRanges] diffInformation: ${JSON.stringify(workingTreeDiffInformation)}`); + this.logger.trace(`[CommandCenter][revertSelectedRanges] diffInformation changes: ${JSON.stringify(workingTreeLineChanges)}`); + + const modifiedDocument = textEditor.document; + const selections = textEditor.selections; + const selectedChanges = workingTreeLineChanges.filter(change => { + const modifiedRange = getModifiedRange(modifiedDocument, change); + return selections.every(selection => !selection.intersection(modifiedRange)); + }); + + if (selectedChanges.length === workingTreeLineChanges.length) { + window.showInformationMessage(l10n.t('The selection range does not contain any changes.')); + return; + } + + this.logger.trace(`[CommandCenter][revertSelectedRanges] selectedChanges: ${JSON.stringify(selectedChanges)}`); + + const selectionsBeforeRevert = textEditor.selections; + await this._revertChanges(textEditor, selectedChanges); + textEditor.selections = selectionsBeforeRevert; + } + + private async _revertChanges(textEditor: TextEditor, changes: LineChange[]): Promise { + const modifiedDocument = textEditor.document; + const modifiedUri = modifiedDocument.uri; + + if (modifiedUri.scheme !== 'file') { + return; + } + + const originalUri = toGitUri(modifiedUri, '~'); + const originalDocument = await workspace.openTextDocument(originalUri); + const visibleRangesBeforeRevert = textEditor.visibleRanges; + const result = applyLineChanges(originalDocument, modifiedDocument, changes); + + const edit = new WorkspaceEdit(); + edit.replace(modifiedUri, new Range(new Position(0, 0), modifiedDocument.lineAt(modifiedDocument.lineCount - 1).range.end), result); + workspace.applyEdit(edit); + + await modifiedDocument.save(); + + textEditor.revealRange(visibleRangesBeforeRevert[0]); + } + + @command('git.unstage') + async unstage(...resourceStates: SourceControlResourceState[]): Promise { + resourceStates = resourceStates.filter(s => !!s); + + if (resourceStates.length === 0 || (resourceStates[0] && !(resourceStates[0].resourceUri instanceof Uri))) { + const resource = this.getSCMResource(); + + if (!resource) { + return; + } + + resourceStates = [resource]; + } + + const scmResources = resourceStates + .filter(s => s instanceof Resource && s.resourceGroupType === ResourceGroupType.Index) as Resource[]; + + if (!scmResources.length) { + return; + } + + const resources = scmResources.map(r => r.resourceUri); + await this.runByRepository(resources, async (repository, resources) => repository.revert(resources)); + } + + @command('git.unstageAll', { repository: true }) + async unstageAll(repository: Repository): Promise { + await repository.revert([]); + } + + @command('git.unstageSelectedRanges') + async unstageSelectedRanges(): Promise { + const textEditor = window.activeTextEditor; + + if (!textEditor) { + return; + } + + const modifiedDocument = textEditor.document; + const modifiedUri = modifiedDocument.uri; + + const repository = this.model.getRepository(modifiedUri); + if (!repository) { + return; + } + + const resource = repository.indexGroup.resourceStates + .find(r => pathEquals(r.resourceUri.fsPath, modifiedUri.fsPath)); + if (!resource) { + return; + } + + const indexDiffInformation = getIndexDiffInformation(textEditor); + if (!indexDiffInformation) { + return; + } + + const indexLineChanges = toLineChanges(indexDiffInformation); + + this.logger.trace(`[CommandCenter][unstageSelectedRanges] diffInformation: ${JSON.stringify(indexDiffInformation)}`); + this.logger.trace(`[CommandCenter][unstageSelectedRanges] diffInformation changes: ${JSON.stringify(indexLineChanges)}`); + + const originalUri = toGitUri(resource.original, 'HEAD'); + const originalDocument = await workspace.openTextDocument(originalUri); + const selectedLines = toLineRanges(textEditor.selections, modifiedDocument); + + const selectedDiffs = indexLineChanges + .map(change => selectedLines.reduce((result, range) => result || intersectDiffWithRange(modifiedDocument, change, range), null)) + .filter(c => !!c) as LineChange[]; + + if (!selectedDiffs.length) { + window.showInformationMessage(l10n.t('The selection range does not contain any changes.')); + return; + } + + this.logger.trace(`[CommandCenter][unstageSelectedRanges] selectedDiffs: ${JSON.stringify(selectedDiffs)}`); + + // if (modifiedUri.scheme === 'file') { + // // Editor + // this.logger.trace(`[CommandCenter][unstageSelectedRanges] changes: ${JSON.stringify(selectedDiffs)}`); + // await this._unstageChanges(textEditor, selectedDiffs); + // return; + // } + + const selectedDiffsInverted = selectedDiffs.map(invertLineChange); + this.logger.trace(`[CommandCenter][unstageSelectedRanges] selectedDiffsInverted: ${JSON.stringify(selectedDiffsInverted)}`); + + const result = applyLineChanges(modifiedDocument, originalDocument, selectedDiffsInverted); + await repository.stage(modifiedDocument.uri, result, modifiedDocument.encoding); + } + + @command('git.unstageFile') + async unstageFile(uri: Uri): Promise { + uri = uri ?? window.activeTextEditor?.document.uri; + + if (!uri) { + return; + } + + const repository = this.model.getRepository(uri); + if (!repository) { + return; + } + + const resources = repository.indexGroup.resourceStates + .filter(r => r.multiFileDiffEditorModifiedUri?.toString() === uri.toString() || r.multiDiffEditorOriginalUri?.toString() === uri.toString()) + .map(r => r.resourceUri); + + if (resources.length === 0) { + return; + } + + await repository.revert(resources); + } + + @command('git.unstageChange') + async unstageChange(uri: Uri, changes: LineChange[], index: number): Promise { + if (!uri) { + return; + } + + const textEditor = window.visibleTextEditors.filter(e => e.document.uri.toString() === uri.toString())[0]; + if (!textEditor) { + return; + } + + await this._unstageChanges(textEditor, [changes[index]]); + } + + private async _unstageChanges(textEditor: TextEditor, changes: LineChange[]): Promise { + const modifiedDocument = textEditor.document; + const modifiedUri = modifiedDocument.uri; + + if (modifiedUri.scheme !== 'file') { + return; + } + + const workingTreeDiffInformation = getWorkingTreeDiffInformation(textEditor); + if (!workingTreeDiffInformation) { + return; + } + + // Approach to unstage change(s): + // - use file on disk as original document + // - revert all changes from the working tree + // - revert the specify change(s) from the index + const workingTreeDiffs = toLineChanges(workingTreeDiffInformation); + const workingTreeDiffsInverted = workingTreeDiffs.map(invertLineChange); + const changesInverted = changes.map(invertLineChange); + const diffsInverted = [...changesInverted, ...workingTreeDiffsInverted].sort(compareLineChanges); + + const originalUri = toGitUri(modifiedUri, 'HEAD'); + const originalDocument = await workspace.openTextDocument(originalUri); + const result = applyLineChanges(modifiedDocument, originalDocument, diffsInverted); + + await this.runByRepository(modifiedUri, async (repository, resource) => + await repository.stage(resource, result, modifiedDocument.encoding)); + } + + @command('git.clean') + async clean(...resourceStates: SourceControlResourceState[]): Promise { + // Remove duplicate resources + const resourceUris = new Set(); + resourceStates = resourceStates.filter(s => { + if (s === undefined) { + return false; + } + + if (resourceUris.has(s.resourceUri.toString())) { + return false; + } + + resourceUris.add(s.resourceUri.toString()); + return true; + }); + + if (resourceStates.length === 0 || (resourceStates[0] && !(resourceStates[0].resourceUri instanceof Uri))) { + const resource = this.getSCMResource(); + + if (!resource) { + return; + } + + resourceStates = [resource]; + } + + const scmResources = resourceStates.filter(s => s instanceof Resource + && (s.resourceGroupType === ResourceGroupType.WorkingTree || s.resourceGroupType === ResourceGroupType.Untracked)) as Resource[]; + + if (!scmResources.length) { + return; + } + + await this._cleanAll(scmResources); + } + + @command('git.cleanAll', { repository: true }) + async cleanAll(repository: Repository): Promise { + await this._cleanAll(repository.workingTreeGroup.resourceStates); + } + + @command('git.cleanAllTracked', { repository: true }) + async cleanAllTracked(repository: Repository): Promise { + const resources = repository.workingTreeGroup.resourceStates + .filter(r => r.type !== Status.UNTRACKED && r.type !== Status.IGNORED); + + if (resources.length === 0) { + return; + } + + await this._cleanTrackedChanges(resources); + } + + @command('git.cleanAllUntracked', { repository: true }) + async cleanAllUntracked(repository: Repository): Promise { + const resources = [...repository.workingTreeGroup.resourceStates, ...repository.untrackedGroup.resourceStates] + .filter(r => r.type === Status.UNTRACKED || r.type === Status.IGNORED); + + if (resources.length === 0) { + return; + } + + await this._cleanUntrackedChanges(resources); + } + + private async _cleanAll(resources: Resource[]): Promise { + if (resources.length === 0) { + return; + } + + const trackedResources = resources.filter(r => r.type !== Status.UNTRACKED && r.type !== Status.IGNORED); + const untrackedResources = resources.filter(r => r.type === Status.UNTRACKED || r.type === Status.IGNORED); + + if (untrackedResources.length === 0) { + // Tracked files only + await this._cleanTrackedChanges(resources); + } else if (trackedResources.length === 0) { + // Untracked files only + await this._cleanUntrackedChanges(resources); + } else { + // Tracked & Untracked files + const [untrackedMessage, untrackedMessageDetail] = this.getDiscardUntrackedChangesDialogDetails(untrackedResources); + + const trackedMessage = trackedResources.length === 1 + ? l10n.t('\n\nAre you sure you want to discard changes in \'{0}\'?', path.basename(trackedResources[0].resourceUri.fsPath)) + : l10n.t('\n\nAre you sure you want to discard ALL changes in {0} files?', trackedResources.length); + + const yesTracked = trackedResources.length === 1 + ? l10n.t('Discard 1 Tracked File') + : l10n.t('Discard All {0} Tracked Files', trackedResources.length); + + const yesAll = l10n.t('Discard All {0} Files', resources.length); + const pick = await window.showWarningMessage(`${untrackedMessage} ${untrackedMessageDetail}${trackedMessage}\n\nThis is IRREVERSIBLE!\nYour current working set will be FOREVER LOST if you proceed.`, { modal: true }, yesTracked, yesAll); + + if (pick === yesTracked) { + resources = trackedResources; + } else if (pick !== yesAll) { + return; + } + + const resourceUris = resources.map(r => r.resourceUri); + await this.runByRepository(resourceUris, async (repository, resources) => repository.clean(resources)); + } + } + + private async _cleanTrackedChanges(resources: Resource[]): Promise { + const allResourcesDeleted = resources.every(r => r.type === Status.DELETED); + + const message = allResourcesDeleted + ? resources.length === 1 + ? l10n.t('Are you sure you want to restore \'{0}\'?', path.basename(resources[0].resourceUri.fsPath)) + : l10n.t('Are you sure you want to restore ALL {0} files?', resources.length) + : resources.length === 1 + ? l10n.t('Are you sure you want to discard changes in \'{0}\'?', path.basename(resources[0].resourceUri.fsPath)) + : l10n.t('Are you sure you want to discard ALL changes in {0} files?\n\nThis is IRREVERSIBLE!\nYour current working set will be FOREVER LOST if you proceed.', resources.length); + + const yes = allResourcesDeleted + ? resources.length === 1 + ? l10n.t('Restore File') + : l10n.t('Restore All {0} Files', resources.length) + : resources.length === 1 + ? l10n.t('Discard File') + : l10n.t('Discard All {0} Files', resources.length); + + const pick = await window.showWarningMessage(message, { modal: true }, yes); + + if (pick !== yes) { + return; + } + + const resourceUris = resources.map(r => r.resourceUri); + await this.runByRepository(resourceUris, async (repository, resources) => repository.clean(resources)); + } + + private async _cleanUntrackedChanges(resources: Resource[]): Promise { + const [message, messageDetail, primaryAction] = this.getDiscardUntrackedChangesDialogDetails(resources); + const pick = await window.showWarningMessage(message, { detail: messageDetail, modal: true }, primaryAction); + + if (pick !== primaryAction) { + return; + } + + const resourceUris = resources.map(r => r.resourceUri); + await this.runByRepository(resourceUris, async (repository, resources) => repository.clean(resources)); + } + + private getDiscardUntrackedChangesDialogDetails(resources: Resource[]): [string, string, string] { + const config = workspace.getConfiguration('git'); + const discardUntrackedChangesToTrash = config.get('discardUntrackedChangesToTrash', true) && !isRemote && !isLinuxSnap; + + const messageWarning = !discardUntrackedChangesToTrash + ? resources.length === 1 + ? '\n\n' + l10n.t('This is IRREVERSIBLE!\nThis file will be FOREVER LOST if you proceed.') + : '\n\n' + l10n.t('This is IRREVERSIBLE!\nThese files will be FOREVER LOST if you proceed.') + : ''; + + const message = resources.length === 1 + ? l10n.t('Are you sure you want to DELETE the following untracked file: \'{0}\'?{1}', path.basename(resources[0].resourceUri.fsPath), messageWarning) + : l10n.t('Are you sure you want to DELETE the {0} untracked files?{1}', resources.length, messageWarning); + + const messageDetail = discardUntrackedChangesToTrash + ? isWindows + ? resources.length === 1 + ? l10n.t('You can restore this file from the Recycle Bin.') + : l10n.t('You can restore these files from the Recycle Bin.') + : resources.length === 1 + ? l10n.t('You can restore this file from the Trash.') + : l10n.t('You can restore these files from the Trash.') + : ''; + + const primaryAction = discardUntrackedChangesToTrash + ? isWindows + ? l10n.t('Move to Recycle Bin') + : l10n.t('Move to Trash') + : resources.length === 1 + ? l10n.t('Delete File') + : l10n.t('Delete All {0} Files', resources.length); + + return [message, messageDetail, primaryAction]; + } + + private async smartCommit( + repository: Repository, + getCommitMessage: () => Promise, + opts: CommitOptions + ): Promise { + const config = workspace.getConfiguration('git', Uri.file(repository.root)); + let promptToSaveFilesBeforeCommit = config.get<'always' | 'staged' | 'never'>('promptToSaveFilesBeforeCommit'); + + // migration + if (typeof promptToSaveFilesBeforeCommit === 'boolean') { + promptToSaveFilesBeforeCommit = promptToSaveFilesBeforeCommit ? 'always' : 'never'; + } + + let enableSmartCommit = config.get('enableSmartCommit') === true; + let noStagedChanges = repository.indexGroup.resourceStates.length === 0; + let noUnstagedChanges = repository.workingTreeGroup.resourceStates.length === 0; + + if (!opts.empty) { + if (promptToSaveFilesBeforeCommit !== 'never') { + let documents = workspace.textDocuments + .filter(d => !d.isUntitled && d.isDirty && isDescendant(repository.root, d.uri.fsPath)); + + if (promptToSaveFilesBeforeCommit === '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 + ? l10n.t('The following file has unsaved changes which won\'t be included in the commit if you proceed: {0}.\n\nWould you like to save it before committing?', path.basename(documents[0].uri.fsPath)) + : l10n.t('There are {0} unsaved files.\n\nWould you like to save them before committing?', documents.length); + const saveAndCommit = l10n.t('Save All & Commit Changes'); + const commit = l10n.t('Commit Changes'); + const pick = await window.showWarningMessage(message, { modal: true }, saveAndCommit, commit); + + if (pick === saveAndCommit) { + await Promise.all(documents.map(d => d.save())); + + // After saving the dirty documents, if there are any documents that are part of the + // index group we have to add them back in order for the saved changes to be committed + documents = documents + .filter(d => repository.indexGroup.resourceStates.some(s => pathEquals(s.resourceUri.fsPath, d.uri.fsPath))); + await repository.add(documents.map(d => d.uri)); + + noStagedChanges = repository.indexGroup.resourceStates.length === 0; + noUnstagedChanges = repository.workingTreeGroup.resourceStates.length === 0; + } else if (pick !== commit) { + return; // do not commit on cancel + } + } + } + + // no changes, and the user has not configured to commit all in this case + if (!noUnstagedChanges && noStagedChanges && !enableSmartCommit && !opts.all && !opts.amend) { + const suggestSmartCommit = config.get('suggestSmartCommit') === true; + + if (!suggestSmartCommit) { + return; + } + + // prompt the user if we want to commit all or not + const message = l10n.t('There are no staged changes to commit.\n\nWould you like to stage all your changes and commit them directly?'); + const yes = l10n.t('Yes'); + const always = l10n.t('Always'); + const never = l10n.t('Never'); + const pick = await window.showWarningMessage(message, { modal: true }, yes, always, never); + + if (pick === always) { + enableSmartCommit = true; + config.update('enableSmartCommit', true, true); + } else if (pick === never) { + config.update('suggestSmartCommit', false, true); + return; + } else if (pick === yes) { + enableSmartCommit = true; + } else { + // Cancel + return; + } + } + + // smart commit + if (enableSmartCommit && !opts.all) { + opts = { ...opts, all: noStagedChanges }; + } + } + + // Enable signing of commits if the setting is enabled. If the setting is not enabled, + // we set the option to undefined so that we let git use the repository/global config. + opts.signCommit = config.get('enableCommitSigning') === true ? true : undefined; + + if (config.get('alwaysSignOff')) { + opts.signoff = true; + } + + if (config.get('useEditorAsCommitInput')) { + opts.useEditor = true; + + if (config.get('verboseCommit')) { + opts.verbose = true; + } + } + + const smartCommitChanges = config.get<'all' | 'tracked'>('smartCommitChanges'); + + if ( + ( + // no changes + (noStagedChanges && noUnstagedChanges) + // or no staged changes and not `all` + || (!opts.all && noStagedChanges) + // 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 + // merge not in progress + && !repository.mergeInProgress + // rebase not in progress + && repository.rebaseCommit === undefined + ) { + const commitAnyway = l10n.t('Create Empty Commit'); + const answer = await window.showInformationMessage(l10n.t('There are no changes to commit.'), commitAnyway); + + if (answer !== commitAnyway) { + return; + } + + opts.empty = true; + } + + if (opts.noVerify) { + if (!config.get('allowNoVerifyCommit')) { + await window.showErrorMessage(l10n.t('Commits without verification are not allowed, please enable them with the "git.allowNoVerifyCommit" setting.')); + return; + } + + if (config.get('confirmNoVerifyCommit')) { + const message = l10n.t('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 = l10n.t('OK'); + const neverAgain = l10n.t('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; + } + } + } + + const message = await getCommitMessage(); + + if (!message && !opts.amend && !opts.useEditor) { + return; + } + + if (opts.all && smartCommitChanges === 'tracked') { + opts.all = 'tracked'; + } + + if (opts.all && config.get<'mixed' | 'separate' | 'hidden'>('untrackedChanges') !== 'mixed') { + opts.all = 'tracked'; + } + + // Diagnostics commit hook + const diagnosticsResult = await evaluateDiagnosticsCommitHook(repository, opts, this.logger); + if (!diagnosticsResult) { + return; + } + + // Branch protection commit hook + const branchProtectionPrompt = config.get<'alwaysCommit' | 'alwaysCommitToNewBranch' | 'alwaysPrompt'>('branchProtectionPrompt')!; + if (repository.isBranchProtected() && (branchProtectionPrompt === 'alwaysPrompt' || branchProtectionPrompt === 'alwaysCommitToNewBranch')) { + const commitToNewBranch = l10n.t('Commit to a New Branch'); + + let pick: string | undefined = commitToNewBranch; + + if (branchProtectionPrompt === 'alwaysPrompt') { + const message = l10n.t('You are trying to commit to a protected branch. How would you like to proceed?'); + const commit = l10n.t('Commit Anyway'); + + pick = await window.showWarningMessage(message, { modal: true }, commitToNewBranch, commit); + } + + if (!pick) { + return; + } else if (pick === commitToNewBranch) { + const branchName = await this.promptForBranchName(repository); + + if (!branchName) { + return; + } + + await repository.branch(branchName, true); + } + } + + await repository.commit(message, opts); + } + + private async commitWithAnyInput(repository: Repository, opts: CommitOptions): Promise { + const message = repository.inputBox.value; + const root = Uri.file(repository.root); + const config = workspace.getConfiguration('git', root); + + const getCommitMessage = async () => { + let _message: string | undefined = message; + + if (!_message && !config.get('useEditorAsCommitInput')) { + const value: string | undefined = undefined; + + if (opts && opts.amend && repository.HEAD && repository.HEAD.commit) { + return undefined; + } + + const branchName = repository.headShortName; + let placeHolder: string; + + if (branchName) { + placeHolder = l10n.t('Message (commit on "{0}")', branchName); + } else { + placeHolder = l10n.t('Commit message'); + } + + _message = await window.showInputBox({ + value, + placeHolder, + prompt: l10n.t('Please provide a commit message'), + ignoreFocusOut: true + }); + } + + return _message; + }; + + await this.smartCommit(repository, getCommitMessage, opts); + } + + @command('git.commit', { repository: true }) + async commit(repository: Repository, postCommitCommand?: string | null): Promise { + await this.commitWithAnyInput(repository, { postCommitCommand }); + } + + @command('git.commitAmend', { repository: true }) + async commitAmend(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { amend: true }); + } + + @command('git.commitSigned', { repository: true }) + async commitSigned(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { signoff: true }); + } + + @command('git.commitStaged', { repository: true }) + async commitStaged(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: false }); + } + + @command('git.commitStagedSigned', { repository: true }) + async commitStagedSigned(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: false, signoff: true }); + } + + @command('git.commitStagedAmend', { repository: true }) + async commitStagedAmend(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: false, amend: true }); + } + + @command('git.commitAll', { repository: true }) + async commitAll(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: true }); + } + + @command('git.commitAllSigned', { repository: true }) + async commitAllSigned(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: true, signoff: true }); + } + + @command('git.commitAllAmend', { repository: true }) + async commitAllAmend(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: true, amend: true }); + } + + @command('git.commitMessageAccept') + async commitMessageAccept(arg?: Uri): Promise { + if (!arg && !window.activeTextEditor) { return; } + arg ??= window.activeTextEditor!.document.uri; + + // Close the tab + this._closeEditorTab(arg); + } + + @command('git.commitMessageDiscard') + async commitMessageDiscard(arg?: Uri): Promise { + if (!arg && !window.activeTextEditor) { return; } + arg ??= window.activeTextEditor!.document.uri; + + // Clear the contents of the editor + const editors = window.visibleTextEditors + .filter(e => e.document.languageId === 'git-commit' && e.document.uri.toString() === arg!.toString()); + + if (editors.length !== 1) { return; } + + const commitMsgEditor = editors[0]; + const commitMsgDocument = commitMsgEditor.document; + + const editResult = await commitMsgEditor.edit(builder => { + const firstLine = commitMsgDocument.lineAt(0); + const lastLine = commitMsgDocument.lineAt(commitMsgDocument.lineCount - 1); + + builder.delete(new Range(firstLine.range.start, lastLine.range.end)); + }); + + if (!editResult) { return; } + + // Save the document + const saveResult = await commitMsgDocument.save(); + if (!saveResult) { return; } + + // Close the tab + this._closeEditorTab(arg); + } + + private _closeEditorTab(uri: Uri): void { + const tabToClose = window.tabGroups.all.map(g => g.tabs).flat() + .filter(t => t.input instanceof TabInputText && t.input.uri.toString() === uri.toString()); + + window.tabGroups.close(tabToClose); + } + + private async _commitEmpty(repository: Repository, noVerify?: boolean): Promise { + const root = Uri.file(repository.root); + const config = workspace.getConfiguration('git', root); + const shouldPrompt = config.get('confirmEmptyCommits') === true; + + if (shouldPrompt) { + const message = l10n.t('Are you sure you want to create an empty commit?'); + const yes = l10n.t('Yes'); + const neverAgain = l10n.t('Yes, Don\'t Show Again'); + const pick = await window.showWarningMessage(message, { modal: true }, yes, neverAgain); + + if (pick === neverAgain) { + await config.update('confirmEmptyCommits', false, true); + } else if (pick !== yes) { + return; + } + } + + await this.commitWithAnyInput(repository, { empty: true, noVerify }); + } + + @command('git.commitEmpty', { repository: true }) + async commitEmpty(repository: Repository): Promise { + await this._commitEmpty(repository); + } + + @command('git.commitNoVerify', { repository: true }) + async commitNoVerify(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { noVerify: true }); + } + + @command('git.commitStagedNoVerify', { repository: true }) + async commitStagedNoVerify(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: false, noVerify: true }); + } + + @command('git.commitStagedSignedNoVerify', { repository: true }) + async commitStagedSignedNoVerify(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: false, signoff: true, noVerify: true }); + } + + @command('git.commitAmendNoVerify', { repository: true }) + async commitAmendNoVerify(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { amend: true, noVerify: true }); + } + + @command('git.commitSignedNoVerify', { repository: true }) + async commitSignedNoVerify(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { signoff: true, noVerify: true }); + } + + @command('git.commitStagedAmendNoVerify', { repository: true }) + async commitStagedAmendNoVerify(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: false, amend: true, noVerify: true }); + } + + @command('git.commitAllNoVerify', { repository: true }) + async commitAllNoVerify(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: true, noVerify: true }); + } + + @command('git.commitAllSignedNoVerify', { repository: true }) + async commitAllSignedNoVerify(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: true, signoff: true, noVerify: true }); + } + + @command('git.commitAllAmendNoVerify', { repository: true }) + async commitAllAmendNoVerify(repository: Repository): Promise { + await this.commitWithAnyInput(repository, { all: true, amend: true, noVerify: true }); + } + + @command('git.commitEmptyNoVerify', { repository: true }) + async commitEmptyNoVerify(repository: Repository): Promise { + await this._commitEmpty(repository, true); + } + + @command('git.restoreCommitTemplate', { repository: true }) + async restoreCommitTemplate(repository: Repository): Promise { + repository.inputBox.value = await repository.getCommitTemplate(); + } + + @command('git.undoCommit', { repository: true }) + async undoCommit(repository: Repository): Promise { + const HEAD = repository.HEAD; + + if (!HEAD || !HEAD.commit) { + window.showWarningMessage(l10n.t('Can\'t undo because HEAD doesn\'t point to any commit.')); + return; + } + + const commit = await repository.getCommit('HEAD'); + + if (commit.parents.length > 1) { + const yes = l10n.t('Undo merge commit'); + const result = await window.showWarningMessage(l10n.t('The last commit was a merge commit. Are you sure you want to undo it?'), { modal: true }, yes); + + if (result !== yes) { + return; + } + } + + if (commit.parents.length > 0) { + await repository.reset('HEAD~'); + } else { + await repository.deleteRef('HEAD'); + await this.unstageAll(repository); + } + + repository.inputBox.value = commit.message; + } + + @command('git.checkout', { repository: true }) + async checkout(repository: Repository, treeish?: string): Promise { + return this._checkout(repository, { treeish }); + } + + @command('git.graph.checkout', { repository: true }) + async checkout2(repository: Repository, historyItem?: SourceControlHistoryItem, historyItemRefId?: string): Promise { + const historyItemRef = historyItem?.references?.find(r => r.id === historyItemRefId); + if (!historyItemRef) { + return; + } + + const config = workspace.getConfiguration('git', Uri.file(repository.root)); + const pullBeforeCheckout = config.get('pullBeforeCheckout', false) === true; + + // Branch, tag + if (historyItemRef.id.startsWith('refs/heads/') || historyItemRef.id.startsWith('refs/tags/')) { + await repository.checkout(historyItemRef.name, { pullBeforeCheckout }); + return; + } + + // Remote branch + const branches = await repository.findTrackingBranches(historyItemRef.name); + if (branches.length > 0) { + await repository.checkout(branches[0].name!, { pullBeforeCheckout }); + } else { + await repository.checkoutTracking(historyItemRef.name); + } + } + + @command('git.checkoutDetached', { repository: true }) + async checkoutDetached(repository: Repository, treeish?: string): Promise { + return this._checkout(repository, { detached: true, treeish }); + } + + @command('git.graph.checkoutDetached', { repository: true }) + async checkoutDetached2(repository: Repository, historyItem?: SourceControlHistoryItem): Promise { + if (!historyItem) { + return false; + } + return this._checkout(repository, { detached: true, treeish: historyItem.id }); + } + + private async _checkout(repository: Repository, opts?: { detached?: boolean; treeish?: string }): Promise { + if (typeof opts?.treeish === 'string') { + await repository.checkout(opts?.treeish, opts); + return true; + } + + const createBranch = new CreateBranchItem(); + const createBranchFrom = new CreateBranchFromItem(); + const checkoutDetached = new CheckoutDetachedItem(); + const picks: QuickPickItem[] = []; + const commands: QuickPickItem[] = []; + + if (!opts?.detached) { + commands.push(createBranch, createBranchFrom, checkoutDetached); + } + + const disposables: Disposable[] = []; + const quickPick = window.createQuickPick(); + quickPick.busy = true; + quickPick.sortByLabel = false; + quickPick.matchOnDetail = false; + quickPick.placeholder = opts?.detached + ? l10n.t('Select a branch to checkout in detached mode') + : l10n.t('Select a branch or tag to checkout'); + + quickPick.show(); + picks.push(... await createCheckoutItems(repository, opts?.detached)); + + const setQuickPickItems = () => { + switch (true) { + case quickPick.value === '': + quickPick.items = [...commands, ...picks]; + break; + case commands.length === 0: + quickPick.items = picks; + break; + case picks.length === 0: + quickPick.items = commands; + break; + default: + quickPick.items = [...picks, { label: '', kind: QuickPickItemKind.Separator }, ...commands]; + break; + } + }; + + setQuickPickItems(); + quickPick.busy = false; + + const choice = await new Promise(c => { + disposables.push(quickPick.onDidHide(() => c(undefined))); + disposables.push(quickPick.onDidAccept(() => c(quickPick.activeItems[0]))); + disposables.push((quickPick.onDidTriggerItemButton((e) => { + const button = e.button as QuickInputButton & { actual: RemoteSourceAction }; + const item = e.item as CheckoutItem; + if (button.actual && item.refName) { + button.actual.run(item.refRemote ? item.refName.substring(item.refRemote.length + 1) : item.refName); + } + + c(undefined); + }))); + disposables.push(quickPick.onDidChangeValue(() => setQuickPickItems())); + }); + + dispose(disposables); + quickPick.dispose(); + + if (!choice) { + return false; + } + + if (choice === createBranch) { + 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 { + const item = choice as CheckoutItem; + + try { + await item.run(repository, opts); + } catch (err) { + if (err.gitErrorCode !== GitErrorCodes.DirtyWorkTree && err.gitErrorCode !== GitErrorCodes.WorktreeBranchAlreadyUsed) { + throw err; + } + + if (err.gitErrorCode === GitErrorCodes.WorktreeBranchAlreadyUsed) { + // Not checking out in a worktree (use standard error handling) + if (!repository.dotGit.commonPath) { + await this.handleWorktreeBranchAlreadyUsed(err); + return false; + } + + // Check out in a worktree (check if worktree's main repository is open in workspace and if branch is already checked out in main repository) + const commonPath = path.dirname(repository.dotGit.commonPath); + if (workspace.workspaceFolders && workspace.workspaceFolders.some(folder => pathEquals(folder.uri.fsPath, commonPath))) { + const mainRepository = this.model.getRepository(commonPath); + if (mainRepository && item.refName && item.refName.replace(`${item.refRemote}/`, '') === mainRepository.HEAD?.name) { + const message = l10n.t('Branch "{0}" is already checked out in the current window.', item.refName); + await window.showErrorMessage(message, { modal: true }); + return false; + } + } + + // Check out in a worktree, (branch is already checked out in existing worktree) + await this.handleWorktreeBranchAlreadyUsed(err); + return false; + } + + const stash = l10n.t('Stash & Checkout'); + const migrate = l10n.t('Migrate Changes'); + const force = l10n.t('Force Checkout'); + const choice = await window.showWarningMessage(l10n.t('Your local changes would be overwritten by checkout.'), { modal: true }, stash, migrate, force); + + if (choice === force) { + await this.cleanAll(repository); + await item.run(repository, opts); + } else if (choice === stash || choice === migrate) { + if (await this._stash(repository, true)) { + await item.run(repository, opts); + + if (choice === migrate) { + await this.stashPopLatest(repository); + } + } + } + } + } + + return true; + } + + @command('git.branch', { repository: true }) + async branch(repository: Repository, historyItem?: SourceControlHistoryItem): Promise { + await this._branch(repository, undefined, false, historyItem?.id); + } + + @command('git.branchFrom', { repository: true }) + async branchFrom(repository: Repository): Promise { + await this._branch(repository, undefined, true); + } + + private async promptForBranchName(repository: Repository, defaultName?: string, initialValue?: string): Promise { + const config = workspace.getConfiguration('git'); + const branchPrefix = config.get('branchPrefix')!; + const branchWhitespaceChar = config.get('branchWhitespaceChar')!; + const branchValidationRegex = config.get('branchValidationRegex')!; + const branchRandomNameEnabled = config.get('branchRandomName.enable', false); + const refs = await repository.getRefs({ pattern: 'refs/heads' }); + + if (defaultName) { + return sanitizeBranchName(defaultName, branchWhitespaceChar); + } + + const getBranchName = async (): Promise => { + return await repository.generateRandomBranchName() ?? branchPrefix; + }; + + const getValueSelection = (value: string): [number, number] | undefined => { + return value.startsWith(branchPrefix) ? [branchPrefix.length, value.length] : undefined; + }; + + const getValidationMessage = (name: string): string | InputBoxValidationMessage | undefined => { + const validateName = new RegExp(branchValidationRegex); + const sanitizedName = sanitizeBranchName(name, branchWhitespaceChar); + + // Check if branch name already exists + const existingBranch = refs.find(ref => ref.name === sanitizedName); + if (existingBranch) { + return l10n.t('Branch "{0}" already exists', sanitizedName); + } + + if (validateName.test(sanitizedName)) { + // If the sanitized name that we will use is different than what is + // in the input box, show an info message to the user informing them + // the branch name that will be used. + return name === sanitizedName + ? undefined + : { + message: l10n.t('The new branch will be "{0}"', sanitizedName), + severity: InputBoxValidationSeverity.Info + }; + } + + return l10n.t('Branch name needs to match regex: {0}', branchValidationRegex); + }; + + const disposables: Disposable[] = []; + const inputBox = window.createInputBox(); + + inputBox.placeholder = l10n.t('Branch name'); + inputBox.prompt = l10n.t('Please provide a new branch name'); + + inputBox.buttons = branchRandomNameEnabled ? [ + { + iconPath: new ThemeIcon('refresh'), + tooltip: l10n.t('Regenerate Branch Name'), + location: QuickInputButtonLocation.Inline + } + ] : []; + + inputBox.value = initialValue ?? await getBranchName(); + inputBox.valueSelection = getValueSelection(inputBox.value); + inputBox.validationMessage = getValidationMessage(inputBox.value); + inputBox.ignoreFocusOut = true; + + inputBox.show(); + + const branchName = await new Promise((resolve) => { + disposables.push(inputBox.onDidHide(() => resolve(undefined))); + disposables.push(inputBox.onDidAccept(() => resolve(inputBox.value))); + disposables.push(inputBox.onDidChangeValue(value => { + inputBox.validationMessage = getValidationMessage(value); + })); + disposables.push(inputBox.onDidTriggerButton(async () => { + inputBox.value = await getBranchName(); + inputBox.valueSelection = getValueSelection(inputBox.value); + })); + }); + + dispose(disposables); + inputBox.dispose(); + + return sanitizeBranchName(branchName || '', branchWhitespaceChar); + } + + private async _branch(repository: Repository, defaultName?: string, from = false, target?: string): Promise { + target = target ?? 'HEAD'; + + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + const commitShortHashLength = config.get('commitShortHashLength') ?? 7; + + if (from) { + const getRefPicks = async () => { + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); + const refProcessors = new RefItemsProcessor(repository, [ + new RefProcessor(RefType.Head), + new RefProcessor(RefType.RemoteHead), + new RefProcessor(RefType.Tag) + ]); + + return [new HEADItem(repository, commitShortHashLength), ...refProcessors.processRefs(refs)]; + }; + + const placeHolder = l10n.t('Select a ref to create the branch from'); + const choice = await window.showQuickPick(getRefPicks(), { placeHolder }); + + if (!choice) { + return; + } + + if (choice instanceof RefItem && choice.refName) { + target = choice.refName; + } + } + + const branchName = await this.promptForBranchName(repository, defaultName); + + if (!branchName) { + return; + } + + await repository.branch(branchName, true, target); + } + + private async pickRef(items: Promise, placeHolder: string): Promise { + const disposables: Disposable[] = []; + const quickPick = window.createQuickPick(); + + quickPick.placeholder = placeHolder; + quickPick.sortByLabel = false; + quickPick.busy = true; + + quickPick.show(); + + quickPick.items = await items; + quickPick.busy = false; + + const choice = await new Promise(resolve => { + disposables.push(quickPick.onDidHide(() => resolve(undefined))); + disposables.push(quickPick.onDidAccept(() => resolve(quickPick.activeItems[0]))); + }); + + dispose(disposables); + quickPick.dispose(); + + return choice; + } + + @command('git.deleteBranch', { repository: true }) + async deleteBranch(repository: Repository, name: string | undefined, force?: boolean): Promise { + await this._deleteBranch(repository, undefined, name, { remote: false, force }); + } + + @command('git.graph.deleteBranch', { repository: true }) + async deleteBranch2(repository: Repository, historyItem?: SourceControlHistoryItem, historyItemRefId?: string): Promise { + const historyItemRef = historyItem?.references?.find(r => r.id === historyItemRefId); + if (!historyItemRef) { + return; + } + + // Local branch + if (historyItemRef.id.startsWith('refs/heads/')) { + if (historyItemRef.id === repository.historyProvider.currentHistoryItemRef?.id) { + window.showInformationMessage(l10n.t('The active branch cannot be deleted.')); + return; + } + + await this._deleteBranch(repository, undefined, historyItemRef.name, { remote: false }); + return; + } + + // Remote branch + if (historyItemRef.id === repository.historyProvider.currentHistoryItemRemoteRef?.id) { + window.showInformationMessage(l10n.t('The remote branch of the active branch cannot be deleted.')); + return; + } + + const index = historyItemRef.name.indexOf('/'); + if (index === -1) { + return; + } + + const remoteName = historyItemRef.name.substring(0, index); + const refName = historyItemRef.name.substring(index + 1); + + await this._deleteBranch(repository, remoteName, refName, { remote: true }); + } + + @command('git.graph.compareWithRemote', { repository: true }) + async compareWithRemote(repository: Repository, historyItem?: SourceControlHistoryItem): Promise { + if (!historyItem || !repository.historyProvider.currentHistoryItemRemoteRef) { + return; + } + + await this._openChangesBetweenRefs( + repository, + { + id: repository.historyProvider.currentHistoryItemRemoteRef.revision, + displayId: repository.historyProvider.currentHistoryItemRemoteRef.name + }, + { + id: historyItem.id, + displayId: getHistoryItemDisplayName(historyItem) + }); + } + + @command('git.graph.compareWithMergeBase', { repository: true }) + async compareWithMergeBase(repository: Repository, historyItem?: SourceControlHistoryItem): Promise { + if (!historyItem || !repository.historyProvider.currentHistoryItemBaseRef) { + return; + } + + await this._openChangesBetweenRefs( + repository, + { + id: repository.historyProvider.currentHistoryItemBaseRef.revision, + displayId: repository.historyProvider.currentHistoryItemBaseRef.name + }, + { + id: historyItem.id, + displayId: getHistoryItemDisplayName(historyItem) + }); + } + + @command('git.graph.compareRef', { repository: true }) + async compareRef(repository: Repository, historyItem?: SourceControlHistoryItem): Promise { + if (!repository || !historyItem) { + return; + } + + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + + const getRefPicks = async () => { + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); + const processors = [ + new RefProcessor(RefType.Head, BranchItem), + new RefProcessor(RefType.RemoteHead, BranchItem), + new RefProcessor(RefType.Tag, BranchItem) + ]; + + const itemsProcessor = new RefItemsProcessor(repository, processors); + return itemsProcessor.processRefs(refs); + }; + + const placeHolder = l10n.t('Select a reference to compare with'); + const sourceRef = await this.pickRef(getRefPicks(), placeHolder); + + if (!(sourceRef instanceof BranchItem) || !sourceRef.ref.commit) { + return; + } + + await this._openChangesBetweenRefs( + repository, + { + id: sourceRef.ref.commit, + displayId: sourceRef.ref.name + }, + { + id: historyItem.id, + displayId: getHistoryItemDisplayName(historyItem) + }); + } + + private async _openChangesBetweenRefs(repository: Repository, ref1: { id: string | undefined; displayId: string | undefined }, ref2: { id: string | undefined; displayId: string | undefined }): Promise { + if (!repository || !ref1.id || !ref2.id) { + return; + } + + try { + const changes = await repository.diffBetweenWithStats(ref1.id, ref2.id); + + if (changes.length === 0) { + window.showInformationMessage(l10n.t('There are no changes between "{0}" and "{1}".', ref1.displayId ?? ref1.id, ref2.displayId ?? ref2.id)); + return; + } + + const multiDiffSourceUri = Uri.from({ scheme: 'git-ref-compare', path: `${repository.root}/${ref1.id}..${ref2.id}` }); + const resources = changes.map(change => toMultiFileDiffEditorUris(change, ref1.id!, ref2.id!)); + + await commands.executeCommand('_workbench.openMultiDiffEditor', { + multiDiffSourceUri, + title: `${ref1.displayId ?? ref1.id} \u2194 ${ref2.displayId ?? ref2.id}`, + resources + }); + } catch (err) { + window.showErrorMessage(l10n.t('Failed to open changes between "{0}" and "{1}": {2}', ref1.displayId ?? ref1.id, ref2.displayId ?? ref2.id, err.message)); + } + } + + @command('git.deleteRemoteBranch', { repository: true }) + async deleteRemoteBranch(repository: Repository): Promise { + await this._deleteBranch(repository, undefined, undefined, { remote: true }); + } + + private async _deleteBranch(repository: Repository, remote: string | undefined, name: string | undefined, options: { remote: boolean; force?: boolean }): Promise { + let run: (force?: boolean) => Promise; + + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + + if (!options.remote && typeof name === 'string') { + // Local branch + run = force => repository.deleteBranch(name!, force); + } else if (options.remote && typeof remote === 'string' && typeof name === 'string') { + // Remote branch + run = force => repository.deleteRemoteRef(remote, name!, { force }); + } else { + const getBranchPicks = async () => { + const pattern = options.remote ? 'refs/remotes' : 'refs/heads'; + const refs = await repository.getRefs({ pattern, includeCommitDetails: showRefDetails }); + const processors = options.remote + ? [new RefProcessor(RefType.RemoteHead, BranchDeleteItem)] + : [new RefProcessor(RefType.Head, BranchDeleteItem)]; + + const itemsProcessor = new RefItemsProcessor(repository, processors, { + skipCurrentBranch: true, + skipCurrentBranchRemote: true + }); + + return itemsProcessor.processRefs(refs); + }; + + const placeHolder = !options.remote + ? l10n.t('Select a branch to delete') + : l10n.t('Select a remote branch to delete'); + + const choice = await this.pickRef(getBranchPicks(), placeHolder); + + if (!(choice instanceof BranchDeleteItem) || !choice.refName) { + return; + } + name = choice.refName; + run = force => choice.run(repository, force); + } + + try { + await run(options.force); + } catch (err) { + if (err.gitErrorCode !== GitErrorCodes.BranchNotFullyMerged) { + throw err; + } + + const message = l10n.t('The branch "{0}" is not fully merged. Delete anyway?', name); + const yes = l10n.t('Delete Branch'); + const pick = await window.showWarningMessage(message, { modal: true }, yes); + + if (pick === yes) { + await run(true); + } + } + } + + @command('git.renameBranch', { repository: true }) + async renameBranch(repository: Repository): Promise { + const currentBranchName = repository.HEAD && repository.HEAD.name; + const branchName = await this.promptForBranchName(repository, undefined, currentBranchName); + + if (!branchName) { + return; + } + + try { + await repository.renameBranch(branchName); + } catch (err) { + switch (err.gitErrorCode) { + case GitErrorCodes.InvalidBranchName: + window.showErrorMessage(l10n.t('Invalid branch name')); + return; + case GitErrorCodes.BranchAlreadyExists: + window.showErrorMessage(l10n.t('A branch named "{0}" already exists', branchName)); + return; + default: + throw err; + } + } + } + + @command('git.merge', { repository: true }) + async merge(repository: Repository): Promise { + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + + const getQuickPickItems = async (): Promise => { + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); + const itemsProcessor = new RefItemsProcessor(repository, [ + new RefProcessor(RefType.Head, MergeItem), + new RefProcessor(RefType.RemoteHead, MergeItem), + new RefProcessor(RefType.Tag, MergeItem) + ], { + skipCurrentBranch: true, + skipCurrentBranchRemote: true + }); + + return itemsProcessor.processRefs(refs); + }; + + const placeHolder = l10n.t('Select a branch or tag to merge from'); + const choice = await this.pickRef(getQuickPickItems(), placeHolder); + + if (choice instanceof MergeItem) { + await choice.run(repository); + } + } + + @command('git.mergeAbort', { repository: true }) + async abortMerge(repository: Repository): Promise { + await repository.mergeAbort(); + } + + @command('git.rebase', { repository: true }) + async rebase(repository: Repository): Promise { + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + const commitShortHashLength = config.get('commitShortHashLength') ?? 7; + + const getQuickPickItems = async (): Promise => { + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); + const itemsProcessor = new RefItemsProcessor(repository, [ + new RefProcessor(RefType.Head, RebaseItem), + new RefProcessor(RefType.RemoteHead, RebaseItem) + ], { + skipCurrentBranch: true, + skipCurrentBranchRemote: true + }); + + const quickPickItems = itemsProcessor.processRefs(refs); + + if (repository.HEAD?.upstream) { + const upstreamRef = refs.find(ref => ref.type === RefType.RemoteHead && + ref.name === `${repository.HEAD!.upstream!.remote}/${repository.HEAD!.upstream!.name}`); + + if (upstreamRef) { + quickPickItems.splice(0, 0, new RebaseUpstreamItem(upstreamRef, commitShortHashLength)); + } + } + + return quickPickItems; + }; + + const placeHolder = l10n.t('Select a branch to rebase onto'); + const choice = await this.pickRef(getQuickPickItems(), placeHolder); + + if (choice instanceof RebaseItem) { + await choice.run(repository); + } + } + + @command('git.createTag', { repository: true }) + async createTag(repository: Repository, historyItem?: SourceControlHistoryItem): Promise { + await this._createTag(repository, historyItem?.id); + } + + @command('git.deleteTag', { repository: true }) + async deleteTag(repository: Repository): Promise { + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + const commitShortHashLength = config.get('commitShortHashLength') ?? 7; + + const tagPicks = async (): Promise => { + const remoteTags = await repository.getRefs({ pattern: 'refs/tags', includeCommitDetails: showRefDetails }); + return remoteTags.length === 0 + ? [{ label: l10n.t('$(info) This repository has no tags.') }] + : remoteTags.map(ref => new TagDeleteItem(ref, commitShortHashLength)); + }; + + const placeHolder = l10n.t('Select a tag to delete'); + const choice = await this.pickRef(tagPicks(), placeHolder); + + if (choice instanceof TagDeleteItem) { + await choice.run(repository); + } + } + + @command('git.migrateWorktreeChanges', { repository: true, repositoryFilter: ['repository', 'submodule'] }) + async migrateWorktreeChanges(repository: Repository): Promise { + let worktreeRepository: Repository | undefined; + + const worktrees = await repository.getWorktrees(); + if (worktrees.length === 1) { + worktreeRepository = this.model.getRepository(worktrees[0].path); + } else { + const worktreePicks = async (): Promise => { + return worktrees.length === 0 + ? [{ label: l10n.t('$(info) This repository has no worktrees.') }] + : worktrees.map(worktree => new WorktreeItem(worktree)); + }; + + const placeHolder = l10n.t('Select a worktree to migrate changes from'); + const choice = await this.pickRef(worktreePicks(), placeHolder); + + if (!choice || !(choice instanceof WorktreeItem)) { + return; + } + + worktreeRepository = this.model.getRepository(choice.worktree.path); + } + + if (!worktreeRepository || worktreeRepository.kind !== 'worktree') { + return; + } + + await repository.migrateChanges(worktreeRepository.root, { + confirmation: true, deleteFromSource: true, untracked: true + }); + } + + @command('git.openWorktreeMergeEditor') + async openWorktreeMergeEditor(uri: Uri): Promise { + type InputData = { uri: Uri; title: string }; + const mergeUris = toMergeUris(uri); + + const current: InputData = { uri: mergeUris.ours, title: l10n.t('Workspace') }; + const incoming: InputData = { uri: mergeUris.theirs, title: l10n.t('Worktree') }; + + await commands.executeCommand('_open.mergeEditor', { + base: mergeUris.base, + input1: current, + input2: incoming, + output: uri + }); + } + + @command('git.createWorktree', { repository: true, repositoryFilter: ['repository', 'submodule'] }) + async createWorktree(repository?: Repository): Promise { + if (!repository) { + return; + } + + await this._createWorktree(repository); + } + + async _createWorktree(repository: Repository): Promise { + const config = workspace.getConfiguration('git'); + const branchPrefix = config.get('branchPrefix')!; + + // Get commitish and branch for the new worktree + const worktreeDetails = await this.getWorktreeCommitishAndBranch(repository); + if (!worktreeDetails) { + return; + } + + const { commitish, branch } = worktreeDetails; + const worktreeName = ((branch ?? commitish).startsWith(branchPrefix) + ? (branch ?? commitish).substring(branchPrefix.length).replace(/\//g, '-') + : (branch ?? commitish).replace(/\//g, '-')); + + // Get path for the new worktree + const worktreePath = await this.getWorktreePath(repository, worktreeName); + if (!worktreePath) { + return; + } + + try { + await repository.createWorktree({ path: worktreePath, branch, commitish: commitish }); + } catch (err) { + if (err instanceof GitError && err.gitErrorCode === GitErrorCodes.WorktreeAlreadyExists) { + await this.handleWorktreeAlreadyExists(err); + } else if (err instanceof GitError && err.gitErrorCode === GitErrorCodes.WorktreeBranchAlreadyUsed) { + await this.handleWorktreeBranchAlreadyUsed(err); + } else { + throw err; + } + } + } + + private async getWorktreeCommitishAndBranch(repository: Repository): Promise<{ commitish: string; branch: string | undefined } | undefined> { + const config = workspace.getConfiguration('git', Uri.file(repository.root)); + const showRefDetails = config.get('showReferenceDetails') === true; + + const createBranch = new CreateBranchItem(); + const getBranchPicks = async () => { + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); + const itemsProcessor = new RefItemsProcessor(repository, [ + new RefProcessor(RefType.Head), + new RefProcessor(RefType.RemoteHead), + new RefProcessor(RefType.Tag) + ]); + const branchItems = itemsProcessor.processRefs(refs); + return [createBranch, { label: '', kind: QuickPickItemKind.Separator }, ...branchItems]; + }; + + const placeHolder = l10n.t('Select a branch or tag to create the new worktree from'); + const choice = await this.pickRef(getBranchPicks(), placeHolder); + + if (!choice) { + return undefined; + } + + if (choice === createBranch) { + // Create new branch + const branch = await this.promptForBranchName(repository); + if (!branch) { + return undefined; + } + + return { commitish: 'HEAD', branch }; + } else { + // Existing reference + if (!(choice instanceof RefItem) || !choice.refName) { + return undefined; + } + + if (choice.refName === repository.HEAD?.name) { + const message = l10n.t('Branch "{0}" is already checked out in the current repository.', choice.refName); + const createBranch = l10n.t('Create New Branch'); + const pick = await window.showWarningMessage(message, { modal: true }, createBranch); + + if (pick === createBranch) { + const branch = await this.promptForBranchName(repository); + if (!branch) { + return undefined; + } + + return { commitish: 'HEAD', branch }; + } else { + return undefined; + } + } else { + // Check whether the selected branch is checked out in an existing worktree + const worktree = repository.worktrees.find(worktree => worktree.ref === choice.refId); + if (worktree) { + const message = l10n.t('Branch "{0}" is already checked out in the worktree at "{1}".', choice.refName, worktree.path); + await this.handleWorktreeConflict(worktree.path, message); + return; + } + return { commitish: choice.refName, branch: undefined }; + } + } + } + + private async getWorktreePath(repository: Repository, worktreeName: string): Promise { + const getWorktreePath = async (): Promise => { + const worktreeRoot = this.globalState.get(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${repository.root}`); + const defaultUri = worktreeRoot ? Uri.file(worktreeRoot) : Uri.file(path.dirname(repository.root)); + + const uris = await window.showOpenDialog({ + defaultUri, + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: l10n.t('Select as Worktree Destination'), + }); + + if (!uris || uris.length === 0) { + return; + } + + return path.join(uris[0].fsPath, worktreeName); + }; + + const getValueSelection = (value: string): [number, number] | undefined => { + if (!value || !worktreeName) { + return; + } + + const start = value.length - worktreeName.length; + return [start, value.length]; + }; + + const getValidationMessage = (value: string): InputBoxValidationMessage | undefined => { + const worktree = repository.worktrees.find(worktree => pathEquals(path.normalize(worktree.path), path.normalize(value))); + return worktree ? { + message: l10n.t('A worktree already exists at "{0}".', value), + severity: InputBoxValidationSeverity.Warning + } : undefined; + }; + + // Default worktree path is based on the last worktree location or a worktree folder for the repository + const defaultWorktreeRoot = this.globalState.get(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${repository.root}`); + const defaultWorktreePath = defaultWorktreeRoot + ? path.join(defaultWorktreeRoot, worktreeName) + : repository.kind === 'worktree' + ? path.join(path.dirname(repository.root), worktreeName) + : path.join(path.dirname(repository.root), `${path.basename(repository.root)}.worktrees`, worktreeName); + + const disposables: Disposable[] = []; + const inputBox = window.createInputBox(); + disposables.push(inputBox); + + inputBox.placeholder = l10n.t('Worktree path'); + inputBox.prompt = l10n.t('Please provide a worktree path'); + inputBox.value = defaultWorktreePath; + inputBox.valueSelection = getValueSelection(inputBox.value); + inputBox.validationMessage = getValidationMessage(inputBox.value); + inputBox.ignoreFocusOut = true; + inputBox.buttons = [ + { + iconPath: new ThemeIcon('folder'), + tooltip: l10n.t('Select Worktree Destination'), + location: QuickInputButtonLocation.Inline + } + ]; + + inputBox.show(); + + const worktreePath = await new Promise((resolve) => { + disposables.push(inputBox.onDidHide(() => resolve(undefined))); + disposables.push(inputBox.onDidAccept(() => resolve(inputBox.value))); + disposables.push(inputBox.onDidChangeValue(value => { + inputBox.validationMessage = getValidationMessage(value); + })); + disposables.push(inputBox.onDidTriggerButton(async () => { + inputBox.value = await getWorktreePath() ?? ''; + inputBox.valueSelection = getValueSelection(inputBox.value); + })); + }); + + dispose(disposables); + + return worktreePath; + } + + private async handleWorktreeBranchAlreadyUsed(err: GitError): Promise { + const match = err.stderr?.match(/fatal: '([^']+)' is already used by worktree at '([^']+)'/); + + if (!match) { + return; + } + + const [, branch, path] = match; + const message = l10n.t('Branch "{0}" is already checked out in the worktree at "{1}".', branch, path); + await this.handleWorktreeConflict(path, message); + } + + private async handleWorktreeAlreadyExists(err: GitError): Promise { + const match = err.stderr?.match(/fatal: '([^']+)'/); + + if (!match) { + return; + } + + const [, path] = match; + const message = l10n.t('A worktree already exists at "{0}".', path); + await this.handleWorktreeConflict(path, message); + } + + private async handleWorktreeConflict(path: string, message: string): Promise { + await this.model.openRepository(path, true, true); + + const worktreeRepository = this.model.getRepository(path); + + if (!worktreeRepository) { + return; + } + + const openWorktree = l10n.t('Open Worktree in Current Window'); + const openWorktreeInNewWindow = l10n.t('Open Worktree in New Window'); + const choice = await window.showWarningMessage(message, { modal: true }, openWorktree, openWorktreeInNewWindow); + + if (choice === openWorktree) { + await this.openWorktreeInCurrentWindow(worktreeRepository); + } else if (choice === openWorktreeInNewWindow) { + await this.openWorktreeInNewWindow(worktreeRepository); + } + return; + } + + @command('git.deleteWorktree', { repository: true, repositoryFilter: ['repository', 'submodule'] }) + async deleteWorktreeFromPalette(repository: Repository): Promise { + const config = workspace.getConfiguration('git', Uri.file(repository.root)); + const commitShortHashLength = config.get('commitShortHashLength') ?? 7; + + const worktreePicks = async (): Promise => { + const worktrees = await repository.getWorktreeDetails(); + return worktrees.length === 0 + ? [{ label: l10n.t('$(info) This repository has no worktrees.') }] + : worktrees.map(worktree => new WorktreeDeleteItem(worktree, commitShortHashLength)); + }; + + const placeHolder = l10n.t('Select a worktree to delete'); + const choice = await this.pickRef(worktreePicks(), placeHolder); + + if (choice instanceof WorktreeDeleteItem) { + await choice.run(repository); + } + } + + @command('git.deleteWorktree2', { repository: true, repositoryFilter: ['worktree'] }) + async deleteWorktree(repository: Repository): Promise { + if (!repository.dotGit.commonPath) { + return; + } + + const mainRepository = this.model.getRepository(path.dirname(repository.dotGit.commonPath)); + if (!mainRepository) { + await window.showErrorMessage(l10n.t('You cannot delete the worktree you are currently in. Please switch to the main repository first.'), { modal: true }); + return; + } + + await mainRepository.deleteWorktree(repository.root); + } + + @command('git.openWorktree', { repository: true }) + async openWorktreeInCurrentWindow(repository: Repository): Promise { + if (!repository) { + return; + } + + const uri = Uri.file(repository.root); + await commands.executeCommand('vscode.openFolder', uri, { forceReuseWindow: true }); + } + + @command('git.openWorktreeInNewWindow', { repository: true }) + async openWorktreeInNewWindow(repository: Repository): Promise { + if (!repository) { + return; + } + + const uri = Uri.file(repository.root); + await commands.executeCommand('vscode.openFolder', uri, { forceNewWindow: true }); + } + + @command('git.graph.deleteTag', { repository: true }) + async deleteTag2(repository: Repository, historyItem?: SourceControlHistoryItem, historyItemRefId?: string): Promise { + const historyItemRef = historyItem?.references?.find(r => r.id === historyItemRefId); + if (!historyItemRef) { + return; + } + + await repository.deleteTag(historyItemRef.name); + } + + @command('git.deleteRemoteTag', { repository: true }) + async deleteRemoteTag(repository: Repository): Promise { + const config = workspace.getConfiguration('git'); + const commitShortHashLength = config.get('commitShortHashLength') ?? 7; + + const remotePicks = repository.remotes + .filter(r => r.pushUrl !== undefined) + .map(r => new RemoteItem(repository, r)); + + if (remotePicks.length === 0) { + window.showErrorMessage(l10n.t("Your repository has no remotes configured to push to.")); + return; + } + + let remoteName = remotePicks[0].remoteName; + if (remotePicks.length > 1) { + const remotePickPlaceholder = l10n.t('Select a remote to delete a tag from'); + const remotePick = await window.showQuickPick(remotePicks, { placeHolder: remotePickPlaceholder }); + + if (!remotePick) { + return; + } + + remoteName = remotePick.remoteName; + } + + const remoteTagPicks = async (): Promise => { + const remoteTagsRaw = await repository.getRemoteRefs(remoteName, { tags: true }); + + // Deduplicate annotated and lightweight tags + const remoteTagNames = new Set(); + const remoteTags: Ref[] = []; + + for (const tag of remoteTagsRaw) { + const tagName = (tag.name ?? '').replace(/\^{}$/, ''); + if (!remoteTagNames.has(tagName)) { + remoteTags.push({ ...tag, name: tagName }); + remoteTagNames.add(tagName); + } + } + + return remoteTags.length === 0 + ? [{ label: l10n.t('$(info) Remote "{0}" has no tags.', remoteName) }] + : remoteTags.map(ref => new RemoteTagDeleteItem(ref, commitShortHashLength)); + }; + + const tagPickPlaceholder = l10n.t('Select a remote tag to delete'); + const remoteTagPick = await window.showQuickPick(remoteTagPicks(), { placeHolder: tagPickPlaceholder }); + + if (remoteTagPick instanceof RemoteTagDeleteItem) { + await remoteTagPick.run(repository, remoteName); + } + } + + @command('git.fetch', { repository: true }) + async fetch(repository: Repository): Promise { + if (repository.remotes.length === 0) { + window.showWarningMessage(l10n.t('This repository has no remotes configured to fetch from.')); + return; + } + + if (repository.remotes.length === 1) { + await repository.fetchDefault(); + return; + } + + const remoteItems: RemoteItem[] = repository.remotes.map(r => new RemoteItem(repository, r)); + + if (repository.HEAD?.upstream?.remote) { + // Move default remote to the top + const defaultRemoteIndex = remoteItems + .findIndex(r => r.remoteName === repository.HEAD!.upstream!.remote); + + if (defaultRemoteIndex !== -1) { + remoteItems.splice(0, 0, ...remoteItems.splice(defaultRemoteIndex, 1)); + } + } + + const quickpick = window.createQuickPick(); + quickpick.placeholder = l10n.t('Select a remote to fetch'); + quickpick.canSelectMany = false; + quickpick.items = [...remoteItems, { label: '', kind: QuickPickItemKind.Separator }, new FetchAllRemotesItem(repository)]; + + quickpick.show(); + const remoteItem = await new Promise(resolve => { + quickpick.onDidAccept(() => resolve(quickpick.activeItems[0] as RemoteItem | FetchAllRemotesItem)); + quickpick.onDidHide(() => resolve(undefined)); + }); + quickpick.hide(); + + if (!remoteItem) { + return; + } + + await remoteItem.run(); + } + + @command('git.fetchPrune', { repository: true }) + async fetchPrune(repository: Repository): Promise { + if (repository.remotes.length === 0) { + window.showWarningMessage(l10n.t('This repository has no remotes configured to fetch from.')); + return; + } + + await repository.fetchPrune(); + } + + + @command('git.fetchAll', { repository: true }) + async fetchAll(repository: Repository): Promise { + if (repository.remotes.length === 0) { + window.showWarningMessage(l10n.t('This repository has no remotes configured to fetch from.')); + return; + } + + await repository.fetchAll(); + } + + @command('git.fetchRef', { repository: true }) + async fetchRef(repository: Repository, ref?: string): Promise { + ref = ref ?? repository?.historyProvider.currentHistoryItemRemoteRef?.id; + if (!repository || !ref) { + return; + } + + const branch = await repository.getBranch(ref); + await repository.fetch({ remote: branch.remote, ref: branch.name }); + } + + @command('git.pullFrom', { repository: true }) + async pullFrom(repository: Repository): Promise { + const config = workspace.getConfiguration('git'); + const commitShortHashLength = config.get('commitShortHashLength') ?? 7; + + const remotes = repository.remotes; + + if (remotes.length === 0) { + window.showWarningMessage(l10n.t('Your repository has no remotes configured to pull from.')); + return; + } + + let remoteName = remotes[0].name; + if (remotes.length > 1) { + const remotePicks = remotes.filter(r => r.fetchUrl !== undefined).map(r => ({ label: r.name, description: r.fetchUrl! })); + const placeHolder = l10n.t('Pick a remote to pull the branch from'); + const remotePick = await window.showQuickPick(remotePicks, { placeHolder }); + + if (!remotePick) { + return; + } + + remoteName = remotePick.label; + } + + const getBranchPicks = async (): Promise => { + const remoteRefs = await repository.getRefs({ pattern: `refs/remotes/${remoteName}/` }); + return remoteRefs.map(r => new RefItem(r, commitShortHashLength)); + }; + + const branchPlaceHolder = l10n.t('Pick a branch to pull from'); + const branchPick = await this.pickRef(getBranchPicks(), branchPlaceHolder); + + if (!branchPick || !branchPick.refName) { + return; + } + + const remoteCharCnt = remoteName.length; + await repository.pullFrom(false, remoteName, branchPick.refName.slice(remoteCharCnt + 1)); + } + + @command('git.pull', { repository: true }) + async pull(repository: Repository): Promise { + const remotes = repository.remotes; + + if (remotes.length === 0) { + window.showWarningMessage(l10n.t('Your repository has no remotes configured to pull from.')); + return; + } + + await repository.pull(repository.HEAD); + } + + @command('git.pullRebase', { repository: true }) + async pullRebase(repository: Repository): Promise { + const remotes = repository.remotes; + + if (remotes.length === 0) { + window.showWarningMessage(l10n.t('Your repository has no remotes configured to pull from.')); + return; + } + + await repository.pullWithRebase(repository.HEAD); + } + + @command('git.pullRef', { repository: true }) + async pullRef(repository: Repository, ref?: string): Promise { + ref = ref ?? repository?.historyProvider.currentHistoryItemRemoteRef?.id; + if (!repository || !ref) { + return; + } + + const branch = await repository.getBranch(ref); + await repository.pullFrom(false, branch.remote, branch.name); + } + + private async _push(repository: Repository, pushOptions: PushOptions) { + const remotes = repository.remotes; + + if (remotes.length === 0) { + if (pushOptions.silent) { + return; + } + + const addRemote = l10n.t('Add Remote'); + const result = await window.showWarningMessage(l10n.t('Your repository has no remotes configured to push to.'), addRemote); + + if (result === addRemote) { + await this.addRemote(repository); + } + + return; + } + + const config = workspace.getConfiguration('git', Uri.file(repository.root)); + let forcePushMode: ForcePushMode | undefined = undefined; + + if (pushOptions.forcePush) { + if (!config.get('allowForcePush')) { + await window.showErrorMessage(l10n.t('Force push is not allowed, please enable it with the "git.allowForcePush" setting.')); + return; + } + + const useForcePushWithLease = config.get('useForcePushWithLease') === true; + const useForcePushIfIncludes = config.get('useForcePushIfIncludes') === true; + forcePushMode = useForcePushWithLease ? useForcePushIfIncludes ? ForcePushMode.ForceWithLeaseIfIncludes : ForcePushMode.ForceWithLease : ForcePushMode.Force; + + if (config.get('confirmForcePush')) { + const message = l10n.t('You are about to force push your changes, this can be destructive and could inadvertently overwrite changes made by others.\n\nAre you sure to continue?'); + const yes = l10n.t('OK'); + const neverAgain = l10n.t('OK, Don\'t Ask Again'); + const pick = await window.showWarningMessage(message, { modal: true }, yes, neverAgain); + + if (pick === neverAgain) { + config.update('confirmForcePush', false, true); + } else if (pick !== yes) { + return; + } + } + } + + if (pushOptions.pushType === PushType.PushFollowTags) { + await repository.pushFollowTags(undefined, forcePushMode); + return; + } + + if (pushOptions.pushType === PushType.PushTags) { + await repository.pushTags(undefined, forcePushMode); + } + + if (!repository.HEAD || !repository.HEAD.name) { + if (!pushOptions.silent) { + window.showWarningMessage(l10n.t('Please check out a branch to push to a remote.')); + } + return; + } + + if (pushOptions.pushType === PushType.Push) { + try { + await repository.push(repository.HEAD, forcePushMode); + } catch (err) { + if (err.gitErrorCode !== GitErrorCodes.NoUpstreamBranch) { + throw err; + } + + if (pushOptions.silent) { + return; + } + + if (this.globalState.get('confirmBranchPublish', true)) { + const branchName = repository.HEAD.name; + const message = l10n.t('The branch "{0}" has no remote branch. Would you like to publish this branch?', branchName); + const yes = l10n.t('OK'); + const neverAgain = l10n.t('OK, Don\'t Ask Again'); + const pick = await window.showWarningMessage(message, { modal: true }, yes, neverAgain); + + if (pick === yes || pick === neverAgain) { + if (pick === neverAgain) { + this.globalState.update('confirmBranchPublish', false); + } + await this.publish(repository); + } + } else { + await this.publish(repository); + } + } + } else { + const branchName = repository.HEAD.name; + if (!pushOptions.pushTo?.remote) { + const addRemote = new AddRemoteItem(this); + const picks = [...remotes.filter(r => r.pushUrl !== undefined).map(r => ({ label: r.name, description: r.pushUrl })), addRemote]; + const placeHolder = l10n.t('Pick a remote to publish the branch "{0}" to:', branchName); + const choice = await window.showQuickPick(picks, { placeHolder }); + + if (!choice) { + return; + } + + if (choice === addRemote) { + const newRemote = await this.addRemote(repository); + + if (newRemote) { + await repository.pushTo(newRemote, branchName, undefined, forcePushMode); + } + } else { + await repository.pushTo(choice.label, branchName, undefined, forcePushMode); + } + } else { + await repository.pushTo(pushOptions.pushTo.remote, pushOptions.pushTo.refspec || branchName, pushOptions.pushTo.setUpstream, forcePushMode); + } + } + } + + @command('git.push', { repository: true }) + async push(repository: Repository): Promise { + await this._push(repository, { pushType: PushType.Push }); + } + + @command('git.pushForce', { repository: true }) + async pushForce(repository: Repository): Promise { + await this._push(repository, { pushType: PushType.Push, forcePush: true }); + } + + @command('git.pushWithTags', { repository: true }) + async pushFollowTags(repository: Repository): Promise { + await this._push(repository, { pushType: PushType.PushFollowTags }); + } + + @command('git.pushWithTagsForce', { repository: true }) + async pushFollowTagsForce(repository: Repository): Promise { + await this._push(repository, { pushType: PushType.PushFollowTags, forcePush: true }); + } + + @command('git.pushRef', { repository: true }) + async pushRef(repository: Repository): Promise { + if (!repository) { + return; + } + + await this._push(repository, { pushType: PushType.Push }); + } + + @command('git.cherryPick', { repository: true }) + async cherryPick(repository: Repository): Promise { + const hash = await window.showInputBox({ + placeHolder: l10n.t('Commit Hash'), + prompt: l10n.t('Please provide the commit hash'), + ignoreFocusOut: true + }); + + if (!hash) { + return; + } + + await repository.cherryPick(hash); + } + + @command('git.graph.cherryPick', { repository: true }) + async cherryPick2(repository: Repository, historyItem?: SourceControlHistoryItem): Promise { + if (!historyItem) { + return; + } + + await repository.cherryPick(historyItem.id); + } + + @command('git.cherryPickAbort', { repository: true }) + async cherryPickAbort(repository: Repository): Promise { + await repository.cherryPickAbort(); + } + + @command('git.pushTo', { repository: true }) + async pushTo(repository: Repository, remote?: string, refspec?: string, setUpstream?: boolean): Promise { + await this._push(repository, { pushType: PushType.PushTo, pushTo: { remote: remote, refspec: refspec, setUpstream: setUpstream } }); + } + + @command('git.pushToForce', { repository: true }) + async pushToForce(repository: Repository, remote?: string, refspec?: string, setUpstream?: boolean): Promise { + await this._push(repository, { pushType: PushType.PushTo, pushTo: { remote: remote, refspec: refspec, setUpstream: setUpstream }, forcePush: true }); + } + + @command('git.pushTags', { repository: true }) + async pushTags(repository: Repository): Promise { + await this._push(repository, { pushType: PushType.PushTags }); + } + + @command('git.addRemote', { repository: true }) + async addRemote(repository: Repository): Promise { + const url = await pickRemoteSource({ + providerLabel: provider => l10n.t('Add remote from {0}', provider.name), + urlLabel: l10n.t('Add remote from URL') + }); + + if (!url) { + return; + } + + const resultName = await window.showInputBox({ + placeHolder: l10n.t('Remote name'), + prompt: l10n.t('Please provide a remote name'), + ignoreFocusOut: true, + validateInput: (name: string) => { + if (!sanitizeRemoteName(name)) { + return l10n.t('Remote name format invalid'); + } else if (repository.remotes.find(r => r.name === name)) { + return l10n.t('Remote "{0}" already exists.', name); + } + + return null; + } + }); + + const name = sanitizeRemoteName(resultName || ''); + + if (!name) { + return; + } + + await repository.addRemote(name, url.trim()); + await repository.fetch({ remote: name }); + return name; + } + + @command('git.removeRemote', { repository: true }) + async removeRemote(repository: Repository): Promise { + const remotes = repository.remotes; + + if (remotes.length === 0) { + window.showErrorMessage(l10n.t('Your repository has no remotes.')); + return; + } + + const picks: RemoteItem[] = repository.remotes.map(r => new RemoteItem(repository, r)); + const placeHolder = l10n.t('Pick a remote to remove'); + + const remote = await window.showQuickPick(picks, { placeHolder }); + + if (!remote) { + return; + } + + await repository.removeRemote(remote.remoteName); + } + + private async _sync(repository: Repository, rebase: boolean): Promise { + const HEAD = repository.HEAD; + + if (!HEAD) { + return; + } else if (!HEAD.upstream) { + this._push(repository, { pushType: PushType.Push }); + return; + } + + const remoteName = HEAD.remote || HEAD.upstream.remote; + const remote = repository.remotes.find(r => r.name === remoteName); + const isReadonly = remote && remote.isReadOnly; + + const config = workspace.getConfiguration('git'); + const shouldPrompt = !isReadonly && config.get('confirmSync') === true; + + if (shouldPrompt) { + const message = l10n.t('This action will pull and push commits from and to "{0}/{1}".', HEAD.upstream.remote, HEAD.upstream.name); + const yes = l10n.t('OK'); + const neverAgain = l10n.t('OK, Don\'t Show Again'); + const pick = await window.showWarningMessage(message, { modal: true }, yes, neverAgain); + + if (pick === neverAgain) { + await config.update('confirmSync', false, true); + } else if (pick !== yes) { + return; + } + } + + await repository.sync(HEAD, rebase); + } + + @command('git.sync', { repository: true }) + async sync(repository: Repository): Promise { + const config = workspace.getConfiguration('git', Uri.file(repository.root)); + const rebase = config.get('rebaseWhenSync', false) === true; + + try { + await this._sync(repository, rebase); + } catch (err) { + if (/Cancelled/i.test(err && (err.message || err.stderr || ''))) { + return; + } + + throw err; + } + } + + @command('git._syncAll') + async syncAll(): Promise { + await Promise.all(this.model.repositories.map(async repository => { + const config = workspace.getConfiguration('git', Uri.file(repository.root)); + const rebase = config.get('rebaseWhenSync', false) === true; + + const HEAD = repository.HEAD; + + if (!HEAD || !HEAD.upstream) { + return; + } + + await repository.sync(HEAD, rebase); + })); + } + + @command('git.syncRebase', { repository: true }) + async syncRebase(repository: Repository): Promise { + try { + await this._sync(repository, true); + } catch (err) { + if (/Cancelled/i.test(err && (err.message || err.stderr || ''))) { + return; + } + + throw err; + } + } + + @command('git.publish', { repository: true }) + async publish(repository: Repository): Promise { + const branchName = repository.HEAD && repository.HEAD.name || ''; + const remotes = repository.remotes; + + if (remotes.length === 0) { + const publishers = this.model.getRemoteSourcePublishers(); + + if (publishers.length === 0) { + window.showWarningMessage(l10n.t('Your repository has no remotes configured to publish to.')); + return; + } + + let publisher: RemoteSourcePublisher; + + if (publishers.length === 1) { + publisher = publishers[0]; + } else { + const picks = publishers + .map(provider => ({ label: (provider.icon ? `$(${provider.icon}) ` : '') + l10n.t('Publish to {0}', provider.name), alwaysShow: true, provider })); + const placeHolder = l10n.t('Pick a provider to publish the branch "{0}" to:', branchName); + const choice = await window.showQuickPick(picks, { placeHolder }); + + if (!choice) { + return; + } + + publisher = choice.provider; + } + + await publisher.publishRepository(new ApiRepository(repository)); + this.model.firePublishEvent(repository, branchName); + + return; + } + + if (remotes.length === 1) { + await repository.pushTo(remotes[0].name, branchName, true); + this.model.firePublishEvent(repository, branchName); + + return; + } + + const addRemote = new AddRemoteItem(this); + const picks = [...repository.remotes.map(r => ({ label: r.name, description: r.pushUrl })), addRemote]; + const placeHolder = l10n.t('Pick a remote to publish the branch "{0}" to:', branchName); + const choice = await window.showQuickPick(picks, { placeHolder }); + + if (!choice) { + return; + } + + if (choice === addRemote) { + const newRemote = await this.addRemote(repository); + + if (newRemote) { + await repository.pushTo(newRemote, branchName, true); + + this.model.firePublishEvent(repository, branchName); + } + } else { + await repository.pushTo(choice.label, branchName, true); + + this.model.firePublishEvent(repository, branchName); + } + } + + @command('git.ignore') + async ignore(...resourceStates: SourceControlResourceState[]): Promise { + resourceStates = resourceStates.filter(s => !!s); + + if (resourceStates.length === 0 || (resourceStates[0] && !(resourceStates[0].resourceUri instanceof Uri))) { + const resource = this.getSCMResource(); + + if (!resource) { + return; + } + + resourceStates = [resource]; + } + + const resources = resourceStates + .filter(s => s instanceof Resource) + .map(r => r.resourceUri); + + if (!resources.length) { + return; + } + + await this.runByRepository(resources, async (repository, resources) => repository.ignore(resources)); + } + + @command('git.revealInExplorer') + async revealInExplorer(resourceState: SourceControlResourceState): Promise { + if (!resourceState) { + return; + } + + if (!(resourceState.resourceUri instanceof Uri)) { + return; + } + + await commands.executeCommand('revealInExplorer', resourceState.resourceUri); + } + + @command('git.revealFileInOS.linux') + @command('git.revealFileInOS.mac') + @command('git.revealFileInOS.windows') + async revealFileInOS(resourceState: SourceControlResourceState): Promise { + if (!resourceState) { + return; + } + + if (!(resourceState.resourceUri instanceof Uri)) { + return; + } + + await commands.executeCommand('revealFileInOS', resourceState.resourceUri); + } + + private async _stash(repository: Repository, includeUntracked = false, staged = false): Promise { + const noUnstagedChanges = repository.workingTreeGroup.resourceStates.length === 0 + && (!includeUntracked || repository.untrackedGroup.resourceStates.length === 0); + const noStagedChanges = repository.indexGroup.resourceStates.length === 0; + + if (staged) { + if (noStagedChanges) { + window.showInformationMessage(l10n.t('There are no staged changes to stash.')); + return false; + } + } else { + if (noUnstagedChanges && noStagedChanges) { + window.showInformationMessage(l10n.t('There are no changes to stash.')); + return false; + } + } + + 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 + ? l10n.t('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)) + : l10n.t('There are {0} unsaved files.\n\nWould you like to save them before stashing?', documents.length); + const saveAndStash = l10n.t('Save All & Stash'); + const stash = l10n.t('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 false; // do not stash on cancel + } + } + } + + let message: string | undefined; + + if (config.get('useCommitInputAsStashMessage') && (!repository.sourceControl.commitTemplate || repository.inputBox.value !== repository.sourceControl.commitTemplate)) { + message = repository.inputBox.value; + } + + message = await window.showInputBox({ + value: message, + prompt: l10n.t('Optionally provide a stash message'), + placeHolder: l10n.t('Stash message') + }); + + if (typeof message === 'undefined') { + return false; + } + + try { + await repository.createStash(message, includeUntracked, staged); + return true; + } catch (err) { + if (/You do not have the initial commit yet/.test(err.stderr || '')) { + window.showInformationMessage(l10n.t('The repository does not have any commits. Please make an initial commit before creating a stash.')); + return false; + } + + throw err; + } + } + + @command('git.stash', { repository: true }) + async stash(repository: Repository): Promise { + const result = await this._stash(repository); + return result; + } + + @command('git.stashStaged', { repository: true }) + async stashStaged(repository: Repository): Promise { + const result = await this._stash(repository, false, true); + return result; + } + + @command('git.stashIncludeUntracked', { repository: true }) + async stashIncludeUntracked(repository: Repository): Promise { + const result = await this._stash(repository, true); + return result; + } + + @command('git.stashPop', { repository: true }) + async stashPop(repository: Repository): Promise { + const placeHolder = l10n.t('Pick a stash to pop'); + const stash = await this.pickStash(repository, placeHolder); + + if (!stash) { + return; + } + + await repository.popStash(stash.index); + } + + @command('git.stashPopLatest', { repository: true }) + async stashPopLatest(repository: Repository): Promise { + const stashes = await repository.getStashes(); + + if (stashes.length === 0) { + window.showInformationMessage(l10n.t('There are no stashes in the repository.')); + return; + } + + await repository.popStash(); + } + + @command('git.stashPopEditor') + async stashPopEditor(uri: Uri): Promise { + const result = await this.getStashFromUri(uri); + if (!result) { + return; + } + + await commands.executeCommand('workbench.action.closeActiveEditor'); + await result.repository.popStash(result.stash.index); + } + + @command('git.stashApply', { repository: true }) + async stashApply(repository: Repository): Promise { + const placeHolder = l10n.t('Pick a stash to apply'); + const stash = await this.pickStash(repository, placeHolder); + + if (!stash) { + return; + } + + await repository.applyStash(stash.index); + } + + @command('git.stashApplyLatest', { repository: true }) + async stashApplyLatest(repository: Repository): Promise { + const stashes = await repository.getStashes(); + + if (stashes.length === 0) { + window.showInformationMessage(l10n.t('There are no stashes in the repository.')); + return; + } + + await repository.applyStash(); + } + + @command('git.stashApplyEditor') + async stashApplyEditor(uri: Uri): Promise { + const result = await this.getStashFromUri(uri); + if (!result) { + return; + } + + await commands.executeCommand('workbench.action.closeActiveEditor'); + await result.repository.applyStash(result.stash.index); + } + + @command('git.stashDrop', { repository: true }) + async stashDrop(repository: Repository): Promise { + const placeHolder = l10n.t('Pick a stash to drop'); + const stash = await this.pickStash(repository, placeHolder); + + if (!stash) { + return; + } + + await this._stashDrop(repository, stash.index, stash.description); + } + + @command('git.stashDropAll', { repository: true }) + async stashDropAll(repository: Repository): Promise { + const stashes = await repository.getStashes(); + + if (stashes.length === 0) { + window.showInformationMessage(l10n.t('There are no stashes in the repository.')); + return; + } + + // request confirmation for the operation + const yes = l10n.t('Yes'); + const question = stashes.length === 1 ? + l10n.t('Are you sure you want to drop ALL stashes? There is 1 stash that will be subject to pruning, and MAY BE IMPOSSIBLE TO RECOVER.') : + l10n.t('Are you sure you want to drop ALL stashes? There are {0} stashes that will be subject to pruning, and MAY BE IMPOSSIBLE TO RECOVER.', stashes.length); + + const result = await window.showWarningMessage(question, { modal: true }, yes); + if (result !== yes) { + return; + } + + await repository.dropStash(); + } + + @command('git.stashDropEditor') + async stashDropEditor(uri: Uri): Promise { + const result = await this.getStashFromUri(uri); + if (!result) { + return; + } + + if (await this._stashDrop(result.repository, result.stash.index, result.stash.description)) { + await commands.executeCommand('workbench.action.closeActiveEditor'); + } + } + + async _stashDrop(repository: Repository, index: number, description: string): Promise { + const yes = l10n.t('Yes'); + const result = await window.showWarningMessage( + l10n.t('Are you sure you want to drop the stash: {0}?', description), + { modal: true }, + yes + ); + if (result !== yes) { + return false; + } + + await repository.dropStash(index); + return true; + } + + @command('git.stashView', { repository: true }) + async stashView(repository: Repository): Promise { + const placeHolder = l10n.t('Pick a stash to view'); + const stash = await this.pickStash(repository, placeHolder); + + if (!stash) { + return; + } + + await this._viewStash(repository, stash); + } + + private async pickStash(repository: Repository, placeHolder: string): Promise { + const getStashQuickPickItems = async (): Promise => { + const stashes = await repository.getStashes(); + return stashes.length > 0 ? + stashes.map(stash => new StashItem(stash)) : + [{ label: l10n.t('$(info) This repository has no stashes.') }]; + }; + + const result = await window.showQuickPick(getStashQuickPickItems(), { placeHolder }); + return result instanceof StashItem ? result.stash : undefined; + } + + private async getStashFromUri(uri: Uri | undefined): Promise<{ repository: Repository; stash: Stash } | undefined> { + if (!uri || uri.scheme !== 'git-stash') { + return undefined; + } + + const stashUri = fromGitUri(uri); + + // Repository + const repository = this.model.getRepository(stashUri.path); + if (!repository) { + return undefined; + } + + // Stash + const regex = /^stash@{(\d+)}$/; + const match = regex.exec(stashUri.ref); + if (!match) { + return undefined; + } + + const [, index] = match; + const stashes = await repository.getStashes(); + const stash = stashes.find(stash => stash.index === parseInt(index)); + if (!stash) { + return undefined; + } + + return { repository, stash }; + } + + private async _viewStash(repository: Repository, stash: Stash): Promise { + const stashChanges = await repository.showStash(stash.index); + if (!stashChanges || stashChanges.length === 0) { + return; + } + + // A stash commit can have up to 3 parents: + // 1. The first parent is the commit that was HEAD when the stash was created. + // 2. The second parent is the commit that represents the index when the stash was created. + // 3. The third parent (when present) represents the untracked files when the stash was created. + const stashFirstParentCommit = stash.parents.length > 0 ? stash.parents[0] : `${stash.hash}^`; + const stashUntrackedFilesParentCommit = stash.parents.length === 3 ? stash.parents[2] : undefined; + const stashUntrackedFiles: string[] = []; + + if (stashUntrackedFilesParentCommit) { + const untrackedFiles = await repository.getObjectFiles(stashUntrackedFilesParentCommit); + stashUntrackedFiles.push(...untrackedFiles.map(f => path.join(repository.root, f.file))); + } + + const title = `Git Stash #${stash.index}: ${stash.description}`; + const multiDiffSourceUri = toGitUri(Uri.file(repository.root), `stash@{${stash.index}}`, { scheme: 'git-stash' }); + + const resources: { originalUri: Uri | undefined; modifiedUri: Uri | undefined }[] = []; + for (const change of stashChanges) { + const isChangeUntracked = !!stashUntrackedFiles.find(f => pathEquals(f, change.uri.fsPath)); + const modifiedUriRef = !isChangeUntracked ? stash.hash : stashUntrackedFilesParentCommit ?? stash.hash; + + resources.push(toMultiFileDiffEditorUris(change, stashFirstParentCommit, modifiedUriRef)); + } + + commands.executeCommand('_workbench.openMultiDiffEditor', { multiDiffSourceUri, title, resources }); + } + + @command('git.timeline.openDiff', { repository: false }) + async timelineOpenDiff(item: TimelineItem, uri: Uri | undefined, _source: string) { + const cmd = this.resolveTimelineOpenDiffCommand( + item, uri, + { + preserveFocus: true, + preview: true, + viewColumn: ViewColumn.Active + }, + ); + if (cmd === undefined) { + return undefined; + } + + return commands.executeCommand(cmd.command, ...(cmd.arguments ?? [])); + } + + resolveTimelineOpenDiffCommand(item: TimelineItem, uri: Uri | undefined, options?: TextDocumentShowOptions): Command | undefined { + if (uri === undefined || uri === null || !GitTimelineItem.is(item)) { + return undefined; + } + + const basename = path.basename(uri.fsPath); + + let title; + if ((item.previousRef === 'HEAD' || item.previousRef === '~') && item.ref === '') { + title = l10n.t('{0} (Working Tree)', basename); + } + else if (item.previousRef === 'HEAD' && item.ref === '~') { + title = l10n.t('{0} (Index)', basename); + } else { + title = l10n.t('{0} ({1}) \u2194 {0} ({2})', basename, item.shortPreviousRef, item.shortRef); + } + + return { + command: 'vscode.diff', + title: l10n.t('Open Comparison'), + arguments: [toGitUri(uri, item.previousRef), item.ref === '' ? uri : toGitUri(uri, item.ref), title, options] + }; + } + + @command('git.timeline.viewCommit', { repository: false }) + async timelineViewCommit(item: TimelineItem, uri: Uri | undefined, _source: string) { + if (!GitTimelineItem.is(item)) { + return; + } + + const cmd = await this._resolveTimelineOpenCommitCommand( + item, uri, + { + preserveFocus: true, + preview: true, + viewColumn: ViewColumn.Active + }, + ); + if (cmd === undefined) { + return undefined; + } + + return commands.executeCommand(cmd.command, ...(cmd.arguments ?? [])); + } + + private async _resolveTimelineOpenCommitCommand(item: TimelineItem, uri: Uri | undefined, options?: TextDocumentShowOptions): Promise { + if (uri === undefined || uri === null || !GitTimelineItem.is(item)) { + return undefined; + } + + const repository = await this.model.getRepository(uri.fsPath); + if (!repository) { + return undefined; + } + + const commit = await repository.getCommit(item.ref); + const commitParentId = commit.parents.length > 0 ? commit.parents[0] : await repository.getEmptyTree(); + const changes = await repository.diffBetweenWithStats(commitParentId, commit.hash); + const resources = changes.map(c => toMultiFileDiffEditorUris(c, commitParentId, commit.hash)); + + const title = `${item.shortRef} - ${subject(commit.message)}`; + const multiDiffSourceUri = Uri.from({ scheme: 'scm-history-item', path: `${repository.root}/${commitParentId}..${commit.hash}` }); + const reveal = { modifiedUri: toGitUri(uri, commit.hash) }; + + return { + command: '_workbench.openMultiDiffEditor', + title: l10n.t('Open Commit'), + arguments: [{ multiDiffSourceUri, title, resources, reveal }, options] + }; + } + + @command('git.timeline.copyCommitId', { repository: false }) + async timelineCopyCommitId(item: TimelineItem, _uri: Uri | undefined, _source: string) { + if (!GitTimelineItem.is(item)) { + return; + } + + env.clipboard.writeText(item.ref); + } + + @command('git.timeline.copyCommitMessage', { repository: false }) + async timelineCopyCommitMessage(item: TimelineItem, _uri: Uri | undefined, _source: string) { + if (!GitTimelineItem.is(item)) { + return; + } + + env.clipboard.writeText(item.message); + } + + private _selectedForCompare: { uri: Uri; item: GitTimelineItem } | undefined; + + @command('git.timeline.selectForCompare', { repository: false }) + async timelineSelectForCompare(item: TimelineItem, uri: Uri | undefined, _source: string) { + if (!GitTimelineItem.is(item) || !uri) { + return; + } + + this._selectedForCompare = { uri, item }; + await commands.executeCommand('setContext', 'git.timeline.selectedForCompare', true); + } + + @command('git.timeline.compareWithSelected', { repository: false }) + async timelineCompareWithSelected(item: TimelineItem, uri: Uri | undefined, _source: string) { + if (!GitTimelineItem.is(item) || !uri || !this._selectedForCompare || uri.toString() !== this._selectedForCompare.uri.toString()) { + return; + } + + const { item: selected } = this._selectedForCompare; + + const basename = path.basename(uri.fsPath); + let leftTitle; + if ((selected.previousRef === 'HEAD' || selected.previousRef === '~') && selected.ref === '') { + leftTitle = l10n.t('{0} (Working Tree)', basename); + } + else if (selected.previousRef === 'HEAD' && selected.ref === '~') { + leftTitle = l10n.t('{0} (Index)', basename); + } else { + leftTitle = l10n.t('{0} ({1})', basename, selected.shortRef); + } + + let rightTitle; + if ((item.previousRef === 'HEAD' || item.previousRef === '~') && item.ref === '') { + rightTitle = l10n.t('{0} (Working Tree)', basename); + } + else if (item.previousRef === 'HEAD' && item.ref === '~') { + rightTitle = l10n.t('{0} (Index)', basename); + } else { + rightTitle = l10n.t('{0} ({1})', basename, item.shortRef); + } + + + const title = l10n.t('{0} \u2194 {1}', leftTitle, rightTitle); + await commands.executeCommand('vscode.diff', selected.ref === '' ? uri : toGitUri(uri, selected.ref), item.ref === '' ? uri : toGitUri(uri, item.ref), title); + } + + @command('git.rebaseAbort', { repository: true }) + async rebaseAbort(repository: Repository): Promise { + if (repository.rebaseCommit) { + await repository.rebaseAbort(); + } else { + await window.showInformationMessage(l10n.t('No rebase in progress.')); + } + } + + @command('git.closeAllDiffEditors', { repository: true }) + closeDiffEditors(repository: Repository): void { + repository.closeDiffEditors(undefined, undefined, true); + } + + @command('git.closeAllUnmodifiedEditors') + closeUnmodifiedEditors(): void { + const editorTabsToClose: Tab[] = []; + + // Collect all modified files + const modifiedFiles: string[] = []; + for (const repository of this.model.repositories) { + modifiedFiles.push(...repository.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)); + modifiedFiles.push(...repository.workingTreeGroup.resourceStates.map(r => r.resourceUri.fsPath)); + modifiedFiles.push(...repository.untrackedGroup.resourceStates.map(r => r.resourceUri.fsPath)); + modifiedFiles.push(...repository.mergeGroup.resourceStates.map(r => r.resourceUri.fsPath)); + } + + // Collect all editor tabs that are not dirty and not modified + for (const tab of window.tabGroups.all.map(g => g.tabs).flat()) { + if (tab.isDirty) { + continue; + } + + if (tab.input instanceof TabInputText || tab.input instanceof TabInputNotebook) { + const { uri } = tab.input; + if (!modifiedFiles.find(p => pathEquals(p, uri.fsPath))) { + editorTabsToClose.push(tab); + } + } + } + + // Close editors + window.tabGroups.close(editorTabsToClose, true); + } + + @command('git.openRepositoriesInParentFolders') + async openRepositoriesInParentFolders(): Promise { + const parentRepositories: string[] = []; + + const title = l10n.t('Open Repositories In Parent Folders'); + const placeHolder = l10n.t('Pick a repository to open'); + + const allRepositoriesLabel = l10n.t('All Repositories'); + const allRepositoriesQuickPickItem: QuickPickItem = { label: allRepositoriesLabel }; + const repositoriesQuickPickItems: QuickPickItem[] = this.model.parentRepositories + .sort(compareRepositoryLabel).map(r => new RepositoryItem(r)); + + const items = this.model.parentRepositories.length === 1 ? [...repositoriesQuickPickItems] : + [...repositoriesQuickPickItems, { label: '', kind: QuickPickItemKind.Separator }, allRepositoriesQuickPickItem]; + + const repositoryItem = await window.showQuickPick(items, { title, placeHolder }); + if (!repositoryItem) { + return; + } + + if (repositoryItem === allRepositoriesQuickPickItem) { + // All Repositories + parentRepositories.push(...this.model.parentRepositories); + } else { + // One Repository + parentRepositories.push((repositoryItem as RepositoryItem).path); + } + + for (const parentRepository of parentRepositories) { + await this.model.openParentRepository(parentRepository); + } + } + + @command('git.manageUnsafeRepositories') + async manageUnsafeRepositories(): Promise { + const unsafeRepositories: string[] = []; + + const quickpick = window.createQuickPick(); + quickpick.title = l10n.t('Manage Unsafe Repositories'); + quickpick.placeholder = l10n.t('Pick a repository to mark as safe and open'); + + const allRepositoriesLabel = l10n.t('All Repositories'); + const allRepositoriesQuickPickItem: QuickPickItem = { label: allRepositoriesLabel }; + const repositoriesQuickPickItems: QuickPickItem[] = this.model.unsafeRepositories + .sort(compareRepositoryLabel).map(r => new RepositoryItem(r)); + + quickpick.items = this.model.unsafeRepositories.length === 1 ? [...repositoriesQuickPickItems] : + [...repositoriesQuickPickItems, { label: '', kind: QuickPickItemKind.Separator }, allRepositoriesQuickPickItem]; + + quickpick.show(); + const repositoryItem = await new Promise( + resolve => { + quickpick.onDidAccept(() => resolve(quickpick.activeItems[0])); + quickpick.onDidHide(() => resolve(undefined)); + }); + quickpick.hide(); + + if (!repositoryItem) { + return; + } + + if (repositoryItem.label === allRepositoriesLabel) { + // All Repositories + unsafeRepositories.push(...this.model.unsafeRepositories); + } else { + // One Repository + unsafeRepositories.push((repositoryItem as RepositoryItem).path); + } + + for (const unsafeRepository of unsafeRepositories) { + // Mark as Safe + await this.git.addSafeDirectory(this.model.getUnsafeRepositoryPath(unsafeRepository)!); + + // Open Repository + await this.model.openRepository(unsafeRepository); + this.model.deleteUnsafeRepository(unsafeRepository); + } + } + + @command('git.viewChanges', { repository: true }) + async viewChanges(repository: Repository): Promise { + await this._viewResourceGroupChanges(repository, repository.workingTreeGroup); + } + + @command('git.viewStagedChanges', { repository: true }) + async viewStagedChanges(repository: Repository): Promise { + await this._viewResourceGroupChanges(repository, repository.indexGroup); + } + + @command('git.viewUntrackedChanges', { repository: true }) + async viewUnstagedChanges(repository: Repository): Promise { + await this._viewResourceGroupChanges(repository, repository.untrackedGroup); + } + + private async _viewResourceGroupChanges(repository: Repository, resourceGroup: GitResourceGroup): Promise { + if (resourceGroup.resourceStates.length === 0) { + switch (resourceGroup.id) { + case 'index': + window.showInformationMessage(l10n.t('The repository does not have any staged changes.')); + break; + case 'workingTree': + window.showInformationMessage(l10n.t('The repository does not have any changes.')); + break; + case 'untracked': + window.showInformationMessage(l10n.t('The repository does not have any untracked changes.')); + break; + } + return; + } + + await commands.executeCommand('_workbench.openScmMultiDiffEditor', { + title: `${repository.sourceControl.label}: ${resourceGroup.label}`, + repositoryUri: Uri.file(repository.root), + resourceGroupId: resourceGroup.id + }); + } + + @command('git.copyCommitId', { repository: true }) + async copyCommitId(repository: Repository, historyItem: SourceControlHistoryItem): Promise { + if (!repository || !historyItem) { + return; + } + + env.clipboard.writeText(historyItem.id); + } + + @command('git.copyCommitMessage', { repository: true }) + async copyCommitMessage(repository: Repository, historyItem: SourceControlHistoryItem): Promise { + if (!repository || !historyItem) { + return; + } + + env.clipboard.writeText(historyItem.message); + } + + @command('git.viewCommit', { repository: true }) + async viewCommit(repository: Repository, historyItemId: string, revealUri?: Uri): Promise { + if (!repository || !historyItemId) { + return; + } + + const rootUri = Uri.file(repository.root); + const config = workspace.getConfiguration('git', rootUri); + const commitShortHashLength = config.get('commitShortHashLength', 7); + + const commit = await repository.getCommit(historyItemId); + const title = `${truncate(historyItemId, commitShortHashLength, false)} - ${subject(commit.message)}`; + const historyItemParentId = commit.parents.length > 0 ? commit.parents[0] : await repository.getEmptyTree(); + + const multiDiffSourceUri = Uri.from({ scheme: 'scm-history-item', path: `${repository.root}/${historyItemParentId}..${historyItemId}` }); + + const changes = await repository.diffBetweenWithStats(historyItemParentId, historyItemId); + const resources = changes.map(c => toMultiFileDiffEditorUris(c, historyItemParentId, historyItemId)); + const reveal = revealUri ? { modifiedUri: toGitUri(revealUri, historyItemId) } : undefined; + + await commands.executeCommand('_workbench.openMultiDiffEditor', { multiDiffSourceUri, title, resources, reveal }); + } + + @command('git.copyContentToClipboard') + async copyContentToClipboard(content: string): Promise { + if (typeof content !== 'string') { + return; + } + + env.clipboard.writeText(content); + } + + @command('git.blame.toggleEditorDecoration') + toggleBlameEditorDecoration(): void { + this._toggleBlameSetting('blame.editorDecoration.enabled'); + } + + @command('git.blame.toggleStatusBarItem') + toggleBlameStatusBarItem(): void { + this._toggleBlameSetting('blame.statusBarItem.enabled'); + } + + private _toggleBlameSetting(setting: string): void { + const config = workspace.getConfiguration('git'); + const enabled = config.get(setting) === true; + + config.update(setting, !enabled, true); + } + + @command('git.repositories.createBranch', { repository: true }) + async artifactGroupCreateBranch(repository: Repository): Promise { + if (!repository) { + return; + } + + await this._branch(repository, undefined, false); + } + + @command('git.repositories.createTag', { repository: true }) + async artifactGroupCreateTag(repository: Repository): Promise { + if (!repository) { + return; + } + + await this._createTag(repository); + } + + @command('git.repositories.createWorktree', { repository: true }) + async artifactGroupCreateWorktree(repository: Repository): Promise { + if (!repository) { + return; + } + + await this._createWorktree(repository); + } + + @command('git.repositories.checkout', { repository: true }) + async artifactCheckout(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + await this._checkout(repository, { treeish: artifact.name }); + } + + @command('git.repositories.checkoutDetached', { repository: true }) + async artifactCheckoutDetached(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + await this._checkout(repository, { treeish: artifact.name, detached: true }); + } + + @command('git.repositories.merge', { repository: true }) + async artifactMerge(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + await repository.merge(artifact.id); + } + + @command('git.repositories.rebase', { repository: true }) + async artifactRebase(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + await repository.rebase(artifact.id); + } + + @command('git.repositories.createFrom', { repository: true }) + async artifactCreateFrom(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + await this._branch(repository, undefined, false, artifact.id); + } + + @command('git.repositories.compareRef', { repository: true }) + async artifactCompareWith(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + const config = workspace.getConfiguration('git'); + const showRefDetails = config.get('showReferenceDetails') === true; + + const getRefPicks = async () => { + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); + const processors = [ + new RefProcessor(RefType.Head, BranchItem), + new RefProcessor(RefType.RemoteHead, BranchItem), + new RefProcessor(RefType.Tag, BranchItem) + ]; + + const itemsProcessor = new RefItemsProcessor(repository, processors); + return itemsProcessor.processRefs(refs); + }; + + const placeHolder = l10n.t('Select a reference to compare with'); + const sourceRef = await this.pickRef(getRefPicks(), placeHolder); + + if (!(sourceRef instanceof BranchItem) || !sourceRef.ref.commit) { + return; + } + + await this._openChangesBetweenRefs( + repository, + { + id: sourceRef.ref.commit, + displayId: sourceRef.ref.name + }, + { + id: artifact.id, + displayId: artifact.name + }); + } + + private async _createTag(repository: Repository, ref?: string): Promise { + const inputTagName = await window.showInputBox({ + placeHolder: l10n.t('Tag name'), + prompt: l10n.t('Please provide a tag name'), + ignoreFocusOut: true + }); + + if (!inputTagName) { + return; + } + + const inputMessage = await window.showInputBox({ + placeHolder: l10n.t('Message'), + prompt: l10n.t('Please provide a message to annotate the tag'), + ignoreFocusOut: true + }); + + const name = inputTagName.replace(/^\.|\/\.|\.\.|~|\^|:|\/$|\.lock$|\.lock\/|\\|\*|\s|^\s*$|\.$/g, '-'); + await repository.tag({ name, message: inputMessage, ref }); + } + + @command('git.repositories.deleteBranch', { repository: true }) + async artifactDeleteBranch(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + const message = l10n.t('Are you sure you want to delete branch "{0}"? This action will permanently remove the branch reference from the repository.', artifact.name); + const yes = l10n.t('Delete Branch'); + const result = await window.showWarningMessage(message, { modal: true }, yes); + if (result !== yes) { + return; + } + + await this._deleteBranch(repository, undefined, artifact.name, { remote: false }); + } + + @command('git.repositories.deleteTag', { repository: true }) + async artifactDeleteTag(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + const message = l10n.t('Are you sure you want to delete tag "{0}"? This action will permanently remove the tag reference from the repository.', artifact.name); + const yes = l10n.t('Delete Tag'); + const result = await window.showWarningMessage(message, { modal: true }, yes); + if (result !== yes) { + return; + } + + await repository.deleteTag(artifact.name); + } + + @command('git.repositories.stashView', { repository: true }) + async artifactStashView(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + // Extract stash index from artifact id + const regex = /^stash@\{(\d+)\}$/; + const match = regex.exec(artifact.id); + if (!match) { + return; + } + + const stashes = await repository.getStashes(); + const stash = stashes.find(s => s.index === parseInt(match[1])); + if (!stash) { + return; + } + + await this._viewStash(repository, stash); + } + + @command('git.repositories.stashApply', { repository: true }) + async artifactStashApply(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + // Extract stash index from artifact id (format: "stash@{index}") + const regex = /^stash@\{(\d+)\}$/; + const match = regex.exec(artifact.id); + if (!match) { + return; + } + + const stashIndex = parseInt(match[1]); + await repository.applyStash(stashIndex); + } + + @command('git.repositories.stashPop', { repository: true }) + async artifactStashPop(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + // Extract stash index from artifact id (format: "stash@{index}") + const regex = /^stash@\{(\d+)\}$/; + const match = regex.exec(artifact.id); + if (!match) { + return; + } + + const stashIndex = parseInt(match[1]); + await repository.popStash(stashIndex); + } + + @command('git.repositories.stashDrop', { repository: true }) + async artifactStashDrop(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + // Extract stash index from artifact id + const regex = /^stash@\{(\d+)\}$/; + const match = regex.exec(artifact.id); + if (!match) { + return; + } + + await this._stashDrop(repository, parseInt(match[1]), artifact.name); + } + + @command('git.repositories.openWorktree', { repository: true }) + async artifactOpenWorktree(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + const uri = Uri.file(artifact.id); + await commands.executeCommand('vscode.openFolder', uri, { forceReuseWindow: true }); + } + + @command('git.repositories.openWorktreeInNewWindow', { repository: true }) + async artifactOpenWorktreeInNewWindow(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + const uri = Uri.file(artifact.id); + await commands.executeCommand('vscode.openFolder', uri, { forceNewWindow: true }); + } + + @command('git.repositories.deleteWorktree', { repository: true }) + async artifactDeleteWorktree(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + await repository.deleteWorktree(artifact.id); + } + + @command('git.repositories.worktreeCopyBranchName', { repository: true }) + async artifactWorktreeCopyBranchName(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + const worktrees = await repository.getWorktreeDetails(); + const worktree = worktrees.find(w => w.path === artifact.id); + if (!worktree || worktree.detached) { + return; + } + + env.clipboard.writeText(worktree.ref.substring(11)); + } + + @command('git.repositories.worktreeCopyCommitHash', { repository: true }) + async artifactWorktreeCopyCommitHash(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + const worktrees = await repository.getWorktreeDetails(); + const worktree = worktrees.find(w => w.path === artifact.id); + if (!worktree?.commitDetails) { + return; + } + + env.clipboard.writeText(worktree.commitDetails.hash); + } + + @command('git.repositories.worktreeCopyPath', { repository: true }) + async artifactWorktreeCopyPath(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + env.clipboard.writeText(artifact.id); + } + + @command('git.repositories.copyCommitHash', { repository: true }) + async artifactCopyCommitHash(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + const commit = await repository.getCommit(artifact.id); + env.clipboard.writeText(commit.hash); + } + + @command('git.repositories.copyBranchName', { repository: true }) + async artifactCopyBranchName(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + env.clipboard.writeText(artifact.name); + } + + @command('git.repositories.copyTagName', { repository: true }) + async artifactCopyTagName(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + env.clipboard.writeText(artifact.name); + } + + @command('git.repositories.copyStashName', { repository: true }) + async artifactCopyStashName(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + env.clipboard.writeText(artifact.name); + } + + @command('git.repositories.stashCopyBranchName', { repository: true }) + async artifactStashCopyBranchName(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact?.description) { + return; + } + + const stashes = await repository.getStashes(); + const stash = stashes.find(s => artifact.id === `stash@{${s.index}}`); + if (!stash?.branchName) { + return; + } + + env.clipboard.writeText(stash.branchName); + } + + private createCommand(id: string, key: string, method: Function, options: ScmCommandOptions): (...args: any[]) => any { + const result = (...args: any[]) => { + let result: Promise; + + if (!options.repository) { + result = Promise.resolve(method.apply(this, args)); + } else { + // try to guess the repository based on the first argument + const repository = this.model.getRepository(args[0]); + let repositoryPromise: Promise; + + if (repository) { + repositoryPromise = Promise.resolve(repository); + } else { + repositoryPromise = this.model.pickRepository(options.repositoryFilter); + } + + result = repositoryPromise.then(repository => { + if (!repository) { + return Promise.resolve(); + } + + return Promise.resolve(method.apply(this, [repository, ...args.slice(1)])); + }); + } + + /* __GDPR__ + "git.command" : { + "owner": "lszomoru", + "command" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The command id of the command being executed" } + } + */ + this.telemetryReporter.sendTelemetryEvent('git.command', { command: id }); + + return result.catch(err => { + const options: MessageOptions = { + modal: true + }; + + let message: string; + let type: 'error' | 'warning' | 'information' = 'error'; + + const choices = new Map void>(); + const openOutputChannelChoice = l10n.t('Open Git Log'); + const outputChannelLogger = this.logger; + choices.set(openOutputChannelChoice, () => outputChannelLogger.show()); + + const showCommandOutputChoice = l10n.t('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 = l10n.t('Please clean your repository working tree before checkout.'); + break; + case GitErrorCodes.PushRejected: + message = l10n.t('Can\'t push refs to remote. Try running "Pull" first to integrate your changes.'); + break; + case GitErrorCodes.ForcePushWithLeaseRejected: + case GitErrorCodes.ForcePushWithLeaseIfIncludesRejected: + message = l10n.t('Can\'t force push refs to remote. The tip of the remote-tracking branch has been updated since the last checkout. Try running "Pull" first to pull the latest changes from the remote branch first.'); + break; + case GitErrorCodes.Conflict: + message = l10n.t('There are merge conflicts. Please resolve them before committing your changes.'); + type = 'warning'; + choices.clear(); + choices.set(l10n.t('Show Changes'), () => commands.executeCommand('workbench.view.scm')); + options.modal = false; + break; + case GitErrorCodes.StashConflict: + message = l10n.t('There are merge conflicts while applying the stash. Please resolve them before committing your changes.'); + type = 'warning'; + choices.clear(); + choices.set(l10n.t('Show Changes'), () => commands.executeCommand('workbench.view.scm')); + options.modal = false; + break; + case GitErrorCodes.AuthenticationFailed: { + const regex = /Authentication failed for '(.*)'/i; + const match = regex.exec(err.stderr || String(err)); + + message = match + ? l10n.t('Failed to authenticate to git remote:\n\n{0}', match[1]) + : l10n.t('Failed to authenticate to git remote.'); + break; + } + case GitErrorCodes.NoUserNameConfigured: + case GitErrorCodes.NoUserEmailConfigured: + message = l10n.t('Make sure you configure your "user.name" and "user.email" in git.'); + choices.set(l10n.t('Learn More'), () => commands.executeCommand('vscode.open', Uri.parse('https://aka.ms/vscode-setup-git'))); + break; + case GitErrorCodes.EmptyCommitMessage: + message = l10n.t('Commit operation was cancelled due to empty commit message.'); + choices.clear(); + type = 'information'; + options.modal = false; + break; + case GitErrorCodes.CherryPickEmpty: + message = l10n.t('The changes are already present in the current branch.'); + choices.clear(); + type = 'information'; + options.modal = false; + break; + case GitErrorCodes.CherryPickConflict: + message = l10n.t('There were merge conflicts while cherry picking the changes. Resolve the conflicts before committing them.'); + type = 'warning'; + choices.set(l10n.t('Show Changes'), () => commands.executeCommand('workbench.view.scm')); + options.modal = false; + break; + default: { + const hintLines = (err.stderr || err.stdout || err.message || String(err)) + .replace(/^error: /mi, '') + .replace(/^> husky.*$/mi, '') + .split(/[\r\n]/) + .filter((line: string) => !!line); + + message = hintLines.length > 0 + ? l10n.t('Git: {0}', err.stdout ? hintLines[hintLines.length - 1] : hintLines[0]) + : l10n.t('Git error'); + + break; + } + } + + if (!message) { + console.error(err); + return; + } + + // We explicitly do not await this promise, because we do not + // want the command execution to be stuck waiting for the user + // to take action on the notification. + this.showErrorNotification(type, message, options, choices); + }); + }; + + // patch this object, so people can call methods directly + (this as Record)[key] = result; + + return result; + } + + private async showErrorNotification(type: 'error' | 'warning' | 'information', message: string, options: MessageOptions, choices: Map void>): Promise { + let result: string | undefined; + const allChoices = Array.from(choices.keys()); + + switch (type) { + case 'error': + result = await window.showErrorMessage(message, options, ...allChoices); + break; + case 'warning': + result = await window.showWarningMessage(message, options, ...allChoices); + break; + case 'information': + result = await window.showInformationMessage(message, options, ...allChoices); + break; + } + + if (result) { + const resultFn = choices.get(result); + + resultFn?.(); + } + } + + private getSCMResource(uri?: Uri): Resource | undefined { + uri = uri ?? window.activeNotebookEditor?.notebook.uri ?? window.activeTextEditor?.document.uri; + + // A notebook diff editor is neither the active text nor notebook editor, so + // fall back to its modified resource. + if (!uri && window.tabGroups.activeTabGroup.activeTab?.input instanceof TabInputNotebookDiff) { + uri = window.tabGroups.activeTabGroup.activeTab.input.modified; + } + + this.logger.debug(`[CommandCenter][getSCMResource] git.getSCMResource.uri: ${uri && uri.toString()}`); + + for (const r of this.model.repositories.map(r => r.root)) { + this.logger.debug(`[CommandCenter][getSCMResource] repo root: ${r}`); + } + + if (!uri) { + return undefined; + } + + if (isGitUri(uri)) { + const { path } = fromGitUri(uri); + uri = Uri.file(path); + } + + if (uri.scheme === 'file') { + const uriString = uri.toString(); + const repository = this.model.getRepository(uri); + + if (!repository) { + return undefined; + } + + return repository.workingTreeGroup.resourceStates.filter(r => r.resourceUri.toString() === uriString)[0] + || repository.indexGroup.resourceStates.filter(r => r.resourceUri.toString() === uriString)[0] + || repository.mergeGroup.resourceStates.filter(r => r.resourceUri.toString() === uriString)[0]; + } + return undefined; + } + + private runByRepository(resource: Uri, fn: (repository: Repository, resource: Uri) => Promise): Promise; + private runByRepository(resources: Uri[], fn: (repository: Repository, resources: Uri[]) => Promise): Promise; + private async runByRepository(arg: Uri | Uri[], fn: (repository: Repository, resources: any) => Promise): Promise { + const resources = arg instanceof Uri ? [arg] : arg; + const isSingleResource = arg instanceof Uri; + + const groups = resources.reduce((result, resource) => { + let repository = this.model.getRepository(resource); + + if (!repository) { + console.warn('Could not find git repository for ', resource); + return result; + } + + // Could it be a submodule? + if (pathEquals(resource.fsPath, repository.root)) { + repository = this.model.getRepositoryForSubmodule(resource) || repository; + } + + const tuple = result.filter(p => p.repository === repository)[0]; + + if (tuple) { + tuple.resources.push(resource); + } else { + result.push({ repository, resources: [resource] }); + } + + return result; + }, [] as { repository: Repository; resources: Uri[] }[]); + + const promises = groups + .map(({ repository, resources }) => fn(repository as Repository, isSingleResource ? resources[0] : resources)); + + return Promise.all(promises); + } + + dispose(): void { + this.disposables.forEach(d => d.dispose()); + } +}