diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index ddfd457a088..a079ee1f8f8 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -351,5 +351,6 @@ export const enum GitErrorCodes { NoPathFound = 'NoPathFound', UnknownPath = 'UnknownPath', EmptyCommitMessage = 'EmptyCommitMessage', - BranchFastForwardRejected = 'BranchFastForwardRejected' + BranchFastForwardRejected = 'BranchFastForwardRejected', + TagConflict = 'TagConflict' } diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index ba53de43f0c..4262600197e 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -1764,6 +1764,25 @@ export class Repository { } } + async fetchTags(options: { remote: string; tags: string[]; force?: boolean }): Promise { + const args = ['fetch']; + const spawnOptions: SpawnOptions = { + env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent } + }; + + args.push(options.remote); + + for (const tag of options.tags) { + args.push(`refs/tags/${tag}:refs/tags/${tag}`); + } + + if (options.force) { + args.push('--force'); + } + + await this.exec(args, spawnOptions); + } + async pull(rebase?: boolean, remote?: string, branch?: string, options: PullOptions = {}): Promise { const args = ['pull']; @@ -1803,6 +1822,8 @@ export class Repository { err.gitErrorCode = GitErrorCodes.CantLockRef; } else if (/cannot rebase onto multiple branches/i.test(err.stderr || '')) { err.gitErrorCode = GitErrorCodes.CantRebaseMultipleBranches; + } else if (/! \[rejected\].*\(would clobber existing tag\)/m.test(err.stderr || '')) { + err.gitErrorCode = GitErrorCodes.TagConflict; } throw err; diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 3f49e11bf6d..3af93d30f17 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -11,7 +11,7 @@ import TelemetryReporter from '@vscode/extension-telemetry'; import { Branch, Change, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status, CommitOptions, BranchQuery, FetchOptions } from './api/git'; import { AutoFetcher } from './autofetch'; import { debounce, memoize, throttle } from './decorators'; -import { Commit, GitError, Repository as BaseRepository, Stash, Submodule, LogFileOptions } from './git'; +import { Commit, GitError, Repository as BaseRepository, Stash, Submodule, LogFileOptions, PullOptions } from './git'; import { StatusBarCommands } from './statusbar'; import { toGitUri } from './uri'; import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, IDisposable, isDescendant, onceEvent, pathEquals, relativePath } from './util'; @@ -1658,12 +1658,28 @@ export class Repository implements Disposable { } if (await this.checkIfMaybeRebased(this.HEAD?.name)) { - await this.repository.pull(rebase, remote, branch, { unshallow, tags }); + this._pullAndHandleTagConflict(rebase, remote, branch, { unshallow, tags }); } }); }); } + private async _pullAndHandleTagConflict(rebase?: boolean, remote?: string, branch?: string, options: PullOptions = {}): Promise { + try { + await this.repository.pull(rebase, remote, branch, options); + } + catch (err) { + if (err.gitErrorCode !== GitErrorCodes.TagConflict) { + throw err; + } + + // Handle tag(s) conflict + if (await this.handleTagConflict(remote, err.stderr)) { + await this.repository.pull(rebase, remote, branch, options); + } + } + } + @throttle async push(head: Branch, forcePushMode?: ForcePushMode): Promise { let remote: string | undefined; @@ -1724,7 +1740,7 @@ export class Repository implements Disposable { } if (await this.checkIfMaybeRebased(this.HEAD?.name)) { - await this.repository.pull(rebase, remoteName, pullBranch, { tags, cancellationToken }); + this._pullAndHandleTagConflict(rebase, remoteName, pullBranch, { tags, cancellationToken }); } }; @@ -2476,6 +2492,38 @@ export class Repository implements Disposable { return config.get('optimisticUpdate') === true; } + private async handleTagConflict(remote: string | undefined, raw: string): Promise { + // Ensure there is a remote + remote = remote ?? this.HEAD?.upstream?.remote; + if (!remote) { + throw new Error('Unable to resolve tag conflict due to missing remote.'); + } + + // Extract tag names from message + const tags: string[] = []; + for (const match of raw.matchAll(/^ ! \[rejected\]\s+([^\s]+)\s+->\s+([^\s]+)\s+\(would clobber existing tag\)$/gm)) { + if (match.length === 3) { + tags.push(match[1]); + } + } + if (tags.length === 0) { + throw new Error(`Unable to extract tag names from error message: ${raw}`); + } + + // Notification + const replaceLocalTags = l10n.t('Replace Local Tag(s)'); + const message = l10n.t('Unable to pull from remote repository due to conflicting tag(s): {0}. Would you like to resolve the conflict by replacing the local tag(s)?', tags.join(', ')); + const choice = await window.showErrorMessage(message, { modal: true }, replaceLocalTags); + + if (choice !== replaceLocalTags) { + return false; + } + + // Force fetch tags + await this.repository.fetchTags({ remote, tags, force: true }); + return true; + } + public isBranchProtected(name: string = this.HEAD?.name ?? ''): boolean { return this.isBranchProtectedMatcher ? this.isBranchProtectedMatcher(name) : false; }