/*--------------------------------------------------------------------------------------------- * 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, QuickInputButtonLocation, languages, SourceControlArtifact } from 'vscode'; import TelemetryReporter from '@vscode/extension-telemetry'; import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator'; import { ForcePushMode, GitErrorCodes, RefType, Status, CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref, Worktree } from './api/git'; import { Git, GitError, Stash } 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'; 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}`; } } 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 = {}): Function { return (value: unknown, context: ClassMethodDecoratorContext) => { if (typeof value !== 'function' || context.kind !== 'method') { throw new Error('not supported'); } const key = context.name.toString(); Commands.push({ commandId, key, method: 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 }): Promise { await 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.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 answer = await window.showWarningMessage(l10n.t('This will create a Git repository in "{0}". Are you sure you want to continue?', uri.fsPath), 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); } @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); } } @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(l10n.t('HEAD version of "{0}" is not available.', path.basename(resource.resourceUri.fsPath))); 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.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; const enableCommitSigning = config.get('enableCommitSigning') === 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 configured opts.signCommit = enableCommitSigning; 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 generateRandomBranchName(repository: Repository, separator: string): Promise { const config = workspace.getConfiguration('git'); const branchRandomNameDictionary = config.get('branchRandomName.dictionary')!; const dictionaries: string[][] = []; for (const dictionary of branchRandomNameDictionary) { if (dictionary.toLowerCase() === 'adjectives') { dictionaries.push(adjectives); } if (dictionary.toLowerCase() === 'animals') { dictionaries.push(animals); } if (dictionary.toLowerCase() === 'colors') { dictionaries.push(colors); } if (dictionary.toLowerCase() === 'numbers') { dictionaries.push(NumberDictionary.generate({ length: 3 })); } } if (dictionaries.length === 0) { return ''; } // 5 attempts to generate a random branch name for (let index = 0; index < 5; index++) { const randomName = uniqueNamesGenerator({ dictionaries, length: dictionaries.length, separator }); // Check for local ref conflict const refs = await repository.getRefs({ pattern: `refs/heads/${randomName}` }); if (refs.length === 0) { return randomName; } } return ''; } 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 => { const branchName = branchRandomNameEnabled ? await this.generateRandomBranchName(repository, branchWhitespaceChar) : ''; return `${branchPrefix}${branchName}`; }; 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.diffBetween2(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) : 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); 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.diffBetween2(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.diffBetween2(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); } 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 hint = (err.stderr || err.message || String(err)) .replace(/^error: /mi, '') .replace(/^> husky.*$/mi, '') .split(/[\r\n]/) .filter((line: string) => !!line) [0]; message = hint ? l10n.t('Git: {0}', hint) : 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 ? uri : (window.activeTextEditor && window.activeTextEditor.document.uri); 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()); } }