diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 2be6cec8dea..b57a18e988b 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -7,7 +7,7 @@ import { Model } from '../model'; import { Repository as BaseRepository, Resource } from '../repository'; -import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, SourceControlHistoryItemDetailsProvider, GitErrorCodes } from './git'; +import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, SourceControlHistoryItemDetailsProvider, GitErrorCodes, CloneOptions } from './git'; import { Event, SourceControlInputBox, Uri, SourceControl, Disposable, commands, CancellationToken } from 'vscode'; import { combinedDisposable, filterEvent, mapEvent } from '../util'; import { toGitUri } from '../uri'; @@ -399,6 +399,12 @@ export class ApiImpl implements API { return this.getRepository(root) || null; } + 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 }); + return result ? Uri.file(result) : null; + } + async openRepository(root: Uri): Promise { if (root.scheme !== 'file') { return null; diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index fdfb8b397bc..f7813f13e8c 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -183,6 +183,20 @@ export interface InitOptions { defaultBranch?: string; } +export interface CloneOptions { + parentPath?: Uri; + /** + * ref is only used if the repository cache is missed. + */ + ref?: string; + recursive?: boolean; + /** + * If no postCloneAction is provided, then the users setting for git.openAfterClone is used. + */ + postCloneAction?: 'none' | 'open' | 'prompt'; + skipCache?: boolean; +} + export interface RefQuery { readonly contains?: string; readonly count?: number; @@ -366,7 +380,11 @@ export interface API { getRepository(uri: Uri): Repository | null; getRepositoryRoot(uri: Uri): Promise; init(root: Uri, options?: InitOptions): Promise; - openRepository(root: Uri): Promise + /** + * @returns The URI of either the cloned repository, or the workspace file or folder which contains the cloned repository. + */ + clone(uri: Uri, options?: CloneOptions): Promise; + openRepository(root: Uri): Promise; registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 301f6bdef9f..a7452e9d8f4 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -5,7 +5,7 @@ import * as os from 'os'; import * as path from 'path'; -import { Command, commands, Disposable, MessageOptions, Position, ProgressLocation, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider, InputBoxValidationSeverity, TabInputText, TabInputTextMerge, QuickPickItemKind, TextDocument, LogOutputChannel, l10n, Memento, UIKind, QuickInputButton, ThemeIcon, SourceControlHistoryItem, SourceControl, InputBoxValidationMessage, Tab, TabInputNotebook, QuickInputButtonLocation, languages } from 'vscode'; +import { Command, commands, Disposable, MessageOptions, Position, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider, InputBoxValidationSeverity, TabInputText, TabInputTextMerge, QuickPickItemKind, TextDocument, LogOutputChannel, l10n, Memento, UIKind, QuickInputButton, ThemeIcon, SourceControlHistoryItem, SourceControl, InputBoxValidationMessage, Tab, TabInputNotebook, QuickInputButtonLocation, languages, } from 'vscode'; import TelemetryReporter from '@vscode/extension-telemetry'; import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator'; import { ForcePushMode, GitErrorCodes, RefType, Status, CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref } from './api/git'; @@ -944,144 +944,6 @@ export class CommandCenter { } } - async cloneRepository(url?: string, parentPath?: string, options: { recursive?: boolean; ref?: string } = {}): 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 } - let action: PostCloneAction | undefined = undefined; - - 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 }); - } - } 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 getRepositoriesWithRemote(repositories: Repository[]) { return repositories.reduce<(QuickPickItem & { repository: Repository })[]>((items, repository) => { const remote = repository.remotes.find((r) => r.name === repository.HEAD?.upstream?.remote); @@ -1154,12 +1016,12 @@ export class CommandCenter { @command('git.clone') async clone(url?: string, parentPath?: string, options?: { ref?: string }): Promise { - await this.cloneRepository(url, parentPath, options); + await this.model.clone(url, { parentPath, ...options }); } @command('git.cloneRecursive') async cloneRecursive(url?: string, parentPath?: string): Promise { - await this.cloneRepository(url, parentPath, { recursive: true }); + await this.model.clone(url, { parentPath, recursive: true }); } @command('git.init') diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index e96a9d31290..2d70991f527 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -3,6 +3,7 @@ * 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'; @@ -20,7 +21,8 @@ import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { IPostCommitCommandsProviderRegistry } from './postCommitCommands'; import { IBranchProtectionProviderRegistry } from './branchProtection'; import { ISourceControlHistoryItemDetailsProviderRegistry } from './historyItemDetailsProvider'; -import { RepositoryCache } from './repositoryCache'; +import { RepositoryCache, RepositoryCacheInfo } from './repositoryCache'; +import { pickRemoteSource } from './remoteSource'; class RepositoryPick implements QuickPickItem { @memoize get label(): string { @@ -61,6 +63,16 @@ 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; @@ -1092,6 +1104,227 @@ 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'); diff --git a/extensions/git/src/repositoryCache.ts b/extensions/git/src/repositoryCache.ts index 6a00974e0b3..ed798726953 100644 --- a/extensions/git/src/repositoryCache.ts +++ b/extensions/git/src/repositoryCache.ts @@ -4,9 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import { LogOutputChannel, Memento, workspace } from 'vscode'; -import * as path from 'path'; import { LRUCache } from './cache'; import { Remote } from './api/git'; +import { isDescendant } from './util'; + +export interface RepositoryCacheInfo { + workspacePath: string; // path of the workspace folder or workspace file +} + +function isRepositoryCacheInfo(obj: unknown): obj is RepositoryCacheInfo { + if (!obj || typeof obj !== 'object') { + return false; + } + const rec = obj as Record; + return typeof rec.workspacePath === 'string'; +} export class RepositoryCache { @@ -14,8 +26,8 @@ export class RepositoryCache { private static readonly MAX_REPO_ENTRIES = 30; // Max repositories tracked private static readonly MAX_FOLDER_ENTRIES = 10; // Max folders per repository - // Outer LRU: repoUrl -> inner LRU (folderPathOrWorkspaceFile -> true). Only keys matter. - private readonly lru = new LRUCache>(RepositoryCache.MAX_REPO_ENTRIES); + // Outer LRU: repoUrl -> inner LRU (folderPathOrWorkspaceFile -> RepositoryCacheInfo). + private readonly lru = new LRUCache>(RepositoryCache.MAX_REPO_ENTRIES); constructor(public readonly _globalState: Memento, private readonly _logger: LogOutputChannel) { this.load(); @@ -40,14 +52,16 @@ export class RepositoryCache { set(repoUrl: string, rootPath: string): void { let foldersLru = this.lru.get(repoUrl); if (!foldersLru) { - foldersLru = new LRUCache(RepositoryCache.MAX_FOLDER_ENTRIES); + foldersLru = new LRUCache(RepositoryCache.MAX_FOLDER_ENTRIES); } const folderPathOrWorkspaceFile: string | undefined = this._findWorkspaceForRepo(rootPath); if (!folderPathOrWorkspaceFile) { return; } - foldersLru.set(folderPathOrWorkspaceFile, true); // touch entry + foldersLru.set(folderPathOrWorkspaceFile, { + workspacePath: folderPathOrWorkspaceFile + }); // touch entry this.lru.set(repoUrl, foldersLru); this.save(); } @@ -62,13 +76,7 @@ export class RepositoryCache { const sorted = [...this._workspaceFolders].sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length); for (const folder of sorted) { const folderPath = folder.uri.fsPath; - const relToFolder = path.relative(folderPath, rootPath); - if (relToFolder === '' || (!relToFolder.startsWith('..') && !path.isAbsolute(relToFolder))) { - folderPathOrWorkspaceFile = folderPath; - break; - } - const relFromFolder = path.relative(rootPath, folderPath); - if (relFromFolder === '' || (!relFromFolder.startsWith('..') && !path.isAbsolute(relFromFolder))) { + if (isDescendant(folderPath, rootPath) || isDescendant(rootPath, folderPath)) { folderPathOrWorkspaceFile = folderPath; break; } @@ -105,9 +113,9 @@ export class RepositoryCache { /** * We should possibly support converting between ssh remotes and http remotes. */ - get(repoUrl: string): string[] | undefined { + get(repoUrl: string): RepositoryCacheInfo[] | undefined { const inner = this.lru.get(repoUrl); - return inner ? Array.from(inner.keys()) : undefined; + return inner ? Array.from(inner.values()) : undefined; } delete(repoUrl: string, folderPathOrWorkspaceFile: string) { @@ -129,42 +137,43 @@ export class RepositoryCache { private load(): void { try { - const raw = this._globalState.get<[string, [string, true][]][]>(RepositoryCache.STORAGE_KEY); - if (Array.isArray(raw)) { - for (const [repo, storedFolders] of raw) { - if (typeof repo !== 'string' || !Array.isArray(storedFolders)) { + const raw = this._globalState.get<[string, [string, RepositoryCacheInfo][]][]>(RepositoryCache.STORAGE_KEY); + if (!Array.isArray(raw)) { + return; + } + for (const [repo, storedFolders] of raw) { + if (typeof repo !== 'string' || !Array.isArray(storedFolders)) { + continue; + } + const inner = new LRUCache(RepositoryCache.MAX_FOLDER_ENTRIES); + for (const entry of storedFolders) { + if (!Array.isArray(entry) || entry.length !== 2) { continue; } - const inner = new LRUCache(RepositoryCache.MAX_FOLDER_ENTRIES); - for (const entry of storedFolders) { - let folderPath: string | undefined; - if (Array.isArray(entry) && entry.length === 2) { - const [workspaceFolder, _] = entry; - if (typeof workspaceFolder === 'string') { - folderPath = workspaceFolder; - } - } - if (folderPath) { - inner.set(folderPath, true); - } - } - if (inner.size) { - this.lru.set(repo, inner); + const [folderPath, info] = entry; + if (typeof folderPath !== 'string' || !isRepositoryCacheInfo(info)) { + continue; } + + inner.set(folderPath, info); + } + if (inner.size) { + this.lru.set(repo, inner); } } + } catch { this._logger.warn('[CachedRepositories][load] Failed to load cached repositories from global state.'); } } private save(): void { - // Serialize as [repoUrl, [folderPathOrWorkspaceFile, true][]] preserving outer LRU order. - const serialized: [string, [string, true][]][] = []; + // Serialize as [repoUrl, [folderPathOrWorkspaceFile, RepositoryCacheInfo][]] preserving outer LRU order. + const serialized: [string, [string, RepositoryCacheInfo][]][] = []; for (const [repo, inner] of this.lru) { - const folders: [string, true][] = []; - for (const [folder, _] of inner) { - folders.push([folder, true]); + const folders: [string, RepositoryCacheInfo][] = []; + for (const [folder, info] of inner) { + folders.push([folder, info]); } serialized.push([repo, folders]); } diff --git a/extensions/git/src/test/repositoryCache.test.ts b/extensions/git/src/test/repositoryCache.test.ts index f5a8e315817..8e289334fab 100644 --- a/extensions/git/src/test/repositoryCache.test.ts +++ b/extensions/git/src/test/repositoryCache.test.ts @@ -74,11 +74,13 @@ suite('RepositoryCache', () => { test('set & get basic', () => { const memento = new InMemoryMemento(); - const cache = new TestRepositoryCache(memento, new MockLogOutputChannel(), undefined, [{ uri: Uri.file('/workspace/repo'), name: 'workspace', index: 0 }]); - cache.set('https://example.com/repo.git', '/workspace/repo'); - const folders = cache.get('https://example.com/repo.git')!.map(folder => folder.replace(/\\/g, '/')); + const folder = Uri.file('/workspace/repo'); + const cache = new TestRepositoryCache(memento, new MockLogOutputChannel(), undefined, [{ uri: folder, name: 'workspace', index: 0 }]); + + cache.set('https://example.com/repo.git', folder.fsPath); + const folders = cache.get('https://example.com/repo.git')!.map(folder => folder.workspacePath); assert.ok(folders, 'folders should be defined'); - assert.deepStrictEqual(folders, ['/workspace/repo']); + assert.deepStrictEqual(folders, [folder.fsPath]); }); test('inner LRU capped at 10 entries', () => { @@ -90,13 +92,13 @@ suite('RepositoryCache', () => { const cache = new TestRepositoryCache(memento, new MockLogOutputChannel(), undefined, workspaceFolders); const repo = 'https://example.com/repo.git'; for (let i = 1; i <= 12; i++) { - cache.set(repo, `/ws/folder-${i.toString().padStart(2, '0')}`); + cache.set(repo, Uri.file(`/ws/folder-${i.toString().padStart(2, '0')}`).fsPath); } - const folders = cache.get(repo)!.map(folder => folder.replace(/\\/g, '/')); + const folders = cache.get(repo)!.map(folder => folder.workspacePath); assert.strictEqual(folders.length, 10, 'should only retain 10 most recent folders'); - assert.ok(!folders.includes('/ws/folder-01'), 'oldest folder-01 should be evicted'); - assert.ok(!folders.includes('/ws/folder-02'), 'second oldest folder-02 should be evicted'); - assert.ok(folders.includes('/ws/folder-12'), 'latest folder should be present'); + assert.ok(!folders.includes(Uri.file('/ws/folder-01').fsPath), 'oldest folder-01 should be evicted'); + assert.ok(!folders.includes(Uri.file('/ws/folder-02').fsPath), 'second oldest folder-02 should be evicted'); + assert.ok(folders.includes(Uri.file('/ws/folder-12').fsPath), 'latest folder should be present'); }); test('outer LRU capped at 30 repos', () => { @@ -108,7 +110,7 @@ suite('RepositoryCache', () => { const cache = new TestRepositoryCache(memento, new MockLogOutputChannel(), undefined, workspaceFolders); for (let i = 1; i <= 35; i++) { const repo = `https://example.com/r${i}.git`; - cache.set(repo, `/ws/r${i}`); + cache.set(repo, Uri.file(`/ws/r${i}`).fsPath); } assert.strictEqual(cache.get('https://example.com/r1.git'), undefined, 'oldest repo should be trimmed'); assert.ok(cache.get('https://example.com/r35.git'), 'newest repo should remain'); @@ -126,9 +128,9 @@ suite('RepositoryCache', () => { const b = Uri.file('/ws/b').fsPath; cache.set(repo, a); cache.set(repo, b); - assert.deepStrictEqual(new Set(cache.get(repo)!), new Set([a, b])); + assert.deepStrictEqual(new Set(cache.get(repo)?.map(folder => folder.workspacePath)), new Set([a, b])); cache.delete(repo, a); - assert.deepStrictEqual(cache.get(repo)!, [b]); + assert.deepStrictEqual(cache.get(repo)!.map(folder => folder.workspacePath), [b]); cache.delete(repo, b); assert.strictEqual(cache.get(repo), undefined, 'repo should be pruned when last folder removed'); });