Fix git submodule path cache retention (#7687)
This commit is contained in:
parent
d912482752
commit
acb35ee649
|
|
@ -1,6 +1,7 @@
|
|||
import type { GitRuntimeOptions } from './git-runtime-options'
|
||||
import { gitOptionsForWorktree } from './git-runtime-options'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { runWithGitReadCacheInvalidation } from './status'
|
||||
|
||||
/**
|
||||
* Reject branch names git would parse as an option (`-`/`--…`) or that aren't a
|
||||
|
|
@ -27,7 +28,9 @@ export async function checkoutBranch(
|
|||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
assertValidBranchName(branch)
|
||||
await gitExecFileAsync(['checkout', branch, '--'], gitOptionsForWorktree(worktreePath, options))
|
||||
await runWithGitReadCacheInvalidation(() =>
|
||||
gitExecFileAsync(['checkout', branch, '--'], gitOptionsForWorktree(worktreePath, options))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { GitRuntimeOptions } from './git-runtime-options'
|
|||
import { gitOptionsForWorktree } from './git-runtime-options'
|
||||
import { validateGitPushTarget } from './push-target-validation'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { runWithGitReadCacheInvalidation } from './status'
|
||||
|
||||
async function getConfiguredPushTarget(
|
||||
worktreePath: string,
|
||||
|
|
@ -249,7 +250,9 @@ export async function gitPull(
|
|||
// Why: plain `git pull` uses the user's configured pull strategy (merge by
|
||||
// default) so diverged branches reconcile instead of erroring out. Conflicts
|
||||
// surface through the existing conflict-resolution flow.
|
||||
await gitPullWithArgs(worktreePath, [], pushTarget, options)
|
||||
await runWithGitReadCacheInvalidation(() =>
|
||||
gitPullWithArgs(worktreePath, [], pushTarget, options)
|
||||
)
|
||||
}
|
||||
|
||||
export async function gitFastForward(
|
||||
|
|
@ -257,7 +260,9 @@ export async function gitFastForward(
|
|||
pushTarget?: GitPushTarget,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
await gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options)
|
||||
await runWithGitReadCacheInvalidation(() =>
|
||||
gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options)
|
||||
)
|
||||
}
|
||||
|
||||
export async function gitPullRebaseFromBase(
|
||||
|
|
@ -265,18 +270,20 @@ export async function gitPullRebaseFromBase(
|
|||
baseRef: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const source = await resolveGitRemoteRebaseSource(
|
||||
(args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)),
|
||||
baseRef
|
||||
)
|
||||
await gitExecFileAsync(
|
||||
['pull', '--rebase', source.remoteName, source.branchName],
|
||||
gitOptionsForWorktree(worktreePath, options)
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error(normalizeGitErrorMessage(error, 'pull'))
|
||||
}
|
||||
await runWithGitReadCacheInvalidation(async () => {
|
||||
try {
|
||||
const source = await resolveGitRemoteRebaseSource(
|
||||
(args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)),
|
||||
baseRef
|
||||
)
|
||||
await gitExecFileAsync(
|
||||
['pull', '--rebase', source.remoteName, source.branchName],
|
||||
gitOptionsForWorktree(worktreePath, options)
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error(normalizeGitErrorMessage(error, 'pull'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function gitFetch(
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ vi.mock('./runner', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('./status', () => ({
|
||||
resolveGitDir: resolveGitDirMock
|
||||
resolveGitDir: resolveGitDirMock,
|
||||
runWithGitReadCacheInvalidation: <T>(run: () => Promise<T>) => run()
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, gitStreamStdoutMock } = vi.hoisted(
|
||||
() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileAsyncBufferMock: vi.fn(),
|
||||
gitStreamStdoutMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('./runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
gitExecFileAsyncBuffer: gitExecFileAsyncBufferMock,
|
||||
gitStreamStdout: gitStreamStdoutMock,
|
||||
gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({
|
||||
...env,
|
||||
GIT_OPTIONAL_LOCKS: '0'
|
||||
})
|
||||
}))
|
||||
|
||||
import {
|
||||
MAX_SUBMODULE_PATHS_CACHE_ENTRIES,
|
||||
abortMerge,
|
||||
abortRebase,
|
||||
clearSubmodulePathsCacheForTests,
|
||||
getSubmodulePathsCacheCountForTests,
|
||||
listSubmodulePaths
|
||||
} from './status'
|
||||
import { checkoutBranch } from './checkout'
|
||||
import { gitPull, gitPullRebaseFromBase } from './remote'
|
||||
import { addWorktree, removeWorktree } from './worktree'
|
||||
|
||||
describe('submodule path cache', () => {
|
||||
beforeEach(() => {
|
||||
clearSubmodulePathsCacheForTests()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncBufferMock.mockReset()
|
||||
gitStreamStdoutMock.mockReset()
|
||||
gitExecFileAsyncMock.mockImplementation((_args: string[], options?: { cwd?: string }) =>
|
||||
Promise.resolve({
|
||||
stdout: `submodule.lib.path ${String(options?.cwd ?? 'repo').replace(/^.*[/\\\\]/, '')}-lib\n`
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
clearSubmodulePathsCacheForTests()
|
||||
})
|
||||
|
||||
it('prunes expired entries even when later reads use different worktrees', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
|
||||
await listSubmodulePaths('/repo-a')
|
||||
await listSubmodulePaths('/repo-b')
|
||||
|
||||
expect(getSubmodulePathsCacheCountForTests()).toBe(2)
|
||||
|
||||
vi.setSystemTime(5_001)
|
||||
await expect(listSubmodulePaths('/repo-c')).resolves.toEqual(['repo-c-lib'])
|
||||
|
||||
expect(getSubmodulePathsCacheCountForTests()).toBe(1)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('stays bounded through prolonged worktree churn and keeps recently reused worktrees', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
|
||||
for (let wave = 0; wave < 4; wave += 1) {
|
||||
for (let i = 0; i < MAX_SUBMODULE_PATHS_CACHE_ENTRIES; i += 1) {
|
||||
await listSubmodulePaths(`/wave-${wave}-repo-${i}`)
|
||||
}
|
||||
expect(getSubmodulePathsCacheCountForTests()).toBe(MAX_SUBMODULE_PATHS_CACHE_ENTRIES)
|
||||
vi.advanceTimersByTime(5_001)
|
||||
}
|
||||
|
||||
await expect(listSubmodulePaths('/retained-repo')).resolves.toEqual(['retained-repo-lib'])
|
||||
for (let i = 0; i < MAX_SUBMODULE_PATHS_CACHE_ENTRIES - 1; i += 1) {
|
||||
await listSubmodulePaths(`/final-repo-${i}`)
|
||||
}
|
||||
await expect(listSubmodulePaths('/retained-repo')).resolves.toEqual(['retained-repo-lib'])
|
||||
await listSubmodulePaths(`/final-repo-${MAX_SUBMODULE_PATHS_CACHE_ENTRIES - 1}`)
|
||||
await listSubmodulePaths('/overflow-repo')
|
||||
|
||||
expect(getSubmodulePathsCacheCountForTests()).toBe(MAX_SUBMODULE_PATHS_CACHE_ENTRIES)
|
||||
|
||||
const callsBeforeRetainedRead = gitExecFileAsyncMock.mock.calls.length
|
||||
await expect(listSubmodulePaths('/retained-repo')).resolves.toEqual(['retained-repo-lib'])
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(callsBeforeRetainedRead)
|
||||
|
||||
await expect(listSubmodulePaths('/final-repo-0')).resolves.toEqual(['final-repo-0-lib'])
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(callsBeforeRetainedRead + 1)
|
||||
})
|
||||
|
||||
it('does not let a pre-invalidation read repopulate the cache', async () => {
|
||||
let resolveOldRead: ((value: { stdout: string }) => void) | undefined
|
||||
gitExecFileAsyncMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stdout: string }>((resolve) => {
|
||||
resolveOldRead = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const oldRead = listSubmodulePaths('/repo')
|
||||
expect(resolveOldRead).toBeTypeOf('function')
|
||||
clearSubmodulePathsCacheForTests()
|
||||
resolveOldRead?.({ stdout: 'submodule.lib.path old-lib\n' })
|
||||
|
||||
await expect(oldRead).resolves.toEqual(['old-lib'])
|
||||
expect(getSubmodulePathsCacheCountForTests()).toBe(0)
|
||||
await expect(listSubmodulePaths('/repo')).resolves.toEqual(['repo-lib'])
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not reuse another branch\'s submodule paths after local or WSL checkout', async () => {
|
||||
let modulePath = 'main-lib'
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args[0] === 'checkout') {
|
||||
modulePath = 'feature-lib'
|
||||
return Promise.resolve({ stdout: '' })
|
||||
}
|
||||
return Promise.resolve({ stdout: `submodule.lib.path ${modulePath}\n` })
|
||||
})
|
||||
const runtime = { wslDistro: 'Ubuntu' }
|
||||
|
||||
await expect(listSubmodulePaths('/repo', runtime)).resolves.toEqual(['main-lib'])
|
||||
await checkoutBranch('/repo', 'feature', runtime)
|
||||
await expect(listSubmodulePaths('/repo', runtime)).resolves.toEqual(['feature-lib'])
|
||||
|
||||
const configReads = gitExecFileAsyncMock.mock.calls.filter(
|
||||
([args]) => args[0] === 'config' && args.includes('.gitmodules')
|
||||
)
|
||||
expect(configReads).toHaveLength(2)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['checkout', 'feature', '--'], {
|
||||
cwd: '/repo',
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['pull', () => gitPull('/repo')],
|
||||
['rebase', () => gitPullRebaseFromBase('/repo', 'origin/main')]
|
||||
])('invalidates submodule paths around local %s', async (_name, mutate) => {
|
||||
let modulePath = 'old-lib'
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args[0] === 'remote') {
|
||||
return Promise.resolve({ stdout: 'origin\n' })
|
||||
}
|
||||
if (args[0] === 'pull') {
|
||||
modulePath = 'fresh-lib'
|
||||
return Promise.resolve({ stdout: '' })
|
||||
}
|
||||
if (args[0] === 'config' && args.includes('.gitmodules')) {
|
||||
return Promise.resolve({ stdout: `submodule.lib.path ${modulePath}\n` })
|
||||
}
|
||||
return Promise.resolve({ stdout: '' })
|
||||
})
|
||||
|
||||
await expect(listSubmodulePaths('/repo')).resolves.toEqual(['old-lib'])
|
||||
await mutate()
|
||||
await expect(listSubmodulePaths('/repo')).resolves.toEqual(['fresh-lib'])
|
||||
|
||||
const configReads = gitExecFileAsyncMock.mock.calls.filter(
|
||||
([args]) => args[0] === 'config' && args.includes('.gitmodules')
|
||||
)
|
||||
expect(configReads).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['merge', () => abortMerge('/repo', { wslDistro: 'Ubuntu' })],
|
||||
['rebase', () => abortRebase('/repo', { wslDistro: 'Ubuntu' })]
|
||||
])('drops a cached empty .gitmodules result when %s abort restores it', async (_name, abort) => {
|
||||
let modulePath = ''
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args[1] === '--abort') {
|
||||
modulePath = 'restored-lib'
|
||||
return Promise.resolve({ stdout: '' })
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: modulePath ? `submodule.lib.path ${modulePath}\n` : ''
|
||||
})
|
||||
})
|
||||
|
||||
await expect(listSubmodulePaths('/repo', { wslDistro: 'Ubuntu' })).resolves.toEqual([])
|
||||
await abort()
|
||||
await expect(listSubmodulePaths('/repo', { wslDistro: 'Ubuntu' })).resolves.toEqual([
|
||||
'restored-lib'
|
||||
])
|
||||
|
||||
const configReads = gitExecFileAsyncMock.mock.calls.filter(
|
||||
([args]) => args[0] === 'config' && args.includes('.gitmodules')
|
||||
)
|
||||
expect(configReads).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('drops a same-path negative cache when a local or WSL worktree is recreated', async () => {
|
||||
let recreated = false
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'add') {
|
||||
recreated = true
|
||||
}
|
||||
if (args[0] === 'config' && args.includes('.gitmodules')) {
|
||||
return Promise.resolve({
|
||||
stdout: recreated ? 'submodule.lib.path recreated-lib\n' : ''
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ stdout: '' })
|
||||
})
|
||||
const runtime = { wslDistro: 'Ubuntu' }
|
||||
|
||||
await expect(listSubmodulePaths('/repo-feature', runtime)).resolves.toEqual([])
|
||||
await removeWorktree('/repo', '/repo-feature', true, {
|
||||
...runtime,
|
||||
knownRemovedWorktree: { branch: '', head: '', locked: false }
|
||||
})
|
||||
await addWorktree('/repo', '/repo-feature', 'feature', undefined, false, false, {
|
||||
...runtime,
|
||||
checkoutExistingBranch: true
|
||||
})
|
||||
await expect(listSubmodulePaths('/repo-feature', runtime)).resolves.toEqual([
|
||||
'recreated-lib'
|
||||
])
|
||||
|
||||
const configReads = gitExecFileAsyncMock.mock.calls.filter(
|
||||
([args]) => args[0] === 'config' && args.includes('.gitmodules')
|
||||
)
|
||||
expect(configReads).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -64,8 +64,10 @@ type EffectiveUpstreamStatusCacheEntry = {
|
|||
}
|
||||
|
||||
const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000
|
||||
export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512
|
||||
type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number }
|
||||
const submodulePathsCache = new Map<string, SubmodulePathsCacheEntry>()
|
||||
let submodulePathsCacheGeneration = 0
|
||||
|
||||
// Why: the effective-upstream resolution chain (symbolic-ref + rev-parse ×2-3
|
||||
// + config snapshot) costs 4-5 subprocess spawns and only changes when branch
|
||||
|
|
@ -92,15 +94,37 @@ const statusReadsInFlight = new Map<string, Promise<GitStatusResult>>()
|
|||
// Why: a mutation invalidates both in-flight diff reads and in-flight status
|
||||
// coalescing; clearing only the diff dedupe would let a post-mutation
|
||||
// getStatus() join a pre-mutation read and return stale entries.
|
||||
function clearGitReadInvalidationState(): void {
|
||||
export function invalidateGitReadCaches(): void {
|
||||
gitDiffReadDedupe.clear()
|
||||
statusReadsInFlight.clear()
|
||||
submodulePathsCache.clear()
|
||||
clearSubmodulePathsCache()
|
||||
resolvedUpstreamNameCache.clear()
|
||||
}
|
||||
|
||||
export async function runWithGitReadCacheInvalidation<T>(run: () => Promise<T>): Promise<T> {
|
||||
invalidateGitReadCaches()
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
// Why: a read that started during the mutation can be stale too, so the
|
||||
// post-mutation boundary retires both pre-existing and overlapping reads.
|
||||
invalidateGitReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSubmodulePathsCacheForTests(): void {
|
||||
clearSubmodulePathsCache()
|
||||
}
|
||||
|
||||
function clearSubmodulePathsCache(): void {
|
||||
submodulePathsCache.clear()
|
||||
// Why: a pre-mutation .gitmodules read must not repopulate the cache after
|
||||
// the mutation invalidated it.
|
||||
submodulePathsCacheGeneration += 1
|
||||
}
|
||||
|
||||
export function getSubmodulePathsCacheCountForTests(): number {
|
||||
return submodulePathsCache.size
|
||||
}
|
||||
|
||||
function gitRuntimeOptionsKey(options: GitRuntimeOptions): readonly unknown[] {
|
||||
|
|
@ -113,6 +137,44 @@ function getSubmodulePathsCacheKey(worktreePath: string, options: GitRuntimeOpti
|
|||
return [worktreePath, ...gitRuntimeOptionsKey(options)].join('\0')
|
||||
}
|
||||
|
||||
function pruneExpiredSubmodulePathsCache(now: number): void {
|
||||
for (const [cacheKey, entry] of submodulePathsCache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
submodulePathsCache.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function trimSubmodulePathsCache(): void {
|
||||
while (submodulePathsCache.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) {
|
||||
const oldestKey = submodulePathsCache.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
submodulePathsCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedSubmodulePaths(cacheKey: string, now: number): string[] | null {
|
||||
const cached = submodulePathsCache.get(cacheKey)
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
if (cached.expiresAt <= now) {
|
||||
submodulePathsCache.delete(cacheKey)
|
||||
return null
|
||||
}
|
||||
submodulePathsCache.delete(cacheKey)
|
||||
submodulePathsCache.set(cacheKey, cached)
|
||||
return cached.paths
|
||||
}
|
||||
|
||||
function rememberSubmodulePaths(cacheKey: string, paths: string[], now: number): void {
|
||||
submodulePathsCache.delete(cacheKey)
|
||||
submodulePathsCache.set(cacheKey, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS })
|
||||
trimSubmodulePathsCache()
|
||||
}
|
||||
|
||||
// Why: status tests reuse this reset hook, so every cross-call memoization layer
|
||||
// must reset together even though the historical name mentions upstream only.
|
||||
export function clearEffectiveUpstreamStatusCacheForTests(): void {
|
||||
|
|
@ -120,7 +182,7 @@ export function clearEffectiveUpstreamStatusCacheForTests(): void {
|
|||
effectiveUpstreamStatusInFlight.clear()
|
||||
retiredEffectiveUpstreamStatusInFlight.clear()
|
||||
effectiveUpstreamStatusWriteGeneration.clear()
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
}
|
||||
|
||||
export function getEffectiveUpstreamStatusCacheCountForTests(): number {
|
||||
|
|
@ -908,14 +970,18 @@ export async function abortMerge(
|
|||
worktreePath: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
await gitExecFileAsync(['merge', '--abort'], gitOptionsForWorktree(worktreePath, options))
|
||||
await runWithGitReadCacheInvalidation(() =>
|
||||
gitExecFileAsync(['merge', '--abort'], gitOptionsForWorktree(worktreePath, options))
|
||||
)
|
||||
}
|
||||
|
||||
export async function abortRebase(
|
||||
worktreePath: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
await gitExecFileAsync(['rebase', '--abort'], gitOptionsForWorktree(worktreePath, options))
|
||||
await runWithGitReadCacheInvalidation(() =>
|
||||
gitExecFileAsync(['rebase', '--abort'], gitOptionsForWorktree(worktreePath, options))
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveGitDir(worktreePath: string): Promise<string> {
|
||||
|
|
@ -945,10 +1011,14 @@ export async function listSubmodulePaths(
|
|||
): Promise<string[]> {
|
||||
const now = Date.now()
|
||||
const cacheKey = getSubmodulePathsCacheKey(worktreePath, options)
|
||||
const cached = submodulePathsCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.paths
|
||||
const cached = getCachedSubmodulePaths(cacheKey, now)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
// Why: prune on misses so removed worktrees do not accumulate while hot
|
||||
// cache hits stay O(1).
|
||||
pruneExpiredSubmodulePathsCache(now)
|
||||
const cacheGeneration = submodulePathsCacheGeneration
|
||||
let paths: string[] = []
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
|
|
@ -971,7 +1041,9 @@ export async function listSubmodulePaths(
|
|||
// No .gitmodules (or git config failure) — treat as a repo without submodules.
|
||||
paths = []
|
||||
}
|
||||
submodulePathsCache.set(cacheKey, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS })
|
||||
if (cacheGeneration === submodulePathsCacheGeneration) {
|
||||
rememberSubmodulePaths(cacheKey, paths, Date.now())
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
|
|
@ -1834,14 +1906,14 @@ export async function stageFile(
|
|||
filePath: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
try {
|
||||
await gitExecFileAsync(
|
||||
['add', '--', literalPathspec(filePath, options)],
|
||||
gitOptionsForWorktree(worktreePath, options)
|
||||
)
|
||||
} finally {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1853,13 +1925,13 @@ export async function unstageFile(
|
|||
filePath: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
try {
|
||||
await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath, options)], {
|
||||
...gitOptionsForWorktree(worktreePath, options)
|
||||
})
|
||||
} finally {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1916,7 +1988,7 @@ export async function commitChanges(
|
|||
message: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
try {
|
||||
await gitExecFileAsync(['commit', '-m', message], gitOptionsForWorktree(worktreePath, options))
|
||||
return { success: true }
|
||||
|
|
@ -1939,7 +2011,7 @@ export async function commitChanges(
|
|||
(error instanceof Error ? error.message : 'Commit failed')
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1951,7 +2023,7 @@ export async function discardChanges(
|
|||
filePath: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
const resolvedWorktree = path.resolve(worktreePath)
|
||||
const resolvedTarget = path.resolve(worktreePath, filePath)
|
||||
try {
|
||||
|
|
@ -1986,7 +2058,7 @@ export async function discardChanges(
|
|||
cleanUntrackedPaths(worktreePath, [targetPath], options)
|
||||
)
|
||||
} finally {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2061,7 +2133,7 @@ export async function bulkDiscardChanges(
|
|||
filePaths: string[],
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
if (filePaths.length === 0) {
|
||||
return
|
||||
}
|
||||
|
|
@ -2105,7 +2177,7 @@ export async function bulkDiscardChanges(
|
|||
}
|
||||
)
|
||||
} finally {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2131,7 +2203,7 @@ export async function bulkStageFiles(
|
|||
filePaths: string[],
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
if (filePaths.length === 0) {
|
||||
return
|
||||
}
|
||||
|
|
@ -2144,7 +2216,7 @@ export async function bulkStageFiles(
|
|||
)
|
||||
}
|
||||
} finally {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2156,7 +2228,7 @@ export async function bulkUnstageFiles(
|
|||
filePaths: string[],
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
if (filePaths.length === 0) {
|
||||
return
|
||||
}
|
||||
|
|
@ -2176,6 +2248,6 @@ export async function bulkUnstageFiles(
|
|||
)
|
||||
}
|
||||
} finally {
|
||||
clearGitReadInvalidationState()
|
||||
invalidateGitReadCaches()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
} from '../../shared/git-worktree-command-capabilities'
|
||||
import { getLocalGitCapabilityCache } from './git-capability-state'
|
||||
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
|
||||
import { resolveGitDir } from './status'
|
||||
import { resolveGitDir, runWithGitReadCacheInvalidation } from './status'
|
||||
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
|
||||
|
||||
export type AddWorktreeResult = {
|
||||
|
|
@ -838,14 +838,16 @@ export async function addWorktree(
|
|||
options: AddWorktreeOptions = {}
|
||||
): Promise<AddWorktreeResult> {
|
||||
try {
|
||||
return await performAddWorktree(
|
||||
repoPath,
|
||||
worktreePath,
|
||||
branch,
|
||||
baseBranch,
|
||||
refreshLocalBaseRef,
|
||||
noCheckout,
|
||||
options
|
||||
return await runWithGitReadCacheInvalidation(() =>
|
||||
performAddWorktree(
|
||||
repoPath,
|
||||
worktreePath,
|
||||
branch,
|
||||
baseBranch,
|
||||
refreshLocalBaseRef,
|
||||
noCheckout,
|
||||
options
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
bumpWorktreeScanGeneration(repoPath)
|
||||
|
|
@ -1056,7 +1058,9 @@ export async function moveWorktree(
|
|||
newPath: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
|
||||
await runWithGitReadCacheInvalidation(() =>
|
||||
gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
|
||||
)
|
||||
} finally {
|
||||
bumpWorktreeScanGeneration(repoPath)
|
||||
}
|
||||
|
|
@ -1076,7 +1080,9 @@ export async function removeWorktree(
|
|||
options: RemoveWorktreeOptions = {}
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
try {
|
||||
return await performRemoveWorktree(repoPath, worktreePath, force, options)
|
||||
return await runWithGitReadCacheInvalidation(() =>
|
||||
performRemoveWorktree(repoPath, worktreePath, force, options)
|
||||
)
|
||||
} finally {
|
||||
bumpWorktreeScanGeneration(repoPath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const {
|
|||
mockGitProvider,
|
||||
mockFilesystemProvider,
|
||||
mockMultiplexer,
|
||||
gitExecFileAsyncMock,
|
||||
gitSpawnMock,
|
||||
listWorktreeGraphMock,
|
||||
invalidateAuthorizedRootsCacheMock,
|
||||
|
|
@ -71,6 +72,7 @@ const {
|
|||
notify: vi.fn()
|
||||
},
|
||||
gitSpawnMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
listWorktreeGraphMock: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheMock: vi.fn(),
|
||||
prepareLocalWorktreeRootForRepoMock: vi.fn()
|
||||
|
|
@ -103,8 +105,14 @@ vi.mock('../git/repo', async () => {
|
|||
})
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: vi.fn(),
|
||||
gitSpawn: gitSpawnMock
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
gitExecFileAsyncBuffer: vi.fn(),
|
||||
gitStreamStdout: vi.fn(),
|
||||
gitSpawn: gitSpawnMock,
|
||||
gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({
|
||||
...env,
|
||||
GIT_OPTIONAL_LOCKS: '0'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
|
|
@ -147,6 +155,10 @@ vi.mock('./ssh', () => ({
|
|||
}))
|
||||
|
||||
import { registerRepoHandlers } from './repos'
|
||||
import {
|
||||
clearSubmodulePathsCacheForTests,
|
||||
listSubmodulePaths
|
||||
} from '../git/status'
|
||||
|
||||
beforeEach(() => {
|
||||
clearGitCapabilityStateForTests()
|
||||
|
|
@ -1011,6 +1023,8 @@ describe('repos:addRemote', () => {
|
|||
mockMultiplexer.request.mockReset()
|
||||
mockMultiplexer.notify.mockReset()
|
||||
gitSpawnMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
clearSubmodulePathsCacheForTests()
|
||||
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
|
||||
gitSpawnMock.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
|
|
@ -1969,6 +1983,41 @@ describe('repos:add + repos:clone', () => {
|
|||
expect(result).toHaveProperty('externalWorktreeVisibility', 'hide')
|
||||
})
|
||||
|
||||
it('drops a same-path negative submodule cache before a local clone', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
let cloned = false
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) =>
|
||||
Promise.resolve({
|
||||
stdout:
|
||||
args[0] === 'config' && args.includes('.gitmodules') && cloned
|
||||
? 'submodule.lib.path vendor/lib\n'
|
||||
: '',
|
||||
stderr: ''
|
||||
})
|
||||
)
|
||||
gitSpawnMock.mockImplementationOnce(() => {
|
||||
const proc = createMockCloneProcess()
|
||||
queueMicrotask(() => {
|
||||
cloned = true
|
||||
proc.emit('close', 0, null)
|
||||
})
|
||||
return proc
|
||||
})
|
||||
|
||||
await expect(listSubmodulePaths(clonePath)).resolves.toEqual([])
|
||||
await handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
await expect(listSubmodulePaths(clonePath)).resolves.toEqual(['vendor/lib'])
|
||||
|
||||
const configReads = gitExecFileAsyncMock.mock.calls.filter(
|
||||
([args]) => args[0] === 'config' && args.includes('.gitmodules')
|
||||
)
|
||||
expect(configReads).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('preserves existing badgeColor when repos:clone upgrades folder->git after dedupe', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ import {
|
|||
} from '../project-groups/folder-workspace-path-status'
|
||||
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
|
||||
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
|
||||
import { runWithGitReadCacheInvalidation } from '../git/status'
|
||||
|
||||
// Why: `method` answers "which entry point did the user take?", not "what did
|
||||
// they add?" — so the IPC the renderer invoked IS the method. We never send
|
||||
|
|
@ -982,7 +983,7 @@ async function runWithClonePathLock<T>(clonePathKey: string, task: () => Promise
|
|||
|
||||
try {
|
||||
await previous
|
||||
return await task()
|
||||
return await runWithGitReadCacheInvalidation(task)
|
||||
} finally {
|
||||
release()
|
||||
if (cloneInFlightByPath.get(clonePathKey) === tail) {
|
||||
|
|
|
|||
|
|
@ -1054,6 +1054,42 @@ describe('SshGitProvider', () => {
|
|||
expect(mux.request).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'clone',
|
||||
(provider: SshGitProvider) =>
|
||||
provider.clone(['clone', '--', 'https://example.com/repo.git', 'repo'], '/projects')
|
||||
],
|
||||
[
|
||||
'mutating git.exec',
|
||||
(provider: SshGitProvider) =>
|
||||
provider.exec(['commit', '--allow-empty', '-m', 'initialize'], '/home/user/repo')
|
||||
]
|
||||
])('clears pending diff RPCs when %s runs', async (_name, mutate) => {
|
||||
const diff = {
|
||||
kind: 'text',
|
||||
originalContent: 'old',
|
||||
modifiedContent: 'new',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
}
|
||||
const pendingDiff = deferredValue(diff)
|
||||
mux.request.mockReturnValueOnce(pendingDiff.promise)
|
||||
|
||||
const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true)
|
||||
await waitForRequestCount(mux.request, 1)
|
||||
|
||||
mux.request.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
await mutate(provider)
|
||||
|
||||
mux.request.mockResolvedValueOnce(diff)
|
||||
const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true)
|
||||
|
||||
pendingDiff.resolve()
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([diff, diff])
|
||||
expect(mux.request).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('clears pending branch diff RPCs when a ref-moving provider operation runs', async () => {
|
||||
const diff = {
|
||||
kind: 'text',
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
isMaxBufferOverflowError
|
||||
} from '../git/max-buffer-overflow'
|
||||
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
|
||||
import { gitExecMutatesRepository } from '../../shared/git-exec-mutation'
|
||||
|
||||
type NonInteractiveExecQueueEntry = {
|
||||
started: boolean
|
||||
|
|
@ -761,9 +762,13 @@ export class SshGitProvider implements IGitProvider {
|
|||
cwd: string,
|
||||
options?: { signal?: AbortSignal; timeoutMs?: number }
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const result = options
|
||||
? await requestGitStreamable(this.mux, 'git.exec', { args, cwd }, options)
|
||||
: await requestGitStreamable(this.mux, 'git.exec', { args, cwd })
|
||||
const run = () =>
|
||||
options
|
||||
? requestGitStreamable(this.mux, 'git.exec', { args, cwd }, options)
|
||||
: requestGitStreamable(this.mux, 'git.exec', { args, cwd })
|
||||
const result = gitExecMutatesRepository(args)
|
||||
? await this.runWithDiffDedupeClear(run)
|
||||
: await run()
|
||||
return result as {
|
||||
stdout: string
|
||||
stderr: string
|
||||
|
|
@ -779,6 +784,7 @@ export class SshGitProvider implements IGitProvider {
|
|||
onProgress?: (progress: { phase: string; percent: number }) => void
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
const progressId = `clone-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const unsubscribe = options?.onProgress
|
||||
? this.mux.onNotificationByMethod('git.cloneProgress', (params) => {
|
||||
|
|
@ -811,6 +817,7 @@ export class SshGitProvider implements IGitProvider {
|
|||
throw error
|
||||
} finally {
|
||||
unsubscribe?.()
|
||||
this.gitDiffReadDedupe.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ import {
|
|||
removeWorktree
|
||||
} from '../git/worktree'
|
||||
import * as gitRunner from '../git/runner'
|
||||
import {
|
||||
clearSubmodulePathsCacheForTests,
|
||||
listSubmodulePaths
|
||||
} from '../git/status'
|
||||
import {
|
||||
createSetupRunnerScript,
|
||||
getEffectiveHooks,
|
||||
|
|
@ -6182,6 +6186,43 @@ describe('OrcaRuntimeService', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('drops a same-path negative submodule cache before runtime cloneRepo', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-reclone-'))
|
||||
const clonePath = join(destination, 'reclone')
|
||||
const added: Record<string, unknown>[] = []
|
||||
const cloneStore = {
|
||||
...store,
|
||||
getRepos: () => [...added] as never,
|
||||
addRepo: (repo: Record<string, unknown>) => added.push(repo),
|
||||
getRepo: (id: string) => added.find((repo) => repo.id === id) as never
|
||||
}
|
||||
spawnSpy.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => {
|
||||
void mkdir(clonePath, { recursive: true })
|
||||
.then(() =>
|
||||
writeFile(join(clonePath, '.gitmodules'), '[submodule "lib"]\n\tpath = vendor/lib\n')
|
||||
)
|
||||
.then(() => proc.emit('close', 0, null))
|
||||
})
|
||||
return proc as never
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(cloneStore as never)
|
||||
|
||||
try {
|
||||
clearSubmodulePathsCacheForTests()
|
||||
await expect(listSubmodulePaths(clonePath)).resolves.toEqual([])
|
||||
await runtime.cloneRepo('https://example.com/reclone.git', destination)
|
||||
await expect(listSubmodulePaths(clonePath)).resolves.toEqual(['vendor/lib'])
|
||||
} finally {
|
||||
clearSubmodulePathsCacheForTests()
|
||||
spawnSpy.mockRestore()
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves existing badgeColor on runtime cloneRepo folder->git dedupe upgrade', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
spawnSpy.mockImplementation(() => {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import {
|
|||
buildAgentPromptPasteBytes
|
||||
} from '../../shared/agent-prompt-injection'
|
||||
import { gitExecFileAsync, gitSpawn } from '../git/runner'
|
||||
import { runWithGitReadCacheInvalidation } from '../git/status'
|
||||
import {
|
||||
cleanupClaimedCloneTarget,
|
||||
claimCloneTarget,
|
||||
|
|
@ -11470,12 +11471,14 @@ export class OrcaRuntimeService {
|
|||
|
||||
try {
|
||||
await previous
|
||||
return await this.cloneRepoAfterPathLock(
|
||||
trimmedUrl,
|
||||
trimmedDestination,
|
||||
clonePath,
|
||||
clonePathKey,
|
||||
executionHostId
|
||||
return await runWithGitReadCacheInvalidation(() =>
|
||||
this.cloneRepoAfterPathLock(
|
||||
trimmedUrl,
|
||||
trimmedDestination,
|
||||
clonePath,
|
||||
clonePathKey,
|
||||
executionHostId
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
release()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RelayContext } from './context'
|
||||
import { GitHandler } from './git-handler'
|
||||
import {
|
||||
createMockDispatcher,
|
||||
type MockDispatcher,
|
||||
type RelayDispatcher
|
||||
} from './git-handler-test-setup'
|
||||
|
||||
type GitHandlerSpies = {
|
||||
git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }>
|
||||
gitBuffer(args: string[], cwd: string): Promise<Buffer>
|
||||
spawnClone(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
progressId: string
|
||||
): Promise<{ stdout: string; stderr: string }>
|
||||
}
|
||||
|
||||
describe('GitHandler submodule cache invalidation', () => {
|
||||
let dispatcher: MockDispatcher
|
||||
let handler: GitHandler
|
||||
let target: GitHandlerSpies
|
||||
|
||||
beforeEach(() => {
|
||||
dispatcher = createMockDispatcher()
|
||||
handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext())
|
||||
target = handler as unknown as GitHandlerSpies
|
||||
vi.spyOn(target, 'git').mockResolvedValue({ stdout: '', stderr: '' })
|
||||
vi.spyOn(target, 'gitBuffer').mockResolvedValue(Buffer.from('content\n'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
handler.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'clone',
|
||||
mutate: async () => {
|
||||
vi.spyOn(target, 'spawnClone').mockResolvedValue({ stdout: '', stderr: '' })
|
||||
await dispatcher.callRequest('git.clone', {
|
||||
args: ['clone', '--', 'https://example.com/repo.git', 'repo'],
|
||||
cwd: '/projects',
|
||||
progressId: 'clone-test'
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'mutating git.exec',
|
||||
mutate: () =>
|
||||
dispatcher.callRequest('git.exec', {
|
||||
args: ['commit', '--allow-empty', '-m', 'initialize'],
|
||||
cwd: '/repo'
|
||||
})
|
||||
}
|
||||
])('clears a cached empty .gitmodules result around $name', async ({ mutate }) => {
|
||||
const diffRequest = {
|
||||
worktreePath: '/repo',
|
||||
filePath: 'src/file.ts',
|
||||
staged: false
|
||||
}
|
||||
|
||||
await dispatcher.callRequest('git.diff', diffRequest)
|
||||
await mutate()
|
||||
await dispatcher.callRequest('git.diff', diffRequest)
|
||||
|
||||
const gitSpy = vi.mocked(target.git)
|
||||
const submodulePathReads = gitSpy.mock.calls.filter(
|
||||
([args]) => args[0] === 'config' && args.includes('.gitmodules')
|
||||
)
|
||||
expect(submodulePathReads).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest'
|
|||
import * as path from 'node:path'
|
||||
import type { GitExec } from './git-handler-ops'
|
||||
import {
|
||||
MAX_SUBMODULE_PATHS_CACHE_ENTRIES,
|
||||
SUBMODULE_PATHS_CACHE_TTL_MS,
|
||||
clearSubmodulePathsCache,
|
||||
createSubmodulePathsCache,
|
||||
getSubmodulePathsCacheCount,
|
||||
listSubmodulePathsCached,
|
||||
resolveSubmoduleWorktreePath
|
||||
} from './git-handler-submodule-ops'
|
||||
|
|
@ -56,6 +59,18 @@ describe('listSubmodulePathsCached', () => {
|
|||
expect(calls()).toBe(2)
|
||||
})
|
||||
|
||||
it('prunes expired entries when a different remote worktree misses', async () => {
|
||||
const { git } = gitmodulesExec(['vendor/lib'])
|
||||
const cache = createSubmodulePathsCache()
|
||||
|
||||
await listSubmodulePathsCached(git, '/repo-a', cache, 0)
|
||||
await listSubmodulePathsCached(git, '/repo-b', cache, 0)
|
||||
expect(getSubmodulePathsCacheCount(cache)).toBe(2)
|
||||
|
||||
await listSubmodulePathsCached(git, '/repo-c', cache, SUBMODULE_PATHS_CACHE_TTL_MS + 1)
|
||||
expect(getSubmodulePathsCacheCount(cache)).toBe(1)
|
||||
})
|
||||
|
||||
it('caches an empty result so a submodule-free repo is not re-read', async () => {
|
||||
let calls = 0
|
||||
const git: GitExec = async () => {
|
||||
|
|
@ -71,6 +86,67 @@ describe('listSubmodulePathsCached', () => {
|
|||
expect(second).toEqual([])
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('stays bounded through prolonged remote-worktree churn and retains recent entries', async () => {
|
||||
const { git, calls } = gitmodulesExec(['vendor/lib'])
|
||||
const cache = createSubmodulePathsCache()
|
||||
let now = 0
|
||||
|
||||
for (let wave = 0; wave < 4; wave += 1) {
|
||||
for (let i = 0; i < MAX_SUBMODULE_PATHS_CACHE_ENTRIES; i += 1) {
|
||||
await listSubmodulePathsCached(git, `/wave-${wave}-repo-${i}`, cache, now)
|
||||
}
|
||||
expect(getSubmodulePathsCacheCount(cache)).toBe(MAX_SUBMODULE_PATHS_CACHE_ENTRIES)
|
||||
now += SUBMODULE_PATHS_CACHE_TTL_MS + 1
|
||||
}
|
||||
|
||||
await listSubmodulePathsCached(git, '/retained-repo', cache, now)
|
||||
for (let i = 0; i < MAX_SUBMODULE_PATHS_CACHE_ENTRIES - 1; i += 1) {
|
||||
await listSubmodulePathsCached(git, `/final-repo-${i}`, cache, now)
|
||||
}
|
||||
await listSubmodulePathsCached(git, '/retained-repo', cache, now)
|
||||
await listSubmodulePathsCached(
|
||||
git,
|
||||
`/final-repo-${MAX_SUBMODULE_PATHS_CACHE_ENTRIES - 1}`,
|
||||
cache,
|
||||
now
|
||||
)
|
||||
await listSubmodulePathsCached(git, '/overflow-repo', cache, now)
|
||||
|
||||
expect(getSubmodulePathsCacheCount(cache)).toBe(MAX_SUBMODULE_PATHS_CACHE_ENTRIES)
|
||||
const callsBeforeReads = calls()
|
||||
await listSubmodulePathsCached(git, '/retained-repo', cache, now)
|
||||
expect(calls()).toBe(callsBeforeReads)
|
||||
await listSubmodulePathsCached(git, '/final-repo-0', cache, now)
|
||||
expect(calls()).toBe(callsBeforeReads + 1)
|
||||
})
|
||||
|
||||
it('does not let a pre-mutation SSH read repopulate the cache', async () => {
|
||||
let resolveOldRead: ((value: { stdout: string; stderr: string }) => void) | undefined
|
||||
let calls = 0
|
||||
const git: GitExec = () => {
|
||||
calls += 1
|
||||
if (calls > 1) {
|
||||
return Promise.resolve({ stdout: 'submodule.lib.path fresh-lib\n', stderr: '' })
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
resolveOldRead = resolve
|
||||
})
|
||||
}
|
||||
const cache = createSubmodulePathsCache()
|
||||
|
||||
const oldRead = listSubmodulePathsCached(git, '/repo', cache, 1_000)
|
||||
expect(resolveOldRead).toBeTypeOf('function')
|
||||
clearSubmodulePathsCache(cache)
|
||||
resolveOldRead?.({ stdout: 'submodule.lib.path old-lib\n', stderr: '' })
|
||||
|
||||
await expect(oldRead).resolves.toEqual(['old-lib'])
|
||||
expect(getSubmodulePathsCacheCount(cache)).toBe(0)
|
||||
await expect(listSubmodulePathsCached(git, '/repo', cache, 1_001)).resolves.toEqual([
|
||||
'fresh-lib'
|
||||
])
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSubmoduleWorktreePath', () => {
|
||||
|
|
|
|||
|
|
@ -19,11 +19,69 @@ import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-o
|
|||
* doesn't re-read `.gitmodules` over the (possibly high-latency) SSH link.
|
||||
*/
|
||||
export const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000
|
||||
export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512
|
||||
type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number }
|
||||
export type SubmodulePathsCache = Map<string, SubmodulePathsCacheEntry>
|
||||
export type SubmodulePathsCache = {
|
||||
entries: Map<string, SubmodulePathsCacheEntry>
|
||||
generation: number
|
||||
}
|
||||
|
||||
export function createSubmodulePathsCache(): SubmodulePathsCache {
|
||||
return new Map()
|
||||
return { entries: new Map(), generation: 0 }
|
||||
}
|
||||
|
||||
export function clearSubmodulePathsCache(cache: SubmodulePathsCache): void {
|
||||
cache.entries.clear()
|
||||
// Why: a pre-mutation SSH read must not restore stale .gitmodules paths
|
||||
// after the mutation invalidated them.
|
||||
cache.generation += 1
|
||||
}
|
||||
|
||||
export function getSubmodulePathsCacheCount(cache: SubmodulePathsCache): number {
|
||||
return cache.entries.size
|
||||
}
|
||||
|
||||
function getCachedSubmodulePaths(
|
||||
cache: SubmodulePathsCache,
|
||||
worktreePath: string,
|
||||
now: number
|
||||
): string[] | null {
|
||||
const cached = cache.entries.get(worktreePath)
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
if (cached.expiresAt <= now) {
|
||||
cache.entries.delete(worktreePath)
|
||||
return null
|
||||
}
|
||||
cache.entries.delete(worktreePath)
|
||||
cache.entries.set(worktreePath, cached)
|
||||
return cached.paths
|
||||
}
|
||||
|
||||
function pruneExpiredSubmodulePaths(cache: SubmodulePathsCache, now: number): void {
|
||||
for (const [worktreePath, entry] of cache.entries) {
|
||||
if (entry.expiresAt <= now) {
|
||||
cache.entries.delete(worktreePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rememberSubmodulePaths(
|
||||
cache: SubmodulePathsCache,
|
||||
worktreePath: string,
|
||||
paths: string[],
|
||||
now: number
|
||||
): void {
|
||||
cache.entries.delete(worktreePath)
|
||||
cache.entries.set(worktreePath, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS })
|
||||
while (cache.entries.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) {
|
||||
const oldestPath = cache.entries.keys().next().value
|
||||
if (oldestPath === undefined) {
|
||||
break
|
||||
}
|
||||
cache.entries.delete(oldestPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -38,12 +96,18 @@ export async function listSubmodulePathsCached(
|
|||
cache: SubmodulePathsCache,
|
||||
now: number = Date.now()
|
||||
): Promise<string[]> {
|
||||
const cached = cache.get(worktreePath)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.paths
|
||||
const cached = getCachedSubmodulePaths(cache, worktreePath, now)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
// Why: prune on misses so disconnected worktrees cannot accumulate while
|
||||
// repeated SSH diff clicks keep their O(1) cache-hit path.
|
||||
pruneExpiredSubmodulePaths(cache, now)
|
||||
const cacheGeneration = cache.generation
|
||||
const paths = await listSubmodulePaths(git, worktreePath)
|
||||
cache.set(worktreePath, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS })
|
||||
if (cacheGeneration === cache.generation) {
|
||||
rememberSubmodulePaths(cache, worktreePath, paths, now)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1193,6 +1193,10 @@ describe('GitHandler', () => {
|
|||
|
||||
expect(gitBufferSpy).toHaveBeenCalledTimes(2)
|
||||
expect(gitSpy).toHaveBeenCalledWith(['add', '--', 'src/file.ts'], tmpDir)
|
||||
const submodulePathReads = gitSpy.mock.calls.filter(
|
||||
([args]) => args[0] === 'config' && args.includes('.gitmodules')
|
||||
)
|
||||
expect(submodulePathReads).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('clears pending git.diff reads when a narrow ref fetch runs', async () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
buildSubmoduleInnerCommitRangeDiff,
|
||||
computeSubmodulePointerDiff,
|
||||
computeSubmoduleRangeEntries,
|
||||
clearSubmodulePathsCache,
|
||||
createSubmodulePathsCache,
|
||||
findContainingSubmodule,
|
||||
listSubmodulePathsCached,
|
||||
|
|
@ -39,6 +40,7 @@ import {
|
|||
} from './git-handler-worktree-ops'
|
||||
import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup'
|
||||
import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh'
|
||||
import { gitExecMutatesRepository } from '../shared/git-exec-mutation'
|
||||
import { detectConflictOperation, getStatusOp } from './git-handler-status-ops'
|
||||
import { checkIgnoredPathsOp } from './git-handler-check-ignore'
|
||||
import { resolveRelayPushTarget } from './git-handler-push-target'
|
||||
|
|
@ -182,6 +184,7 @@ export class GitHandler {
|
|||
|
||||
dispose(): void {
|
||||
this.responseStreams.disposeAll()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
|
|
@ -272,14 +275,19 @@ export class GitHandler {
|
|||
return this.responseStreams.startStream(payload, this.dispatcher, context)
|
||||
}
|
||||
|
||||
private async runWithDiffDedupeClear<T>(run: () => Promise<T>): Promise<T> {
|
||||
// Why: git mutations can stale both existing and concurrently-started diff reads.
|
||||
// Clear before and after so later reads never join pre-mutation work.
|
||||
private clearGitMutationReadCaches(): void {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
clearSubmodulePathsCache(this.submodulePathsCache)
|
||||
}
|
||||
|
||||
private async runWithGitReadCacheClear<T>(run: () => Promise<T>): Promise<T> {
|
||||
// Why: git mutations can stale existing and concurrently-started diff and
|
||||
// .gitmodules reads. Clear before and after so later reads cannot join them.
|
||||
this.clearGitMutationReadCaches()
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -476,42 +484,42 @@ export class GitHandler {
|
|||
}
|
||||
|
||||
private async stage(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const filePath = params.filePath as string
|
||||
try {
|
||||
await this.git(['add', '--', filePath], worktreePath)
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async commit(
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const message = params.message as string
|
||||
try {
|
||||
return await commitChangesRelay(this.git.bind(this), worktreePath, message)
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async unstage(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const filePath = params.filePath as string
|
||||
try {
|
||||
await this.git(['restore', '--staged', '--', filePath], worktreePath)
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async bulkStage(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const filePaths = params.filePaths as string[]
|
||||
try {
|
||||
|
|
@ -520,12 +528,12 @@ export class GitHandler {
|
|||
await this.git(['add', '--', ...chunk], worktreePath)
|
||||
}
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async bulkUnstage(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const filePaths = params.filePaths as string[]
|
||||
try {
|
||||
|
|
@ -534,32 +542,32 @@ export class GitHandler {
|
|||
await this.git(['restore', '--staged', '--', ...chunk], worktreePath)
|
||||
}
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async abortMerge(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
try {
|
||||
await this.git(['merge', '--abort'], worktreePath)
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async abortRebase(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
try {
|
||||
await this.git(['rebase', '--abort'], worktreePath)
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async checkout(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const branch = params.branch as string
|
||||
// Defense-in-depth: reject option-like branch tokens (the RPC schema also
|
||||
|
|
@ -573,7 +581,7 @@ export class GitHandler {
|
|||
await this.git(['checkout', branch, '--'], worktreePath)
|
||||
return { ok: true as const, branch }
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -632,7 +640,7 @@ export class GitHandler {
|
|||
}
|
||||
|
||||
private async discard(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const filePath = params.filePath as string
|
||||
try {
|
||||
|
|
@ -661,12 +669,12 @@ export class GitHandler {
|
|||
this.cleanUntrackedPaths(worktreePath, [targetPath])
|
||||
)
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async bulkDiscard(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const filePaths = params.filePaths as string[]
|
||||
if (filePaths.length === 0) {
|
||||
|
|
@ -720,7 +728,7 @@ export class GitHandler {
|
|||
}
|
||||
)
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -826,7 +834,7 @@ export class GitHandler {
|
|||
}
|
||||
|
||||
private async fetch(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
try {
|
||||
try {
|
||||
|
|
@ -845,12 +853,12 @@ export class GitHandler {
|
|||
throw new Error(normalizeGitErrorMessage(error, 'fetch'))
|
||||
}
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async forkSync(params: Record<string, unknown>, context?: RequestContext) {
|
||||
return this.runWithDiffDedupeClear(async () => {
|
||||
return this.runWithGitReadCacheClear(async () => {
|
||||
const worktreePath = params.worktreePath as string
|
||||
const expectedUpstream = validateGitForkSyncExpectedUpstream(params.expectedUpstream, {
|
||||
required: true
|
||||
|
|
@ -882,7 +890,7 @@ export class GitHandler {
|
|||
}
|
||||
|
||||
private async fetchRemoteTrackingRef(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const remote = params.remote
|
||||
const branch = params.branch
|
||||
|
|
@ -930,12 +938,12 @@ export class GitHandler {
|
|||
throw new Error(normalizeGitErrorMessage(error, 'fetch'))
|
||||
}
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchGitLabMergeRequestHead(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const remote = params.remote
|
||||
const mrIid = params.mrIid
|
||||
|
|
@ -970,12 +978,12 @@ export class GitHandler {
|
|||
throw new Error(normalizeGitErrorMessage(error, 'fetch'))
|
||||
}
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async push(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
// Why: mirror src/main/git/remote.ts. Push to a configured upstream when
|
||||
// present so SSH worktrees with non-origin targets do not get repointed.
|
||||
|
|
@ -1000,12 +1008,12 @@ export class GitHandler {
|
|||
throw new Error(normalizeGitErrorMessage(error, 'push'))
|
||||
}
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
private async pullWithArgs(params: Record<string, unknown>, pullArgs: string[]) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
try {
|
||||
try {
|
||||
|
|
@ -1036,7 +1044,7 @@ export class GitHandler {
|
|||
throw new Error(normalizeGitErrorMessage(error, 'pull'))
|
||||
}
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1051,7 +1059,7 @@ export class GitHandler {
|
|||
}
|
||||
|
||||
private async rebaseFromBase(params: Record<string, unknown>) {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const baseRef = params.baseRef as string
|
||||
try {
|
||||
|
|
@ -1065,7 +1073,7 @@ export class GitHandler {
|
|||
throw new Error(normalizeGitErrorMessage(error, 'pull'))
|
||||
}
|
||||
} finally {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1128,7 +1136,10 @@ export class GitHandler {
|
|||
const cwd = params.cwd as string
|
||||
|
||||
validateGitExecArgs(args)
|
||||
const { stdout, stderr } = await this.git(args, cwd, { signal: context?.signal })
|
||||
const run = () => this.git(args, cwd, { signal: context?.signal })
|
||||
const { stdout, stderr } = gitExecMutatesRepository(args)
|
||||
? await this.runWithGitReadCacheClear(run)
|
||||
: await run()
|
||||
return this.maybeStreamResponse({ stdout, stderr }, params, context)
|
||||
}
|
||||
|
||||
|
|
@ -1143,7 +1154,9 @@ export class GitHandler {
|
|||
if (args[0] !== 'clone') {
|
||||
throw new Error('git.clone only supports clone commands.')
|
||||
}
|
||||
return await this.spawnClone(args, cwd, progressId, context)
|
||||
return await this.runWithGitReadCacheClear(() =>
|
||||
this.spawnClone(args, cwd, progressId, context)
|
||||
)
|
||||
}
|
||||
|
||||
private async spawnClone(
|
||||
|
|
@ -1213,7 +1226,7 @@ export class GitHandler {
|
|||
}
|
||||
|
||||
private async renameCurrentBranch(params: Record<string, unknown>) {
|
||||
return this.runWithDiffDedupeClear(async () => {
|
||||
return this.runWithGitReadCacheClear(async () => {
|
||||
const worktreePath = params.worktreePath
|
||||
const newBranch = params.newBranch
|
||||
if (typeof worktreePath !== 'string' || typeof newBranch !== 'string') {
|
||||
|
|
@ -1250,7 +1263,7 @@ export class GitHandler {
|
|||
if (!repoPath || repoPath.includes('\0') || expectedHead.includes('\0')) {
|
||||
throw new Error('Invalid preserved branch force-delete request.')
|
||||
}
|
||||
return this.runWithDiffDedupeClear(() =>
|
||||
return this.runWithGitReadCacheClear(() =>
|
||||
forceDeletePreservedRelayBranch(this.git.bind(this), repoPath, branchName, expectedHead)
|
||||
)
|
||||
}
|
||||
|
|
@ -1359,11 +1372,11 @@ export class GitHandler {
|
|||
}
|
||||
|
||||
private async addWorktree(params: Record<string, unknown>) {
|
||||
return this.runWithDiffDedupeClear(() => addWorktreeOp(this.git.bind(this), params))
|
||||
return this.runWithGitReadCacheClear(() => addWorktreeOp(this.git.bind(this), params))
|
||||
}
|
||||
|
||||
private async removeWorktree(params: Record<string, unknown>) {
|
||||
return this.runWithDiffDedupeClear(() =>
|
||||
return this.runWithGitReadCacheClear(() =>
|
||||
removeWorktreeOp(this.git.bind(this), params, this.gitCapabilities)
|
||||
)
|
||||
}
|
||||
|
|
@ -1373,7 +1386,7 @@ export class GitHandler {
|
|||
}
|
||||
|
||||
private async refreshLocalBaseRefForWorktreeCreate(params: Record<string, unknown>) {
|
||||
return this.runWithDiffDedupeClear(() =>
|
||||
return this.runWithGitReadCacheClear(() =>
|
||||
refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
const MUTATING_GIT_EXEC_SUBCOMMANDS = new Set(['clone', 'commit', 'init'])
|
||||
|
||||
// Why: relay git.exec permits these narrow write shapes alongside read-only
|
||||
// probes, so cache invalidation must distinguish them before dispatch.
|
||||
export function gitExecMutatesRepository(args: readonly string[]): boolean {
|
||||
return MUTATING_GIT_EXEC_SUBCOMMANDS.has(args[0] ?? '')
|
||||
}
|
||||
Loading…
Reference in New Issue