diff --git a/src/main/git/worktree-created-disk-witness.test.ts b/src/main/git/worktree-created-disk-witness.test.ts index e3f7d16b3fc..1e0405a9bff 100644 --- a/src/main/git/worktree-created-disk-witness.test.ts +++ b/src/main/git/worktree-created-disk-witness.test.ts @@ -44,7 +44,8 @@ beforeEach(() => { readRepoLocationMock.mockResolvedValue({ topLevel: worktreePath, // Deliberately not the repo's store, so every case below reaches the disk witness. - commonDir: join(scratchDir, 'elsewhere', '.git') + commonDir: join(scratchDir, 'elsewhere', '.git'), + gitDir: join(scratchDir, 'elsewhere', '.git', 'worktrees', 'feature') }) // Git's own reading disagrees; only the witness can break the tie. readRepoCommonDirFromGitMock.mockResolvedValue(join(scratchDir, 'other-repo', '.git')) @@ -108,7 +109,11 @@ describe('describeCreatedWorktree when Git and the repo disagree', () => { mkdirSync(linkedGitDir, { recursive: true }) writeFileSync(join(repoPath, '.git'), `gitdir: ${linkedGitDir}\n`) writeFileSync(join(linkedGitDir, 'commondir'), '../..\n') - readRepoLocationMock.mockResolvedValue({ topLevel: worktreePath, commonDir }) + readRepoLocationMock.mockResolvedValue({ + topLevel: worktreePath, + commonDir, + gitDir: join(commonDir, 'worktrees', 'feature') + }) await expect(describeCreatedWorktree(repoPath, worktreePath, 'feature')).resolves.toMatchObject( { @@ -135,7 +140,11 @@ describe('describeCreatedWorktree when Git and the repo disagree', () => { const commonDir = join(repoPath, '.git') mkdirSync(commonDir, { recursive: true }) writeFileSync(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') - readRepoLocationMock.mockResolvedValue({ topLevel: worktreePath, commonDir }) + readRepoLocationMock.mockResolvedValue({ + topLevel: worktreePath, + commonDir, + gitDir: join(commonDir, 'worktrees', 'feature') + }) await expect(describeCreatedWorktree(repoPath, worktreePath, 'feature')).resolves.toEqual({ path: worktreePath, head: 'a'.repeat(40), @@ -150,7 +159,11 @@ describe('describeCreatedWorktree before the witness is reached', () => { it('never pays for the disk read when Git already agreed', async () => { const commonDir = join(repoPath, '.git') mkdirSync(commonDir, { recursive: true }) - readRepoLocationMock.mockResolvedValue({ topLevel: worktreePath, commonDir }) + readRepoLocationMock.mockResolvedValue({ + topLevel: worktreePath, + commonDir, + gitDir: join(commonDir, 'worktrees', 'feature') + }) readRepoCommonDirFromGitMock.mockResolvedValue(commonDir) // chmod 000 would make the witness unverifiable; agreement means it is never opened. if (CAN_DENY_READ) { diff --git a/src/main/git/worktree-git-capabilities.test.ts b/src/main/git/worktree-git-capabilities.test.ts index c6d7cdfc6ce..36a7c059315 100644 --- a/src/main/git/worktree-git-capabilities.test.ts +++ b/src/main/git/worktree-git-capabilities.test.ts @@ -93,7 +93,7 @@ describe('worktree Git capabilities', () => { }) ) } - return Promise.resolve({ stdout: '/repo\n/git-store/project.git\n' }) + return Promise.resolve({ stdout: '/repo\n/git-store/project.git\n/git-store/project.git\n' }) }) await listWorktrees('/repo') @@ -103,9 +103,9 @@ describe('worktree Git capabilities', () => { ([args]) => (args as string[])[0] === 'rev-parse' ) expect(revParseCalls.map(([args]) => args)).toEqual([ - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], - ['rev-parse', '--show-toplevel', '--git-common-dir'], - ['rev-parse', '--show-toplevel', '--git-common-dir'] + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir', '--git-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'] ]) }) @@ -118,10 +118,10 @@ describe('worktree Git capabilities', () => { } if (args.includes('--path-format=absolute')) { return Promise.resolve({ - stdout: '--path-format=absolute\n/repo\n/git-store/project.git\n' + stdout: '--path-format=absolute\n/repo\n/git-store/project.git\n/git-store/project.git\n' }) } - return Promise.resolve({ stdout: '/repo\n/git-store/project.git\n' }) + return Promise.resolve({ stdout: '/repo\n/git-store/project.git\n/git-store/project.git\n' }) }) await listWorktrees('/repo') @@ -131,8 +131,8 @@ describe('worktree Git capabilities', () => { ([args]) => (args as string[])[0] === 'rev-parse' ) expect(revParseCalls.map(([args]) => args)).toEqual([ - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], - ['rev-parse', '--show-toplevel', '--git-common-dir'] + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir', '--git-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'] ]) }) }) diff --git a/src/main/git/worktree-list-reader.ts b/src/main/git/worktree-list-reader.ts index b11ed208fa8..1ba9f0a85de 100644 --- a/src/main/git/worktree-list-reader.ts +++ b/src/main/git/worktree-list-reader.ts @@ -23,22 +23,23 @@ import { gitExecFileAsync } from './runner' const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 -type RepoLocation = { topLevel: string; commonDir: string } +type RepoLocation = { topLevel: string; commonDir: string; gitDir: string } function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined { // Old git echoes the unrecognized `--path-format` flag and exits 0, so drop `-`-prefixed lines and - // read the last two path lines (toplevel, git-common-dir); strip only trailing CR — paths may have edge spaces. + // read the last three path lines (toplevel, git-common-dir, git-dir); strip only trailing CR — paths may have edge spaces. const lines = output .split('\n') .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) .filter((line) => line.length > 0 && !line.startsWith('-')) - if (lines.length < 2) { + if (lines.length < 3) { return undefined } - const [topLevel, commonDir] = lines.slice(-2) + const [topLevel, commonDir, gitDir] = lines.slice(-3) return { topLevel: resolveRevParsePath(repoPath, topLevel), - commonDir: resolveRevParsePath(repoPath, commonDir) + commonDir: resolveRevParsePath(repoPath, commonDir), + gitDir: resolveRevParsePath(repoPath, gitDir) } } @@ -55,7 +56,13 @@ export async function readRepoLocation( 'rev-parse-path-format', async () => { const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + [ + 'rev-parse', + '--path-format=absolute', + '--show-toplevel', + '--git-common-dir', + '--git-dir' + ], gitExecOptions(repoPath, options) ) if (hasUnsupportedRevParsePathFormatEcho(stdout)) { @@ -66,7 +73,7 @@ export async function readRepoLocation( }, async () => { const { stdout } = await gitExecFileAsync( - ['rev-parse', '--show-toplevel', '--git-common-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'], gitExecOptions(repoPath, options) ) return parseRepoLocation(resolveBasePath, stdout) @@ -185,6 +192,11 @@ async function normalizeMainWorktreePath( if (!areWorktreePathsEqual(mainWorktree.path, location.commonDir)) { return worktrees } + // Why: a linked worktree of a bare/separate-git-dir repo passes the gate above too, but its toplevel + // is its own folder; relabelling would give the main row that folder's path and repeat it (#23631). + if (!areWorktreePathsEqual(location.gitDir, location.commonDir)) { + return worktrees + } const normalized = [...worktrees] normalized[mainIndex] = { ...mainWorktree, path: location.topLevel } diff --git a/src/main/git/worktree-separate-git-dir.test.ts b/src/main/git/worktree-separate-git-dir.test.ts index e116985b8f1..e177580a24f 100644 --- a/src/main/git/worktree-separate-git-dir.test.ts +++ b/src/main/git/worktree-separate-git-dir.test.ts @@ -177,6 +177,42 @@ describe('git worktree separate git dir paths', () => { } ) + it.skipIf(process.platform === 'win32').each([ + ['bare', 'bare'], + ['separate-git-dir', 'separate-git-dir'] + ] as const)( + 'leaves the main entry unchanged when a %s repo is scanned via its linked worktree', + async (_label, kind) => { + // Here the main entry IS the git-common-dir, so only the repo path's own git dir shows it is + // a linked worktree; relabelling would give two rows the linked folder's path (#23631). + let mainPath: string + let repoForAdd: string + if (kind === 'bare') { + const root = await mkdtemp(path.join(tmpdir(), 'orca-bare-linked-')) + tempRoots.push(root) + const sourcePath = await createCommittedRepo(root, 'source') + mainPath = path.join(root, 'project.git') + execFileSync('git', ['clone', '--bare', '--quiet', sourcePath, mainPath]) + mainPath = await realpath(mainPath) + repoForAdd = mainPath + } else { + const { gitDirPath, worktreePath } = await createSeparateGitDirRepo() + mainPath = gitDirPath + repoForAdd = worktreePath + } + const linkedWorktreePath = path.join(path.dirname(mainPath), 'linked') + git(repoForAdd, ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature']) + const resolvedLinked = await realpath(linkedWorktreePath) + + const worktrees = await listWorktrees(resolvedLinked) + + expect(worktrees.find((worktree) => worktree.isMainWorktree)?.path).toBe(mainPath) + expect(worktrees.filter((worktree) => worktree.path === resolvedLinked)).toEqual([ + expect.objectContaining({ branch: 'refs/heads/feature', isMainWorktree: false }) + ]) + } + ) + it.skipIf(process.platform === 'win32')('does not throw for a bare repo', async () => { const repoPath = await createBareRepo() diff --git a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts index 2df3f7b9f17..b4853ba70f1 100644 --- a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts +++ b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts @@ -13,6 +13,7 @@ import { withTimeoutResult } from './runtime-async-boundaries' import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerprint' import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' import { scanLocalRepoWorktreesForResolution } from './repo-worktree-resolution-scan' +import { dropRepeatedWorktreeRows } from './repeated-worktree-rows' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { listStoredWorktreeRowsForRepo } from './repo-worktree-row-resolution' @@ -69,7 +70,9 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK } } } - const result = await this.listRepoWorktreesForResolutionUncached(repo, projectRuntime) + const result = dropRepeatedWorktreeRows( + await this.listRepoWorktreesForResolutionUncached(repo, projectRuntime) + ) return { result, adminFingerprint: null, adminFingerprintProbe: probe, scannedAt } } diff --git a/src/main/runtime/repeated-worktree-rows.ts b/src/main/runtime/repeated-worktree-rows.ts new file mode 100644 index 00000000000..4cd2ba710ff --- /dev/null +++ b/src/main/runtime/repeated-worktree-rows.ts @@ -0,0 +1,22 @@ +import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' + +/** + * One row per worktree id: git lists a path once per registration, so a stale one naming a live + * checkout repeats it and two rows share one id (#23631). Keep git's first row, as the desktop + * listing does: git prints the main checkout first, and `git worktree prune` drops a registration + * naming it as a "duplicate entry"; repeats of a linked path keep git's order. Exact match only: + * the paths belong to the execution host, whose case and alias rules this process cannot assume. + */ +export function dropRepeatedWorktreeRows( + scan: RuntimeWorktreeScanResult +): RuntimeWorktreeScanResult { + const seenPaths = new Set() + const worktrees = scan.worktrees.filter((worktree) => { + if (seenPaths.has(worktree.path)) { + return false + } + seenPaths.add(worktree.path) + return true + }) + return worktrees.length === scan.worktrees.length ? scan : { ...scan, worktrees } +} diff --git a/src/main/runtime/worktree-scan-repeated-path.test.ts b/src/main/runtime/worktree-scan-repeated-path.test.ts new file mode 100644 index 00000000000..a559e66a70c --- /dev/null +++ b/src/main/runtime/worktree-scan-repeated-path.test.ts @@ -0,0 +1,163 @@ +// Git lists a path twice when a linked registration's gitdir names the main checkout (#23631). +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electronMocks = vi.hoisted(() => { + const ipcMain = { + on: vi.fn(() => ipcMain), + removeListener: vi.fn(() => ipcMain), + emit: vi.fn(() => true) + } + return { + BrowserWindow: { fromId: vi.fn((): unknown => null) }, + webContents: { fromId: vi.fn((): unknown => null) }, + ipcMain, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } + } +}) +vi.mock('electron', () => electronMocks) + +const localScanMock = vi.hoisted(() => vi.fn()) +vi.mock('./repo-worktree-resolution-scan', () => ({ + scanLocalRepoWorktreesForResolution: localScanMock +})) + +vi.mock('./repo-worktree-admin-fingerprint', () => ({ + readRepoWorktreeAdminFingerprint: vi.fn(async () => null) +})) + +const getSshGitProviderMock = vi.hoisted(() => vi.fn()) +vi.mock('../providers/ssh-git-dispatch', async (importOriginal) => ({ + ...(await importOriginal>()), + getSshGitProvider: getSshGitProviderMock +})) + +import { OrcaRuntimeService } from './orca-runtime' +import type { GitWorktreeInfo } from '../../shared/worktree/types' + +const REPO_ID = 'repo-local' +const REPO_PATH = '/home/me/fileLoc' +const MAIN_WORKTREE_ID = `${REPO_ID}::${REPO_PATH}` +const MAIN_ROW = { + path: REPO_PATH, + head: 'abc', + branch: 'refs/heads/dev_ops', + isBare: false, + isMainWorktree: true +} +// Linux paths are case-sensitive: a different spelling is a different checkout. +const OTHER_SPELLING_ROW = { + path: '/home/me/FileLoc', + head: 'def', + branch: 'refs/heads/feat', + isBare: false, + isMainWorktree: false +} + +function makeRuntime(options: { connectionId?: string } = {}): OrcaRuntimeService { + const metaById: Record> = {} + const repos = [ + { + id: REPO_ID, + path: REPO_PATH, + displayName: 'fileLoc', + badgeColor: 'blue', + addedAt: 1, + ...options + } + ] + const store = { + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getRepos: () => repos, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta: (id: string, meta: Record) => { + metaById[id] = { ...metaById[id], ...meta } + return metaById[id] + }, + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }), + getProjects: () => [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub carries the repo, meta, lineage and settings reads a worktree listing makes; the rest of Store is unreached. + return new OrcaRuntimeService(store as never) +} + +function gitLists(worktrees: GitWorktreeInfo[]): void { + localScanMock.mockResolvedValue({ ok: true, worktrees }) +} + +async function listedRows(runtime: OrcaRuntimeService): Promise { + const listed = await runtime.listManagedWorktrees() + return listed.worktrees.map((worktree) => `${worktree.id} ${worktree.branch}`) +} + +describe('worktree scan with a repeated path', () => { + beforeEach(() => { + localScanMock.mockReset() + getSshGitProviderMock.mockReset() + }) + + describe('rows that agree, as reported', () => { + beforeEach(() => { + gitLists([MAIN_ROW, { ...MAIN_ROW, isMainWorktree: false }, OTHER_SPELLING_ROW]) + }) + + it('resolves a branch selector to the main checkout', async () => { + await expect(makeRuntime().showManagedWorktree('branch:dev_ops')).resolves.toMatchObject({ + id: MAIN_WORKTREE_ID, + isMainWorktree: true + }) + }) + + it('resolves the id the CLI sends for `active` and `current` after a listing', async () => { + const runtime = makeRuntime() + await runtime.listManagedWorktrees() + + await expect(runtime.showManagedWorktree(`id:${MAIN_WORKTREE_ID}`)).resolves.toMatchObject({ + id: MAIN_WORKTREE_ID, + isMainWorktree: true + }) + }) + + it('lists each path once for the CLI and for paired clients', async () => { + const runtime = makeRuntime() + const expectedIds = [MAIN_WORKTREE_ID, `${REPO_ID}::/home/me/FileLoc`] + + const listed = await runtime.listManagedWorktrees() + expect(listed.worktrees.map((worktree) => worktree.id)).toEqual(expectedIds) + + const detected = await runtime.listDetectedManagedWorktrees(`id:${REPO_ID}`) + expect(detected.worktrees.map((worktree) => worktree.id)).toEqual(expectedIds) + }) + }) + + // What git prints for a registration whose gitdir names the main checkout: its own branch and HEAD. + it('keeps the main checkout over a stale registration on another branch', async () => { + gitLists([ + MAIN_ROW, + { ...MAIN_ROW, head: 'old', branch: 'refs/heads/stale', isMainWorktree: false } + ]) + const runtime = makeRuntime() + + await expect(listedRows(runtime)).resolves.toEqual([`${MAIN_WORKTREE_ID} refs/heads/dev_ops`]) + await expect(runtime.showManagedWorktree('branch:stale')).rejects.toThrow('selector_not_found') + }) + + it('lists an SSH repo the remote host repeats once', async () => { + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn(async () => [MAIN_ROW, { ...MAIN_ROW, isMainWorktree: false }]) + }) + + await expect(listedRows(makeRuntime({ connectionId: 'builder' }))).resolves.toEqual([ + `${MAIN_WORKTREE_ID} refs/heads/dev_ops` + ]) + expect(localScanMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/git-handler-worktree-git-capabilities.test.ts b/src/relay/git-handler-worktree-git-capabilities.test.ts index 6d58a999393..6a71f6e388b 100644 --- a/src/relay/git-handler-worktree-git-capabilities.test.ts +++ b/src/relay/git-handler-worktree-git-capabilities.test.ts @@ -97,7 +97,7 @@ describe('relay worktree Git capabilities', () => { ) } return Promise.resolve({ - stdout: '/repo\n/git-store/project.git\n', + stdout: '/repo\n/git-store/project.git\n/git-store/project.git\n', stderr: '' }) }) @@ -107,9 +107,9 @@ describe('relay worktree Git capabilities', () => { const revParseCalls = gitSpy.mock.calls.filter(([args]) => args[0] === 'rev-parse') expect(revParseCalls.map(([args]) => args)).toEqual([ - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], - ['rev-parse', '--show-toplevel', '--git-common-dir'], - ['rev-parse', '--show-toplevel', '--git-common-dir'] + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir', '--git-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'] ]) }) }) diff --git a/src/relay/git-handler-worktree-inspection.test.ts b/src/relay/git-handler-worktree-inspection.test.ts index 84cec6a9660..fb7e86718f8 100644 --- a/src/relay/git-handler-worktree-inspection.test.ts +++ b/src/relay/git-handler-worktree-inspection.test.ts @@ -118,6 +118,35 @@ describe('GitHandler', () => { } ) + it.skipIf(process.platform === 'win32')( + 'leaves a bare main entry unchanged when scanned via its linked worktree', + async () => { + // A bare main entry IS the git-common-dir; only the repo path's own git dir shows it is linked. + const sourcePath = path.join(tmpDir, 'source') + mkdirSync(sourcePath) + gitInit(sourcePath) + writeFileSync(path.join(sourcePath, 'file.txt'), 'hello') + gitCommit(sourcePath, 'initial') + const barePath = path.join(tmpDir, 'project.git') + execFileSync('git', ['clone', '--bare', '--quiet', sourcePath, barePath], { stdio: 'pipe' }) + const linkedWorktreePath = path.join(tmpDir, 'linked-wt') + execFileSync('git', ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature'], { + cwd: barePath, + stdio: 'pipe' + }) + const resolvedLinked = await fs.realpath(linkedWorktreePath) + + const result = (await dispatcher.callRequest('git.listWorktrees', { + repoPath: resolvedLinked + })) as Record[] + + expect(result.find((worktree) => worktree.isMainWorktree === true)?.path).toBe( + await fs.realpath(barePath) + ) + expect(result.filter((worktree) => worktree.path === resolvedLinked)).toHaveLength(1) + } + ) + it.skipIf(process.platform === 'win32')( 'leaves the main entry unchanged when scanned via a linked worktree', async () => { diff --git a/src/relay/git-handler-worktree-operations.ts b/src/relay/git-handler-worktree-operations.ts index 9303db71964..261463f10c1 100644 --- a/src/relay/git-handler-worktree-operations.ts +++ b/src/relay/git-handler-worktree-operations.ts @@ -35,22 +35,23 @@ function resolveRelayPath(repoPath: string, value: string): string { : path.posix.resolve(repoPath, value) } -type RelayRepoLocation = { topLevel: string; commonDir: string } +type RelayRepoLocation = { topLevel: string; commonDir: string; gitDir: string } function parseRelayRepoLocation(repoPath: string, output: string): RelayRepoLocation | undefined { - // Old git (pre `--path-format`) echoes the unknown flag and exits 0; drop `-`-prefixed lines, take the last two paths. + // Old git (pre `--path-format`) echoes the unknown flag and exits 0; drop `-`-prefixed lines, take the last three paths. // Strip only the trailing CR, not surrounding spaces — git paths may legitimately start or end with a space. const lines = output .split('\n') .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) .filter((line) => line.length > 0 && !line.startsWith('-')) - if (lines.length < 2) { + if (lines.length < 3) { return undefined } - const [topLevel, commonDir] = lines.slice(-2) + const [topLevel, commonDir, gitDir] = lines.slice(-3) return { topLevel: resolveRelayPath(repoPath, topLevel), - commonDir: resolveRelayPath(repoPath, commonDir) + commonDir: resolveRelayPath(repoPath, commonDir), + gitDir: resolveRelayPath(repoPath, gitDir) } } @@ -71,7 +72,13 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext { 'rev-parse-path-format', async () => { const { stdout } = await this.git( - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + [ + 'rev-parse', + '--path-format=absolute', + '--show-toplevel', + '--git-common-dir', + '--git-dir' + ], repoPath ) if (hasUnsupportedRevParsePathFormatEcho(stdout)) { @@ -82,7 +89,7 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext { }, async () => { const { stdout } = await this.git( - ['rev-parse', '--show-toplevel', '--git-common-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'], repoPath ) return parseRelayRepoLocation(repoPath, stdout) @@ -116,6 +123,10 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext { if (!areRelayWorktreePathsEqual(mainPath, location.commonDir)) { return worktrees } + // Why: a linked worktree of a bare/separate-git-dir repo passes the gate above too; relabelling would repeat its path (#23631). + if (!areRelayWorktreePathsEqual(location.gitDir, location.commonDir)) { + return worktrees + } const normalized = [...worktrees] normalized[mainIndex] = { ...mainWorktree, path: location.topLevel } diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index a692c2890c0..b8c09122cf8 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -164,11 +164,12 @@ describeBinaryCompatibility('real Git binary compatibility', () => { 'rev-parse', '--path-format=absolute', '--show-toplevel', - '--git-common-dir' + '--git-common-dir', + '--git-dir' ]) expect(hasUnsupportedRevParsePathFormatEcho(preferred.stdout)).toBe(!supports(2, 31)) await expect( - runGit(['rev-parse', '--show-toplevel', '--git-common-dir']) + runGit(['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir']) ).resolves.toBeDefined() })