mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
fix(worktrees): list a folder once when git reports it twice (#24357)
When git lists the same folder twice (a leftover worktree registration that points at the main checkout), Orca's runtime listing turned each line into its own worktree with the same id, so `orca worktree current`, `active` and `branch:` failed with selector_ambiguous, and paired clients saw a duplicate row. The runtime scan now keeps git's first row per folder, the rule the desktop sidebar already uses. Separately, for a bare or separate-git-dir repo added through a linked worktree, the scan no longer relabels the main row with that worktree's folder (it relabels only when the folder's git dir is the common git dir), so the worktree keeps its own row and branch in the CLI and the sidebar. No extra git command runs. Part of #23631: the "Profile state writer command timed out" toast in that issue has a separate cause.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>()
|
||||
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 }
|
||||
}
|
||||
@@ -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<Record<string, unknown>>()),
|
||||
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<string, Record<string, unknown>> = {}
|
||||
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<string, unknown>) => {
|
||||
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<string[]> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>[]
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user