diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index f49c697b539..fe897c667e9 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -326,6 +326,10 @@ export class ApiRepository implements Repository { deleteWorktree(path: string, options?: { force?: boolean }): Promise { return this.#repository.deleteWorktree(path, options); } + + migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise { + return this.#repository.migrateChanges(sourceRepositoryPath, options); + } } export class ApiGit implements Git { diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index bdcfb8fde9f..02e84b0d6db 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -292,6 +292,8 @@ export interface Repository { createWorktree(options?: { path?: string; commitish?: string; branch?: string }): Promise; deleteWorktree(path: string, options?: { force?: boolean }): Promise; + + migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; } export interface RemoteSource { diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 0b889012f89..1553a73acd2 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -3383,103 +3383,38 @@ export class CommandCenter { } @command('git.migrateWorktreeChanges', { repository: true, repositoryFilter: ['repository', 'submodule'] }) - async migrateWorktreeChanges(repository: Repository, worktreeUri?: Uri): Promise { + async migrateWorktreeChanges(repository: Repository): Promise { let worktreeRepository: Repository | undefined; - if (worktreeUri !== undefined) { - worktreeRepository = this.model.getRepository(worktreeUri); + + const worktrees = await repository.getWorktrees(); + if (worktrees.length === 1) { + worktreeRepository = this.model.getRepository(worktrees[0].path); } else { - const worktrees = await repository.getWorktrees(); - if (worktrees.length === 1) { - worktreeRepository = this.model.getRepository(worktrees[0].path); - } else { - const worktreePicks = async (): Promise => { - return worktrees.length === 0 - ? [{ label: l10n.t('$(info) This repository has no worktrees.') }] - : worktrees.map(worktree => new WorktreeItem(worktree)); - }; + const worktreePicks = async (): Promise => { + return worktrees.length === 0 + ? [{ label: l10n.t('$(info) This repository has no worktrees.') }] + : worktrees.map(worktree => new WorktreeItem(worktree)); + }; - const placeHolder = l10n.t('Select a worktree to migrate changes from'); - const choice = await this.pickRef(worktreePicks(), placeHolder); + const placeHolder = l10n.t('Select a worktree to migrate changes from'); + const choice = await this.pickRef(worktreePicks(), placeHolder); - if (!choice || !(choice instanceof WorktreeItem)) { - return; - } - - worktreeRepository = this.model.getRepository(choice.worktree.path); + if (!choice || !(choice instanceof WorktreeItem)) { + return; } + + worktreeRepository = this.model.getRepository(choice.worktree.path); } if (!worktreeRepository || worktreeRepository.kind !== 'worktree') { return; } - if (worktreeRepository.indexGroup.resourceStates.length === 0 && - worktreeRepository.workingTreeGroup.resourceStates.length === 0 && - worktreeRepository.untrackedGroup.resourceStates.length === 0) { - await window.showInformationMessage(l10n.t('There are no changes in the selected worktree to migrate.')); - return; - } - - const worktreeChangedFilePaths = [ - ...worktreeRepository.indexGroup.resourceStates, - ...worktreeRepository.workingTreeGroup.resourceStates, - ...worktreeRepository.untrackedGroup.resourceStates - ].map(resource => path.relative(worktreeRepository.root, resource.resourceUri.fsPath)); - - const targetChangedFilePaths = [ - ...repository.workingTreeGroup.resourceStates, - ...repository.untrackedGroup.resourceStates - ].map(resource => path.relative(repository.root, resource.resourceUri.fsPath)); - - // Detect overlapping unstaged files in worktree stash and target repository - const conflicts = worktreeChangedFilePaths.filter(path => targetChangedFilePaths.includes(path)); - - // Check for 'LocalChangesOverwritten' error - if (conflicts.length > 0) { - const maxFilesShown = 5; - const filesToShow = conflicts.slice(0, maxFilesShown); - const remainingCount = conflicts.length - maxFilesShown; - - const fileList = filesToShow.join('\n ') + - (remainingCount > 0 ? l10n.t('\n and {0} more file{1}...', remainingCount, remainingCount > 1 ? 's' : '') : ''); - - const message = l10n.t('Your local changes to the following files would be overwritten by merge:\n {0}\n\nPlease stage, commit, or stash your changes in the repository before migrating changes.', fileList); - await window.showErrorMessage(message, { modal: true }); - return; - } - - if (worktreeUri === undefined) { - // Non-interactive migration, do not show confirmation dialog - const message = l10n.t('Proceed with migrating changes to the current repository?'); - const detail = l10n.t('This will apply the worktree\'s changes to this repository and discard changes in the worktree.\nThis is IRREVERSIBLE!'); - const proceed = l10n.t('Proceed'); - const pick = await window.showWarningMessage(message, { modal: true, detail }, proceed); - if (pick !== proceed) { - return; - } - } - - await worktreeRepository.createStash(undefined, true); - const stashes = await worktreeRepository.getStashes(); - - try { - await repository.applyStash(stashes[0].index); - worktreeRepository.dropStash(stashes[0].index); - } catch (err) { - if (err.gitErrorCode !== GitErrorCodes.StashConflict) { - await worktreeRepository.popStash(); - throw err; - } - repository.isWorktreeMigrating = true; - - const message = l10n.t('There are merge conflicts from migrating changes. Please resolve them before committing.'); - const show = l10n.t('Show Changes'); - const choice = await window.showWarningMessage(message, show); - if (choice === show) { - await commands.executeCommand('workbench.view.scm'); - } - worktreeRepository.dropStash(stashes[0].index); - } + await repository.migrateChanges(worktreeRepository.root, { + confirmation: true, + deleteFromSource: false, + untracked: true + }); } @command('git.openWorktreeMergeEditor') diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index d10745ee2b3..3e32384074c 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -2448,6 +2448,90 @@ export class Repository implements Disposable { } } + async migrateChanges(sourceRepositoryRoot: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise { + const sourceRepository = this.repositoryResolver.getRepository(sourceRepositoryRoot); + if (!sourceRepository) { + window.showWarningMessage(l10n.t('The source repository could not be found.')); + return; + } + + if (sourceRepository.indexGroup.resourceStates.length === 0 && + sourceRepository.workingTreeGroup.resourceStates.length === 0 && + sourceRepository.untrackedGroup.resourceStates.length === 0) { + await window.showInformationMessage(l10n.t('There are no changes in the selected worktree to migrate.')); + return; + } + + const sourceFilePaths = [ + ...sourceRepository.indexGroup.resourceStates, + ...sourceRepository.workingTreeGroup.resourceStates, + ...sourceRepository.untrackedGroup.resourceStates + ].map(resource => path.relative(sourceRepository.root, resource.resourceUri.fsPath)); + + const targetFilePaths = [ + ...this.workingTreeGroup.resourceStates, + ...this.untrackedGroup.resourceStates + ].map(resource => path.relative(this.root, resource.resourceUri.fsPath)); + + // Detect overlapping unstaged files in worktree stash and target repository + const conflicts = sourceFilePaths.filter(path => targetFilePaths.includes(path)); + + if (conflicts.length > 0) { + const maxFilesShown = 5; + const filesToShow = conflicts.slice(0, maxFilesShown); + const remainingCount = conflicts.length - maxFilesShown; + + const fileList = filesToShow.join('\n ') + + (remainingCount > 0 ? l10n.t('\n and {0} more file{1}...', remainingCount, remainingCount > 1 ? 's' : '') : ''); + + const message = l10n.t('Your local changes to the following files would be overwritten by merge:\n {0}\n\nPlease stage, commit, or stash your changes in the repository before migrating changes.', fileList); + await window.showErrorMessage(message, { modal: true }); + return; + } + + if (options?.confirmation) { + // Non-interactive migration, do not show confirmation dialog + const message = l10n.t('Proceed with migrating changes to the current repository?'); + const detail = l10n.t('This will apply the worktree\'s changes to this repository and discard changes in the worktree.\nThis is IRREVERSIBLE!'); + const proceed = l10n.t('Proceed'); + const pick = await window.showWarningMessage(message, { modal: true, detail }, proceed); + if (pick !== proceed) { + return; + } + } + + const stashName = `migration-${sourceRepository.HEAD?.name ?? sourceRepository.HEAD?.commit}-${this.HEAD?.name ?? this.HEAD?.commit}`; + await sourceRepository.createStash(stashName, options?.untracked); + const stashes = await sourceRepository.getStashes(); + + try { + await this.applyStash(stashes[0].index); + + if (options?.deleteFromSource) { + await sourceRepository.dropStash(stashes[0].index); + } else { + await sourceRepository.popStash(); + } + } catch (err) { + if (err.gitErrorCode === GitErrorCodes.StashConflict) { + this.isWorktreeMigrating = true; + + const message = l10n.t('There are merge conflicts from migrating changes. Please resolve them before committing.'); + const show = l10n.t('Show Changes'); + const choice = await window.showWarningMessage(message, show); + if (choice === show) { + await commands.executeCommand('workbench.view.scm'); + } + + await sourceRepository.popStash(); + return; + } + + await sourceRepository.popStash(); + throw err; + } + } + private async retryRun(operation: Operation, runOperation: () => Promise): Promise { let attempt = 0;