Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Original file line number Diff line number Diff line change
@@ -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<string>();
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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 }) => {
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/git/git-worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
31 changes: 30 additions & 1 deletion packages/core/src/git/git-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,36 @@ export class GitWorktree implements IGitWorktree {
}

async getFileAtRef(filePath: string, ref: string): Promise<string | null> {
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<string> {
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<string | null> {
Expand Down
Loading