diff --git a/extensions/git/src/cloneManager.ts b/extensions/git/src/cloneManager.ts index 795901dde99..49d57d8763c 100644 --- a/extensions/git/src/cloneManager.ts +++ b/extensions/git/src/cloneManager.ts @@ -144,14 +144,14 @@ export class CloneManager { } if (action === undefined) { - let message = l10n.t('Would you like to open the cloned repository?'); + let message = l10n.t('Would you like to open the 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?'); + message = l10n.t('Would you like to open the repository, or add it to the current workspace?'); choices.push(addToWorkspace); } diff --git a/extensions/git/src/repositoryCache.ts b/extensions/git/src/repositoryCache.ts index ed798726953..5e3f8cbe594 100644 --- a/extensions/git/src/repositoryCache.ts +++ b/extensions/git/src/repositoryCache.ts @@ -26,6 +26,15 @@ export class RepositoryCache { private static readonly MAX_REPO_ENTRIES = 30; // Max repositories tracked private static readonly MAX_FOLDER_ENTRIES = 10; // Max folders per repository + private normalizeRepoUrl(url: string): string { + try { + const trimmed = url.trim(); + return trimmed.replace(/(?:\.git)?\/*$/i, ''); + } catch { + return url; + } + } + // Outer LRU: repoUrl -> inner LRU (folderPathOrWorkspaceFile -> RepositoryCacheInfo). private readonly lru = new LRUCache>(RepositoryCache.MAX_REPO_ENTRIES); @@ -50,7 +59,8 @@ export class RepositoryCache { * @param rootPath Root path of the local repo clone. */ set(repoUrl: string, rootPath: string): void { - let foldersLru = this.lru.get(repoUrl); + const key = this.normalizeRepoUrl(repoUrl); + let foldersLru = this.lru.get(key); if (!foldersLru) { foldersLru = new LRUCache(RepositoryCache.MAX_FOLDER_ENTRIES); } @@ -62,7 +72,7 @@ export class RepositoryCache { foldersLru.set(folderPathOrWorkspaceFile, { workspacePath: folderPathOrWorkspaceFile }); // touch entry - this.lru.set(repoUrl, foldersLru); + this.lru.set(key, foldersLru); this.save(); } @@ -114,12 +124,14 @@ export class RepositoryCache { * We should possibly support converting between ssh remotes and http remotes. */ get(repoUrl: string): RepositoryCacheInfo[] | undefined { - const inner = this.lru.get(repoUrl); + const key = this.normalizeRepoUrl(repoUrl); + const inner = this.lru.get(key); return inner ? Array.from(inner.values()) : undefined; } delete(repoUrl: string, folderPathOrWorkspaceFile: string) { - const inner = this.lru.get(repoUrl); + const key = this.normalizeRepoUrl(repoUrl); + const inner = this.lru.get(key); if (!inner) { return; } @@ -127,10 +139,10 @@ export class RepositoryCache { return; } if (inner.size === 0) { - this.lru.remove(repoUrl); + this.lru.remove(key); } else { // Re-set to bump outer LRU recency after modification - this.lru.set(repoUrl, inner); + this.lru.set(key, inner); } this.save(); } diff --git a/extensions/git/src/test/repositoryCache.test.ts b/extensions/git/src/test/repositoryCache.test.ts index 8e289334fab..ce3ec98272d 100644 --- a/extensions/git/src/test/repositoryCache.test.ts +++ b/extensions/git/src/test/repositoryCache.test.ts @@ -134,4 +134,64 @@ suite('RepositoryCache', () => { cache.delete(repo, b); assert.strictEqual(cache.get(repo), undefined, 'repo should be pruned when last folder removed'); }); + + test('normalizes URLs with trailing .git', () => { + const memento = new InMemoryMemento(); + const folder = Uri.file('/workspace/repo'); + const cache = new TestRepositoryCache(memento, new MockLogOutputChannel(), undefined, [{ uri: folder, name: 'workspace', index: 0 }]); + + // Set with .git extension + cache.set('https://example.com/repo.git', folder.fsPath); + + // Should be able to get with or without .git + const withGit = cache.get('https://example.com/repo.git'); + const withoutGit = cache.get('https://example.com/repo'); + + assert.ok(withGit, 'should find repo when querying with .git'); + assert.ok(withoutGit, 'should find repo when querying without .git'); + assert.deepStrictEqual(withGit, withoutGit, 'should return same result regardless of .git suffix'); + }); + + test('normalizes URLs with trailing slashes and .git', () => { + const memento = new InMemoryMemento(); + const folder = Uri.file('/workspace/repo'); + const cache = new TestRepositoryCache(memento, new MockLogOutputChannel(), undefined, [{ uri: folder, name: 'workspace', index: 0 }]); + + // Set with .git and trailing slashes + cache.set('https://example.com/repo.git///', folder.fsPath); + + // Should be able to get with various combinations + const variations = [ + 'https://example.com/repo.git///', + 'https://example.com/repo.git/', + 'https://example.com/repo.git', + 'https://example.com/repo/', + 'https://example.com/repo' + ]; + + const results = variations.map(url => cache.get(url)); + + // All should return the same non-undefined result + assert.ok(results[0], 'should find repo with original URL'); + for (let i = 1; i < results.length; i++) { + assert.deepStrictEqual(results[i], results[0], `variation ${variations[i]} should return same result`); + } + }); + + test('handles URLs without .git correctly', () => { + const memento = new InMemoryMemento(); + const folder = Uri.file('/workspace/repo'); + const cache = new TestRepositoryCache(memento, new MockLogOutputChannel(), undefined, [{ uri: folder, name: 'workspace', index: 0 }]); + + // Set without .git extension + cache.set('https://example.com/repo', folder.fsPath); + + // Should be able to get with or without .git + const withoutGit = cache.get('https://example.com/repo'); + const withGit = cache.get('https://example.com/repo.git'); + + assert.ok(withoutGit, 'should find repo when querying without .git'); + assert.ok(withGit, 'should find repo when querying with .git'); + assert.deepStrictEqual(withoutGit, withGit, 'should return same result regardless of .git suffix'); + }); });