diff --git a/src/autolinks/autolinks.ts b/src/autolinks/autolinks.ts index f9ba58b0a3b12..7aad6ac8a73ad 100644 --- a/src/autolinks/autolinks.ts +++ b/src/autolinks/autolinks.ts @@ -5,7 +5,7 @@ import type { IntegrationId } from '../constants.integrations'; import { IssueIntegrationId } from '../constants.integrations'; import type { Container } from '../container'; import type { GitRemote } from '../git/models/remote'; -import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from '../git/utils/icons'; +import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from '../git/utils/vscode/icons'; import type { HostingIntegration, IssueIntegration } from '../plus/integrations/integration'; import { fromNow } from '../system/date'; import { debug } from '../system/decorators/log'; diff --git a/src/commands/quickCommand.steps.ts b/src/commands/quickCommand.steps.ts index 1a4b4947b797f..88f1d39a9154e 100644 --- a/src/commands/quickCommand.steps.ts +++ b/src/commands/quickCommand.steps.ts @@ -43,8 +43,8 @@ import type { WorktreeQuickPickItem } from '../git/models/worktree.quickpick'; import { createWorktreeQuickPickItem } from '../git/models/worktree.quickpick'; import { getWorktreesByBranch } from '../git/models/worktree.utils'; import { remoteUrlRegex } from '../git/parsers/remoteParser'; -import type { BranchSortOptions, TagSortOptions } from '../git/utils/sorting'; -import { sortBranches, sortContributors, sortTags, sortWorktrees } from '../git/utils/sorting'; +import type { BranchSortOptions, TagSortOptions } from '../git/utils/vscode/sorting'; +import { sortBranches, sortContributors, sortTags, sortWorktrees } from '../git/utils/vscode/sorting'; import { getApplicablePromo } from '../plus/gk/account/promos'; import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../plus/gk/account/subscription'; import type { LaunchpadCommandArgs } from '../plus/launchpad/launchpad'; diff --git a/src/constants.views.ts b/src/constants.views.ts index 0abe8f8c00916..ad493a0c4ba71 100644 --- a/src/constants.views.ts +++ b/src/constants.views.ts @@ -126,11 +126,10 @@ export type TreeViewNodeTypes = | 'grouping' | 'launchpad' | 'launchpad-item' - | 'merge-status' | 'message' | 'pager' + | 'paused-operation-status' | 'pullrequest' - | 'rebase-status' | 'reflog' | 'reflog-record' | 'remote' diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts index 8762cbc1e0da6..a3f593e4f906d 100644 --- a/src/env/node/git/git.ts +++ b/src/env/node/git/git.ts @@ -75,7 +75,8 @@ const rootSha = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; export const GitErrors = { 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/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, failedToDeleteDirectoryNotEmpty: /failed to delete '(.*?)': Directory not empty/i, @@ -93,18 +94,21 @@ export const GitErrors = { 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, 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/i, + unmergedFiles: /is not possible because you have unmerged files|You have unmerged files/i, unstagedChanges: /You have unstaged changes/i, tagAlreadyExists: /tag .* already exists/i, tagNotFound: /tag .* not found/i, invalidTagName: /invalid tag name/i, remoteRejected: /rejected because the remote contains work/i, + unresolvedConflicts: /You must edit all merge conflicts|Resolve all conflicts/i, }; const GitWarnings = { @@ -1543,7 +1547,7 @@ export class Git { target, ); } catch (ex) { - const msg = ex?.toString() ?? ''; + const msg: string = ex?.toString() ?? ''; if (GitErrors.notAValidObjectName.test(msg)) { throw new Error( diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 036a2d0c42fe4..06e9e7a9d8370 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -97,11 +97,10 @@ import type { GitGraphRowTag, } from '../../../git/models/graph'; import type { GitLog } from '../../../git/models/log'; -import type { GitMergeStatus } from '../../../git/models/merge'; import type { MergeConflict } from '../../../git/models/mergeConflict'; -import type { GitRebaseStatus } from '../../../git/models/rebase'; -import type { GitBranchReference, GitReference, GitTagReference } from '../../../git/models/reference'; -import { createReference, getReferenceFromBranch, isBranchReference } from '../../../git/models/reference.utils'; +import type { GitPausedOperationStatus } from '../../../git/models/pausedOperationStatus'; +import type { GitBranchReference, GitReference } from '../../../git/models/reference'; +import { createReference, isBranchReference } from '../../../git/models/reference.utils'; import type { GitReflog } from '../../../git/models/reflog'; import type { GitRemote } from '../../../git/models/remote'; import { getVisibilityCacheKey, sortRemotes } from '../../../git/models/remote'; @@ -166,9 +165,9 @@ import { parseGitWorktrees } from '../../../git/parsers/worktreeParser'; import { getRemoteProviderMatcher, loadRemoteProviders } from '../../../git/remotes/remoteProviders'; import type { GitSearch, GitSearchResultData, GitSearchResults } from '../../../git/search'; import { getGitArgsFromSearchQuery, getSearchQueryComparisonKey } from '../../../git/search'; -import { getRemoteIconUri } from '../../../git/utils/icons'; -import type { BranchSortOptions, TagSortOptions } from '../../../git/utils/sorting'; -import { sortBranches, sortContributors, sortTags } from '../../../git/utils/sorting'; +import { getRemoteIconUri } from '../../../git/utils/vscode/icons'; +import type { BranchSortOptions, TagSortOptions } from '../../../git/utils/vscode/sorting'; +import { sortBranches, sortContributors, sortTags } from '../../../git/utils/vscode/sorting'; import { showBlameInvalidIgnoreRevsFileWarningMessage, showGenericErrorMessage, @@ -236,6 +235,7 @@ import { } from './git'; import type { GitLocation } from './locator'; import { findGitPath, InvalidGitConfigError, UnableToFindGitError } from './locator'; +import { abortPausedOperation, continuePausedOperation, getPausedOperationStatus } from './operations/pausedOperations'; import { CancelledRunError, fsExists, RunError } from './shell'; const emptyArray = Object.freeze([]) as unknown as any[]; @@ -299,8 +299,7 @@ export class LocalGitProvider implements GitProvider, Disposable { private readonly _branchCache = new Map>(); private readonly _branchesCache = new Map>>(); private readonly _contributorsCache = new Map>>(); - private readonly _mergeStatusCache = new Map>(); - private readonly _rebaseStatusCache = new Map>(); + private readonly _pausedOperationStatusCache = new Map>(); private readonly _remotesCache = new Map>(); private readonly _repoInfoCache = new Map(); private readonly _stashesCache = new Map(); @@ -356,14 +355,17 @@ export class LocalGitProvider implements GitProvider, Disposable { this._trackedPaths.clear(); } - if (e.changed(RepositoryChange.Merge, RepositoryChangeComparisonMode.Any)) { - this._branchCache.delete(repo.path); - this._mergeStatusCache.delete(repo.path); - } - - if (e.changed(RepositoryChange.Rebase, RepositoryChangeComparisonMode.Any)) { + if ( + e.changed( + RepositoryChange.CherryPick, + RepositoryChange.Merge, + RepositoryChange.Rebase, + RepositoryChange.Revert, + RepositoryChangeComparisonMode.Any, + ) + ) { this._branchCache.delete(repo.path); - this._rebaseStatusCache.delete(repo.path); + this._pausedOperationStatusCache.delete(repo.path); } if (e.changed(RepositoryChange.Stash, RepositoryChangeComparisonMode.Any)) { @@ -1479,7 +1481,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } if (!caches.length || caches.includes('status')) { - cachesToClear.push(this._mergeStatusCache, this._rebaseStatusCache); + cachesToClear.push(this._pausedOperationStatusCache); } if (!caches.length || caches.includes('tags')) { @@ -1872,7 +1874,7 @@ export class LocalGitProvider implements GitProvider, Disposable { // Trap and cache expected blame errors if (document.state != null) { - const msg = ex?.toString() ?? ''; + const msg: string = ex?.toString() ?? ''; Logger.debug(scope, `Cache replace (with empty promise): '${key}'; reason=${msg}`); const value: CachedBlame = { @@ -1968,7 +1970,7 @@ export class LocalGitProvider implements GitProvider, Disposable { // Trap and cache expected blame errors if (document.state != null) { - const msg = ex?.toString() ?? ''; + const msg: string = ex?.toString() ?? ''; Logger.debug(scope, `Cache replace (with empty promise): '${key}'; reason=${msg}`); const value: CachedBlame = { @@ -2220,13 +2222,14 @@ export class LocalGitProvider implements GitProvider, Disposable { const [name, upstream] = data[0].split('\n'); - const [rebaseStatusResult, committerDateResult] = await Promise.allSettled([ - isDetachedHead(name) ? this.getRebaseStatus(repoPath) : undefined, + const [pausedOpStatusResult, committerDateResult] = await Promise.allSettled([ + isDetachedHead(name) ? this.getPausedOperationStatus(repoPath) : undefined, this.git.log__recent_committerdate(repoPath, commitOrdering), ]); const committerDate = getSettledValue(committerDateResult); - const rebaseStatus = getSettledValue(rebaseStatusResult); + const pausedOpStatus = getSettledValue(pausedOpStatusResult); + const rebaseStatus = pausedOpStatus?.type === 'rebase' ? pausedOpStatus : undefined; return new GitBranch( this.container, @@ -3539,7 +3542,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } catch (ex) { // Trap and cache expected diff errors if (document.state != null) { - const msg = ex?.toString() ?? ''; + const msg: string = ex?.toString() ?? ''; Logger.debug(scope, `Cache replace (with empty promise): '${key}'`); const value: CachedDiff = { @@ -3624,7 +3627,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } catch (ex) { // Trap and cache expected diff errors if (document.state != null) { - const msg = ex?.toString() ?? ''; + const msg: string = ex?.toString() ?? ''; Logger.debug(scope, `Cache replace (with empty promise): '${key}'`); const value: CachedDiff = { @@ -4417,170 +4420,24 @@ export class LocalGitProvider implements GitProvider, Disposable { } } + @gate() @log() - async getMergeStatus(repoPath: string): Promise { - let status = this.useCaching ? this._mergeStatusCache.get(repoPath) : undefined; - if (status == null) { - async function getCore(this: LocalGitProvider): Promise { - const merge = await this.git.rev_parse__verify(repoPath, 'MERGE_HEAD'); - if (merge == null) return undefined; - - const [branchResult, mergeBaseResult, possibleSourceBranchesResult] = await Promise.allSettled([ - this.getBranch(repoPath), - this.getMergeBase(repoPath, 'MERGE_HEAD', 'HEAD'), - this.getCommitBranches(repoPath, ['MERGE_HEAD'], undefined, { all: true, mode: 'pointsAt' }), - ]); - - const branch = getSettledValue(branchResult); - const mergeBase = getSettledValue(mergeBaseResult); - const possibleSourceBranches = getSettledValue(possibleSourceBranchesResult); - - return { - type: 'merge', - repoPath: repoPath, - mergeBase: mergeBase, - HEAD: createReference(merge, repoPath, { refType: 'revision' }), - current: getReferenceFromBranch(branch!), - incoming: - possibleSourceBranches?.length === 1 - ? createReference(possibleSourceBranches[0], repoPath, { - refType: 'branch', - name: possibleSourceBranches[0], - remote: false, - }) - : undefined, - } satisfies GitMergeStatus; - } - - status = getCore.call(this); - if (this.useCaching) { - this._mergeStatusCache.set(repoPath, status); - } - } - - return status; + getPausedOperationStatus(repoPath: string): Promise { + return getPausedOperationStatus.call( + this, + repoPath, + this.useCaching ? this._pausedOperationStatusCache : undefined, + ); } @log() - async getRebaseStatus(repoPath: string): Promise { - let status = this.useCaching ? this._rebaseStatusCache.get(repoPath) : undefined; - if (status == null) { - async function getCore(this: LocalGitProvider): Promise { - const gitDir = await this.getGitDir(repoPath); - const [rebaseMergeHeadResult, rebaseApplyHeadResult] = await Promise.allSettled([ - this.git.readDotGitFile(gitDir, ['rebase-merge', 'head-name']), - this.git.readDotGitFile(gitDir, ['rebase-apply', 'head-name']), - ]); - const rebaseMergeHead = getSettledValue(rebaseMergeHeadResult); - const rebaseApplyHead = getSettledValue(rebaseApplyHeadResult); - - let branch = rebaseApplyHead ?? rebaseMergeHead; - if (branch == null) return undefined; - - const path = rebaseApplyHead != null ? 'rebase-apply' : 'rebase-merge'; - - const [ - rebaseHeadResult, - origHeadResult, - ontoResult, - stepsNumberResult, - stepsTotalResult, - stepsMessageResult, - ] = await Promise.allSettled([ - this.git.rev_parse__verify(repoPath, 'REBASE_HEAD'), - this.git.readDotGitFile(gitDir, [path, 'orig-head']), - this.git.readDotGitFile(gitDir, [path, 'onto']), - this.git.readDotGitFile(gitDir, [path, 'msgnum'], { numeric: true }), - this.git.readDotGitFile(gitDir, [path, 'end'], { numeric: true }), - this.git - .readDotGitFile(gitDir, [path, 'message'], { throw: true }) - .catch(() => this.git.readDotGitFile(gitDir, [path, 'message-squashed'])), - ]); - - const origHead = getSettledValue(origHeadResult); - const onto = getSettledValue(ontoResult); - if (origHead == null || onto == null) return undefined; - - let mergeBase; - const rebaseHead = getSettledValue(rebaseHeadResult); - if (rebaseHead != null) { - mergeBase = await this.getMergeBase(repoPath, rebaseHead, 'HEAD'); - } else { - mergeBase = await this.getMergeBase(repoPath, onto, origHead); - } - - if (branch.startsWith('refs/heads/')) { - branch = branch.substring(11).trim(); - } - - const [branchTipsResult, tagTipsResult] = await Promise.allSettled([ - this.getCommitBranches(repoPath, [onto], undefined, { all: true, mode: 'pointsAt' }), - this.getCommitTags(repoPath, onto, { mode: 'pointsAt' }), - ]); - - const branchTips = getSettledValue(branchTipsResult); - const tagTips = getSettledValue(tagTipsResult); - - let ontoRef: GitBranchReference | GitTagReference | undefined; - if (branchTips != null) { - for (const ref of branchTips) { - if (ref.startsWith('(no branch, rebasing')) continue; - - ontoRef = createReference(ref, repoPath, { - refType: 'branch', - name: ref, - remote: false, - }); - break; - } - } - if (ontoRef == null && tagTips != null) { - for (const ref of tagTips) { - if (ref.startsWith('(no branch, rebasing')) continue; - - ontoRef = createReference(ref, repoPath, { - refType: 'tag', - name: ref, - }); - break; - } - } - - return { - type: 'rebase', - repoPath: repoPath, - mergeBase: mergeBase, - HEAD: createReference(rebaseHead ?? origHead, repoPath, { refType: 'revision' }), - onto: createReference(onto, repoPath, { refType: 'revision' }), - current: ontoRef, - incoming: createReference(branch, repoPath, { - refType: 'branch', - name: branch, - remote: false, - }), - steps: { - current: { - number: getSettledValue(stepsNumberResult) ?? 0, - commit: - rebaseHead != null - ? createReference(rebaseHead, repoPath, { - refType: 'revision', - message: getSettledValue(stepsMessageResult), - }) - : undefined, - }, - total: getSettledValue(stepsTotalResult) ?? 0, - }, - } satisfies GitRebaseStatus; - } - - status = getCore.call(this); - if (this.useCaching) { - this._rebaseStatusCache.set(repoPath, status); - } - } + abortPausedOperation(repoPath: string, options?: { quit?: boolean }): Promise { + return abortPausedOperation.call(this, repoPath, options); + } - return status; + @log() + continuePausedOperation(repoPath: string, options?: { skip?: boolean }): Promise { + return continuePausedOperation.call(this, repoPath, options); } @log() @@ -5335,11 +5192,11 @@ export class LocalGitProvider implements GitProvider, Disposable { const status = parseGitStatus(data, repoPath, porcelainVersion); if (status?.detached) { - const rebaseStatus = await this.getRebaseStatus(repoPath); - if (rebaseStatus != null) { + const pausedOpStatus = await this.getPausedOperationStatus(repoPath); + if (pausedOpStatus?.type === 'rebase') { return new GitStatus( repoPath, - rebaseStatus.incoming.name, + pausedOpStatus.incoming.name, status.sha, status.files, status.state, diff --git a/src/env/node/git/operations/pausedOperations.ts b/src/env/node/git/operations/pausedOperations.ts new file mode 100644 index 0000000000000..a4fa3290bec34 --- /dev/null +++ b/src/env/node/git/operations/pausedOperations.ts @@ -0,0 +1,446 @@ +import { readdir } from 'fs'; +import { GitErrorHandling } from '../../../../git/commandOptions'; +import { + PausedOperationAbortError, + PausedOperationAbortErrorReason, + PausedOperationContinueError, + PausedOperationContinueErrorReason, +} from '../../../../git/errors'; +import type { + GitCherryPickStatus, + GitMergeStatus, + GitPausedOperationStatus, + GitRebaseStatus, + GitRevertStatus, +} from '../../../../git/models/pausedOperationStatus'; +import type { GitBranchReference, GitTagReference } from '../../../../git/models/reference'; +import { createReference, getReferenceFromBranch } from '../../../../git/models/reference.utils'; +import { Logger } from '../../../../system/logger'; +import { getSettledValue } from '../../../../system/promise'; +import { GitErrors } from '../git'; +import type { LocalGitProvider } from '../localGitProvider'; + +export async function getPausedOperationStatus( + this: LocalGitProvider, + repoPath: string, + cache: Map> | undefined, +): Promise { + let status = cache?.get(repoPath); + if (status == null) { + async function getCore(this: LocalGitProvider): Promise { + const gitDir = await this.getGitDir(repoPath); + + type Operation = 'cherry-pick' | 'merge' | 'rebase-apply' | 'rebase-merge' | 'revert'; + const operation = await new Promise((resolve, _) => { + readdir(gitDir.uri.fsPath, { withFileTypes: true }, (err, entries) => { + if (err != null) { + resolve(undefined); + return; + } + + if (entries.length === 0) { + resolve(undefined); + return; + } + + let entry; + for (entry of entries) { + if (entry.isFile()) { + switch (entry.name) { + case 'CHERRY_PICK_HEAD': + resolve('cherry-pick'); + return; + case 'MERGE_HEAD': + resolve('merge'); + return; + case 'REVERT_HEAD': + resolve('revert'); + return; + } + } else if (entry.isDirectory()) { + switch (entry.name) { + case 'rebase-apply': + resolve('rebase-apply'); + return; + case 'rebase-merge': + resolve('rebase-merge'); + return; + } + } + } + + resolve(undefined); + }); + }); + + if (operation == null) return undefined; + + switch (operation) { + case 'cherry-pick': { + const cherryPickHead = ( + await this.git.exec( + { cwd: repoPath, errors: GitErrorHandling.Ignore }, + 'rev-parse', + '--quiet', + '--verify', + 'CHERRY_PICK_HEAD', + ) + )?.trim(); + if (!cherryPickHead) return undefined; + + const branch = (await this.getBranch(repoPath))!; + + return { + type: 'cherry-pick', + repoPath: repoPath, + // TODO: Validate that these are correct + HEAD: createReference(cherryPickHead, repoPath, { refType: 'revision' }), + current: getReferenceFromBranch(branch), + incoming: createReference(cherryPickHead, repoPath, { refType: 'revision' }), + } satisfies GitCherryPickStatus; + } + case 'merge': { + const mergeHead = ( + await this.git.exec( + { cwd: repoPath, errors: GitErrorHandling.Ignore }, + 'rev-parse', + '--quiet', + '--verify', + 'MERGE_HEAD', + ) + )?.trim(); + if (!mergeHead) return undefined; + + const [branchResult, mergeBaseResult, possibleSourceBranchesResult] = await Promise.allSettled([ + this.getBranch(repoPath), + this.getMergeBase(repoPath, 'MERGE_HEAD', 'HEAD'), + this.getCommitBranches(repoPath, ['MERGE_HEAD'], undefined, { + all: true, + mode: 'pointsAt', + }), + ]); + + const branch = getSettledValue(branchResult)!; + const mergeBase = getSettledValue(mergeBaseResult); + const possibleSourceBranches = getSettledValue(possibleSourceBranchesResult); + + return { + type: 'merge', + repoPath: repoPath, + mergeBase: mergeBase, + HEAD: createReference(mergeHead, repoPath, { refType: 'revision' }), + current: getReferenceFromBranch(branch), + incoming: + possibleSourceBranches?.length === 1 + ? createReference(possibleSourceBranches[0], repoPath, { + refType: 'branch', + name: possibleSourceBranches[0], + remote: false, + }) + : createReference(mergeHead, repoPath, { refType: 'revision' }), + } satisfies GitMergeStatus; + } + case 'revert': { + const revertHead = ( + await this.git.exec( + { cwd: repoPath, errors: GitErrorHandling.Ignore }, + 'rev-parse', + '--quiet', + '--verify', + 'REVERT_HEAD', + ) + )?.trim(); + if (!revertHead) return undefined; + + const branch = (await this.getBranch(repoPath))!; + + return { + type: 'revert', + repoPath: repoPath, + HEAD: createReference(revertHead, repoPath, { refType: 'revision' }), + current: getReferenceFromBranch(branch), + incoming: createReference(revertHead, repoPath, { refType: 'revision' }), + } satisfies GitRevertStatus; + } + case 'rebase-apply': + case 'rebase-merge': { + let branch = await this.git.readDotGitFile(gitDir, [operation, 'head-name']); + if (!branch) return undefined; + + const [ + rebaseHeadResult, + origHeadResult, + ontoResult, + stepsNumberResult, + stepsTotalResult, + stepsMessageResult, + ] = await Promise.allSettled([ + await this.git.exec( + { cwd: repoPath, errors: GitErrorHandling.Ignore }, + 'rev-parse', + '--quiet', + '--verify', + 'REBASE_HEAD', + ), + this.git.readDotGitFile(gitDir, [operation, 'orig-head']), + this.git.readDotGitFile(gitDir, [operation, 'onto']), + this.git.readDotGitFile(gitDir, [operation, 'msgnum'], { numeric: true }), + this.git.readDotGitFile(gitDir, [operation, 'end'], { numeric: true }), + this.git + .readDotGitFile(gitDir, [operation, 'message'], { throw: true }) + .catch(() => this.git.readDotGitFile(gitDir, [operation, 'message-squashed'])), + ]); + + const origHead = getSettledValue(origHeadResult); + const onto = getSettledValue(ontoResult); + if (origHead == null || onto == null) return undefined; + + let mergeBase; + const rebaseHead = getSettledValue(rebaseHeadResult); + if (rebaseHead != null) { + mergeBase = await this.getMergeBase(repoPath, rebaseHead, 'HEAD'); + } else { + mergeBase = await this.getMergeBase(repoPath, onto, origHead); + } + + if (branch.startsWith('refs/heads/')) { + branch = branch.substring(11).trim(); + } + + const [branchTipsResult, tagTipsResult] = await Promise.allSettled([ + this.getCommitBranches(repoPath, [onto], undefined, { all: true, mode: 'pointsAt' }), + this.getCommitTags(repoPath, onto, { mode: 'pointsAt' }), + ]); + + const branchTips = getSettledValue(branchTipsResult); + const tagTips = getSettledValue(tagTipsResult); + + let ontoRef: GitBranchReference | GitTagReference | undefined; + if (branchTips != null) { + for (const ref of branchTips) { + if (ref.startsWith('(no branch, rebasing')) continue; + + ontoRef = createReference(ref, repoPath, { + refType: 'branch', + name: ref, + remote: false, + }); + break; + } + } + if (ontoRef == null && tagTips != null) { + for (const ref of tagTips) { + if (ref.startsWith('(no branch, rebasing')) continue; + + ontoRef = createReference(ref, repoPath, { + refType: 'tag', + name: ref, + }); + break; + } + } + + return { + type: 'rebase', + repoPath: repoPath, + mergeBase: mergeBase, + HEAD: createReference(rebaseHead ?? origHead, repoPath, { refType: 'revision' }), + onto: createReference(onto, repoPath, { refType: 'revision' }), + current: ontoRef, + incoming: createReference(branch, repoPath, { + refType: 'branch', + name: branch, + remote: false, + }), + steps: { + current: { + number: getSettledValue(stepsNumberResult) ?? 0, + commit: + rebaseHead != null + ? createReference(rebaseHead, repoPath, { + refType: 'revision', + message: getSettledValue(stepsMessageResult), + }) + : undefined, + }, + total: getSettledValue(stepsTotalResult) ?? 0, + }, + } satisfies GitRebaseStatus; + } + } + } + + status = getCore.call(this); + if (cache != null) { + cache.set(repoPath, status); + } + } + + return status; +} + +export async function abortPausedOperation( + this: LocalGitProvider, + repoPath: string, + options?: { quit?: boolean }, +): Promise { + const status = await this.getPausedOperationStatus(repoPath); + if (status == null) return; + + try { + switch (status.type) { + case 'cherry-pick': + await this.git.exec( + { cwd: repoPath, errors: GitErrorHandling.Throw }, + 'cherry-pick', + options?.quit ? '--quit' : '--abort', + ); + break; + + case 'merge': + await this.git.exec( + { cwd: repoPath, errors: GitErrorHandling.Throw }, + 'merge', + options?.quit ? '--quit' : '--abort', + ); + break; + + case 'rebase': + await this.git.exec( + { cwd: repoPath, errors: GitErrorHandling.Throw }, + 'rebase', + options?.quit ? '--quit' : '--abort', + ); + break; + + case 'revert': + await this.git.exec( + { cwd: repoPath, errors: GitErrorHandling.Throw }, + 'revert', + options?.quit ? '--quit' : '--abort', + ); + break; + } + } catch (ex) { + debugger; + Logger.error(ex); + const msg: string = ex?.toString() ?? ''; + if (GitErrors.noPausedOperation.test(msg)) { + throw new PausedOperationAbortError( + PausedOperationAbortErrorReason.NothingToAbort, + status.type, + `Cannot abort as there is no ${status.type} operation in progress`, + ex, + ); + } + + throw new PausedOperationAbortError(undefined, status.type, `Cannot abort ${status.type}; ${msg}`, ex); + } +} + +export async function continuePausedOperation( + this: LocalGitProvider, + repoPath: string, + options?: { skip?: boolean }, +): Promise { + const status = await this.getPausedOperationStatus(repoPath); + if (status == null) return; + + try { + switch (status.type) { + case 'cherry-pick': + await this.git.exec( + { cwd: repoPath, errors: GitErrorHandling.Throw }, + 'cherry-pick', + options?.skip ? '--skip' : '--continue', + ); + break; + + case 'merge': + if (options?.skip) throw new Error('Skipping a merge is not supported'); + await this.git.exec({ cwd: repoPath, errors: GitErrorHandling.Throw }, 'merge', '--continue'); + break; + + case 'rebase': + await this.git.exec( + { cwd: repoPath, errors: GitErrorHandling.Throw }, + 'rebase', + options?.skip ? '--skip' : '--continue', + ); + break; + + case 'revert': + await this.git.exec( + { cwd: repoPath, errors: GitErrorHandling.Throw }, + 'revert', + options?.skip ? '--skip' : '--abort', + ); + break; + } + } catch (ex) { + debugger; + Logger.error(ex); + + const msg: string = ex?.toString() ?? ''; + if (GitErrors.noPausedOperation.test(msg)) { + throw new PausedOperationContinueError( + PausedOperationContinueErrorReason.NothingToContinue, + status.type, + `Cannot ${options?.skip ? 'skip' : 'continue'} as there is no ${status.type} operation in progress`, + ex, + ); + } + + if (GitErrors.uncommittedChanges.test(msg)) { + throw new PausedOperationContinueError( + PausedOperationContinueErrorReason.UncommittedChanges, + status.type, + `Cannot ${options?.skip ? 'skip' : `continue ${status.type}`} as there are uncommitted changes`, + ex, + ); + } + + if (GitErrors.unmergedFiles.test(msg)) { + throw new PausedOperationContinueError( + PausedOperationContinueErrorReason.UnmergedFiles, + status.type, + `Cannot ${options?.skip ? 'skip' : `continue ${status.type}`} as there are unmerged files`, + ex, + ); + } + + if (GitErrors.unresolvedConflicts.test(msg)) { + throw new PausedOperationContinueError( + PausedOperationContinueErrorReason.UnresolvedConflicts, + status.type, + `Cannot ${options?.skip ? 'skip' : `continue ${status.type}`} as there are unresolved conflicts`, + ex, + ); + } + + if (GitErrors.unstagedChanges.test(msg)) { + throw new PausedOperationContinueError( + PausedOperationContinueErrorReason.UnstagedChanges, + status.type, + `Cannot ${options?.skip ? 'skip' : `continue ${status.type}`} as there are unstaged changes`, + ex, + ); + } + + if (GitErrors.changesWouldBeOverwritten.test(msg)) { + throw new PausedOperationContinueError( + PausedOperationContinueErrorReason.WouldOverwrite, + status.type, + `Cannot ${options?.skip ? 'skip' : `continue ${status.type}`} as local changes would be overwritten`, + ex, + ); + } + + throw new PausedOperationContinueError( + undefined, + status.type, + `Cannot ${options?.skip ? 'skip' : `continue ${status.type}`}; ${msg}`, + ex, + ); + } +} diff --git a/src/git/errors.ts b/src/git/errors.ts index e1ef081fdfb25..e29c48b11ce8d 100644 --- a/src/git/errors.ts +++ b/src/git/errors.ts @@ -1,3 +1,5 @@ +import type { GitPausedOperation } from './models/pausedOperationStatus'; + export class GitSearchError extends Error { constructor(public readonly original: Error) { super(original.message); @@ -567,3 +569,66 @@ export class TagError extends Error { return this; } } + +export const enum PausedOperationAbortErrorReason { + NothingToAbort, +} + +export class PausedOperationAbortError extends Error { + static is(ex: unknown, reason?: PausedOperationAbortErrorReason): ex is PausedOperationAbortError { + return ex instanceof PausedOperationAbortError && (reason == null || ex.reason === reason); + } + + readonly original?: Error; + readonly reason: PausedOperationAbortErrorReason | undefined; + readonly operation: GitPausedOperation; + + constructor( + reason: PausedOperationAbortErrorReason | undefined, + operation: GitPausedOperation, + message?: string, + original?: Error, + ) { + message ||= 'Unable to abort operation'; + super(message); + + this.original = original; + this.reason = reason; + this.operation = operation; + Error.captureStackTrace?.(this, PausedOperationAbortError); + } +} + +export const enum PausedOperationContinueErrorReason { + NothingToContinue, + UnmergedFiles, + UncommittedChanges, + UnstagedChanges, + UnresolvedConflicts, + WouldOverwrite, +} + +export class PausedOperationContinueError extends Error { + static is(ex: unknown, reason?: PausedOperationContinueErrorReason): ex is PausedOperationContinueError { + return ex instanceof PausedOperationContinueError && (reason == null || ex.reason === reason); + } + + readonly original?: Error; + readonly reason: PausedOperationContinueErrorReason | undefined; + readonly operation: GitPausedOperation; + + constructor( + reason: PausedOperationContinueErrorReason | undefined, + operation: GitPausedOperation, + message?: string, + original?: Error, + ) { + message ||= 'Unable to continue operation'; + super(message); + + this.original = original; + this.reason = reason; + this.operation = operation; + Error.captureStackTrace?.(this, PausedOperationContinueError); + } +} diff --git a/src/git/formatters/commitFormatter.ts b/src/git/formatters/commitFormatter.ts index ce8994ee8567e..69c5198f865ab 100644 --- a/src/git/formatters/commitFormatter.ts +++ b/src/git/formatters/commitFormatter.ts @@ -40,7 +40,7 @@ import { getHighlanderProviders } from '../models/remote'; import { uncommitted, uncommittedStaged } from '../models/revision'; import { isUncommittedStaged, shortenRevision } from '../models/revision.utils'; import type { RemoteProvider } from '../remotes/remoteProvider'; -import { getIssueOrPullRequestMarkdownIcon } from '../utils/icons'; +import { getIssueOrPullRequestMarkdownIcon } from '../utils/vscode/icons'; import type { FormatOptions, RequiredTokenOptions } from './formatter'; import { Formatter } from './formatter'; diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index d8832fe92a37e..c09faaaf3e63a 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -13,9 +13,8 @@ import type { GitDiff, GitDiffFile, GitDiffFiles, GitDiffFilter, GitDiffLine, Gi import type { GitFile, GitFileChange } from './models/file'; import type { GitGraph } from './models/graph'; import type { GitLog } from './models/log'; -import type { GitMergeStatus } from './models/merge'; import type { MergeConflict } from './models/mergeConflict'; -import type { GitRebaseStatus } from './models/rebase'; +import type { GitPausedOperationStatus } from './models/pausedOperationStatus'; import type { GitBranchReference, GitReference } from './models/reference'; import type { GitReflog } from './models/reflog'; import type { GitRemote } from './models/remote'; @@ -28,7 +27,7 @@ import type { GitTreeEntry } from './models/tree'; import type { GitUser } from './models/user'; import type { GitWorktree } from './models/worktree'; import type { GitSearch } from './search'; -import type { BranchSortOptions, TagSortOptions } from './utils/sorting'; +import type { BranchSortOptions, TagSortOptions } from './utils/vscode/sorting'; export type GitCaches = | 'branches' @@ -329,8 +328,6 @@ export interface GitProviderRepository { ref2: string, options?: { forkPoint?: boolean | undefined }, ): Promise; - getMergeStatus(repoPath: string): Promise; - getRebaseStatus(repoPath: string): Promise; getNextComparisonUris( repoPath: string, uri: Uri, @@ -338,6 +335,9 @@ export interface GitProviderRepository { skip?: number, ): Promise; getOldestUnpushedRefForFile(repoPath: string, uri: Uri): Promise; + getPausedOperationStatus?(repoPath: string): Promise; + abortPausedOperation?(repoPath: string, options?: { quit?: boolean }): Promise; + continuePausedOperation?(repoPath: string, options?: { skip?: boolean }): Promise; getPreviousComparisonUris( repoPath: string, uri: Uri, diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index 011cb0ff1b91a..7df3a15ece7c4 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -69,9 +69,8 @@ import type { GitDiff, GitDiffFile, GitDiffFiles, GitDiffFilter, GitDiffLine, Gi import type { GitFile, GitFileChange } from './models/file'; import type { GitGraph } from './models/graph'; import type { GitLog } from './models/log'; -import type { GitMergeStatus } from './models/merge'; import type { MergeConflict } from './models/mergeConflict'; -import type { GitRebaseStatus } from './models/rebase'; +import type { GitPausedOperationStatus } from './models/pausedOperationStatus'; import type { GitBranchReference, GitReference } from './models/reference'; import type { GitReflog } from './models/reflog'; import type { GitRemote } from './models/remote'; @@ -89,8 +88,8 @@ import type { GitUser } from './models/user'; import type { GitWorktree } from './models/worktree'; import type { RemoteProvider } from './remotes/remoteProvider'; import type { GitSearch } from './search'; -import type { BranchSortOptions, TagSortOptions } from './utils/sorting'; -import { sortRepositories } from './utils/sorting'; +import type { BranchSortOptions, TagSortOptions } from './utils/vscode/sorting'; +import { sortRepositories } from './utils/vscode/sorting'; const emptyArray = Object.freeze([]) as unknown as any[]; const emptyDisposable = Object.freeze({ @@ -2152,20 +2151,6 @@ export class GitProviderService implements Disposable { return provider.getMergeBase(path, ref1, ref2, options); } - @gate() - @log() - async getMergeStatus(repoPath: string | Uri): Promise { - const { provider, path } = this.getProvider(repoPath); - return provider.getMergeStatus(path); - } - - @gate() - @log() - async getRebaseStatus(repoPath: string | Uri): Promise { - const { provider, path } = this.getProvider(repoPath); - return provider.getRebaseStatus(path); - } - @log() getNextComparisonUris( repoPath: string | Uri, @@ -2185,6 +2170,27 @@ export class GitProviderService implements Disposable { return provider.getOldestUnpushedRefForFile(path, uri); } + @gate() + @log() + async getPausedOperationStatus(repoPath: string | Uri): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.getPausedOperationStatus?.(path); + } + + @gate() + @log() + async abortPausedOperation(repoPath: string, options?: { quit?: boolean }): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.abortPausedOperation?.(path, options); + } + + @gate() + @log() + async continuePausedOperation(repoPath: string, options?: { skip?: boolean }): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.continuePausedOperation?.(path, options); + } + @log() getPreviousComparisonUris( repoPath: string | Uri, diff --git a/src/git/models/merge.ts b/src/git/models/merge.ts deleted file mode 100644 index 65d7907663d06..0000000000000 --- a/src/git/models/merge.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { GitBranchReference, GitRevisionReference } from './reference'; - -export interface GitMergeStatus { - type: 'merge'; - repoPath: string; - HEAD: GitRevisionReference; - mergeBase: string | undefined; - current: GitBranchReference; - incoming: GitBranchReference | undefined; -} diff --git a/src/git/models/pausedOperationStatus.ts b/src/git/models/pausedOperationStatus.ts new file mode 100644 index 0000000000000..16daac8a7369e --- /dev/null +++ b/src/git/models/pausedOperationStatus.ts @@ -0,0 +1,54 @@ +import type { GitBranchReference, GitRevisionReference, GitTagReference } from './reference'; + +export type GitPausedOperationStatus = GitCherryPickStatus | GitMergeStatus | GitRebaseStatus | GitRevertStatus; +export type GitPausedOperation = + | GitCherryPickStatus['type'] + | GitMergeStatus['type'] + | GitRebaseStatus['type'] + | GitRevertStatus['type']; + +export interface GitCherryPickStatus { + type: 'cherry-pick'; + repoPath: string; + HEAD: GitRevisionReference; + current: GitBranchReference; + incoming: GitRevisionReference; + + mergeBase?: never; +} + +export interface GitMergeStatus { + type: 'merge'; + repoPath: string; + HEAD: GitRevisionReference; + current: GitBranchReference; + incoming: GitBranchReference | GitRevisionReference; + + mergeBase: string | undefined; +} + +export interface GitRebaseStatus { + type: 'rebase'; + repoPath: string; + HEAD: GitRevisionReference; + current: GitBranchReference | GitTagReference | undefined; + incoming: GitBranchReference | GitRevisionReference; + + mergeBase: string | undefined; + onto: GitRevisionReference; + + steps: { + current: { number: number; commit: GitRevisionReference | undefined }; + total: number; + }; +} + +export interface GitRevertStatus { + type: 'revert'; + repoPath: string; + HEAD: GitRevisionReference; + current: GitBranchReference; + incoming: GitRevisionReference; + + mergeBase?: never; +} diff --git a/src/git/models/rebase.ts b/src/git/models/rebase.ts deleted file mode 100644 index ccb8e59bb0d01..0000000000000 --- a/src/git/models/rebase.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { GitBranchReference, GitRevisionReference, GitTagReference } from './reference'; - -export interface GitRebaseStatus { - type: 'rebase'; - repoPath: string; - HEAD: GitRevisionReference; - onto: GitRevisionReference; - mergeBase: string | undefined; - current: GitBranchReference | GitTagReference | undefined; - incoming: GitBranchReference; - - steps: { - current: { number: number; commit: GitRevisionReference | undefined }; - total: number; - }; -} diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index b93dc7eff0322..b57da0a9647d6 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -95,11 +95,12 @@ export const enum RepositoryChange { Remotes = 5, Worktrees = 6, Config = 7, - /** Union of Cherry, Merge, and Rebase */ - Status = 8, + /** Effectively a union of Cherry, Merge, Rebase, and Revert */ + PausedOperationStatus = 8, CherryPick = 9, Merge = 10, Rebase = 11, + Revert = 12, // No file watching required Closed = 100, @@ -147,16 +148,18 @@ export class RepositoryChangeEvent { if ( affected.includes(RepositoryChange.CherryPick) || affected.includes(RepositoryChange.Merge) || - affected.includes(RepositoryChange.Rebase) + affected.includes(RepositoryChange.Rebase) || + affected.includes(RepositoryChange.Revert) ) { - if (!affected.includes(RepositoryChange.Status)) { - affected.push(RepositoryChange.Status); + if (!affected.includes(RepositoryChange.PausedOperationStatus)) { + affected.push(RepositoryChange.PausedOperationStatus); } - } else if (affected.includes(RepositoryChange.Status)) { + } else if (affected.includes(RepositoryChange.PausedOperationStatus)) { changes = new Set(changes); changes.delete(RepositoryChange.CherryPick); changes.delete(RepositoryChange.Merge); changes.delete(RepositoryChange.Rebase); + changes.delete(RepositoryChange.Revert); } } @@ -486,7 +489,7 @@ export class Repository implements Disposable { const match = uri != null ? // Move worktrees first, since if it is in a worktree it isn't affecting this repo directly - /(worktrees|index|HEAD|FETCH_HEAD|ORIG_HEAD|CHERRY_PICK_HEAD|MERGE_HEAD|REBASE_HEAD|rebase-merge|config|refs\/(?:heads|remotes|stash|tags))/.exec( + /(worktrees|index|HEAD|FETCH_HEAD|ORIG_HEAD|CHERRY_PICK_HEAD|MERGE_HEAD|REBASE_HEAD|rebase-merge|REVERT_HEAD|config|refs\/(?:heads|remotes|stash|tags))/.exec( this.container.git.getRelativePath(uri, base), ) : undefined; @@ -514,16 +517,20 @@ export class Repository implements Disposable { return; case 'CHERRY_PICK_HEAD': - this.fireChange(RepositoryChange.CherryPick, RepositoryChange.Status); + this.fireChange(RepositoryChange.CherryPick, RepositoryChange.PausedOperationStatus); return; case 'MERGE_HEAD': - this.fireChange(RepositoryChange.Merge, RepositoryChange.Status); + this.fireChange(RepositoryChange.Merge, RepositoryChange.PausedOperationStatus); return; case 'REBASE_HEAD': case 'rebase-merge': - this.fireChange(RepositoryChange.Rebase, RepositoryChange.Status); + this.fireChange(RepositoryChange.Rebase, RepositoryChange.PausedOperationStatus); + return; + + case 'REVERT_HEAD': + this.fireChange(RepositoryChange.Revert, RepositoryChange.PausedOperationStatus); return; case 'refs/heads': diff --git a/src/git/models/worktree.quickpick.ts b/src/git/models/worktree.quickpick.ts index d2aaf0b2f300c..5ef097419629b 100644 --- a/src/git/models/worktree.quickpick.ts +++ b/src/git/models/worktree.quickpick.ts @@ -4,7 +4,7 @@ import { GlyphChars } from '../../constants'; import { Container } from '../../container'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { pad } from '../../system/string'; -import { getBranchIconPath } from '../utils/icons'; +import { getBranchIconPath } from '../utils/vscode/icons'; import { shortenRevision } from './revision.utils'; import type { GitStatus } from './status'; import type { GitWorktree } from './worktree'; diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index 4d8ba770e58b0..b841c3bbc8fcb 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -12,7 +12,7 @@ import { escapeMarkdown, unescapeMarkdown } from '../../system/markdown'; import { equalsIgnoreCase } from '../../system/string'; import type { Repository } from '../models/repository'; import { isSha } from '../models/revision.utils'; -import { getIssueOrPullRequestMarkdownIcon } from '../utils/icons'; +import { getIssueOrPullRequestMarkdownIcon } from '../utils/vscode/icons'; import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; diff --git a/src/git/remotes/gitlab.ts b/src/git/remotes/gitlab.ts index 9ef36a00387e1..4c9f862f8ee00 100644 --- a/src/git/remotes/gitlab.ts +++ b/src/git/remotes/gitlab.ts @@ -11,7 +11,7 @@ import { escapeMarkdown, unescapeMarkdown } from '../../system/markdown'; import { equalsIgnoreCase } from '../../system/string'; import type { Repository } from '../models/repository'; import { isSha } from '../models/revision.utils'; -import { getIssueOrPullRequestMarkdownIcon } from '../utils/icons'; +import { getIssueOrPullRequestMarkdownIcon } from '../utils/vscode/icons'; import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; diff --git a/src/git/utils/pausedOperationStatus.utils.ts b/src/git/utils/pausedOperationStatus.utils.ts new file mode 100644 index 0000000000000..3db3f758899ff --- /dev/null +++ b/src/git/utils/pausedOperationStatus.utils.ts @@ -0,0 +1,23 @@ +export const pausedOperationStatusStringsByType = { + 'cherry-pick': { + label: 'Cherry picking', + conflicts: 'Resolve conflicts to continue cherry picking', + directionality: 'into', + }, + merge: { + label: 'Merging', + conflicts: 'Resolve conflicts to continue merging', + directionality: 'into', + }, + rebase: { + label: 'Rebasing', + conflicts: 'Resolve conflicts to continue rebasing', + directionality: 'onto', + pending: 'Pending rebase of', + }, + revert: { + label: 'Reverting', + conflicts: 'Resolve conflicts to continue reverting', + directionality: 'in', + }, +} as const; diff --git a/src/git/utils/icons.ts b/src/git/utils/vscode/icons.ts similarity index 90% rename from src/git/utils/icons.ts rename to src/git/utils/vscode/icons.ts index 1c50f9ea74491..bb05d4a0b78b3 100644 --- a/src/git/utils/icons.ts +++ b/src/git/utils/vscode/icons.ts @@ -1,16 +1,16 @@ import type { ColorTheme } from 'vscode'; import { ColorThemeKind, ThemeColor, ThemeIcon, Uri, window } from 'vscode'; -import type { IconPath } from '../../@types/vscode.iconpath'; -import type { Colors } from '../../constants.colors'; -import type { Container } from '../../container'; -import { isLightTheme } from '../../system/vscode/utils'; -import { getIconPathUris } from '../../system/vscode/vscode'; -import type { GitBranch } from '../models/branch'; -import type { IssueOrPullRequest } from '../models/issue'; -import type { GitRemote } from '../models/remote'; -import { getRemoteThemeIconString } from '../models/remote'; -import type { Repository } from '../models/repository'; -import type { GitStatus } from '../models/status'; +import type { IconPath } from '../../../@types/vscode.iconpath'; +import type { Colors } from '../../../constants.colors'; +import type { Container } from '../../../container'; +import { isLightTheme } from '../../../system/vscode/utils'; +import { getIconPathUris } from '../../../system/vscode/vscode'; +import type { GitBranch } from '../../models/branch'; +import type { IssueOrPullRequest } from '../../models/issue'; +import type { GitRemote } from '../../models/remote'; +import { getRemoteThemeIconString } from '../../models/remote'; +import type { Repository } from '../../models/repository'; +import type { GitStatus } from '../../models/status'; export function getBranchIconPath(container: Container, branch: GitBranch | undefined): IconPath { switch (branch?.status) { diff --git a/src/git/utils/sorting.ts b/src/git/utils/vscode/sorting.ts similarity index 94% rename from src/git/utils/sorting.ts rename to src/git/utils/vscode/sorting.ts index bad9589fc8929..eb5e2a4a67683 100644 --- a/src/git/utils/sorting.ts +++ b/src/git/utils/vscode/sorting.ts @@ -1,15 +1,15 @@ -import type { BranchSorting, ContributorSorting, RepositoriesSorting, TagSorting } from '../../config'; -import { sortCompare } from '../../system/string'; -import { configuration } from '../../system/vscode/configuration'; -import type { GitBranch } from '../models/branch'; -import type { GitContributor } from '../models/contributor'; -import { isContributor } from '../models/contributor'; -import type { ContributorQuickPickItem } from '../models/contributor.quickpick'; -import type { Repository } from '../models/repository'; -import type { GitTag } from '../models/tag'; -import type { GitWorktree } from '../models/worktree'; -import { isWorktree } from '../models/worktree'; -import type { WorktreeQuickPickItem } from '../models/worktree.quickpick'; +import type { BranchSorting, ContributorSorting, RepositoriesSorting, TagSorting } from '../../../config'; +import { sortCompare } from '../../../system/string'; +import { configuration } from '../../../system/vscode/configuration'; +import type { GitBranch } from '../../models/branch'; +import type { GitContributor } from '../../models/contributor'; +import { isContributor } from '../../models/contributor'; +import type { ContributorQuickPickItem } from '../../models/contributor.quickpick'; +import type { Repository } from '../../models/repository'; +import type { GitTag } from '../../models/tag'; +import type { GitWorktree } from '../../models/worktree'; +import { isWorktree } from '../../models/worktree'; +import type { WorktreeQuickPickItem } from '../../models/worktree.quickpick'; export interface BranchSortOptions { current?: boolean; diff --git a/src/plus/integrations/providers/github/githubGitProvider.ts b/src/plus/integrations/providers/github/githubGitProvider.ts index 097a6a02166e2..283c68a17ac76 100644 --- a/src/plus/integrations/providers/github/githubGitProvider.ts +++ b/src/plus/integrations/providers/github/githubGitProvider.ts @@ -62,8 +62,6 @@ import type { GitGraphRowTag, } from '../../../../git/models/graph'; import type { GitLog } from '../../../../git/models/log'; -import type { GitMergeStatus } from '../../../../git/models/merge'; -import type { GitRebaseStatus } from '../../../../git/models/rebase'; import type { GitReference } from '../../../../git/models/reference'; import { createReference } from '../../../../git/models/reference.utils'; import type { GitReflog } from '../../../../git/models/reflog'; @@ -90,9 +88,9 @@ import type { GitWorktree } from '../../../../git/models/worktree'; import { getRemoteProviderMatcher, loadRemoteProviders } from '../../../../git/remotes/remoteProviders'; import type { GitSearch, GitSearchResultData, GitSearchResults } from '../../../../git/search'; import { getSearchQueryComparisonKey, parseSearchQuery } from '../../../../git/search'; -import { getRemoteIconUri } from '../../../../git/utils/icons'; -import type { BranchSortOptions, TagSortOptions } from '../../../../git/utils/sorting'; -import { sortBranches, sortTags } from '../../../../git/utils/sorting'; +import { getRemoteIconUri } from '../../../../git/utils/vscode/icons'; +import type { BranchSortOptions, TagSortOptions } from '../../../../git/utils/vscode/sorting'; +import { sortBranches, sortTags } from '../../../../git/utils/vscode/sorting'; import { gate } from '../../../../system/decorators/gate'; import { debug, log } from '../../../../system/decorators/log'; import { filterMap, first, last, map, some, union } from '../../../../system/iterable'; @@ -695,7 +693,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { debugger; // Trap and cache expected blame errors if (document.state != null && !String(ex).includes('No provider registered with')) { - const msg = ex?.toString() ?? ''; + const msg: string = ex?.toString() ?? ''; Logger.debug(scope, `Cache replace (with empty promise): '${key}'`); const value: CachedBlame = { @@ -2561,18 +2559,6 @@ export class GitHubGitProvider implements GitProvider, Disposable { } } - // @gate() - @log() - async getMergeStatus(_repoPath: string): Promise { - return undefined; - } - - // @gate() - @log() - async getRebaseStatus(_repoPath: string): Promise { - return undefined; - } - @log() async getNextComparisonUris( repoPath: string, diff --git a/src/quickpicks/contributorsPicker.ts b/src/quickpicks/contributorsPicker.ts index 2b09ebce2fd03..f2ac0c9f1a23e 100644 --- a/src/quickpicks/contributorsPicker.ts +++ b/src/quickpicks/contributorsPicker.ts @@ -7,7 +7,7 @@ import type { GitContributor } from '../git/models/contributor'; import type { ContributorQuickPickItem } from '../git/models/contributor.quickpick'; import { createContributorQuickPickItem } from '../git/models/contributor.quickpick'; import type { Repository } from '../git/models/repository'; -import { sortContributors } from '../git/utils/sorting'; +import { sortContributors } from '../git/utils/vscode/sorting'; import { debounce } from '../system/function'; import { defer } from '../system/promise'; import { pad, truncate } from '../system/string'; diff --git a/src/quickpicks/items/gitWizard.ts b/src/quickpicks/items/gitWizard.ts index 666b2e66f78ea..78141ca20bce1 100644 --- a/src/quickpicks/items/gitWizard.ts +++ b/src/quickpicks/items/gitWizard.ts @@ -16,7 +16,7 @@ import { getRemoteUpstreamDescription } from '../../git/models/remote'; import type { Repository } from '../../git/models/repository'; import { isRevisionRange, shortenRevision } from '../../git/models/revision.utils'; import type { GitTag } from '../../git/models/tag'; -import { getBranchIconPath, getWorktreeBranchIconPath } from '../../git/utils/icons'; +import { getBranchIconPath, getWorktreeBranchIconPath } from '../../git/utils/vscode/icons'; import { fromNow } from '../../system/date'; import { pad } from '../../system/string'; import { configuration } from '../../system/vscode/configuration'; diff --git a/src/quickpicks/referencePicker.ts b/src/quickpicks/referencePicker.ts index a0133e8a244ba..751822c3b2350 100644 --- a/src/quickpicks/referencePicker.ts +++ b/src/quickpicks/referencePicker.ts @@ -11,7 +11,7 @@ import type { GitBranch } from '../git/models/branch'; import type { GitReference } from '../git/models/reference'; import { isBranchReference, isRevisionReference, isTagReference } from '../git/models/reference.utils'; import type { GitTag } from '../git/models/tag'; -import type { BranchSortOptions, TagSortOptions } from '../git/utils/sorting'; +import type { BranchSortOptions, TagSortOptions } from '../git/utils/vscode/sorting'; import type { KeyboardScope } from '../system/vscode/keyboard'; import { getQuickPickIgnoreFocusOut } from '../system/vscode/utils'; import type { BranchQuickPickItem, RefQuickPickItem, TagQuickPickItem } from './items/gitWizard'; diff --git a/src/trackers/documentTracker.ts b/src/trackers/documentTracker.ts index 1eefa40bbeecc..eac0ce1924ba4 100644 --- a/src/trackers/documentTracker.ts +++ b/src/trackers/documentTracker.ts @@ -167,7 +167,7 @@ export class GitDocumentTracker implements Disposable { e.changed( RepositoryChange.Index, RepositoryChange.Heads, - RepositoryChange.Status, + RepositoryChange.PausedOperationStatus, RepositoryChange.Unknown, RepositoryChangeComparisonMode.Any, ) diff --git a/src/views/branchesView.ts b/src/views/branchesView.ts index 9146fc9ad9ecd..7adaa363501a7 100644 --- a/src/views/branchesView.ts +++ b/src/views/branchesView.ts @@ -44,7 +44,7 @@ export class BranchesRepositoryNode extends RepositoryFolderNode range @@ -266,36 +263,20 @@ export class BranchNode const children = []; const status = getSettledValue(statusResult); - const mergeStatus = getSettledValue(mergeStatusResult); - const rebaseStatus = getSettledValue(rebaseStatusResult); + const pausedOpsStatus = getSettledValue(pausedOpStatusResult); const unpublishedCommits = getSettledValue(unpublishedCommitsResult); if (pullRequest != null) { children.push(new PullRequestNode(this.view, this, pullRequest, branch)); } - if (this.options.showStatus && mergeStatus != null) { + if (pausedOpsStatus != null) { children.push( - new MergeStatusNode( + new PausedOperationStatusNode( this.view, this, branch, - mergeStatus, - status ?? (await this.view.container.git.getStatus(this.uri.repoPath)), - this.root, - ), - ); - } else if ( - this.options.showStatus && - rebaseStatus != null && - (branch.current || branch.name === rebaseStatus.incoming.name) - ) { - children.push( - new RebaseStatusNode( - this.view, - this, - branch, - rebaseStatus, + pausedOpsStatus, status ?? (await this.view.container.git.getStatus(this.uri.repoPath)), this.root, ), diff --git a/src/views/nodes/contributorsNode.ts b/src/views/nodes/contributorsNode.ts index 244d66494d600..6a5aba351b36b 100644 --- a/src/views/nodes/contributorsNode.ts +++ b/src/views/nodes/contributorsNode.ts @@ -2,7 +2,7 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import type { GitUri } from '../../git/gitUri'; import type { GitContributor } from '../../git/models/contributor'; import type { Repository } from '../../git/models/repository'; -import { sortContributors } from '../../git/utils/sorting'; +import { sortContributors } from '../../git/utils/vscode/sorting'; import { debug } from '../../system/decorators/log'; import { configuration } from '../../system/vscode/configuration'; import type { ViewsWithContributorsNode } from '../viewBase'; diff --git a/src/views/nodes/fileHistoryNode.ts b/src/views/nodes/fileHistoryNode.ts index 89ebad6d8cafa..2309cf690aab6 100644 --- a/src/views/nodes/fileHistoryNode.ts +++ b/src/views/nodes/fileHistoryNode.ts @@ -213,7 +213,7 @@ export class FileHistoryNode RepositoryChange.Heads, RepositoryChange.Remotes, RepositoryChange.RemoteProviders, - RepositoryChange.Status, + RepositoryChange.PausedOperationStatus, RepositoryChange.Unknown, RepositoryChangeComparisonMode.Any, ) diff --git a/src/views/nodes/fileRevisionAsCommitNode.ts b/src/views/nodes/fileRevisionAsCommitNode.ts index 1b17d09f9cc96..9774e669bc060 100644 --- a/src/views/nodes/fileRevisionAsCommitNode.ts +++ b/src/views/nodes/fileRevisionAsCommitNode.ts @@ -59,18 +59,12 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode< async getChildren(): Promise { if (!this.commit.file?.hasConflicts) return []; - const [mergeStatusResult, rebaseStatusResult] = await Promise.allSettled([ - this.view.container.git.getMergeStatus(this.commit.repoPath), - this.view.container.git.getRebaseStatus(this.commit.repoPath), - ]); - - const mergeStatus = getSettledValue(mergeStatusResult); - const rebaseStatus = getSettledValue(rebaseStatusResult); - if (mergeStatus == null && rebaseStatus == null) return []; + const pausedOpStatus = await this.view.container.git.getPausedOperationStatus(this.commit.repoPath); + if (pausedOpStatus == null) return []; return [ - new MergeConflictCurrentChangesNode(this.view, this, (mergeStatus ?? rebaseStatus)!, this.file), - new MergeConflictIncomingChangesNode(this.view, this, (mergeStatus ?? rebaseStatus)!, this.file), + new MergeConflictCurrentChangesNode(this.view, this, pausedOpStatus, this.file), + new MergeConflictIncomingChangesNode(this.view, this, pausedOpStatus, this.file), ]; } diff --git a/src/views/nodes/lineHistoryNode.ts b/src/views/nodes/lineHistoryNode.ts index 20ecf32d6f2f0..011ff31d91f7e 100644 --- a/src/views/nodes/lineHistoryNode.ts +++ b/src/views/nodes/lineHistoryNode.ts @@ -217,7 +217,7 @@ export class LineHistoryNode RepositoryChange.Heads, RepositoryChange.Remotes, RepositoryChange.RemoteProviders, - RepositoryChange.Status, + RepositoryChange.PausedOperationStatus, RepositoryChange.Unknown, RepositoryChangeComparisonMode.Any, ) diff --git a/src/views/nodes/mergeConflictCurrentChangesNode.ts b/src/views/nodes/mergeConflictCurrentChangesNode.ts index 1192838c9a3f1..4bc5a33b5ff73 100644 --- a/src/views/nodes/mergeConflictCurrentChangesNode.ts +++ b/src/views/nodes/mergeConflictCurrentChangesNode.ts @@ -6,8 +6,7 @@ import { GlCommand } from '../../constants.commands'; import { GitUri } from '../../git/gitUri'; import type { GitCommit } from '../../git/models/commit'; import type { GitFile } from '../../git/models/file'; -import type { GitMergeStatus } from '../../git/models/merge'; -import type { GitRebaseStatus } from '../../git/models/rebase'; +import type { GitPausedOperationStatus } from '../../git/models/pausedOperationStatus'; import { getReferenceLabel } from '../../git/models/reference.utils'; import { createCommand, createCoreCommand } from '../../system/vscode/command'; import { configuration } from '../../system/vscode/configuration'; @@ -24,7 +23,7 @@ export class MergeConflictCurrentChangesNode extends ViewNode< constructor( view: ViewsWithCommits | FileHistoryView | LineHistoryView, protected override readonly parent: ViewNode, - private readonly status: GitMergeStatus | GitRebaseStatus, + private readonly status: GitPausedOperationStatus, private readonly file: GitFile, ) { super('conflict-current-changes', GitUri.fromFile(file, status.repoPath, 'HEAD'), view, parent); diff --git a/src/views/nodes/mergeConflictFileNode.ts b/src/views/nodes/mergeConflictFileNode.ts index 3951d97a8bdd0..a818a86c13dd3 100644 --- a/src/views/nodes/mergeConflictFileNode.ts +++ b/src/views/nodes/mergeConflictFileNode.ts @@ -3,8 +3,7 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { StatusFileFormatter } from '../../git/formatters/statusFormatter'; import { GitUri } from '../../git/gitUri'; import type { GitFile } from '../../git/models/file'; -import type { GitMergeStatus } from '../../git/models/merge'; -import type { GitRebaseStatus } from '../../git/models/rebase'; +import type { GitPausedOperationStatus } from '../../git/models/pausedOperationStatus'; import { createCoreCommand } from '../../system/vscode/command'; import { relativeDir } from '../../system/vscode/path'; import type { ViewsWithCommits } from '../viewBase'; @@ -20,7 +19,7 @@ export class MergeConflictFileNode extends ViewFileNode<'conflict-file', ViewsWi view: ViewsWithCommits, parent: ViewNode, file: GitFile, - public readonly status: GitMergeStatus | GitRebaseStatus, + public readonly status: GitPausedOperationStatus, ) { super('conflict-file', GitUri.fromFile(file, status.repoPath, status.HEAD.ref), view, parent, file); } diff --git a/src/views/nodes/mergeConflictFilesNode.ts b/src/views/nodes/mergeConflictFilesNode.ts index f603bbc9a642f..480712f342470 100644 --- a/src/views/nodes/mergeConflictFilesNode.ts +++ b/src/views/nodes/mergeConflictFilesNode.ts @@ -1,7 +1,6 @@ import { TreeItem, TreeItemCollapsibleState } from 'vscode'; import { GitUri } from '../../git/gitUri'; -import type { GitMergeStatus } from '../../git/models/merge'; -import type { GitRebaseStatus } from '../../git/models/rebase'; +import type { GitPausedOperationStatus } from '../../git/models/pausedOperationStatus'; import type { GitStatusFile } from '../../git/models/status'; import { makeHierarchical } from '../../system/array'; import { joinPaths, normalizePath } from '../../system/path'; @@ -16,7 +15,7 @@ export class MergeConflictFilesNode extends ViewNode<'conflict-files', ViewsWith constructor( view: ViewsWithCommits, protected override readonly parent: ViewNode, - private readonly status: GitMergeStatus | GitRebaseStatus, + private readonly status: GitPausedOperationStatus, private readonly conflicts: GitStatusFile[], ) { super('conflict-files', GitUri.fromRepoPath(status.repoPath), view, parent); diff --git a/src/views/nodes/mergeConflictIncomingChangesNode.ts b/src/views/nodes/mergeConflictIncomingChangesNode.ts index 4f221f0a01df7..8f9c4d7cc2d06 100644 --- a/src/views/nodes/mergeConflictIncomingChangesNode.ts +++ b/src/views/nodes/mergeConflictIncomingChangesNode.ts @@ -6,8 +6,7 @@ import { GlCommand } from '../../constants.commands'; import { GitUri } from '../../git/gitUri'; import type { GitCommit } from '../../git/models/commit'; import type { GitFile } from '../../git/models/file'; -import type { GitMergeStatus } from '../../git/models/merge'; -import type { GitRebaseStatus } from '../../git/models/rebase'; +import type { GitPausedOperationStatus } from '../../git/models/pausedOperationStatus'; import { getReferenceLabel } from '../../git/models/reference.utils'; import { createCommand, createCoreCommand } from '../../system/vscode/command'; import { configuration } from '../../system/vscode/configuration'; @@ -24,7 +23,7 @@ export class MergeConflictIncomingChangesNode extends ViewNode< constructor( view: ViewsWithCommits | FileHistoryView | LineHistoryView, protected override readonly parent: ViewNode, - private readonly status: GitMergeStatus | GitRebaseStatus, + private readonly status: GitPausedOperationStatus, private readonly file: GitFile, ) { super('conflict-incoming-changes', GitUri.fromFile(file, status.repoPath, status.HEAD.ref), view, parent); diff --git a/src/views/nodes/mergeStatusNode.ts b/src/views/nodes/mergeStatusNode.ts deleted file mode 100644 index 25199684b0bc7..0000000000000 --- a/src/views/nodes/mergeStatusNode.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { Colors } from '../../constants.colors'; -import { GitUri } from '../../git/gitUri'; -import type { GitBranch } from '../../git/models/branch'; -import type { GitMergeStatus } from '../../git/models/merge'; -import { getReferenceLabel } from '../../git/models/reference.utils'; -import type { GitStatus } from '../../git/models/status'; -import { pluralize } from '../../system/string'; -import type { ViewsWithCommits } from '../viewBase'; -import { createViewDecorationUri } from '../viewDecorationProvider'; -import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; -import { MergeConflictFilesNode } from './mergeConflictFilesNode'; - -export class MergeStatusNode extends ViewNode<'merge-status', ViewsWithCommits> { - constructor( - view: ViewsWithCommits, - protected override readonly parent: ViewNode, - public readonly branch: GitBranch, - public readonly mergeStatus: GitMergeStatus, - public readonly status: GitStatus | undefined, - // Specifies that the node is shown as a root - public readonly root: boolean, - ) { - super('merge-status', GitUri.fromRepoPath(mergeStatus.repoPath), view, parent); - - this.updateContext({ branch: branch, root: root, status: 'merging' }); - this._uniqueId = getViewNodeId(this.type, this.context); - } - - get repoPath(): string { - return this.uri.repoPath!; - } - - getChildren(): ViewNode[] { - return this.status?.hasConflicts - ? [new MergeConflictFilesNode(this.view, this, this.mergeStatus, this.status.conflicts)] - : []; - } - - getTreeItem(): TreeItem { - const hasConflicts = this.status?.hasConflicts === true; - const item = new TreeItem( - `${hasConflicts ? 'Resolve conflicts before merging' : 'Merging'} ${ - this.mergeStatus.incoming != null - ? `${getReferenceLabel(this.mergeStatus.incoming, { expand: false, icon: false })} ` - : '' - }into ${getReferenceLabel(this.mergeStatus.current, { expand: false, icon: false })}`, - hasConflicts ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None, - ); - item.id = this.id; - item.contextValue = ContextValues.Merge; - item.description = hasConflicts ? pluralize('conflict', this.status.conflicts.length) : undefined; - item.iconPath = hasConflicts - ? new ThemeIcon( - 'warning', - new ThemeColor( - 'gitlens.decorations.statusMergingOrRebasingConflictForegroundColor' satisfies Colors, - ), - ) - : new ThemeIcon( - 'warning', - new ThemeColor('gitlens.decorations.statusMergingOrRebasingForegroundColor' satisfies Colors), - ); - - const markdown = new MarkdownString( - `Merging ${ - this.mergeStatus.incoming != null ? getReferenceLabel(this.mergeStatus.incoming, { label: false }) : '' - }into ${getReferenceLabel(this.mergeStatus.current, { label: false })}${ - hasConflicts - ? `\n\nResolve ${pluralize('conflict', this.status.conflicts.length)} before continuing` - : '' - }`, - true, - ); - markdown.supportHtml = true; - markdown.isTrusted = true; - item.tooltip = markdown; - item.resourceUri = createViewDecorationUri('status', { status: 'merging', conflicts: hasConflicts }); - - return item; - } -} diff --git a/src/views/nodes/pausedOperationStatusNode.ts b/src/views/nodes/pausedOperationStatusNode.ts new file mode 100644 index 0000000000000..a2941b80ab289 --- /dev/null +++ b/src/views/nodes/pausedOperationStatusNode.ts @@ -0,0 +1,184 @@ +import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; +import type { Colors } from '../../constants.colors'; +import { GitUri } from '../../git/gitUri'; +import type { GitBranch } from '../../git/models/branch'; +import type { GitPausedOperationStatus } from '../../git/models/pausedOperationStatus'; +import { getReferenceLabel } from '../../git/models/reference.utils'; +import type { GitStatus } from '../../git/models/status'; +import { pausedOperationStatusStringsByType } from '../../git/utils/pausedOperationStatus.utils'; +import { pluralize } from '../../system/string'; +import { executeCoreCommand } from '../../system/vscode/command'; +import type { ViewsWithCommits } from '../viewBase'; +import { createViewDecorationUri } from '../viewDecorationProvider'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; +import { MergeConflictFilesNode } from './mergeConflictFilesNode'; +import { RebaseCommitNode } from './rebaseCommitNode'; + +export class PausedOperationStatusNode extends ViewNode<'paused-operation-status', ViewsWithCommits> { + constructor( + view: ViewsWithCommits, + protected override readonly parent: ViewNode, + public readonly branch: GitBranch, + public readonly pausedOpStatus: GitPausedOperationStatus, + public readonly status: GitStatus | undefined, + // Specifies that the node is shown as a root + public readonly root: boolean, + ) { + super('paused-operation-status', GitUri.fromRepoPath(pausedOpStatus.repoPath), view, parent); + + this.updateContext({ branch: branch, root: root, pausedOperation: pausedOpStatus.type }); + this._uniqueId = getViewNodeId(this.type, this.context); + } + + get repoPath(): string { + return this.uri.repoPath!; + } + + async getChildren(): Promise { + if (this.pausedOpStatus.type !== 'rebase') { + return this.status?.hasConflicts + ? [new MergeConflictFilesNode(this.view, this, this.pausedOpStatus, this.status.conflicts)] + : []; + } + + const children: (MergeConflictFilesNode | RebaseCommitNode)[] = []; + + const revision = this.pausedOpStatus.steps.current.commit; + if (revision != null) { + const commit = + revision != null + ? await this.view.container.git.getCommit(this.pausedOpStatus.repoPath, revision.ref) + : undefined; + if (commit != null) { + children.push(new RebaseCommitNode(this.view, this, commit)); + } + } + + if (this.status?.hasConflicts) { + children.push(new MergeConflictFilesNode(this.view, this, this.pausedOpStatus, this.status.conflicts)); + } + + return children; + } + + getTreeItem(): TreeItem { + const hasConflicts = this.status?.hasConflicts === true; + const hasChildren = + this.status?.hasConflicts || + (this.pausedOpStatus.type === 'rebase' && + this.pausedOpStatus.steps.total > 0 && + this.pausedOpStatus.steps.current.commit != null); + + const item = new TreeItem( + this.label, + hasChildren ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None, + ); + item.id = this.id; + + switch (this.pausedOpStatus.type) { + case 'cherry-pick': + item.contextValue = ContextValues.PausedOperationCherryPick; + break; + case 'merge': + item.contextValue = ContextValues.PausedOperationMerge; + break; + case 'rebase': + item.contextValue = ContextValues.PausedOperationRebase; + break; + case 'revert': + item.contextValue = ContextValues.PausedOperationRevert; + break; + } + + item.description = hasConflicts ? pluralize('conflict', this.status.conflicts.length) : undefined; + + const iconColor: Colors = hasConflicts + ? 'gitlens.decorations.statusMergingOrRebasingConflictForegroundColor' + : 'gitlens.decorations.statusMergingOrRebasingForegroundColor'; + item.iconPath = new ThemeIcon('warning', new ThemeColor(iconColor)); + + item.tooltip = this.tooltip; + item.resourceUri = createViewDecorationUri('status', { + status: this.pausedOpStatus.type, + conflicts: hasConflicts, + }); + + return item; + } + + private get label(): string { + const hasConflicts = this.status?.hasConflicts === true; + + if (this.pausedOpStatus.type !== 'rebase') { + const strings = pausedOperationStatusStringsByType[this.pausedOpStatus.type]; + return `${hasConflicts ? strings.conflicts : strings.label} ${getReferenceLabel( + this.pausedOpStatus.incoming, + { + expand: false, + icon: false, + }, + )} ${strings.directionality} ${getReferenceLabel(this.pausedOpStatus.current, { + expand: false, + icon: false, + })}`; + } + + const started = this.pausedOpStatus.steps.total > 0; + const strings = pausedOperationStatusStringsByType[this.pausedOpStatus.type]; + return `${hasConflicts ? strings.conflicts : started ? strings.label : strings.pending} ${getReferenceLabel( + this.pausedOpStatus.incoming, + { expand: false, icon: false }, + )} ${strings.directionality} ${getReferenceLabel(this.pausedOpStatus.current ?? this.pausedOpStatus.onto, { + expand: false, + icon: false, + })}${started ? ` (${this.pausedOpStatus.steps.current.number}/${this.pausedOpStatus.steps.total})` : ''}`; + } + + private get tooltip(): MarkdownString { + const hasConflicts = this.status?.hasConflicts === true; + + let tooltip; + if (this.pausedOpStatus.type !== 'rebase') { + const strings = pausedOperationStatusStringsByType[this.pausedOpStatus.type]; + tooltip = `${strings.label} ${getReferenceLabel(this.pausedOpStatus.incoming, { label: false })} ${ + strings.directionality + } ${getReferenceLabel(this.pausedOpStatus.current, { label: false })}${ + hasConflicts + ? `\n\nResolve ${pluralize('conflict', this.status.conflicts.length)} before continuing` + : '' + }`; + } else { + const started = this.pausedOpStatus.steps.total > 0; + const strings = pausedOperationStatusStringsByType[this.pausedOpStatus.type]; + tooltip = `${started ? strings.label : strings.pending} ${getReferenceLabel(this.pausedOpStatus.incoming, { + label: false, + })} ${strings.directionality} ${getReferenceLabel(this.pausedOpStatus.current ?? this.pausedOpStatus.onto, { + label: false, + })}${ + started + ? `\n\nPaused at step ${this.pausedOpStatus.steps.current.number} of ${ + this.pausedOpStatus.steps.total + }${ + hasConflicts + ? `\\\nResolve ${pluralize('conflict', this.status.conflicts.length)} before continuing` + : '' + }` + : '' + }`; + } + + const markdown = new MarkdownString(tooltip, true); + markdown.supportHtml = true; + markdown.isTrusted = true; + return markdown; + } + + async openRebaseEditor() { + if (this.pausedOpStatus.type !== 'rebase') return; + + const rebaseTodoUri = Uri.joinPath(this.uri, '.git', 'rebase-merge', 'git-rebase-todo'); + await executeCoreCommand('vscode.openWith', rebaseTodoUri, 'gitlens.rebase', { + preview: false, + }); + } +} diff --git a/src/views/nodes/pullRequestNode.ts b/src/views/nodes/pullRequestNode.ts index 731185b44f1b4..6954bae681d94 100644 --- a/src/views/nodes/pullRequestNode.ts +++ b/src/views/nodes/pullRequestNode.ts @@ -12,7 +12,7 @@ import type { GitBranchReference } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import { createRevisionRange } from '../../git/models/revision.utils'; import { getAheadBehindFilesQuery, getCommitsQuery } from '../../git/queryResults'; -import { getIssueOrPullRequestMarkdownIcon, getIssueOrPullRequestThemeIcon } from '../../git/utils/icons'; +import { getIssueOrPullRequestMarkdownIcon, getIssueOrPullRequestThemeIcon } from '../../git/utils/vscode/icons'; import { pluralize } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; diff --git a/src/views/nodes/rebaseStatusNode.ts b/src/views/nodes/rebaseStatusNode.ts deleted file mode 100644 index 8ee4c3e18534b..0000000000000 --- a/src/views/nodes/rebaseStatusNode.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; -import type { Colors } from '../../constants.colors'; -import { GitUri } from '../../git/gitUri'; -import type { GitBranch } from '../../git/models/branch'; -import type { GitRebaseStatus } from '../../git/models/rebase'; -import { getReferenceLabel } from '../../git/models/reference.utils'; -import type { GitStatus } from '../../git/models/status'; -import { pluralize } from '../../system/string'; -import { executeCoreCommand } from '../../system/vscode/command'; -import type { ViewsWithCommits } from '../viewBase'; -import { createViewDecorationUri } from '../viewDecorationProvider'; -import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; -import { MergeConflictFilesNode } from './mergeConflictFilesNode'; -import { RebaseCommitNode } from './rebaseCommitNode'; - -export class RebaseStatusNode extends ViewNode<'rebase-status', ViewsWithCommits> { - constructor( - view: ViewsWithCommits, - protected override readonly parent: ViewNode, - public readonly branch: GitBranch, - public readonly rebaseStatus: GitRebaseStatus, - public readonly status: GitStatus | undefined, - // Specifies that the node is shown as a root - public readonly root: boolean, - ) { - super('rebase-status', GitUri.fromRepoPath(rebaseStatus.repoPath), view, parent); - - this.updateContext({ branch: branch, root: root, status: 'rebasing' }); - this._uniqueId = getViewNodeId(this.type, this.context); - } - - get repoPath(): string { - return this.uri.repoPath!; - } - - async getChildren(): Promise { - const children: (MergeConflictFilesNode | RebaseCommitNode)[] = []; - - const revision = this.rebaseStatus.steps.current.commit; - if (revision != null) { - const commit = - revision != null - ? await this.view.container.git.getCommit(this.rebaseStatus.repoPath, revision.ref) - : undefined; - if (commit != null) { - children.push(new RebaseCommitNode(this.view, this, commit)); - } - } - - if (this.status?.hasConflicts) { - children.push(new MergeConflictFilesNode(this.view, this, this.rebaseStatus, this.status.conflicts)); - } - - return children; - } - - getTreeItem(): TreeItem { - const started = this.rebaseStatus.steps.total > 0; - const pausedAtCommit = started && this.rebaseStatus.steps.current.commit != null; - const hasConflicts = this.status?.hasConflicts === true; - - const item = new TreeItem( - `${hasConflicts ? 'Resolve conflicts to continue rebasing' : started ? 'Rebasing' : 'Pending rebase of'} ${ - this.rebaseStatus.incoming != null - ? getReferenceLabel(this.rebaseStatus.incoming, { expand: false, icon: false }) - : '' - } onto ${getReferenceLabel(this.rebaseStatus.current ?? this.rebaseStatus.onto, { - expand: false, - icon: false, - })}${started ? ` (${this.rebaseStatus.steps.current.number}/${this.rebaseStatus.steps.total})` : ''}`, - pausedAtCommit ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None, - ); - item.id = this.id; - item.contextValue = ContextValues.Rebase; - item.description = hasConflicts ? pluralize('conflict', this.status.conflicts.length) : undefined; - item.iconPath = hasConflicts - ? new ThemeIcon( - 'warning', - new ThemeColor( - 'gitlens.decorations.statusMergingOrRebasingConflictForegroundColor' satisfies Colors, - ), - ) - : new ThemeIcon( - 'warning', - new ThemeColor('gitlens.decorations.statusMergingOrRebasingForegroundColor' satisfies Colors), - ); - - const markdown = new MarkdownString( - `${started ? 'Rebasing' : 'Pending rebase of'} ${ - this.rebaseStatus.incoming != null - ? getReferenceLabel(this.rebaseStatus.incoming, { label: false }) - : '' - } onto ${getReferenceLabel(this.rebaseStatus.current ?? this.rebaseStatus.onto, { label: false })}${ - started - ? `\n\nPaused at step ${this.rebaseStatus.steps.current.number} of ${ - this.rebaseStatus.steps.total - }${ - hasConflicts - ? `\\\nResolve ${pluralize('conflict', this.status.conflicts.length)} before continuing` - : '' - }` - : '' - }`, - true, - ); - markdown.supportHtml = true; - markdown.isTrusted = true; - item.tooltip = markdown; - item.resourceUri = createViewDecorationUri('status', { status: 'rebasing', conflicts: hasConflicts }); - - return item; - } - - async openEditor() { - const rebaseTodoUri = Uri.joinPath(this.uri, '.git', 'rebase-merge', 'git-rebase-todo'); - await executeCoreCommand('vscode.openWith', rebaseTodoUri, 'gitlens.rebase', { - preview: false, - }); - } -} diff --git a/src/views/nodes/repositoryNode.ts b/src/views/nodes/repositoryNode.ts index f39ef067c8b99..a27872ffce2ff 100644 --- a/src/views/nodes/repositoryNode.ts +++ b/src/views/nodes/repositoryNode.ts @@ -7,7 +7,7 @@ import { getHighlanderProviders } from '../../git/models/remote'; import type { RepositoryChangeEvent, RepositoryFileSystemChangeEvent } from '../../git/models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; import type { GitStatus } from '../../git/models/status'; -import { getRepositoryStatusIconPath } from '../../git/utils/icons'; +import { getRepositoryStatusIconPath } from '../../git/utils/vscode/icons'; import type { CloudWorkspace, CloudWorkspaceRepositoryDescriptor, @@ -31,8 +31,7 @@ import { BranchTrackingStatusNode } from './branchTrackingStatusNode'; import { MessageNode } from './common'; import { CompareBranchNode } from './compareBranchNode'; import { ContributorsNode } from './contributorsNode'; -import { MergeStatusNode } from './mergeStatusNode'; -import { RebaseStatusNode } from './rebaseStatusNode'; +import { PausedOperationStatusNode } from './pausedOperationStatusNode'; import { ReflogNode } from './reflogNode'; import { RemotesNode } from './remotesNode'; import { StashesNode } from './stashesNode'; @@ -99,15 +98,9 @@ export class RepositoryNode extends SubscribeableViewNode<'repository', ViewsWit status.rebasing, ); - const [mergeStatus, rebaseStatus] = await Promise.all([ - this.view.container.git.getMergeStatus(status.repoPath), - this.view.container.git.getRebaseStatus(status.repoPath), - ]); - - if (mergeStatus != null) { - children.push(new MergeStatusNode(this.view, this, branch, mergeStatus, status, true)); - } else if (rebaseStatus != null) { - children.push(new RebaseStatusNode(this.view, this, branch, rebaseStatus, status, true)); + const pausedOpStatus = await this.view.container.git.getPausedOperationStatus(status.repoPath); + if (pausedOpStatus != null) { + children.push(new PausedOperationStatusNode(this.view, this, branch, pausedOpStatus, status, true)); } else if (this.view.config.showUpstreamStatus) { if (status.upstream) { if (!status.state.behind && !status.state.ahead) { @@ -453,7 +446,7 @@ export class RepositoryNode extends SubscribeableViewNode<'repository', ViewsWit RepositoryChange.Index, RepositoryChange.Heads, RepositoryChange.Opened, - RepositoryChange.Status, + RepositoryChange.PausedOperationStatus, RepositoryChange.Unknown, RepositoryChangeComparisonMode.Any, ) diff --git a/src/views/nodes/worktreeNode.ts b/src/views/nodes/worktreeNode.ts index a9e802f0399bf..62cf7aa2bbb1e 100644 --- a/src/views/nodes/worktreeNode.ts +++ b/src/views/nodes/worktreeNode.ts @@ -10,7 +10,7 @@ import { getHighlanderProviderName } from '../../git/models/remote'; import { shortenRevision } from '../../git/models/revision.utils'; import type { GitStatus } from '../../git/models/status'; import type { GitWorktree } from '../../git/models/worktree'; -import { getBranchIconPath } from '../../git/utils/icons'; +import { getBranchIconPath } from '../../git/utils/vscode/icons'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; diff --git a/src/views/nodes/worktreesNode.ts b/src/views/nodes/worktreesNode.ts index 097a36879aaf3..d4f172d3a2533 100644 --- a/src/views/nodes/worktreesNode.ts +++ b/src/views/nodes/worktreesNode.ts @@ -3,7 +3,7 @@ import { GlyphChars } from '../../constants'; import { PlusFeatures } from '../../features'; import type { GitUri } from '../../git/gitUri'; import type { Repository } from '../../git/models/repository'; -import { sortWorktrees } from '../../git/utils/sorting'; +import { sortWorktrees } from '../../git/utils/vscode/sorting'; import { filterMap } from '../../system/array'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; diff --git a/src/views/viewDecorationProvider.ts b/src/views/viewDecorationProvider.ts index 2da95cff40d1b..06c5cf847d229 100644 --- a/src/views/viewDecorationProvider.ts +++ b/src/views/viewDecorationProvider.ts @@ -5,6 +5,7 @@ import { GlyphChars, Schemes } from '../constants'; import type { Colors } from '../constants.colors'; import type { GitBranchStatus } from '../git/models/branch'; import type { GitFileStatus } from '../git/models/file'; +import type { GitPausedOperation } from '../git/models/pausedOperationStatus'; export class ViewFileDecorationProvider implements FileDecorationProvider, Disposable { private readonly _onDidChange = new EventEmitter(); @@ -223,7 +224,7 @@ function getRemoteDecoration(uri: Uri, _token: CancellationToken): FileDecoratio } interface StatusViewDecoration { - status: 'merging' | 'rebasing'; + status: GitPausedOperation; conflicts?: boolean; } @@ -231,8 +232,10 @@ function getStatusDecoration(uri: Uri, _token: CancellationToken): FileDecoratio const state = getViewDecoration<'status'>(uri); switch (state?.status) { - case 'rebasing': - case 'merging': + case 'cherry-pick': + case 'merge': + case 'rebase': + case 'revert': if (state?.conflicts) { return { badge: '!', diff --git a/src/views/worktreesView.ts b/src/views/worktreesView.ts index 02cc2d8fa77a0..8d52ea4b63ea9 100644 --- a/src/views/worktreesView.ts +++ b/src/views/worktreesView.ts @@ -42,7 +42,7 @@ export class WorktreesRepositoryNode extends RepositoryFolderNode`; } diff --git a/src/webviews/apps/plus/home/components/branch-card.ts b/src/webviews/apps/plus/home/components/branch-card.ts index 052bf00a5881e..65daf567c27df 100644 --- a/src/webviews/apps/plus/home/components/branch-card.ts +++ b/src/webviews/apps/plus/home/components/branch-card.ts @@ -407,11 +407,18 @@ export abstract class GlBranchCardBase extends GlElement { get branchCardIndicator() { if (!this.branch.opened) return undefined; - const isMerging = this.wip?.mergeStatus != null; - const isRebasing = this.wip?.rebaseStatus != null; - if (isMerging || isRebasing) { + if (this.wip?.pausedOpStatus != null) { if (this.wip?.hasConflicts) return 'conflict'; - return isMerging ? 'merging' : 'rebasing'; + switch (this.wip.pausedOpStatus.type) { + case 'cherry-pick': + return 'cherry-picking'; + case 'merge': + return 'merging'; + case 'rebase': + return 'rebasing'; + case 'revert': + return 'reverting'; + } } const hasWip = diff --git a/src/webviews/apps/plus/home/components/merge-rebase-status.ts b/src/webviews/apps/plus/home/components/merge-rebase-status.ts index 728ee56943789..92d090535ee34 100644 --- a/src/webviews/apps/plus/home/components/merge-rebase-status.ts +++ b/src/webviews/apps/plus/home/components/merge-rebase-status.ts @@ -1,8 +1,7 @@ import { css, html, LitElement, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { when } from 'lit/directives/when.js'; -import type { GitMergeStatus } from '../../../../../git/models/merge'; -import type { GitRebaseStatus } from '../../../../../git/models/rebase'; +import type { GitPausedOperationStatus } from '../../../../../git/models/pausedOperationStatus'; +import { pausedOperationStatusStringsByType } from '../../../../../git/utils/pausedOperationStatus.utils'; import { getReferenceLabel } from '../../../shared/git-utils'; import '../../../shared/components/overlays/tooltip'; @@ -52,44 +51,50 @@ export class GlMergeConflictWarning extends LitElement { conflicts = false; @property({ type: Object }) - merge?: GitMergeStatus; - - @property({ type: Object }) - rebase?: GitRebaseStatus; + pausedOpStatus?: GitPausedOperationStatus; override render() { - if (this.merge == null && this.rebase == null) return nothing; + if (this.pausedOpStatus == null) return nothing; return html` - ${when( - this.merge != null, - () => this.renderMerge(), - () => this.renderRebase(), - )} + ${this.renderStatus(this.pausedOpStatus)} `; } - private renderMerge() { - return html`${this.conflicts ? 'Resolve conflicts before merging' : 'Merging'} into - ${getReferenceLabel(this.merge!.current, { expand: false, icon: false })}`; - } + private renderStatus(pausedOpStatus: GitPausedOperationStatus) { + if (pausedOpStatus.type !== 'rebase') { + const strings = pausedOperationStatusStringsByType[pausedOpStatus.type]; + return html`${this.conflicts ? strings.conflicts : strings.label} + + ${getReferenceLabel(pausedOpStatus.incoming, { expand: false, icon: false })} ${strings.directionality} + ${getReferenceLabel(pausedOpStatus.current, { expand: false, icon: false })}`; + } - private renderRebase() { - const started = this.rebase!.steps.total > 0; + const started = pausedOpStatus.steps.total > 0; + const strings = pausedOperationStatusStringsByType[pausedOpStatus.type]; return html`${this.conflicts ? 'Resolve conflicts to continue rebasing' : started ? 'Rebasing' : 'Pending rebase'} - onto - ${getReferenceLabel(this.rebase!.current ?? this.rebase!.onto, { + >${this.conflicts ? strings.conflicts : started ? strings.label : strings.pending} + + ${getReferenceLabel(pausedOpStatus.incoming, { expand: false, icon: false })} ${strings.directionality} + ${getReferenceLabel(pausedOpStatus.current ?? pausedOpStatus.onto, { expand: false, icon: false, })}${started - ? html`(${this.rebase!.steps.current.number}/${this.rebase!.steps.total})` + ? html`(${pausedOpStatus.steps.current.number}/${pausedOpStatus.steps.total})` : nothing}`; } } diff --git a/src/webviews/apps/shared/components/card/card.css.ts b/src/webviews/apps/shared/components/card/card.css.ts index 6705a36ba3dfb..acce55851f705 100644 --- a/src/webviews/apps/shared/components/card/card.css.ts +++ b/src/webviews/apps/shared/components/card/card.css.ts @@ -35,8 +35,10 @@ export const cardStyles = css` border-inline-start-color: var(--gl-card-indicator-border, var(--vscode-gitDecoration-addedResourceForeground)); } + .card.is-cherry-picking, + .card.is-merging, .card.is-rebasing, - .card.is-merging { + .card.is-reverting { border-inline-start-color: var( --gl-card-indicator-border, var(--vscode-gitlens-decorations\\.statusMergingOrRebasingForegroundColor) diff --git a/src/webviews/apps/shared/components/card/card.ts b/src/webviews/apps/shared/components/card/card.ts index ea0b550b4eb5a..4d1efb179dc25 100644 --- a/src/webviews/apps/shared/components/card/card.ts +++ b/src/webviews/apps/shared/components/card/card.ts @@ -18,8 +18,10 @@ export class GlCard extends LitElement { indicator?: | 'base' | 'active' + | 'cherry-picking' | 'merging' | 'rebasing' + | 'reverting' | 'conflict' | 'issue-open' | 'issue-closed' diff --git a/src/webviews/apps/tsconfig.json b/src/webviews/apps/tsconfig.json index 45ddc48eef33e..7020338ae45df 100644 --- a/src/webviews/apps/tsconfig.json +++ b/src/webviews/apps/tsconfig.json @@ -22,9 +22,10 @@ "../../constants.ts", "../../constants.*.ts", "../../features.ts", + "../../git/utils/*.ts", "../../subscription.ts", "../../system/*.ts", "../../system/decorators/log.ts" ], - "exclude": [] + "exclude": ["**/vscode/**/*"] } diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index 635307b9195e3..ba3cec8ee207d 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -34,7 +34,7 @@ import { createRevisionRange } from '../../git/models/revision.utils'; import type { GitStatus } from '../../git/models/status'; import type { GitWorktree } from '../../git/models/worktree'; import { getOpenedWorktreesByBranch, groupWorktreesByBranch } from '../../git/models/worktree.utils'; -import { sortBranches } from '../../git/utils/sorting'; +import { sortBranches } from '../../git/utils/vscode/sorting'; import { showPatchesView } from '../../plus/drafts/actions'; import type { Subscription } from '../../plus/gk/account/subscription'; import { isSubscriptionStatePaidOrTrial } from '../../plus/gk/account/subscription'; @@ -754,7 +754,7 @@ export class HomeWebviewProvider implements WebviewProvider; diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index 858e96002fb7d..0325f259d6f8f 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -92,7 +92,7 @@ import { isSha, shortenRevision } from '../../../git/models/revision.utils'; import { getWorktreesByBranch } from '../../../git/models/worktree.utils'; import type { GitSearch } from '../../../git/search'; import { getSearchQueryComparisonKey, parseSearchQuery } from '../../../git/search'; -import { getRemoteIconUri } from '../../../git/utils/icons'; +import { getRemoteIconUri } from '../../../git/utils/vscode/icons'; import type { FeaturePreviewChangeEvent, SubscriptionChangeEvent } from '../../../plus/gk/account/subscriptionService'; import type { ConnectionStateChangeEvent } from '../../../plus/integrations/integrationService'; import { remoteProviderIdToIntegrationId } from '../../../plus/integrations/integrationService'; @@ -948,7 +948,7 @@ export class GraphWebviewProvider implements WebviewProvider