diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index b57a18e988b..55dbdf19d34 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -15,6 +15,7 @@ import { GitExtensionImpl } from './extension'; import { GitBaseApi } from '../git-base'; import { PickRemoteSourceOptions } from '../typings/git-base'; import { OperationKind, OperationResult } from '../operation'; +import { CloneManager } from '../cloneManager'; class ApiInputBox implements InputBox { #inputBox: SourceControlInputBox; @@ -331,10 +332,12 @@ export class ApiGit implements Git { export class ApiImpl implements API { #model: Model; + #cloneManager: CloneManager; readonly git: ApiGit; - constructor(model: Model) { - this.#model = model; + constructor(privates: { model: Model; cloneManager: CloneManager }) { + this.#model = privates.model; + this.#cloneManager = privates.cloneManager; this.git = new ApiGit(this.#model); } @@ -401,7 +404,7 @@ export class ApiImpl implements API { async clone(uri: Uri, options?: CloneOptions): Promise { const parentPath = options?.parentPath?.fsPath; - const result = await this.#model.clone(uri.toString(), { parentPath, recursive: options?.recursive, ref: options?.ref, postCloneAction: options?.postCloneAction, skipCache: options?.skipCache }); + const result = await this.#cloneManager.clone(uri.toString(), { parentPath, recursive: options?.recursive, ref: options?.ref, postCloneAction: options?.postCloneAction, skipCache: options?.skipCache }); return result ? Uri.file(result) : null; } diff --git a/extensions/git/src/api/extension.ts b/extensions/git/src/api/extension.ts index d381ebc7f64..3bbb717e23f 100644 --- a/extensions/git/src/api/extension.ts +++ b/extensions/git/src/api/extension.ts @@ -7,6 +7,7 @@ import { Model } from '../model'; import { GitExtension, Repository, API } from './git'; import { ApiRepository, ApiImpl } from './api1'; import { Event, EventEmitter } from 'vscode'; +import { CloneManager } from '../cloneManager'; function deprecated(original: any, context: ClassMemberDecoratorContext) { if (context.kind !== 'method') { @@ -28,6 +29,7 @@ export class GitExtensionImpl implements GitExtension { readonly onDidChangeEnablement: Event = this._onDidChangeEnablement.event; private _model: Model | undefined = undefined; + private _cloneManager: CloneManager | undefined = undefined; set model(model: Model | undefined) { this._model = model; @@ -46,10 +48,15 @@ export class GitExtensionImpl implements GitExtension { return this._model; } - constructor(model?: Model) { - if (model) { + set cloneManager(cloneManager: CloneManager | undefined) { + this._cloneManager = cloneManager; + } + + constructor(privates?: { model: Model; cloneManager: CloneManager }) { + if (privates) { this.enabled = true; - this._model = model; + this._model = privates.model; + this._cloneManager = privates.cloneManager; } } @@ -72,7 +79,7 @@ export class GitExtensionImpl implements GitExtension { } getAPI(version: number): API { - if (!this._model) { + if (!this._model || !this._cloneManager) { throw new Error('Git model not found'); } @@ -80,6 +87,6 @@ export class GitExtensionImpl implements GitExtension { throw new Error(`No API version ${version} found.`); } - return new ApiImpl(this._model); + return new ApiImpl({ model: this._model, cloneManager: this._cloneManager }); } } diff --git a/extensions/git/src/cloneManager.ts b/extensions/git/src/cloneManager.ts new file mode 100644 index 00000000000..bea8aa64ad3 --- /dev/null +++ b/extensions/git/src/cloneManager.ts @@ -0,0 +1,250 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { pickRemoteSource } from './remoteSource'; +import { l10n, workspace, window, Uri, ProgressLocation, commands } from 'vscode'; +import { RepositoryCache, RepositoryCacheInfo } from './repositoryCache'; +import TelemetryReporter from '@vscode/extension-telemetry'; +import { Model } from './model'; + +type PostCloneAction = 'none' | 'open' | 'prompt'; + +export interface CloneOptions { + parentPath?: string; + ref?: string; + recursive?: boolean; + postCloneAction?: PostCloneAction; + skipCache?: boolean; +} + +export class CloneManager { + constructor(private readonly model: Model, + private readonly telemetryReporter: TelemetryReporter, + private readonly repositoryCache: RepositoryCache) { } + + clone(url?: string, options: CloneOptions = {}) { + const cachedRepository = url ? this.repositoryCache.get(url) : undefined; + if (url && !options.skipCache && cachedRepository && (cachedRepository.length > 0)) { + return this.tryOpenExistingRepository(cachedRepository, url, options.postCloneAction, options.parentPath, options.ref); + } + return this.cloneRepository(url, options.parentPath, options); + } + + private async cloneRepository(url?: string, parentPath?: string, options: { recursive?: boolean; ref?: string; postCloneAction?: PostCloneAction } = {}): Promise { + if (!url || typeof url !== 'string') { + url = await pickRemoteSource({ + providerLabel: provider => l10n.t('Clone from {0}', provider.name), + urlLabel: l10n.t('Clone from URL') + }); + } + + if (!url) { + /* __GDPR__ + "clone" : { + "owner": "lszomoru", + "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } + } + */ + this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'no_URL' }); + return; + } + + url = url.trim().replace(/^git\s+clone\s+/, ''); + + if (!parentPath) { + const config = workspace.getConfiguration('git'); + let defaultCloneDirectory = config.get('defaultCloneDirectory') || os.homedir(); + defaultCloneDirectory = defaultCloneDirectory.replace(/^~/, os.homedir()); + + const uris = await window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri: Uri.file(defaultCloneDirectory), + title: l10n.t('Choose a folder to clone {0} into', url), + openLabel: l10n.t('Select as Repository Destination') + }); + + if (!uris || uris.length === 0) { + /* __GDPR__ + "clone" : { + "owner": "lszomoru", + "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } + } + */ + this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'no_directory' }); + return; + } + + const uri = uris[0]; + parentPath = uri.fsPath; + } + + try { + const opts = { + location: ProgressLocation.Notification, + title: l10n.t('Cloning git repository "{0}"...', url), + cancellable: true + }; + + const repositoryPath = await window.withProgress( + opts, + (progress, token) => this.model.git.clone(url!, { parentPath: parentPath!, progress, recursive: options.recursive, ref: options.ref }, token) + ); + + const config = workspace.getConfiguration('git'); + const openAfterClone = config.get<'always' | 'alwaysNewWindow' | 'whenNoFolderOpen' | 'prompt'>('openAfterClone'); + + enum PostCloneAction { Open, OpenNewWindow, AddToWorkspace, None } + let action: PostCloneAction | undefined = undefined; + + if (options.postCloneAction) { + if (options.postCloneAction === 'open') { + action = PostCloneAction.Open; + } else if (options.postCloneAction === 'none') { + action = PostCloneAction.None; + } + } else { + if (openAfterClone === 'always') { + action = PostCloneAction.Open; + } else if (openAfterClone === 'alwaysNewWindow') { + action = PostCloneAction.OpenNewWindow; + } else if (openAfterClone === 'whenNoFolderOpen' && !workspace.workspaceFolders) { + action = PostCloneAction.Open; + } + } + + if (action === undefined) { + let message = l10n.t('Would you like to open the cloned repository?'); + const open = l10n.t('Open'); + const openNewWindow = l10n.t('Open in New Window'); + const choices = [open, openNewWindow]; + + const addToWorkspace = l10n.t('Add to Workspace'); + if (workspace.workspaceFolders) { + message = l10n.t('Would you like to open the cloned repository, or add it to the current workspace?'); + choices.push(addToWorkspace); + } + + const result = await window.showInformationMessage(message, { modal: true }, ...choices); + + action = result === open ? PostCloneAction.Open + : result === openNewWindow ? PostCloneAction.OpenNewWindow + : result === addToWorkspace ? PostCloneAction.AddToWorkspace : undefined; + } + + /* __GDPR__ + "clone" : { + "owner": "lszomoru", + "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" }, + "openFolder": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Indicates whether the folder is opened following the clone operation" } + } + */ + this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'success' }, { openFolder: action === PostCloneAction.Open || action === PostCloneAction.OpenNewWindow ? 1 : 0 }); + + const uri = Uri.file(repositoryPath); + + if (action === PostCloneAction.Open) { + commands.executeCommand('vscode.openFolder', uri, { forceReuseWindow: true }); + } else if (action === PostCloneAction.AddToWorkspace) { + workspace.updateWorkspaceFolders(workspace.workspaceFolders!.length, 0, { uri }); + } else if (action === PostCloneAction.OpenNewWindow) { + commands.executeCommand('vscode.openFolder', uri, { forceNewWindow: true }); + } + + return repositoryPath; + } catch (err) { + if (/already exists and is not an empty directory/.test(err && err.stderr || '')) { + /* __GDPR__ + "clone" : { + "owner": "lszomoru", + "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } + } + */ + this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'directory_not_empty' }); + } else if (/Cancelled/i.test(err && (err.message || err.stderr || ''))) { + return; + } else { + /* __GDPR__ + "clone" : { + "owner": "lszomoru", + "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } + } + */ + this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'error' }); + } + + throw err; + } + } + + private async doPostCloneAction(target: string, postCloneAction?: PostCloneAction): Promise { + const forceReuseWindow = ((workspace.workspaceFile === undefined) && (workspace.workspaceFolders === undefined)); + if (postCloneAction === 'open') { + await commands.executeCommand('vscode.openFolder', Uri.file(target), { forceReuseWindow }); + } + } + + private async chooseExistingRepository(url: string, existingCachedRepositories: RepositoryCacheInfo[], ref: string | undefined, parentPath?: string, postCloneAction?: PostCloneAction): Promise { + try { + const items: { label: string; description?: string; item?: RepositoryCacheInfo }[] = existingCachedRepositories.map(knownFolder => { + const isWorkspace = knownFolder.workspacePath.endsWith('.code-workspace'); + const label = isWorkspace ? l10n.t('Workspace: {0}', path.basename(knownFolder.workspacePath, '.code-workspace')) : path.basename(knownFolder.workspacePath); + return { label, description: knownFolder.workspacePath, item: knownFolder }; + }); + const cloneAgain = { label: l10n.t('Clone again') }; + items.push(cloneAgain); + const placeHolder = l10n.t('Open Existing Repository Clone'); + const pick = await window.showQuickPick(items, { placeHolder, canPickMany: false }); + if (pick === cloneAgain) { + return (await this.cloneRepository(url, parentPath, { ref, postCloneAction })) ?? undefined; + } + if (!pick?.item) { + return undefined; + } + return pick.item.workspacePath; + } catch { + return undefined; + } + } + + private async tryOpenExistingRepository(cachedRepository: RepositoryCacheInfo[], url: string, postCloneAction?: PostCloneAction, parentPath?: string, ref?: string): Promise { + // Gather existing folders/workspace files (ignore ones that no longer exist) + const existingCachedRepositories: RepositoryCacheInfo[] = (await Promise.all(cachedRepository.map(async folder => { + const stat = await fs.promises.stat(folder.workspacePath).catch(() => undefined); + if (stat) { + return folder; + } + return undefined; + } + ))).filter((folder): folder is RepositoryCacheInfo => folder !== undefined); + + if (!existingCachedRepositories.length) { + // fallback to clone + return (await this.cloneRepository(url, parentPath, { ref, postCloneAction }) ?? undefined); + } + + // First, find the cached repo that exists in the current workspace + const matchingInCurrentWorkspace = existingCachedRepositories?.find(cachedRepo => { + return workspace.workspaceFolders?.some(workspaceFolder => workspaceFolder.uri.fsPath === cachedRepo.workspacePath); + }); + + if (matchingInCurrentWorkspace) { + return matchingInCurrentWorkspace.workspacePath; + } + + let repoForWorkspace: string | undefined = (existingCachedRepositories.length === 1 ? existingCachedRepositories[0].workspacePath : undefined); + if (!repoForWorkspace) { + repoForWorkspace = await this.chooseExistingRepository(url, existingCachedRepositories, ref, parentPath, postCloneAction); + } + if (repoForWorkspace) { + return this.doPostCloneAction(repoForWorkspace, postCloneAction); + } + return undefined; + } +} diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index a7452e9d8f4..cc0ad43098c 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -19,6 +19,7 @@ import { GitTimelineItem } from './timelineProvider'; import { ApiRepository } from './api/api1'; import { getRemoteSourceActions, pickRemoteSource } from './remoteSource'; import { RemoteSourceAction } from './typings/git-base'; +import { CloneManager } from './cloneManager'; abstract class CheckoutCommandItem implements QuickPickItem { abstract get label(): string; @@ -774,7 +775,8 @@ export class CommandCenter { private model: Model, private globalState: Memento, private logger: LogOutputChannel, - private telemetryReporter: TelemetryReporter + private telemetryReporter: TelemetryReporter, + private cloneManager: CloneManager ) { this.disposables = Commands.map(({ commandId, key, method, options }) => { const command = this.createCommand(commandId, key, method, options); @@ -1016,12 +1018,12 @@ export class CommandCenter { @command('git.clone') async clone(url?: string, parentPath?: string, options?: { ref?: string }): Promise { - await this.model.clone(url, { parentPath, ...options }); + await this.cloneManager.clone(url, { parentPath, ...options }); } @command('git.cloneRecursive') async cloneRecursive(url?: string, parentPath?: string): Promise { - await this.model.clone(url, { parentPath, recursive: true }); + await this.cloneManager.clone(url, { parentPath, recursive: true }); } @command('git.init') diff --git a/extensions/git/src/main.ts b/extensions/git/src/main.ts index e48e2e339b5..30c8fbaacdb 100644 --- a/extensions/git/src/main.ts +++ b/extensions/git/src/main.ts @@ -27,6 +27,7 @@ import { GitPostCommitCommandsProvider } from './postCommitCommands'; import { GitEditSessionIdentityProvider } from './editSessionIdentityProvider'; import { GitCommitInputBoxCodeActionsProvider, GitCommitInputBoxDiagnosticsManager } from './diagnostics'; import { GitBlameController } from './blame'; +import { CloneManager } from './cloneManager'; const deactivateTasks: { (): Promise }[] = []; @@ -36,7 +37,7 @@ export async function deactivate(): Promise { } } -async function createModel(context: ExtensionContext, logger: LogOutputChannel, telemetryReporter: TelemetryReporter, disposables: Disposable[]): Promise { +async function createModel(context: ExtensionContext, logger: LogOutputChannel, telemetryReporter: TelemetryReporter, disposables: Disposable[]): Promise<{ model: Model; cloneManager: CloneManager }> { const pathValue = workspace.getConfiguration('git').get('path'); let pathHints = Array.isArray(pathValue) ? pathValue : pathValue ? [pathValue] : []; @@ -90,6 +91,7 @@ async function createModel(context: ExtensionContext, logger: LogOutputChannel, }); const model = new Model(git, askpass, context.globalState, context.workspaceState, logger, telemetryReporter); disposables.push(model); + const cloneManager = new CloneManager(model, telemetryReporter, model.repositoryCache); const onRepository = () => commands.executeCommand('setContext', 'gitOpenRepositoryCount', `${model.repositories.length}`); model.onDidOpenRepository(onRepository, null, disposables); @@ -108,7 +110,7 @@ async function createModel(context: ExtensionContext, logger: LogOutputChannel, git.onOutput.addListener('log', onOutput); disposables.push(toDisposable(() => git.onOutput.removeListener('log', onOutput))); - const cc = new CommandCenter(git, model, context.globalState, logger, telemetryReporter); + const cc = new CommandCenter(git, model, context.globalState, logger, telemetryReporter, cloneManager); disposables.push( cc, new GitFileSystemProvider(model, logger), @@ -134,7 +136,7 @@ async function createModel(context: ExtensionContext, logger: LogOutputChannel, checkGitVersion(info); commands.executeCommand('setContext', 'gitVersion2.35', git.compareGitVersionTo('2.35') >= 0); - return model; + return { model, cloneManager }; } async function isGitRepository(folder: WorkspaceFolder): Promise { @@ -210,13 +212,18 @@ export async function _activate(context: ExtensionContext): Promise workspace.getConfiguration('git', null).get('enabled') === true); const result = new GitExtensionImpl(); - eventToPromise(onEnabled).then(async () => result.model = await createModel(context, logger, telemetryReporter, disposables)); + eventToPromise(onEnabled).then(async () => { + const { model, cloneManager } = await createModel(context, logger, telemetryReporter, disposables); + result.model = model; + result.cloneManager = cloneManager; + }); return result; } try { - const model = await createModel(context, logger, telemetryReporter, disposables); - return new GitExtensionImpl(model); + const { model, cloneManager } = await createModel(context, logger, telemetryReporter, disposables); + + return new GitExtensionImpl({ model, cloneManager }); } catch (err) { console.warn(err.message); logger.warn(`[main] Failed to create model: ${err}`); diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index 2d70991f527..b2c536e5e07 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as os from 'os'; import { workspace, WorkspaceFoldersChangeEvent, Uri, window, Event, EventEmitter, QuickPickItem, Disposable, SourceControl, SourceControlResourceGroup, TextEditor, Memento, commands, LogOutputChannel, l10n, ProgressLocation, WorkspaceFolder, ThemeIcon } from 'vscode'; import TelemetryReporter from '@vscode/extension-telemetry'; import { IRepositoryResolver, Repository, RepositoryState } from './repository'; @@ -21,8 +20,7 @@ import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { IPostCommitCommandsProviderRegistry } from './postCommitCommands'; import { IBranchProtectionProviderRegistry } from './branchProtection'; import { ISourceControlHistoryItemDetailsProviderRegistry } from './historyItemDetailsProvider'; -import { RepositoryCache, RepositoryCacheInfo } from './repositoryCache'; -import { pickRemoteSource } from './remoteSource'; +import { RepositoryCache } from './repositoryCache'; class RepositoryPick implements QuickPickItem { @memoize get label(): string { @@ -63,16 +61,6 @@ interface OpenRepository extends Disposable { repository: Repository; } -type PostCloneAction = 'none' | 'open' | 'prompt'; - -export interface CloneOptions { - parentPath?: string; - ref?: string; - recursive?: boolean; - postCloneAction?: PostCloneAction; - skipCache?: boolean; -} - class ClosedRepositoriesManager { private _repositories: Set; @@ -288,11 +276,14 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi */ private _workspaceFolders = new Map(); - private repositoryCache: RepositoryCache; + private readonly _repositoryCache: RepositoryCache; + get repositoryCache(): RepositoryCache { + return this._repositoryCache; + } private disposables: Disposable[] = []; - constructor(readonly git: Git, private readonly askpass: Askpass, private globalState: Memento, readonly workspaceState: Memento, private logger: LogOutputChannel, private telemetryReporter: TelemetryReporter) { + constructor(readonly git: Git, private readonly askpass: Askpass, private globalState: Memento, readonly workspaceState: Memento, private logger: LogOutputChannel, private readonly telemetryReporter: TelemetryReporter) { // Repositories managers this._closedRepositoriesManager = new ClosedRepositoriesManager(workspaceState); this._parentRepositoriesManager = new ParentRepositoriesManager(globalState); @@ -313,7 +304,7 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi this.setState('uninitialized'); this.doInitialScan().finally(() => this.setState('initialized')); - this.repositoryCache = new RepositoryCache(globalState, logger); + this._repositoryCache = new RepositoryCache(globalState, logger); } private async doInitialScan(): Promise { @@ -669,7 +660,7 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi // Open repository const [dotGit, repositoryRootRealPath] = await Promise.all([this.git.getRepositoryDotGit(repositoryRoot), this.getRepositoryRootRealPath(repositoryRoot)]); const gitRepository = this.git.open(repositoryRoot, repositoryRootRealPath, dotGit, this.logger); - const repository = new Repository(gitRepository, this, this, this, this, this, this, this.globalState, this.logger, this.telemetryReporter, this.repositoryCache); + const repository = new Repository(gitRepository, this, this, this, this, this, this, this.globalState, this.logger, this.telemetryReporter, this._repositoryCache); this.open(repository); this._closedRepositoriesManager.deleteRepository(repository.root); @@ -681,7 +672,7 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi // Do not await this, we want SCM // to know about the repo asap repository.status().then(() => { - this.repositoryCache.update(repository.remotes, [], repository.root); + this._repositoryCache.update(repository.remotes, [], repository.root); }); } catch (err) { // noop @@ -876,7 +867,7 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi this.logger.info(`[Model][close] Repository: ${repository.root}`); this._closedRepositoriesManager.addRepository(openRepository.repository.root); - this.repositoryCache.update(repository.remotes, [], repository.root); + this._repositoryCache.update(repository.remotes, [], repository.root); openRepository.dispose(); } @@ -1104,227 +1095,6 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi return this._unsafeRepositoriesManager.deleteRepository(repository); } - async clone(url?: string, options: CloneOptions = {}) { - const cachedRepository = url ? this.repositoryCache.get(url) : undefined; - if (url && !options.skipCache && cachedRepository && (cachedRepository.length > 0)) { - return this.tryOpenExistingRepository(cachedRepository, url, options.postCloneAction, options.parentPath, options.ref); - } - return this.cloneRepository(url, options.parentPath, options); - } - - async cloneRepository(url?: string, parentPath?: string, options: { recursive?: boolean; ref?: string; postCloneAction?: PostCloneAction } = {}): Promise { - if (!url || typeof url !== 'string') { - url = await pickRemoteSource({ - providerLabel: provider => l10n.t('Clone from {0}', provider.name), - urlLabel: l10n.t('Clone from URL') - }); - } - - if (!url) { - /* __GDPR__ - "clone" : { - "owner": "lszomoru", - "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } - } - */ - this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'no_URL' }); - return; - } - - url = url.trim().replace(/^git\s+clone\s+/, ''); - - if (!parentPath) { - const config = workspace.getConfiguration('git'); - let defaultCloneDirectory = config.get('defaultCloneDirectory') || os.homedir(); - defaultCloneDirectory = defaultCloneDirectory.replace(/^~/, os.homedir()); - - const uris = await window.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - defaultUri: Uri.file(defaultCloneDirectory), - title: l10n.t('Choose a folder to clone {0} into', url), - openLabel: l10n.t('Select as Repository Destination') - }); - - if (!uris || uris.length === 0) { - /* __GDPR__ - "clone" : { - "owner": "lszomoru", - "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } - } - */ - this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'no_directory' }); - return; - } - - const uri = uris[0]; - parentPath = uri.fsPath; - } - - try { - const opts = { - location: ProgressLocation.Notification, - title: l10n.t('Cloning git repository "{0}"...', url), - cancellable: true - }; - - const repositoryPath = await window.withProgress( - opts, - (progress, token) => this.git.clone(url!, { parentPath: parentPath!, progress, recursive: options.recursive, ref: options.ref }, token) - ); - - const config = workspace.getConfiguration('git'); - const openAfterClone = config.get<'always' | 'alwaysNewWindow' | 'whenNoFolderOpen' | 'prompt'>('openAfterClone'); - - enum PostCloneAction { Open, OpenNewWindow, AddToWorkspace, None } - let action: PostCloneAction | undefined = undefined; - - if (options.postCloneAction) { - if (options.postCloneAction === 'open') { - action = PostCloneAction.Open; - } else if (options.postCloneAction === 'none') { - action = PostCloneAction.None; - } - } else { - if (openAfterClone === 'always') { - action = PostCloneAction.Open; - } else if (openAfterClone === 'alwaysNewWindow') { - action = PostCloneAction.OpenNewWindow; - } else if (openAfterClone === 'whenNoFolderOpen' && !workspace.workspaceFolders) { - action = PostCloneAction.Open; - } - } - - if (action === undefined) { - let message = l10n.t('Would you like to open the cloned repository?'); - const open = l10n.t('Open'); - const openNewWindow = l10n.t('Open in New Window'); - const choices = [open, openNewWindow]; - - const addToWorkspace = l10n.t('Add to Workspace'); - if (workspace.workspaceFolders) { - message = l10n.t('Would you like to open the cloned repository, or add it to the current workspace?'); - choices.push(addToWorkspace); - } - - const result = await window.showInformationMessage(message, { modal: true }, ...choices); - - action = result === open ? PostCloneAction.Open - : result === openNewWindow ? PostCloneAction.OpenNewWindow - : result === addToWorkspace ? PostCloneAction.AddToWorkspace : undefined; - } - - /* __GDPR__ - "clone" : { - "owner": "lszomoru", - "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" }, - "openFolder": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Indicates whether the folder is opened following the clone operation" } - } - */ - this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'success' }, { openFolder: action === PostCloneAction.Open || action === PostCloneAction.OpenNewWindow ? 1 : 0 }); - - const uri = Uri.file(repositoryPath); - - if (action === PostCloneAction.Open) { - commands.executeCommand('vscode.openFolder', uri, { forceReuseWindow: true }); - } else if (action === PostCloneAction.AddToWorkspace) { - workspace.updateWorkspaceFolders(workspace.workspaceFolders!.length, 0, { uri }); - } else if (action === PostCloneAction.OpenNewWindow) { - commands.executeCommand('vscode.openFolder', uri, { forceNewWindow: true }); - } - - return repositoryPath; - } catch (err) { - if (/already exists and is not an empty directory/.test(err && err.stderr || '')) { - /* __GDPR__ - "clone" : { - "owner": "lszomoru", - "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } - } - */ - this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'directory_not_empty' }); - } else if (/Cancelled/i.test(err && (err.message || err.stderr || ''))) { - return; - } else { - /* __GDPR__ - "clone" : { - "owner": "lszomoru", - "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } - } - */ - this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'error' }); - } - - throw err; - } - } - - private async postCloneAction(target: string, postCloneAction?: PostCloneAction): Promise { - const forceReuseWindow = ((workspace.workspaceFile === undefined) && (workspace.workspaceFolders === undefined)); - if (postCloneAction === 'open') { - await commands.executeCommand('vscode.openFolder', Uri.file(target), { forceReuseWindow }); - } - } - - private async chooseExistingRepository(url: string, existingCachedRepositories: RepositoryCacheInfo[], ref: string | undefined, parentPath?: string, postCloneAction?: PostCloneAction): Promise { - try { - const items: { label: string; description?: string; item?: RepositoryCacheInfo }[] = existingCachedRepositories.map(knownFolder => { - const isWorkspace = knownFolder.workspacePath.endsWith('.code-workspace'); - const label = isWorkspace ? l10n.t('Workspace: {0}', path.basename(knownFolder.workspacePath, '.code-workspace')) : path.basename(knownFolder.workspacePath); - return { label, description: knownFolder.workspacePath, item: knownFolder }; - }); - const cloneAgain = { label: l10n.t('Clone again') }; - items.push(cloneAgain); - const placeHolder = l10n.t('Open Existing Repository Clone'); - const pick = await window.showQuickPick(items, { placeHolder, canPickMany: false }); - if (pick === cloneAgain) { - return (await this.cloneRepository(url, parentPath, { ref, postCloneAction })) ?? undefined; - } - if (!pick?.item) { - return undefined; - } - return pick.item.workspacePath; - } catch { - return undefined; - } - } - - private async tryOpenExistingRepository(cachedRepository: RepositoryCacheInfo[], url: string, postCloneAction?: PostCloneAction, parentPath?: string, ref?: string): Promise { - // Gather existing folders/workspace files (ignore ones that no longer exist) - const existingCachedRepositories: RepositoryCacheInfo[] = (await Promise.all(cachedRepository.map(async folder => { - const stat = await fs.promises.stat(folder.workspacePath).catch(() => undefined); - if (stat) { - return folder; - } - return undefined; - } - ))).filter((folder): folder is RepositoryCacheInfo => folder !== undefined); - - if (!existingCachedRepositories.length) { - // fallback to clone - return (await this.cloneRepository(url, parentPath, { ref, postCloneAction }) ?? undefined); - } - - // First, find the cached repo that exists in the current workspace - const matchingInCurrentWorkspace = existingCachedRepositories?.find(cachedRepo => { - return workspace.workspaceFolders?.some(workspaceFolder => workspaceFolder.uri.fsPath === cachedRepo.workspacePath); - }); - - if (matchingInCurrentWorkspace) { - return matchingInCurrentWorkspace.workspacePath; - } - - let repoForWorkspace: string | undefined = (existingCachedRepositories.length === 1 ? existingCachedRepositories[0].workspacePath : undefined); - if (!repoForWorkspace) { - repoForWorkspace = await this.chooseExistingRepository(url, existingCachedRepositories, ref, parentPath, postCloneAction); - } - if (repoForWorkspace) { - return this.postCloneAction(repoForWorkspace, postCloneAction); - } - return undefined; - } - private async isRepositoryOutsideWorkspace(repositoryPath: string): Promise { const workspaceFolders = (workspace.workspaceFolders || []) .filter(folder => folder.uri.scheme === 'file');