diff --git a/apps/emdash-desktop/src/renderer/lib/monaco/git-model-invalidation.test.ts b/apps/emdash-desktop/src/renderer/lib/monaco/git-model-invalidation.test.ts new file mode 100644 index 0000000000..5c05fb09f7 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/lib/monaco/git-model-invalidation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { HEAD_REF, STAGED_REF, type GitRef } from '@shared/core/git/types'; +import { commitRef, refsEqual } from '@shared/core/git/utils'; +import { invalidateWorktreeGitModels } from './git-model-invalidation'; +import type { MonacoModelRegistry } from './monaco-model-registry'; + +/** Minimal registry whose findGitUris matches by ref like the real one (via refsEqual). */ +function makeRegistry(entries: Array<{ ref: GitRef; uri: string }>) { + const invalidated: string[] = []; + const registry = { + findGitUris: ({ ref }: { ref?: GitRef }) => + entries.filter((e) => ref !== undefined && refsEqual(e.ref, ref)).map((e) => e.uri), + invalidateModel: (uri: string) => { + invalidated.push(uri); + return Promise.resolve(); + }, + } as unknown as MonacoModelRegistry; + return { registry, invalidated }; +} + +describe('invalidateWorktreeGitModels', () => { + it('invalidates both the head DiffMode and the commit-HEAD models on a head update (#2576)', () => { + const { registry, invalidated } = makeRegistry([ + { ref: HEAD_REF, uri: 'git://ws/HEAD/a.ts' }, + { ref: commitRef('HEAD'), uri: 'git://ws/HEAD/b.ts' }, + { ref: STAGED_REF, uri: 'git://ws/STAGED/c.ts' }, + ]); + + invalidateWorktreeGitModels(registry, 'ws', 'head'); + + // The commit-HEAD model (b.ts) is the one that used to go stale after a branch switch. + expect(invalidated.sort()).toEqual(['git://ws/HEAD/a.ts', 'git://ws/HEAD/b.ts']); + }); + + it('invalidates only the staged models on a status update', () => { + const { registry, invalidated } = makeRegistry([ + { ref: HEAD_REF, uri: 'git://ws/HEAD/a.ts' }, + { ref: commitRef('HEAD'), uri: 'git://ws/HEAD/b.ts' }, + { ref: STAGED_REF, uri: 'git://ws/STAGED/c.ts' }, + ]); + + invalidateWorktreeGitModels(registry, 'ws', 'status'); + + expect(invalidated).toEqual(['git://ws/STAGED/c.ts']); + }); + + it('de-duplicates a uri matched by more than one HEAD representation', () => { + const { registry, invalidated } = makeRegistry([ + { ref: HEAD_REF, uri: 'git://ws/HEAD/a.ts' }, + { ref: commitRef('HEAD'), uri: 'git://ws/HEAD/a.ts' }, + ]); + + invalidateWorktreeGitModels(registry, 'ws', 'head'); + + expect(invalidated).toEqual(['git://ws/HEAD/a.ts']); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/lib/monaco/git-model-invalidation.ts b/apps/emdash-desktop/src/renderer/lib/monaco/git-model-invalidation.ts new file mode 100644 index 0000000000..e17879b294 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/lib/monaco/git-model-invalidation.ts @@ -0,0 +1,35 @@ +import { HEAD_REF, STAGED_REF, type GitRef } from '@shared/core/git/types'; +import { commitRef } from '@shared/core/git/utils'; +import type { MonacoModelRegistry } from './monaco-model-registry'; + +/** + * Re-fetch the git models invalidated by a worktree update. + * + * On a `status` update only the staged snapshot changed. On any other update + * (notably `head`, fired on commit or a branch switch in the same worktree) the + * current HEAD moved, so every model that follows HEAD must be re-fetched. + * + * The diff view expresses "current HEAD" two ways — the `head` DiffMode + * (`HEAD_REF`) and a commit ref pinned to the literal `'HEAD'` + * (`commitRef('HEAD')`). They compare equal only within their own kind, so both + * must be queried; otherwise a `commitRef('HEAD')` original keeps the previous + * branch's content after a switch and the Changed diff shows stale changes. + * See #2576. + * + * Pure (no event wiring) so it can be unit-tested without the renderer runtime. + */ +export function invalidateWorktreeGitModels( + registry: MonacoModelRegistry, + workspaceId: string, + updateKind: string +): void { + const refs: GitRef[] = updateKind === 'status' ? [STAGED_REF] : [HEAD_REF, commitRef('HEAD')]; + const invalidated = new Set(); + for (const ref of refs) { + for (const uri of registry.findGitUris({ workspaceId, ref })) { + if (invalidated.has(uri)) continue; + invalidated.add(uri); + void registry.invalidateModel(uri); + } + } +} diff --git a/apps/emdash-desktop/src/renderer/lib/monaco/invalidation-bridges.ts b/apps/emdash-desktop/src/renderer/lib/monaco/invalidation-bridges.ts index c6f25bfda7..8a0286ea8c 100644 --- a/apps/emdash-desktop/src/renderer/lib/monaco/invalidation-bridges.ts +++ b/apps/emdash-desktop/src/renderer/lib/monaco/invalidation-bridges.ts @@ -2,7 +2,7 @@ import { events } from '@renderer/lib/ipc'; import type { FileWatchEvent } from '@shared/core/fs/fs'; import { fsWatchEventChannel } from '@shared/core/fs/fsEvents'; import { gitRepoUpdateChannel, gitWorktreeUpdateChannel } from '@shared/core/git/events'; -import { HEAD_REF, STAGED_REF } from '@shared/core/git/types'; +import { invalidateWorktreeGitModels } from './git-model-invalidation'; import type { MonacoModelRegistry } from './monaco-model-registry'; /** Disk models for paths affected by a watch event (atomic saves often use create/delete, not modify). */ @@ -47,12 +47,9 @@ export function wireModelRegistryInvalidation(registry: MonacoModelRegistry): () } }); - // Workspace index/HEAD changes → invalidate staged or HEAD git:// models. + // Workspace index/HEAD changes → invalidate staged or HEAD-following git:// models. const unsubWorkspace = events.on(gitWorktreeUpdateChannel, ({ workspaceId, update }) => { - const ref = update.kind === 'status' ? STAGED_REF : HEAD_REF; - for (const uri of registry.findGitUris({ workspaceId, ref })) { - void registry.invalidateModel(uri); - } + invalidateWorktreeGitModels(registry, workspaceId, update.kind); }); const unsubRefs = events.on(gitRepoUpdateChannel, ({ projectId, update }) => { diff --git a/packages/core/src/git/git-worktree.test.ts b/packages/core/src/git/git-worktree.test.ts index 17efaa2a46..bb122d4231 100644 --- a/packages/core/src/git/git-worktree.test.ts +++ b/packages/core/src/git/git-worktree.test.ts @@ -194,6 +194,40 @@ describe('GitWorktree', () => { } }); + it('resolves HEAD against the calling worktree, not the shared repository (#2576)', async () => { + // The GitRepository is shared per common dir and its exec is bound to the first + // worktree that opens it. A symbolic ref like HEAD must still resolve against the + // worktree that asks, otherwise a linked worktree on its own branch reads the wrong + // branch's blob (the stale Changed-diff in #2576). + const repo = await makeRepo(); // main: tracked.txt = 'before\n' + const linkedParent = await mkdtemp(path.join(tmpdir(), 'emdash-shared-linked-')); + const linkedPath = path.join(linkedParent, 'wt'); // must not pre-exist for `worktree add` + await execFileAsync('git', ['branch', 'feature'], { cwd: repo }); + await execFileAsync('git', ['worktree', 'add', linkedPath, 'feature'], { cwd: repo }); + await writeFile(path.join(linkedPath, 'tracked.txt'), 'feature-content\n', 'utf8'); + await execFileAsync('git', ['commit', '-am', 'feature change'], { cwd: linkedPath }); + + const watcher = new FileWatchService(); + const runtime = new GitRuntime({ watcher }); + try { + // Open the main worktree first so the shared repository binds its exec to it. + const mainLease = await runtime.openWorktree(repo); + await expect(mainLease.value.getFileAtRef('tracked.txt', 'HEAD')).resolves.toBe('before\n'); + + // The linked worktree shares that repository but its HEAD is on `feature`. + const linkedLease = await runtime.openWorktree(linkedPath); + await expect(linkedLease.value.getFileAtRef('tracked.txt', 'HEAD')).resolves.toBe( + 'feature-content\n' + ); + + await linkedLease.release(); + await mainLease.release(); + } finally { + await runtime.dispose(); + await watcher.dispose(); + } + }); + it('refreshes staged status when an external commit advances the branch ref', async () => { const repo = await makeRepo(); const watcher = new FileWatchService(); diff --git a/packages/core/src/git/git-worktree.ts b/packages/core/src/git/git-worktree.ts index 7dd23281aa..33979abcdc 100644 --- a/packages/core/src/git/git-worktree.ts +++ b/packages/core/src/git/git-worktree.ts @@ -188,7 +188,36 @@ export class GitWorktree implements IGitWorktree { } async getFileAtRef(filePath: string, ref: string): Promise { - return this.repository.readBlobAtRef(ref, filePath); + return this.repository.readBlobAtRef(await this.resolveRefOid(ref), filePath); + } + + /** + * Resolve a ref to an immutable commit oid against *this* worktree's HEAD. + * + * The `GitRepository` is shared by every worktree on the same common dir, and its + * `exec` is bound to whichever worktree opened it first. Passing a symbolic ref like + * `HEAD` straight to `repository.readBlobAtRef` would therefore resolve it against that + * first worktree, not the caller — so a task worktree on its own branch reads the wrong + * branch's blob (empty, or the previous branch's content after a switch). That is the + * stale Changed-diff in #2576. Resolving here with the worktree-bound `exec` pins the + * correct HEAD, and the resulting oid is content-addressed so the shared object store + * reads it regardless of cwd. Already-immutable oids and resolution failures pass through + * untouched (the existing `git show` fallback still handles odd refs). + */ + private async resolveRefOid(ref: string): Promise { + if (/^[0-9a-f]{40}$/i.test(ref) || /^[0-9a-f]{64}$/i.test(ref)) return ref; + try { + const { stdout } = await this.exec.exec([ + 'rev-parse', + '--verify', + '--quiet', + `${ref}^{commit}`, + ]); + const oid = stdout.trim(); + return oid.length > 0 ? oid : ref; + } catch { + return ref; + } } async getFileAtIndex(filePath: string): Promise {