diff --git a/src/vs/platform/windows/electron-main/windowsFinder.ts b/src/vs/platform/windows/electron-main/windowsFinder.ts index 1fc60b1355431c..a360cc2f143726 100644 --- a/src/vs/platform/windows/electron-main/windowsFinder.ts +++ b/src/vs/platform/windows/electron-main/windowsFinder.ts @@ -3,14 +3,116 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs'; +import { Schemas } from '../../../base/common/network.js'; +import { resolve } from '../../../base/common/path.js'; import { extUriBiasedIgnorePathCase } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { ICodeWindow } from '../../window/electron-main/window.js'; import { IResolvedWorkspace, ISingleFolderWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IWorkspaceIdentifier } from '../../workspace/common/workspace.js'; +// Matches paths pointing into a git linked worktree's private metadata +// directory, e.g. `/.git/worktrees//COMMIT_EDITMSG`. +// Captures the full path of the worktree's metadata directory itself, i.e. +// everything up to and including `.git/worktrees/`. Accepts both `/` +// and `\` as path separators. +const gitWorktreeFilePathRegex = /^(.*[\\/]\.git[\\/]worktrees[\\/][^\\/]+)[\\/]/; + +// Returns the folder(s) a window has open, for both single-folder windows and +// (resolved) multi-root workspace windows. Returns an empty array when the +// window has no folders opened, or its workspace could not be resolved. +async function getOpenedFolderUris(window: ICodeWindow, localWorkspaceResolver: (workspace: IWorkspaceIdentifier) => Promise): Promise { + const workspace = window.openedWorkspace; + if (isSingleFolderWorkspaceIdentifier(workspace)) { + return [workspace.uri]; + } + + if (isWorkspaceIdentifier(workspace)) { + const resolvedWorkspace = await localWorkspaceResolver(workspace); + return resolvedWorkspace ? resolvedWorkspace.folders.map(folder => folder.uri) : []; + } + + return []; +} + +/** + * Git linked worktrees store their private per-worktree files (such as + * `COMMIT_EDITMSG` or `rebase-merge/git-rebase-todo`) inside the *main* + * worktree's `.git/worktrees/` directory, even though the corresponding + * working directory lives elsewhere on disk. A plain parent-folder match on + * such a file's path therefore always resolves to the window that has the + * main worktree open, never the window with the linked worktree the file + * actually belongs to. + * + * This detects that case and, if the linked worktree is open in one of the + * candidate windows (as a single-folder window or as one folder of a + * multi-root workspace window), returns that window instead. Returns + * `undefined` when the path is not a git worktree metadata path, or when no + * candidate window has the corresponding linked worktree open, so callers + * can fall back to their normal matching logic. + */ +async function findWindowOnGitWorktreeFile(windows: ICodeWindow[], fileUri: URI, localWorkspaceResolver: (workspace: IWorkspaceIdentifier) => Promise): Promise { + if (fileUri.scheme !== Schemas.file) { + return undefined; + } + + const worktreeMatch = gitWorktreeFilePathRegex.exec(fileUri.fsPath); + if (!worktreeMatch) { + return undefined; + } + + // The exact directory the file's worktree metadata lives in, e.g. + // `/path/to/main/.git/worktrees/linked`. Candidate windows are matched + // against this full path rather than just the trailing `` segment, + // so that two unrelated repositories that happen to use the same worktree + // name (e.g. both named `linked`) cannot be confused with one another. + const worktreeGitDir = URI.file(worktreeMatch[1]); + + for (const window of windows) { + for (const openedFolder of await getOpenedFolderUris(window, localWorkspaceResolver)) { + if (openedFolder.scheme !== Schemas.file) { + continue; + } + + // A linked worktree's working directory contains a plain-text `.git` + // *file* (not a directory) of the form `gitdir: /path/to/main/.git/worktrees/`. + // Reading this will fail (and is safely skipped) for ordinary repositories, + // where `.git` is a directory, and for folders that are not a git repository at all. + let gitFileContents: string; + try { + gitFileContents = await fs.promises.readFile(URI.joinPath(openedFolder, '.git').fsPath, 'utf8'); + } catch { + continue; + } + + const gitDirMatch = /^gitdir:\s*(.+)$/m.exec(gitFileContents); + if (!gitDirMatch) { + continue; + } + + // The pointer is usually absolute, but resolve it relative to the + // worktree's own folder in case it is ever written as a relative path. + const resolvedGitDir = URI.file(resolve(openedFolder.fsPath, gitDirMatch[1].trim())); + if (extUriBiasedIgnorePathCase.isEqual(resolvedGitDir, worktreeGitDir)) { + return window; + } + } + } + + return undefined; +} + export async function findWindowOnFile(windows: ICodeWindow[], fileUri: URI, localWorkspaceResolver: (workspace: IWorkspaceIdentifier) => Promise): Promise { - // First check for windows with workspaces that have a parent folder of the provided path opened + // First, check whether the file is a git linked worktree's private metadata + // file and, if so, prefer the window that has that linked worktree open + // (see `findWindowOnGitWorktreeFile` for why this needs special handling) + const gitWorktreeWindow = await findWindowOnGitWorktreeFile(windows, fileUri, localWorkspaceResolver); + if (gitWorktreeWindow) { + return gitWorktreeWindow; + } + + // Then check for windows with workspaces that have a parent folder of the provided path opened for (const window of windows) { const workspace = window.openedWorkspace; if (isWorkspaceIdentifier(workspace)) { diff --git a/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts b/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts index 85214ff775b894..a26ff441f93c0e 100644 --- a/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts +++ b/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import * as fs from 'fs'; +import { tmpdir } from 'os'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { join } from '../../../../base/common/path.js'; @@ -17,7 +19,9 @@ import { findWindowOnFile } from '../../electron-main/windowsFinder.js'; import { toWorkspaceFolders } from '../../../workspaces/common/workspaces.js'; import { IWorkspaceIdentifier } from '../../../workspace/common/workspace.js'; import { FileAccess } from '../../../../base/common/network.js'; +import { Promises } from '../../../../base/node/pfs.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { getRandomTestPath } from '../../../../base/test/node/testUtils.js'; import { FocusMode, IApplicationBadge } from '../../../native/common/native.js'; suite('WindowsFinder', () => { @@ -115,5 +119,106 @@ suite('WindowsFinder', () => { assert.strictEqual(await findWindowOnFile([window], URI.file(join(fixturesFolder, 'vscode_workspace_2_folder', 'nested_vscode_folder', 'subfolder', 'file.txt')), localWorkspaceResolver), window); }); + suite('Git linked worktree', () => { + + let testDir: string; + let mainWorktreeFolder: string; + let linkedWorktreeFolder: string; + + setup(async () => { + testDir = getRandomTestPath(tmpdir(), 'vsctests', 'windowsfinder-worktree'); + mainWorktreeFolder = join(testDir, 'main'); + linkedWorktreeFolder = join(testDir, 'linked'); + + // Simulate a main worktree with a real `.git` directory that + // contains the linked worktree's private metadata directory + await fs.promises.mkdir(join(mainWorktreeFolder, '.git', 'worktrees', 'linked'), { recursive: true }); + await fs.promises.writeFile(join(mainWorktreeFolder, '.git', 'worktrees', 'linked', 'COMMIT_EDITMSG'), ''); + + // Simulate the linked worktree's folder with a plain-text `.git` file + // pointing back at the main worktree's `worktrees/linked` directory + await fs.promises.mkdir(linkedWorktreeFolder, { recursive: true }); + await fs.promises.writeFile(join(linkedWorktreeFolder, '.git'), `gitdir: ${join(mainWorktreeFolder, '.git', 'worktrees', 'linked')}\n`); + }); + + teardown(() => { + return Promises.rm(testDir); + }); + + test('Linked worktree window wins for worktree metadata file over main worktree window', async () => { + const mainWindow: ICodeWindow = createTestCodeWindow({ lastFocusTime: 1, openedFolderUri: URI.file(mainWorktreeFolder) }); + const linkedWindow: ICodeWindow = createTestCodeWindow({ lastFocusTime: 2, openedFolderUri: URI.file(linkedWorktreeFolder) }); + + const worktreeFile = URI.file(join(mainWorktreeFolder, '.git', 'worktrees', 'linked', 'COMMIT_EDITMSG')); + + // Without worktree-awareness, this file is a child of `mainWorktreeFolder` + // (but not of `linkedWorktreeFolder`), so a plain parent-folder match + // would incorrectly resolve to `mainWindow` here. + assert.ok(extUriBiasedIgnorePathCase.isEqualOrParent(worktreeFile, URI.file(mainWorktreeFolder))); + assert.ok(!extUriBiasedIgnorePathCase.isEqualOrParent(worktreeFile, URI.file(linkedWorktreeFolder))); + + assert.strictEqual(await findWindowOnFile([mainWindow, linkedWindow], worktreeFile, localWorkspaceResolver), linkedWindow); + }); + + test('Falls back to normal matching when no window has the linked worktree open', async () => { + const mainWindow: ICodeWindow = createTestCodeWindow({ lastFocusTime: 1, openedFolderUri: URI.file(mainWorktreeFolder) }); + + const worktreeFile = URI.file(join(mainWorktreeFolder, '.git', 'worktrees', 'linked', 'COMMIT_EDITMSG')); + assert.strictEqual(await findWindowOnFile([mainWindow], worktreeFile, localWorkspaceResolver), mainWindow); + }); + + test('Does not misroute when a path merely looks like a worktree metadata path', async () => { + // A folder that happens to contain a `.git/worktrees/` path segment + // but is not connected to any window's real `.git` pointer file: no window + // should be preferred based on that coincidence, and normal parent-folder + // matching should still apply. + const coincidentalFolder = join(testDir, 'not-a-worktree'); + await fs.promises.mkdir(join(coincidentalFolder, '.git', 'worktrees', 'coincidence'), { recursive: true }); + + const coincidentalWindow: ICodeWindow = createTestCodeWindow({ lastFocusTime: 1, openedFolderUri: URI.file(coincidentalFolder) }); + const linkedWindow: ICodeWindow = createTestCodeWindow({ lastFocusTime: 2, openedFolderUri: URI.file(linkedWorktreeFolder) }); + + const filePath = URI.file(join(coincidentalFolder, '.git', 'worktrees', 'coincidence', 'file.txt')); + assert.strictEqual(await findWindowOnFile([linkedWindow, coincidentalWindow], filePath, localWorkspaceResolver), coincidentalWindow); + }); + + test('Does not confuse two unrelated repositories that use the same worktree name', async () => { + // A second, entirely unrelated main worktree that also happens to have + // a linked worktree named `linked` (a common, unremarkable name). Its + // `.git/worktrees/linked` metadata directory must not be confused with + // `mainWorktreeFolder`'s own `linked` worktree just because the trailing + // path segment is identical. + const otherMainWorktreeFolder = join(testDir, 'other-main'); + const otherLinkedWorktreeFolder = join(testDir, 'other-linked'); + await fs.promises.mkdir(join(otherMainWorktreeFolder, '.git', 'worktrees', 'linked'), { recursive: true }); + await fs.promises.mkdir(otherLinkedWorktreeFolder, { recursive: true }); + await fs.promises.writeFile(join(otherLinkedWorktreeFolder, '.git'), `gitdir: ${join(otherMainWorktreeFolder, '.git', 'worktrees', 'linked')}\n`); + + const linkedWindow: ICodeWindow = createTestCodeWindow({ lastFocusTime: 1, openedFolderUri: URI.file(linkedWorktreeFolder) }); + const otherLinkedWindow: ICodeWindow = createTestCodeWindow({ lastFocusTime: 2, openedFolderUri: URI.file(otherLinkedWorktreeFolder) }); + + const worktreeFile = URI.file(join(mainWorktreeFolder, '.git', 'worktrees', 'linked', 'COMMIT_EDITMSG')); + + // The unrelated window is listed first so that a basename-only match + // (rather than a full-path match) would incorrectly win here. + assert.strictEqual(await findWindowOnFile([otherLinkedWindow, linkedWindow], worktreeFile, localWorkspaceResolver), linkedWindow); + }); + + test('Linked worktree window wins when opened as part of a multi-root workspace', async () => { + const linkedWorkspace: IWorkspaceIdentifier = { + id: Date.now().toString(), + configPath: URI.file(join(testDir, 'linked.code-workspace')) + }; + const linkedWorkspaceFolders = toWorkspaceFolders([{ path: linkedWorktreeFolder }, { path: join(fixturesFolder, 'vscode_folder') }], linkedWorkspace.configPath, extUriBiasedIgnorePathCase); + const linkedWorkspaceResolver = async (workspace: IWorkspaceIdentifier) => { return workspace === linkedWorkspace ? { id: linkedWorkspace.id, configPath: workspace.configPath, folders: linkedWorkspaceFolders } : undefined; }; + + const mainWindow: ICodeWindow = createTestCodeWindow({ lastFocusTime: 1, openedFolderUri: URI.file(mainWorktreeFolder) }); + const multiRootWindow: ICodeWindow = createTestCodeWindow({ lastFocusTime: 2, openedWorkspace: linkedWorkspace }); + + const worktreeFile = URI.file(join(mainWorktreeFolder, '.git', 'worktrees', 'linked', 'COMMIT_EDITMSG')); + assert.strictEqual(await findWindowOnFile([mainWindow, multiRootWindow], worktreeFile, linkedWorkspaceResolver), multiRootWindow); + }); + }); + ensureNoDisposablesAreLeakedInTestSuite(); });