mirror of
https://github.com/microsoft/vscode.git
synced 2025-12-24 12:19:20 +00:00
Improve layering for git model (#272857)
* Improve layering for git model - Git clone doesn't belong in the model, removed it - All the extra repo picking didn't seem to fit into `Git` though, as that is really about git operations - Added a `CloneUtils` namespace for all the clone stuff to live. * Update extensions/git/src/clone.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * CloneManager class * public/private
This commit is contained in:
@@ -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<Uri | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean> = 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 });
|
||||
}
|
||||
}
|
||||
|
||||
250
extensions/git/src/cloneManager.ts
Normal file
250
extensions/git/src/cloneManager.ts
Normal file
@@ -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<string | undefined> {
|
||||
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<string>('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<undefined> {
|
||||
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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
// Gather existing folders/workspace files (ignore ones that no longer exist)
|
||||
const existingCachedRepositories: RepositoryCacheInfo[] = (await Promise.all<RepositoryCacheInfo | undefined>(cachedRepository.map(async folder => {
|
||||
const stat = await fs.promises.stat(folder.workspacePath).catch(() => undefined);
|
||||
if (stat) {
|
||||
return folder;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
))).filter<RepositoryCacheInfo>((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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
await this.model.clone(url, { parentPath, ...options });
|
||||
await this.cloneManager.clone(url, { parentPath, ...options });
|
||||
}
|
||||
|
||||
@command('git.cloneRecursive')
|
||||
async cloneRecursive(url?: string, parentPath?: string): Promise<void> {
|
||||
await this.model.clone(url, { parentPath, recursive: true });
|
||||
await this.cloneManager.clone(url, { parentPath, recursive: true });
|
||||
}
|
||||
|
||||
@command('git.init')
|
||||
|
||||
@@ -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<any> }[] = [];
|
||||
|
||||
@@ -36,7 +37,7 @@ export async function deactivate(): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
async function createModel(context: ExtensionContext, logger: LogOutputChannel, telemetryReporter: TelemetryReporter, disposables: Disposable[]): Promise<Model> {
|
||||
async function createModel(context: ExtensionContext, logger: LogOutputChannel, telemetryReporter: TelemetryReporter, disposables: Disposable[]): Promise<{ model: Model; cloneManager: CloneManager }> {
|
||||
const pathValue = workspace.getConfiguration('git').get<string | string[]>('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<boolean> {
|
||||
@@ -210,13 +212,18 @@ export async function _activate(context: ExtensionContext): Promise<GitExtension
|
||||
const onEnabled = filterEvent(onConfigChange, () => workspace.getConfiguration('git', null).get<boolean>('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}`);
|
||||
|
||||
@@ -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<string>;
|
||||
@@ -288,11 +276,14 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi
|
||||
*/
|
||||
private _workspaceFolders = new Map<string, string>();
|
||||
|
||||
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<void> {
|
||||
@@ -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<string | undefined> {
|
||||
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<string>('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<undefined> {
|
||||
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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
// Gather existing folders/workspace files (ignore ones that no longer exist)
|
||||
const existingCachedRepositories: RepositoryCacheInfo[] = (await Promise.all<RepositoryCacheInfo | undefined>(cachedRepository.map(async folder => {
|
||||
const stat = await fs.promises.stat(folder.workspacePath).catch(() => undefined);
|
||||
if (stat) {
|
||||
return folder;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
))).filter<RepositoryCacheInfo>((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<boolean> {
|
||||
const workspaceFolders = (workspace.workspaceFolders || [])
|
||||
.filter(folder => folder.uri.scheme === 'file');
|
||||
|
||||
Reference in New Issue
Block a user