fix(git): keep worktree views transactionally consistent
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Git worktree selection is an atomic view boundary: status, branch choices
|
||||
* and history must always come from the same checkout. A stale history row is
|
||||
* especially dangerous because its revert/cherry-pick action targets the
|
||||
* currently selected checkout.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { GitView } from '../src/client/GitView.tsx'
|
||||
import { api, type GitLogEntry, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
|
||||
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const MAIN = 'C:/repo/main'
|
||||
const AGENT = 'C:/repo/agent'
|
||||
|
||||
const inventories: GitWorktree[] = [
|
||||
{ path: MAIN, branch: 'main', current: true, changes: 0 },
|
||||
{ path: AGENT, branch: 'agent', current: false, changes: 1 },
|
||||
]
|
||||
|
||||
function statusFor(target?: string): GitStatusResult {
|
||||
return target === AGENT
|
||||
? { isRepo: true, branch: 'agent', entries: [{ path: 'agent-change.ts', xy: ' M' }] }
|
||||
: { isRepo: true, branch: 'main', entries: [{ path: 'main-change.ts', xy: ' M' }] }
|
||||
}
|
||||
|
||||
function logFor(target?: string, index = 0): GitLogEntry[] {
|
||||
const agent = target === AGENT
|
||||
const digit = agent ? 'a' : 'b'
|
||||
const suffix = index.toString(16).padStart(8, '0')
|
||||
return [{
|
||||
hash: `${digit.repeat(6)}${suffix.slice(-1)}`,
|
||||
hashFull: `${digit.repeat(32)}${suffix}`,
|
||||
subject: agent ? `Agent checkout commit ${index}` : `Main checkout commit ${index}`,
|
||||
author: 'Test',
|
||||
date: '2026-08-20 00:00:00 +0800',
|
||||
refs: agent ? 'HEAD -> agent' : 'HEAD -> main',
|
||||
}]
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolvePromise!: (value: T) => void
|
||||
const promise = new Promise<T>((resolve) => { resolvePromise = resolve })
|
||||
return { promise, resolve: resolvePromise }
|
||||
}
|
||||
|
||||
async function flushEffects(): Promise<void> {
|
||||
await act(async () => { await Promise.resolve() })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('GitView linked-worktree consistency', () => {
|
||||
it('refreshes status, branches and history together on auto and manual selection', async () => {
|
||||
vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
|
||||
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
|
||||
const branch = vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
|
||||
current: target === AGENT ? 'agent' : 'main',
|
||||
names: target === AGENT ? ['agent'] : ['main'],
|
||||
}))
|
||||
const log = vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(createElement(GitView, {
|
||||
scope: { sessionId: 'session', cwd: MAIN },
|
||||
onOpenFile: () => {},
|
||||
onOpenDiff: () => {},
|
||||
visible: false,
|
||||
}))
|
||||
})
|
||||
await flushEffects()
|
||||
|
||||
const selects = container.querySelectorAll<HTMLSelectElement>('select')
|
||||
const worktreeSelect = selects[0]!
|
||||
// A clean primary + exactly one dirty linked checkout auto-selects the
|
||||
// linked checkout and loads every target-derived surface from it.
|
||||
expect(worktreeSelect.value).toBe(AGENT)
|
||||
expect(container.textContent).toContain('agent-change.ts')
|
||||
expect(container.textContent).toContain('Agent checkout commit')
|
||||
expect(container.textContent).not.toContain('Main checkout commit')
|
||||
expect(branch).toHaveBeenCalledWith(expect.anything(), AGENT)
|
||||
expect(log).toHaveBeenCalledWith(expect.anything(), 20, 0, AGENT)
|
||||
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(worktreeSelect, MAIN)
|
||||
worktreeSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
})
|
||||
await flushEffects()
|
||||
|
||||
expect(worktreeSelect.value).toBe(MAIN)
|
||||
expect(container.textContent).toContain('main-change.ts')
|
||||
expect(container.textContent).toContain('Main checkout commit')
|
||||
expect(container.textContent).not.toContain('Agent checkout commit')
|
||||
expect(branch).toHaveBeenLastCalledWith(expect.anything(), MAIN)
|
||||
expect(log).toHaveBeenLastCalledWith(expect.anything(), 20, 0, MAIN)
|
||||
} finally {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a late history page when the selected worktree changes', async () => {
|
||||
const lateAgentPage = deferred<GitLogEntry[]>()
|
||||
vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
|
||||
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
|
||||
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
|
||||
current: target === AGENT ? 'agent' : 'main',
|
||||
names: target === AGENT ? ['agent'] : ['main'],
|
||||
}))
|
||||
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, skip, target) => {
|
||||
if (target === AGENT && skip === 20) return lateAgentPage.promise
|
||||
if (target === AGENT) return Array.from({ length: 20 }, (_value, index) => logFor(AGENT, index)[0]!)
|
||||
return logFor(MAIN)
|
||||
})
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(createElement(GitView, {
|
||||
scope: { sessionId: 'session', cwd: MAIN },
|
||||
onOpenFile: () => {},
|
||||
onOpenDiff: () => {},
|
||||
visible: false,
|
||||
}))
|
||||
})
|
||||
await flushEffects()
|
||||
|
||||
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
|
||||
const loadMore = [...container.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => /Load more|加载更多/.test(button.textContent ?? ''))
|
||||
expect(loadMore).not.toBeUndefined()
|
||||
await act(async () => { loadMore!.click() })
|
||||
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(worktreeSelect, MAIN)
|
||||
worktreeSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
})
|
||||
await flushEffects()
|
||||
expect(container.textContent).toContain('Main checkout commit 0')
|
||||
|
||||
lateAgentPage.resolve(logFor(AGENT, 99))
|
||||
await flushEffects()
|
||||
expect(container.textContent).toContain('Main checkout commit 0')
|
||||
expect(container.textContent).not.toContain('Agent checkout commit 99')
|
||||
} finally {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -33,8 +33,25 @@ describe('linked Git worktrees', () => {
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'))).toEqual([
|
||||
{ path: 'C:/repo/main checkout', branch: 'main' },
|
||||
{ path: 'C:/repo/agent checkout', branch: 'HEAD' },
|
||||
{ path: 'C:/repo/main checkout', branch: 'main', locked: false, prunable: false },
|
||||
{ path: 'C:/repo/agent checkout', branch: 'HEAD', locked: false, prunable: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves locked metadata and marks stale records prunable', () => {
|
||||
expect(parseWorktreeList([
|
||||
'worktree C:/repo/locked',
|
||||
'HEAD abc',
|
||||
'branch refs/heads/locked',
|
||||
'locked in use',
|
||||
'',
|
||||
'worktree C:/repo/missing',
|
||||
'HEAD def',
|
||||
'prunable gitdir file points to non-existent location',
|
||||
'',
|
||||
].join('\0'))).toEqual([
|
||||
{ path: 'C:/repo/locked', branch: 'locked', locked: true, prunable: false },
|
||||
{ path: 'C:/repo/missing', branch: 'HEAD', locked: false, prunable: true },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -60,6 +77,16 @@ describe('linked Git worktrees', () => {
|
||||
{ path: 'tracked.txt', xy: ' M' },
|
||||
])
|
||||
await expect(resolveWorktree(main, root)).rejects.toThrow('unknown linked worktree')
|
||||
|
||||
// Git retains administrative metadata after a checkout directory is
|
||||
// deleted, reporting the record as prunable. It must disappear from both
|
||||
// the selector inventory and the accepted command-target allowlist.
|
||||
rmSync(agent, { recursive: true, force: true })
|
||||
const remaining = await worktrees(main)
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(resolve(remaining[0]!.path)).toBe(resolve(main))
|
||||
expect(remaining[0]!.current).toBe(true)
|
||||
await expect(resolveWorktree(main, agent)).rejects.toThrow('unknown linked worktree')
|
||||
} finally {
|
||||
try { git(main, ['worktree', 'remove', '--force', agent]) } catch { /* fixture may not be fully initialized */ }
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
|
||||
Reference in New Issue
Block a user