diff --git a/src/commands/git/reset.ts b/src/commands/git/reset.ts index 160d4e3c4e2e2..facbdc681722e 100644 --- a/src/commands/git/reset.ts +++ b/src/commands/git/reset.ts @@ -4,8 +4,10 @@ import type { GitLog } from '../../git/models/log'; import type { GitReference, GitRevisionReference, GitTagReference } from '../../git/models/reference'; import { getReferenceLabel } from '../../git/models/reference.utils'; import type { Repository } from '../../git/models/repository'; +import { showGenericErrorMessage } from '../../messages'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; +import { Logger } from '../../system/logger'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { PartialStepState, @@ -69,8 +71,19 @@ export class ResetGitCommand extends QuickCommand { return this._canSkipConfirm; } - execute(state: ResetStepState) { - state.repo.reset(...state.flags, state.reference.ref); + async execute(state: ResetStepState) { + try { + await state.repo.git.reset( + { + hard: state.flags.includes('--hard'), + soft: state.flags.includes('--soft'), + }, + state.reference.ref, + ); + } catch (ex) { + Logger.error(ex, this.title); + void showGenericErrorMessage(ex.message); + } } protected async *steps(state: PartialStepState): StepGenerator { @@ -156,7 +169,7 @@ export class ResetGitCommand extends QuickCommand { } endSteps(state); - this.execute(state as ResetStepState); + await this.execute(state as ResetStepState); } return state.counter < 0 ? StepResultBreak : undefined; diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts index bd28e0c0fa5b5..4a70cec6392da 100644 --- a/src/env/node/git/git.ts +++ b/src/env/node/git/git.ts @@ -20,6 +20,8 @@ import { PullErrorReason, PushError, PushErrorReason, + ResetError, + ResetErrorReason, StashPushError, StashPushErrorReason, TagError, @@ -73,42 +75,47 @@ const textDecoder = new TextDecoder('utf8'); const rootSha = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; export const GitErrors = { + alreadyCheckedOut: /already checked out/i, + alreadyExists: /already exists/i, + ambiguousArgument: /fatal:\s*ambiguous argument ['"].+['"]: unknown revision or path not in the working tree/i, badRevision: /bad revision '(.*?)'/i, cantLockRef: /cannot lock ref|unable to update local ref/i, changesWouldBeOverwritten: /Your local changes to the following files would be overwritten|Your local changes would be overwritten/i, commitChangesFirst: /Please, commit your changes before you can/i, conflict: /^CONFLICT \([^)]+\): \b/m, + detachedHead: /You are in 'detached HEAD' state/i, + entryNotUpToDate: /error:\s*Entry ['"].+['"] not uptodate\. Cannot merge\./i, failedToDeleteDirectoryNotEmpty: /failed to delete '(.*?)': Directory not empty/i, + invalidLineCount: /file .+? has only \d+ lines/i, invalidObjectName: /invalid object name: (.*)\s/i, invalidObjectNameList: /could not open object name list: (.*)\s/i, + invalidTagName: /invalid tag name/i, + mainWorkingTree: /is a main working tree/i, noFastForward: /\(non-fast-forward\)/i, noMergeBase: /no merge base/i, noRemoteRepositorySpecified: /No remote repository specified\./i, + noUpstream: /^fatal: The current branch .* has no upstream branch/i, notAValidObjectName: /Not a valid object name/i, notAWorkingTree: /'(.*?)' is not a working tree/i, noUserNameConfigured: /Please tell me who you are\./i, - invalidLineCount: /file .+? has only \d+ lines/i, - uncommittedChanges: /contains modified or untracked files/i, - alreadyExists: /already exists/i, - alreadyCheckedOut: /already checked out/i, - mainWorkingTree: /is a main working tree/i, - noUpstream: /^fatal: The current branch .* has no upstream branch/i, noPausedOperation: /no merge (?:in progress|to abort)|no cherry-pick(?: or revert)? in progress|no rebase in progress/i, permissionDenied: /Permission.*denied/i, pushRejected: /^error: failed to push some refs to\b/m, rebaseMultipleBranches: /cannot rebase onto multiple branches/i, + refLocked: /fatal:\s*cannot lock ref ['"].+['"]: unable to create file/i, remoteAhead: /rejected because the remote contains work/i, remoteConnection: /Could not read from remote repository/i, - tagConflict: /! \[rejected\].*\(would clobber existing tag\)/m, - unmergedFiles: /is not possible because you have unmerged files|You have unmerged files/i, - unstagedChanges: /You have unstaged changes/i, + remoteRejected: /rejected because the remote contains work/i, tagAlreadyExists: /tag .* already exists/i, + tagConflict: /! \[rejected\].*\(would clobber existing tag\)/m, tagNotFound: /tag .* not found/i, - invalidTagName: /invalid tag name/i, - remoteRejected: /rejected because the remote contains work/i, + uncommittedChanges: /contains modified or untracked files/i, + unmergedChanges: /error:\s*you need to resolve your current index first/i, + unmergedFiles: /is not possible because you have unmerged files|You have unmerged files/i, unresolvedConflicts: /You must edit all merge conflicts|Resolve all conflicts/i, + unstagedChanges: /You have unstaged changes/i, }; const GitWarnings = { @@ -177,6 +184,16 @@ const tagErrorAndReason: [RegExp, TagErrorReason][] = [ [GitErrors.remoteRejected, TagErrorReason.RemoteRejected], ]; +const resetErrorAndReason: [RegExp, ResetErrorReason][] = [ + [GitErrors.ambiguousArgument, ResetErrorReason.AmbiguousArgument], + [GitErrors.changesWouldBeOverwritten, ResetErrorReason.ChangesWouldBeOverwritten], + [GitErrors.detachedHead, ResetErrorReason.DetachedHead], + [GitErrors.entryNotUpToDate, ResetErrorReason.EntryNotUpToDate], + [GitErrors.permissionDenied, ResetErrorReason.PermissionDenied], + [GitErrors.refLocked, ResetErrorReason.RefLocked], + [GitErrors.unmergedChanges, ResetErrorReason.UnmergedChanges], +]; + export class Git { /** Map of running git commands -- avoids running duplicate overlaping commands */ private readonly pendingCommands = new Map>(); @@ -1627,8 +1644,33 @@ export class Git { return this.exec({ cwd: repoPath }, 'remote', 'get-url', remote); } - reset(repoPath: string | undefined, pathspecs: string[]) { - return this.exec({ cwd: repoPath }, 'reset', '-q', '--', ...pathspecs); + async reset( + repoPath: string, + pathspecs: string[], + options?: { hard?: boolean; soft?: never; ref?: string } | { soft?: boolean; hard?: never; ref?: string }, + ): Promise { + try { + const flags = []; + if (options?.hard) { + flags.push('--hard'); + } else if (options?.soft) { + flags.push('--soft'); + } + + if (options?.ref) { + flags.push(options.ref); + } + await this.exec({ cwd: repoPath }, 'reset', '-q', ...flags, '--', ...pathspecs); + } catch (ex) { + const msg: string = ex?.toString() ?? ''; + for (const [error, reason] of resetErrorAndReason) { + if (error.test(msg) || error.test(ex.stderr ?? '')) { + throw new ResetError(reason, ex); + } + } + + throw new ResetError(ResetErrorReason.Other, ex); + } } async rev_list( diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 1b732eb2b5be8..420d6528d376e 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -4942,6 +4942,11 @@ export class LocalGitProvider implements GitProvider, Disposable { } } + @log() + async reset(repoPath: string, ref: string, options?: { hard?: boolean } | { soft?: boolean }): Promise { + await this.git.reset(repoPath, [], { ...options, ref: ref }); + } + @log({ args: { 2: false } }) async runGitCommandViaTerminal( repoPath: string, diff --git a/src/git/actions/repository.ts b/src/git/actions/repository.ts index 6d07d4117d3ef..b81b890274ce7 100644 --- a/src/git/actions/repository.ts +++ b/src/git/actions/repository.ts @@ -1,4 +1,3 @@ -import type { ResetGitCommandArgs } from '../../commands/git/reset'; import { Container } from '../../container'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import { executeGitCommand } from '../actions'; @@ -41,11 +40,18 @@ export function rebase(repo?: string | Repository, ref?: GitReference, interacti export function reset( repo?: string | Repository, ref?: GitRevisionReference | GitTagReference, - flags?: NonNullable['flags'], + options?: { hard?: boolean; soft?: never } | { hard?: never; soft?: boolean }, ) { + const flags: Array<'--hard' | '--soft'> = []; + if (options?.hard) { + flags.push('--hard'); + } else if (options?.soft) { + flags.push('--soft'); + } + return executeGitCommand({ command: 'reset', - confirm: flags == null || flags.includes('--hard'), + confirm: options == null || options.hard, state: { repo: repo, reference: ref, flags: flags }, }); } diff --git a/src/git/errors.ts b/src/git/errors.ts index e29c48b11ce8d..c427c70a6886f 100644 --- a/src/git/errors.ts +++ b/src/git/errors.ts @@ -632,3 +632,66 @@ export class PausedOperationContinueError extends Error { Error.captureStackTrace?.(this, PausedOperationContinueError); } } + +export const enum ResetErrorReason { + AmbiguousArgument, + ChangesWouldBeOverwritten, + DetachedHead, + EntryNotUpToDate, + PermissionDenied, + RefLocked, + Other, + UnmergedChanges, +} + +export class ResetError extends Error { + static is(ex: unknown, reason?: ResetErrorReason): ex is ResetError { + return ex instanceof ResetError && (reason == null || ex.reason === reason); + } + + readonly original?: Error; + readonly reason: ResetErrorReason | undefined; + constructor(reason?: ResetErrorReason, original?: Error); + constructor(message?: string, original?: Error); + constructor(messageOrReason: string | ResetErrorReason | undefined, original?: Error) { + let message; + let reason: ResetErrorReason | undefined; + if (messageOrReason == null) { + message = 'Unable to reset'; + } else if (typeof messageOrReason === 'string') { + message = messageOrReason; + reason = undefined; + } else { + reason = messageOrReason; + message = 'Unable to reset'; + switch (reason) { + case ResetErrorReason.UnmergedChanges: + message = `${message} because there are unmerged changes`; + break; + case ResetErrorReason.AmbiguousArgument: + message = `${message} because the argument is ambiguous`; + break; + case ResetErrorReason.EntryNotUpToDate: + message = `${message} because the entry is not up to date`; + break; + case ResetErrorReason.RefLocked: + message = `${message} because the ref is locked`; + break; + case ResetErrorReason.PermissionDenied: + message = `${message} because you don't have permission to modify affected files`; + break; + case ResetErrorReason.DetachedHead: + message = `${message} because you are in a detached HEAD state`; + break; + case ResetErrorReason.ChangesWouldBeOverwritten: + message = `${message} because your local changes would be overwritten`; + break; + } + } + super(message); + + this.original = original; + this.reason = reason; + Error.captureStackTrace?.(this, ResetError); + } +} diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index a26e81b946302..0bdbf33a235db 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -134,6 +134,8 @@ export interface GitProviderRepository { pruneRemote?(repoPath: string, name: string): Promise; removeRemote?(repoPath: string, name: string): Promise; + reset?(repoPath: string, ref: string, options?: { hard?: boolean } | { soft?: boolean }): Promise; + checkout?( repoPath: string, ref: string, diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index e61415bd1595d..485a8bee90b70 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -1349,6 +1349,18 @@ export class GitProviderService implements Disposable { return provider.removeRemote(path, name); } + @log() + async reset( + repoPath: string | Uri, + options: { hard?: boolean } | { soft?: boolean } = {}, + ref: string, + ): Promise { + const { provider, path } = this.getProvider(repoPath); + if (provider.reset == null) throw new ProviderNotSupportedError(provider.descriptor.name); + + return provider.reset(path, ref, options); + } + @log() applyChangesToWorkingFile(uri: GitUri, ref1?: string, ref2?: string): Promise { const { provider } = this.getProvider(uri); diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index 9386c7e39b815..51ad01f1c713a 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -822,11 +822,6 @@ export class Repository implements Disposable { ); } - @log() - reset(...args: string[]) { - void this.runTerminalCommand('reset', ...args); - } - resume() { if (!this._suspended) return;