From 5eda0a0dbdc0fa29209f5e8f8bd80178d5ca91a1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:50:51 -0700 Subject: [PATCH] fix(source-control): keep status refresh for worktrees whose admin dirs differ only by case (#28586) On case-sensitive filesystems Git can register two linked worktrees whose admin dirs are `Foo` and `foo`. The status-attribution memo lookup folds case, so it kept only one checkout per key and a `git add` in the other was attributed to its sibling, skipping that worktree's Source Control refresh. Name every checkout that shares the folded key instead. Co-authored-by: Claude --- ...orktree-head-identity-cached-paths.test.ts | 24 +++++++++++++++++++ .../worktree-head-identity-cached-paths.ts | 13 ++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 src/main/ipc/worktree-head-identity-cached-paths.test.ts diff --git a/src/main/ipc/worktree-head-identity-cached-paths.test.ts b/src/main/ipc/worktree-head-identity-cached-paths.test.ts new file mode 100644 index 00000000000..a45130dcecf --- /dev/null +++ b/src/main/ipc/worktree-head-identity-cached-paths.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { cachedWorktreePathsForScope } from './worktree-head-identity-cached-paths' +import { createWorktreeHeadIdentityCache } from './worktree-head-identity-reader' +import { headIdentityScopeForEntry } from './worktree-head-identity-scope' + +const HEAD = 'a'.repeat(40) + +describe('cachedWorktreePathsForScope', () => { + it('names every checkout whose admin dir folds to the same key', () => { + // Case-sensitive filesystems let Git create both `Foo` and `foo` admin dirs. + const cache = createWorktreeHeadIdentityCache() + cache.entries.set('Foo', { worktreePath: '/a/Foo', head: HEAD, branch: 'refs/heads/x' }) + cache.entries.set('foo', { worktreePath: '/b/foo', head: HEAD, branch: 'refs/heads/y' }) + + const paths = cachedWorktreePathsForScope(cache, headIdentityScopeForEntry('Foo')) + + expect(paths).toEqual(expect.arrayContaining(['/a/Foo', '/b/foo'])) + }) + + it('stays unattributed when an entry is not in the memo', () => { + const cache = createWorktreeHeadIdentityCache() + expect(cachedWorktreePathsForScope(cache, headIdentityScopeForEntry('wt'))).toBeNull() + }) +}) diff --git a/src/main/ipc/worktree-head-identity-cached-paths.ts b/src/main/ipc/worktree-head-identity-cached-paths.ts index 4796e3b8400..c0375509ed5 100644 --- a/src/main/ipc/worktree-head-identity-cached-paths.ts +++ b/src/main/ipc/worktree-head-identity-cached-paths.ts @@ -20,16 +20,19 @@ export function cachedWorktreePathsForScope( } paths.push(cache.primary.worktreePath) } - const pathsByEntryKey = new Map() + // Why: keys fold case, so on case-sensitive filesystems `Foo` and `foo` are + // distinct admin dirs sharing one key; name both rather than drop the writer. + const pathsByEntryKey = new Map() for (const [name, identity] of cache.entries) { - pathsByEntryKey.set(headIdentityEntryKey(name), identity.worktreePath) + const key = headIdentityEntryKey(name) + pathsByEntryKey.set(key, [...(pathsByEntryKey.get(key) ?? []), identity.worktreePath]) } for (const key of scope.entryNames) { - const path = pathsByEntryKey.get(key) - if (path === undefined) { + const entryPaths = pathsByEntryKey.get(key) + if (entryPaths === undefined) { return null } - paths.push(path) + paths.push(...entryPaths) } return paths }