mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Merge remote-tracking branch 'origin/main' into entrypoint-ssh-openclaw-final
This commit is contained in:
@@ -117,6 +117,11 @@ export class AgentAwakeService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Agents this runtime has seen working recently, independent of the awake setting. */
|
||||
getWorkingAgentCount(): number {
|
||||
return this.getEligibleRunningStatusCount()
|
||||
}
|
||||
|
||||
subscribe(listener: (status: ComputerAwakeStatus) => void): () => void {
|
||||
this.statusListeners.add(listener)
|
||||
return () => this.statusListeners.delete(listener)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer, _internals } from './server'
|
||||
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
|
||||
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
|
||||
import type { EnrichedAgentHookEventPayload } from './server/server-types'
|
||||
import { buildBody, PANE } from './server.test-fixtures'
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) }))
|
||||
|
||||
const CONNECTION = 'conn-1'
|
||||
const T0 = 1_800_000_000_000
|
||||
|
||||
function ingest(
|
||||
server: AgentHookServer,
|
||||
payload: Record<string, unknown>,
|
||||
options: { isReplay?: boolean } = {}
|
||||
): void {
|
||||
const event = normalizeHookPayload(
|
||||
createHookListenerState(),
|
||||
'claude',
|
||||
buildBody(payload),
|
||||
'production'
|
||||
)
|
||||
if (!event) {
|
||||
throw new Error('normalizeHookPayload rejected a known-good Claude fixture')
|
||||
}
|
||||
server.ingestRemote({ ...event, ...(options.isReplay ? { isReplay: true } : {}) }, CONNECTION)
|
||||
}
|
||||
|
||||
describe('the observation clock a relay replay must not restamp', () => {
|
||||
let server: AgentHookServer
|
||||
let emitted: EnrichedAgentHookEventPayload[]
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(T0)
|
||||
server = new AgentHookServer()
|
||||
emitted = []
|
||||
server.setListener((payload) => {
|
||||
emitted.push(payload)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.setListener(null)
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const lastForPane = (): EnrichedAgentHookEventPayload =>
|
||||
emitted.toReversed().find((event) => event.paneKey === PANE)!
|
||||
|
||||
it('holds the observation time across a reconnect replay while delivery order advances', () => {
|
||||
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
|
||||
expect(lastForPane().evidenceObservedAt).toBe(T0)
|
||||
|
||||
vi.setSystemTime(T0 + 25 * 60 * 1000)
|
||||
// A lost transport clears the row; the age of the evidence it restates is not a claim.
|
||||
server.clearStatusEntriesForConnection(CONNECTION)
|
||||
ingest(
|
||||
server,
|
||||
{ hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' },
|
||||
{ isReplay: true }
|
||||
)
|
||||
|
||||
const replayed = lastForPane()
|
||||
expect(replayed.payload.state).toBe('working')
|
||||
// Delivery order must still clear the connection watermark, or the renderer drops the row.
|
||||
expect(replayed.receivedAt).toBeGreaterThan(T0 + 25 * 60 * 1000 - 1)
|
||||
expect(replayed.evidenceObservedAt).toBe(T0)
|
||||
})
|
||||
|
||||
it('lets a live event restamp the observation time after a replay', () => {
|
||||
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
|
||||
vi.setSystemTime(T0 + 25 * 60 * 1000)
|
||||
server.clearStatusEntriesForConnection(CONNECTION)
|
||||
ingest(
|
||||
server,
|
||||
{ hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' },
|
||||
{ isReplay: true }
|
||||
)
|
||||
|
||||
vi.setSystemTime(T0 + 26 * 60 * 1000)
|
||||
ingest(server, { hook_event_name: 'PreToolUse', tool_name: 'Edit' })
|
||||
expect(lastForPane().evidenceObservedAt).toBe(T0 + 26 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('gives a torn-down pane no inherited observation time', () => {
|
||||
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
|
||||
server.clearPaneState(PANE)
|
||||
|
||||
vi.setSystemTime(T0 + 25 * 60 * 1000)
|
||||
ingest(
|
||||
server,
|
||||
{ hook_event_name: 'UserPromptSubmit', prompt: 'a new session' },
|
||||
{ isReplay: true }
|
||||
)
|
||||
expect(lastForPane().evidenceObservedAt).toBe(T0 + 25 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
@@ -97,6 +97,10 @@ export abstract class AgentHookServerState {
|
||||
protected closedAgentStatusPaneKeys = new Set<string>()
|
||||
protected restartedStatusLaunchTokenHashByPaneKey = new Map<string, string>()
|
||||
protected connectionTimestampWatermarkById = new Map<string, number>()
|
||||
// Why: survives the row itself. A transport clear deletes the pane's status row on purpose
|
||||
// (absence, not completion), but the *age* of the evidence a later replay restates is not a
|
||||
// claim about the pane and must not be lost with it. Bounded like its sibling maps.
|
||||
protected evidenceObservedAtByPaneKey = new Map<string, number>()
|
||||
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
|
||||
protected lastWrittenJson: string | null = null
|
||||
// Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own
|
||||
|
||||
@@ -13,6 +13,9 @@ import type { EnrichedAgentHookEventPayload } from './server-types'
|
||||
import { agentTypeToPromptSentAgentKind } from './server-status-identity'
|
||||
import { AgentHookServerStatusDisposition } from './server-status-disposition'
|
||||
|
||||
/** Bounds the retained observation clock; eviction only degrades a replay to `now`. */
|
||||
const MAX_REMEMBERED_EVIDENCE_OBSERVATIONS = 1024
|
||||
|
||||
export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition {
|
||||
protected attachStatusTiming(
|
||||
payload: AgentHookEventPayload,
|
||||
@@ -41,10 +44,38 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
|
||||
return {
|
||||
...payload,
|
||||
receivedAt: now,
|
||||
evidenceObservedAt: this.resolveEvidenceObservedAt(payload, previous, now),
|
||||
stateStartedAt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A replay restates evidence already observed; it is not a new observation. Keeping
|
||||
* `receivedAt` at `now` preserves delivery order (the connection-clear watermark and the
|
||||
* renderer's four `<` drops all depend on it), while this clock records when the evidence
|
||||
* was actually seen — so the staleness window measures age, not reconnect count.
|
||||
* Without a remembered time the honest answer is `now`, which is today's behaviour.
|
||||
*/
|
||||
private resolveEvidenceObservedAt(
|
||||
payload: AgentHookEventPayload,
|
||||
previous: EnrichedAgentHookEventPayload | undefined,
|
||||
now: number
|
||||
): number {
|
||||
const remembered =
|
||||
previous?.evidenceObservedAt ?? this.evidenceObservedAtByPaneKey.get(payload.paneKey)
|
||||
const observedAt = payload.isReplay === true && remembered !== undefined ? remembered : now
|
||||
this.evidenceObservedAtByPaneKey.delete(payload.paneKey)
|
||||
this.evidenceObservedAtByPaneKey.set(payload.paneKey, observedAt)
|
||||
while (this.evidenceObservedAtByPaneKey.size > MAX_REMEMBERED_EVIDENCE_OBSERVATIONS) {
|
||||
const oldest = this.evidenceObservedAtByPaneKey.keys().next().value
|
||||
if (typeof oldest !== 'string') {
|
||||
break
|
||||
}
|
||||
this.evidenceObservedAtByPaneKey.delete(oldest)
|
||||
}
|
||||
return observedAt
|
||||
}
|
||||
|
||||
protected hashPromptForTelemetryDedupe(prompt: string): string {
|
||||
return createHash('sha256')
|
||||
.update(this.promptSentHashSalt)
|
||||
|
||||
@@ -94,6 +94,8 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
|
||||
this.currentAuthorityObservations.delete(resolvedPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
|
||||
// Why: the pane itself is gone, so its observation clock describes nothing a later pane owns.
|
||||
this.evidenceObservedAtByPaneKey.delete(resolvedPaneKey)
|
||||
let clearedAlias = false
|
||||
for (const [legacyPaneKey, alias] of this.legacyPaneKeyAliases) {
|
||||
if (alias.stablePaneKey === resolvedPaneKey) {
|
||||
@@ -105,6 +107,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey)
|
||||
this.evidenceObservedAtByPaneKey.delete(legacyPaneKey)
|
||||
clearedAlias = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-ty
|
||||
// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears).
|
||||
export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
|
||||
receivedAt: number
|
||||
/** When this evidence was first observed, as distinct from `receivedAt`. A relay reconnect
|
||||
* replays cached rows and `receivedAt` must restamp to clear the connection watermark, so
|
||||
* only this clock can answer how old the evidence itself is. Persisted so it survives a
|
||||
* main restart; absent means "never separately observed" and consumers use `receivedAt`. */
|
||||
evidenceObservedAt?: number
|
||||
stateStartedAt: number
|
||||
/** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */
|
||||
observation?: AgentStatusObservation
|
||||
|
||||
@@ -35,7 +35,9 @@ export class CliCommandInstallation extends CliCommandInspection {
|
||||
|
||||
const inspected = await this.inspectStableSymlink(commandPath, launcherPath)
|
||||
if (inspected.status.state === 'conflict') {
|
||||
throw new Error(`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`)
|
||||
throw new Error(
|
||||
`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`
|
||||
)
|
||||
}
|
||||
if (inspected.status.state === 'installed') {
|
||||
return
|
||||
@@ -54,7 +56,9 @@ export class CliCommandInstallation extends CliCommandInspection {
|
||||
|
||||
if (!(await capturedExpectedEntry(quarantine, inspected))) {
|
||||
await this.restoreQuarantinedCommand(quarantine, commandPath)
|
||||
throw new Error(`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`)
|
||||
throw new Error(
|
||||
`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -116,7 +116,9 @@ export class CliInstaller extends CliPathRegistration {
|
||||
throw new Error(initialStatus.detail ?? 'CLI registration is unavailable on this build.')
|
||||
}
|
||||
if (initialStatus.state === 'conflict') {
|
||||
throw new Error(`Refusing to replace non-Orca command at ${initialStatus.commandPath}. Remove it and register again if it is no longer needed.`)
|
||||
throw new Error(
|
||||
`Refusing to replace non-Orca command at ${initialStatus.commandPath}. Remove it and register again if it is no longer needed.`
|
||||
)
|
||||
}
|
||||
const extractedRoot = await this.ensureLinuxAppImagePayload()
|
||||
const status = extractedRoot
|
||||
@@ -126,7 +128,9 @@ export class CliInstaller extends CliPathRegistration {
|
||||
throw new Error(status.detail ?? 'CLI registration is unavailable on this build.')
|
||||
}
|
||||
if (status.state === 'conflict') {
|
||||
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`)
|
||||
throw new Error(
|
||||
`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary.
|
||||
|
||||
@@ -207,7 +207,9 @@ export class WslCliInstaller {
|
||||
throw new Error(status.detail ?? 'WSL CLI registration is unavailable.')
|
||||
}
|
||||
if (status.state === 'conflict') {
|
||||
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`)
|
||||
throw new Error(
|
||||
`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`
|
||||
)
|
||||
}
|
||||
|
||||
await this.run(
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
|
||||
import { validate } from '../telemetry/validator'
|
||||
|
||||
const { trackMock, accessSyncMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted(
|
||||
() => ({
|
||||
trackMock: vi.fn(),
|
||||
accessSyncMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(() => true),
|
||||
readFileSyncMock: vi.fn(),
|
||||
getVersionMock: vi.fn(() => '1.4.191')
|
||||
})
|
||||
)
|
||||
vi.mock('../telemetry/client', () => ({ track: trackMock }))
|
||||
vi.mock('node:fs', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
accessSync: accessSyncMock,
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock
|
||||
}))
|
||||
vi.mock('node:os', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
homedir: () => '/Users/alice'
|
||||
}))
|
||||
vi.mock('../../shared/app-environment', () => ({
|
||||
getAppEnvironment: () => ({ getVersion: getVersionMock })
|
||||
}))
|
||||
|
||||
import {
|
||||
classifyDaemonAdoptionOrigin,
|
||||
trackDaemonAdopted,
|
||||
trackDaemonPtyCwdDeniedIfDiverged
|
||||
} from './daemon-adoption-telemetry-event'
|
||||
|
||||
const stalePidRecord: ParsedDaemonPid = {
|
||||
pid: 1530,
|
||||
startedAtMs: 1,
|
||||
entryPath: '/x/daemon-entry.js',
|
||||
appVersion: '1.4.187',
|
||||
launchNonce: 'n',
|
||||
linuxStartTicks: null,
|
||||
bootId: null,
|
||||
spawnerExecPath:
|
||||
'/Users/alice/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca'
|
||||
}
|
||||
const origin = { app_version_match: 'different', spawner_path_class: 'updater-cache' } as const
|
||||
const PID_PATH = '/fake/daemon.pid'
|
||||
|
||||
beforeEach(() => {
|
||||
trackMock.mockReset()
|
||||
accessSyncMock.mockReset()
|
||||
existsSyncMock.mockReset().mockReturnValue(true)
|
||||
readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(stalePidRecord))
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('classifyDaemonAdoptionOrigin', () => {
|
||||
it('compares the recorded app version and classifies the spawner path', () => {
|
||||
expect(classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin)
|
||||
expect(classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' })).toEqual({
|
||||
app_version_match: 'same',
|
||||
spawner_path_class: 'updater-cache'
|
||||
})
|
||||
expect(classifyDaemonAdoptionOrigin(null)).toEqual({
|
||||
app_version_match: 'unknown',
|
||||
spawner_path_class: 'unknown'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackDaemonAdopted', () => {
|
||||
it('emits a validator-accepted payload', () => {
|
||||
trackDaemonAdopted(stalePidRecord, 'intact', 7)
|
||||
expect(trackMock).toHaveBeenCalledTimes(1)
|
||||
const [name, props] = trackMock.mock.calls[0]
|
||||
expect(name).toBe('daemon_adopted')
|
||||
expect(props).toEqual({
|
||||
...origin,
|
||||
tcc_attribution: 'intact',
|
||||
live_session_count_bucket: '6+'
|
||||
})
|
||||
expect(validate('daemon_adopted', props).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('swallows a throwing telemetry client', () => {
|
||||
trackMock.mockImplementationOnce(() => {
|
||||
throw new Error('posthog exploded')
|
||||
})
|
||||
expect(() => trackDaemonAdopted(null, 'unknown', null)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackDaemonPtyCwdDeniedIfDiverged', () => {
|
||||
it('emits only when the daemon was denied and the app can read the same cwd', () => {
|
||||
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
|
||||
expect(accessSyncMock).toHaveBeenCalledWith('/Users/alice/Documents/repo', expect.any(Number))
|
||||
expect(trackMock).toHaveBeenCalledTimes(1)
|
||||
const [name, props] = trackMock.mock.calls[0]
|
||||
expect(name).toBe('daemon_pty_cwd_denied')
|
||||
expect(props).toEqual({ cwd_class: 'documents', ...origin })
|
||||
expect(validate('daemon_pty_cwd_denied', props).ok).toBe(true)
|
||||
})
|
||||
|
||||
// False positives would drown the signal this event exists to measure, so every
|
||||
// non-divergent shape must stay silent.
|
||||
it('stays silent when the daemon could read the cwd or did not report', () => {
|
||||
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', true, PID_PATH)
|
||||
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', undefined, PID_PATH)
|
||||
trackDaemonPtyCwdDeniedIfDiverged(undefined, false, PID_PATH)
|
||||
expect(accessSyncMock).not.toHaveBeenCalled()
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays silent when the app cannot read the cwd either (no divergence)', () => {
|
||||
accessSyncMock.mockImplementation(() => {
|
||||
throw Object.assign(new Error('EACCES'), { code: 'EACCES' })
|
||||
})
|
||||
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attributes the denial to the daemon recorded right now, not a startup snapshot', () => {
|
||||
readFileSyncMock.mockReturnValue(
|
||||
JSON.stringify({
|
||||
...stalePidRecord,
|
||||
appVersion: '1.4.191',
|
||||
spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca'
|
||||
})
|
||||
)
|
||||
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
|
||||
expect(readFileSyncMock).toHaveBeenCalledWith(PID_PATH, 'utf8')
|
||||
expect(trackMock.mock.calls[0][1]).toEqual({
|
||||
cwd_class: 'documents',
|
||||
app_version_match: 'same',
|
||||
spawner_path_class: 'applications'
|
||||
})
|
||||
})
|
||||
|
||||
it('swallows a throwing app environment or pid-record read instead of failing the spawn', () => {
|
||||
getVersionMock.mockImplementationOnce(() => {
|
||||
throw new Error('AppEnvironment not initialized')
|
||||
})
|
||||
expect(() =>
|
||||
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
|
||||
).not.toThrow()
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays silent off macOS', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
trackDaemonPtyCwdDeniedIfDiverged('/home/alice/Documents/repo', false, PID_PATH)
|
||||
expect(accessSyncMock).not.toHaveBeenCalled()
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows a throwing telemetry client', () => {
|
||||
trackMock.mockImplementationOnce(() => {
|
||||
throw new Error('posthog exploded')
|
||||
})
|
||||
expect(() =>
|
||||
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// App-side emitters for `daemon_adopted` and `daemon_pty_cwd_denied` (#17696). Both sit on the
|
||||
// daemon launch / PTY spawn path, so every failure dies here — telemetry can never cost a terminal.
|
||||
|
||||
import { accessSync, constants as fsConstants, existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import {
|
||||
classifyDaemonPtyCwd,
|
||||
classifyDaemonSpawnerPath,
|
||||
type DaemonAdoptedAppVersionMatch,
|
||||
type DaemonSpawnerPathClass
|
||||
} from '../../shared/daemon-adoption-telemetry'
|
||||
import { bucketDaemonLiveSessionCount } from '../../shared/daemon-lifecycle-telemetry'
|
||||
import type { EventProps } from '../../shared/telemetry-events'
|
||||
import { track } from '../telemetry/client'
|
||||
import { readDaemonPidRecord } from './daemon-endpoint-incarnation'
|
||||
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
|
||||
import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
|
||||
|
||||
export type DaemonAdoptionOrigin = Pick<
|
||||
EventProps<'daemon_pty_cwd_denied'>,
|
||||
'app_version_match' | 'spawner_path_class'
|
||||
>
|
||||
|
||||
/** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */
|
||||
export function classifyDaemonAdoptionOrigin(
|
||||
pidRecord: ParsedDaemonPid | null
|
||||
): DaemonAdoptionOrigin {
|
||||
const appVersionMatch: DaemonAdoptedAppVersionMatch = !pidRecord?.appVersion
|
||||
? 'unknown'
|
||||
: pidRecord.appVersion === getAppEnvironment().getVersion()
|
||||
? 'same'
|
||||
: 'different'
|
||||
const spawnerPathClass: DaemonSpawnerPathClass = classifyDaemonSpawnerPath(
|
||||
pidRecord?.spawnerExecPath ?? null,
|
||||
existsSync
|
||||
)
|
||||
return { app_version_match: appVersionMatch, spawner_path_class: spawnerPathClass }
|
||||
}
|
||||
|
||||
// Adopted a daemon that a previous app launch forked (macOS only; that is where attribution matters).
|
||||
export function trackDaemonAdopted(
|
||||
pidRecord: ParsedDaemonPid | null,
|
||||
tccAttribution: MacDaemonTccAttributionHealth,
|
||||
liveSessionCount: number | null
|
||||
): void {
|
||||
try {
|
||||
track('daemon_adopted', {
|
||||
...classifyDaemonAdoptionOrigin(pidRecord),
|
||||
tcc_attribution: tccAttribution,
|
||||
live_session_count_bucket: bucketDaemonLiveSessionCount(liveSessionCount)
|
||||
})
|
||||
} catch {
|
||||
// Telemetry is best-effort; a dropped event must not fail daemon adoption.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits only on proven divergence: the daemon reported the cwd unreadable AND this process can
|
||||
* read it. A cwd neither can read (chmod, ENOENT, unmounted volume) is not the #17696 shape.
|
||||
*/
|
||||
export function trackDaemonPtyCwdDeniedIfDiverged(
|
||||
cwd: string | undefined,
|
||||
cwdReadableByDaemon: boolean | undefined,
|
||||
pidPath: string | null
|
||||
): void {
|
||||
try {
|
||||
if (process.platform !== 'darwin' || !cwd || cwdReadableByDaemon !== false) {
|
||||
return
|
||||
}
|
||||
accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK)
|
||||
// Why read now, not the adapter's startup snapshot: a respawn swaps the daemon under a
|
||||
// long-lived adapter, and the denial must be attributed to the daemon that just spawned.
|
||||
track('daemon_pty_cwd_denied', {
|
||||
cwd_class: classifyDaemonPtyCwd(cwd, homedir()),
|
||||
...classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath))
|
||||
})
|
||||
} catch {
|
||||
// Either the app cannot read it (no divergence) or telemetry failed; neither may reach the caller.
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@ export type DaemonCreateOrAttachResult = {
|
||||
wslDistro?: string | null
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
incarnationId?: PtyIncarnationId
|
||||
/**
|
||||
* Whether the daemon process itself could read the requested cwd at spawn. Only the daemon's own
|
||||
* verdict counts: macOS TCC scopes folder access per process tree, so the app's view of the same
|
||||
* path proves nothing about the daemon's (#17696). Omitted by daemons predating this field.
|
||||
*/
|
||||
cwdReadableByDaemon?: boolean
|
||||
}
|
||||
|
||||
export function getDaemonSessionResultMetadata(session: {
|
||||
|
||||
@@ -34,7 +34,7 @@ export type RelocatedDaemonHost = {
|
||||
const HOST_SUBDIR = 'daemon-host'
|
||||
const MARKER_NAME = '.materialized.json'
|
||||
|
||||
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync.
|
||||
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync.
|
||||
const LOCAL_HOST_ROOT_NAME = 'Orca'
|
||||
|
||||
// Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it.
|
||||
|
||||
@@ -50,7 +50,8 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
|
||||
unbindLocalProviderListenersMock,
|
||||
rebindLocalProviderListenersMock,
|
||||
trackDaemonReplacedMock,
|
||||
trackDaemonRetiredMock
|
||||
trackDaemonRetiredMock,
|
||||
trackDaemonAdoptedMock
|
||||
} = state
|
||||
|
||||
// Why: both fakes are annotated with constructor types so the exported factories widen to
|
||||
@@ -82,6 +83,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
|
||||
if (result.mode) {
|
||||
this.handle.mode = result.mode
|
||||
}
|
||||
if (result.adopted) {
|
||||
this.handle.adopted = true
|
||||
}
|
||||
return {
|
||||
socketPath: result.socketPath,
|
||||
tokenPath: result.tokenPath
|
||||
@@ -199,6 +203,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
|
||||
trackDaemonReplaced: trackDaemonReplacedMock,
|
||||
trackDaemonRetired: trackDaemonRetiredMock
|
||||
}),
|
||||
daemonAdoptionTelemetryEvent: () => ({
|
||||
trackDaemonAdopted: trackDaemonAdoptedMock
|
||||
}),
|
||||
daemonSpawner: () => ({
|
||||
DaemonSpawner: MockDaemonSpawner,
|
||||
getDaemonSocketPath: (_dir: string, version?: number) =>
|
||||
|
||||
@@ -41,7 +41,8 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) {
|
||||
unbindLocalProviderListenersMock,
|
||||
rebindLocalProviderListenersMock,
|
||||
trackDaemonReplacedMock,
|
||||
trackDaemonRetiredMock
|
||||
trackDaemonRetiredMock,
|
||||
trackDaemonAdoptedMock
|
||||
} = state
|
||||
|
||||
vi.resetModules()
|
||||
@@ -64,6 +65,7 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) {
|
||||
rebindLocalProviderListenersMock.mockClear()
|
||||
trackDaemonReplacedMock.mockClear()
|
||||
trackDaemonRetiredMock.mockClear()
|
||||
trackDaemonAdoptedMock.mockClear()
|
||||
checkDaemonHealthMock.mockClear()
|
||||
checkDaemonHealthMock.mockResolvedValue('healthy')
|
||||
healthCheckDaemonMock.mockClear()
|
||||
|
||||
@@ -47,6 +47,7 @@ export type MockAdapterConstructor = new (opts: MockAdapter['options']) => MockA
|
||||
/** Handle the fake spawner hands back from ensureRunning/getHandle. */
|
||||
export type MockSpawnerHandle = {
|
||||
mode?: 'degraded-new-pty-fallback'
|
||||
adopted?: true
|
||||
releaseAdoptionLease?: () => void
|
||||
shutdown: () => Promise<void>
|
||||
}
|
||||
@@ -95,6 +96,7 @@ export type EnsureRunningOverride = () => Promise<{
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
mode?: 'degraded-new-pty-fallback'
|
||||
adopted?: true
|
||||
}>
|
||||
|
||||
/** Every stub daemon-init's suites share, plus the control knobs they mutate per test. */
|
||||
@@ -143,6 +145,7 @@ export type DaemonInitMockState = {
|
||||
rebindLocalProviderListenersMock: Mock<(...args: unknown[]) => void>
|
||||
trackDaemonReplacedMock: Mock<(...args: unknown[]) => void>
|
||||
trackDaemonRetiredMock: Mock<(...args: unknown[]) => void>
|
||||
trackDaemonAdoptedMock: Mock<(...args: unknown[]) => void>
|
||||
}
|
||||
|
||||
/** net.connect stubs the suites install in beforeEach. */
|
||||
|
||||
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
isPackagedMock,
|
||||
getMacDaemonTccAttributionHealthMock,
|
||||
trackDaemonAdoptedMock,
|
||||
probeSocketExistsMock,
|
||||
readFileSyncMock,
|
||||
unlinkSyncMock,
|
||||
@@ -42,6 +44,7 @@ vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartT
|
||||
vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse())
|
||||
vi.mock('./client', () => moduleFactories.client())
|
||||
vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent())
|
||||
vi.mock('./daemon-adoption-telemetry-event', () => moduleFactories.daemonAdoptionTelemetryEvent())
|
||||
vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner())
|
||||
vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter())
|
||||
vi.mock('../ipc/pty', () => moduleFactories.ipcPty())
|
||||
@@ -228,6 +231,48 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// #17696: adopting a daemon from an earlier app launch is invisible to daemon_lifecycle, so
|
||||
// it gets its own event — macOS only, and only for adopted (not freshly forked) daemons.
|
||||
it('reports a macOS daemon adoption with its TCC attribution and live session bucket', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
const mod = await importFresh()
|
||||
ensureRunningOverrides.push(async () => ({
|
||||
socketPath: '/fake/adopted-socket',
|
||||
tokenPath: '/fake/adopted-token',
|
||||
adopted: true
|
||||
}))
|
||||
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
|
||||
defaultListSessionsSessions.push({ sessionId: 'wt-1@@a' }, { sessionId: 'wt-1@@b' })
|
||||
|
||||
await mod.initDaemonPtyProvider()
|
||||
await vi.waitFor(() => expect(trackDaemonAdoptedMock).toHaveBeenCalledOnce())
|
||||
|
||||
// null pid record: the harness has no pid file, which the emitter classifies as 'unknown'.
|
||||
expect(trackDaemonAdoptedMock).toHaveBeenCalledWith(null, 'severed', 2)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not report adoption for a freshly forked daemon or off macOS', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(trackDaemonAdoptedMock).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
const linuxMod = await importFresh()
|
||||
ensureRunningOverrides.push(async () => ({
|
||||
socketPath: '/fake/adopted-socket',
|
||||
tokenPath: '/fake/adopted-token',
|
||||
adopted: true
|
||||
}))
|
||||
await linuxMod.initDaemonPtyProvider()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(trackDaemonAdoptedMock).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => {
|
||||
const mod = await importFresh()
|
||||
ensureRunningOverrides.push(async () => ({
|
||||
|
||||
@@ -156,6 +156,7 @@ function createDaemonInitMockState(): DaemonInitMockState {
|
||||
const rebindLocalProviderListenersMock = vi.fn()
|
||||
const trackDaemonReplacedMock = vi.fn()
|
||||
const trackDaemonRetiredMock = vi.fn()
|
||||
const trackDaemonAdoptedMock = vi.fn()
|
||||
|
||||
return {
|
||||
getPathMock,
|
||||
@@ -197,7 +198,8 @@ function createDaemonInitMockState(): DaemonInitMockState {
|
||||
unbindLocalProviderListenersMock,
|
||||
rebindLocalProviderListenersMock,
|
||||
trackDaemonReplacedMock,
|
||||
trackDaemonRetiredMock
|
||||
trackDaemonRetiredMock,
|
||||
trackDaemonAdoptedMock
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ function createPreservedDaemonHandle(
|
||||
mode?: 'degraded-new-pty-fallback'
|
||||
): DaemonProcessHandle {
|
||||
const handle: DaemonProcessHandle = {
|
||||
adopted: true,
|
||||
shutdown: async () => {
|
||||
await cleanupDaemonForProtocol(runtimeDir, protocolVersion)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
import type { DaemonProvider } from './daemon-provider-routing'
|
||||
import { installDaemonProvider } from './daemon-provider-state'
|
||||
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
|
||||
import { trackDaemonAdopted } from './daemon-adoption-telemetry-event'
|
||||
import { readDaemonPidRecord } from './daemon-endpoint-incarnation'
|
||||
import { trackDaemonRetired } from './daemon-lifecycle-event'
|
||||
import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type { DaemonRespawnReason } from './daemon-pty-runtime-state'
|
||||
import { DaemonPtyRouter } from './daemon-pty-router'
|
||||
@@ -156,9 +159,37 @@ export async function initDaemonPtyProvider(
|
||||
logDaemonMilestone('daemon-init-done', {
|
||||
legacyAdapters: legacyAdapters.length
|
||||
})
|
||||
if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) {
|
||||
void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter)
|
||||
}
|
||||
await reconcileSeededClaudeLivePtys(routedAdapter)
|
||||
}
|
||||
|
||||
// Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup.
|
||||
async function reportDaemonAdoption(
|
||||
runtimeDir: string,
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
adapter: DaemonPtyAdapter
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [tccAttribution, liveSessionCount] = await Promise.all([
|
||||
getMacDaemonTccAttributionHealth(runtimeDir, socketPath, tokenPath),
|
||||
adapter.listSessions().then(
|
||||
(sessions) => sessions.length,
|
||||
() => null
|
||||
)
|
||||
])
|
||||
trackDaemonAdopted(
|
||||
readDaemonPidRecord(getDaemonPidPath(runtimeDir)),
|
||||
tccAttribution,
|
||||
liveSessionCount
|
||||
)
|
||||
} catch {
|
||||
// Best-effort measurement only.
|
||||
}
|
||||
}
|
||||
|
||||
// Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token.
|
||||
async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise<void> {
|
||||
if (!hasSeededUnconfirmedClaudePtys()) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
HistoryRecoveryContext,
|
||||
PendingDaemonSpawnOperation
|
||||
} from './daemon-pty-runtime-state'
|
||||
import { trackDaemonPtyCwdDeniedIfDiverged } from './daemon-adoption-telemetry-event'
|
||||
import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
import { TerminalKilledError } from './daemon-pty-lifecycle-errors'
|
||||
import { DaemonPtySpawnResult } from './daemon-pty-spawn-result'
|
||||
@@ -246,6 +247,9 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
|
||||
}
|
||||
activeSpawnContext = context
|
||||
const result = await this.createOrAttachSpawn(context, context.historySeedSegments)
|
||||
if (result.isNew && !attachOnly) {
|
||||
trackDaemonPtyCwdDeniedIfDiverged(effectiveCwd, result.cwdReadableByDaemon, this.pidPath)
|
||||
}
|
||||
return this.finishSpawn(context, result)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ export type DaemonPidFile = {
|
||||
|
||||
export type DaemonProcessHandle = {
|
||||
mode?: 'degraded-new-pty-fallback'
|
||||
/** Set when the launcher kept a daemon some earlier app launch forked, rather than forking one. */
|
||||
adopted?: true
|
||||
releaseAdoptionLease?(): void
|
||||
shutdown(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -161,7 +161,10 @@ export class DaemonTerminalAdmission {
|
||||
...(result.launchAgent ? { launchAgent: result.launchAgent } : {}),
|
||||
wslDistro: result.wslDistro,
|
||||
...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}),
|
||||
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
|
||||
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
|
||||
...(result.cwdReadableByDaemon !== undefined
|
||||
? { cwdReadableByDaemon: result.cwdReadableByDaemon }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TerminalAttachCanceledError } from './daemon-errors'
|
||||
|
||||
/** Never resolves; only rejects, so it can bound a wait without settling it. */
|
||||
export function rejectOnAbort(
|
||||
signal: AbortSignal | undefined,
|
||||
sessionId: string
|
||||
): Promise<never> {
|
||||
if (!signal) {
|
||||
return new Promise<never>(() => {})
|
||||
}
|
||||
return new Promise<never>((_resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new TerminalAttachCanceledError(sessionId))
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', () => reject(new TerminalAttachCanceledError(sessionId)), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -54,4 +54,6 @@ export type CreateOrAttachResult = {
|
||||
attachToken: symbol
|
||||
incarnationId: PtyIncarnationId
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
/** Daemon-process verdict on the spawn cwd; only set on a fresh spawn that was given a cwd. */
|
||||
cwdReadableByDaemon?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
import { TerminalHost, type TerminalHostOptions } from './terminal-host'
|
||||
|
||||
vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() }))
|
||||
|
||||
function createMockSubprocess(): SubprocessHandle {
|
||||
let onExitCb: ((code: number) => void) | null = null
|
||||
return {
|
||||
pid: 99999,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => {
|
||||
setTimeout(() => onExitCb?.(0), 5)
|
||||
}),
|
||||
terminateOwnedTree: () => 'unavailable' as const,
|
||||
forceKill: vi.fn(() => onExitCb?.(137)),
|
||||
signal: vi.fn(),
|
||||
onData() {},
|
||||
onExit(cb) {
|
||||
onExitCb = cb
|
||||
},
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
// #17696: only the daemon process can say whether TCC lets it read the cwd, so its verdict
|
||||
// rides on the create result. A non-permission failure must never read as denial.
|
||||
describe('TerminalHost cwd readability verdict', () => {
|
||||
let host: TerminalHost
|
||||
let platformDescriptor: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
||||
const spawnSubprocess: TerminalHostOptions['spawnSubprocess'] = () => createMockSubprocess()
|
||||
host = new TerminalHost({ spawnSubprocess })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await host.dispose()
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor)
|
||||
}
|
||||
})
|
||||
|
||||
const create = (sessionId: string, cwd?: string) =>
|
||||
host.createOrAttach({
|
||||
sessionId,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
...(cwd ? { cwd } : {}),
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
|
||||
it('reports a readable cwd as readable', async () => {
|
||||
expect((await create('readable', process.cwd())).cwdReadableByDaemon).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a missing cwd as readable — absence is not a permission denial', async () => {
|
||||
expect((await create('missing', '/definitely/not/a/real/dir')).cwdReadableByDaemon).toBe(true)
|
||||
})
|
||||
|
||||
it('omits the verdict when no cwd was requested', async () => {
|
||||
expect((await create('no-cwd')).cwdReadableByDaemon).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits the verdict on attach to an existing session', async () => {
|
||||
await create('attach', process.cwd())
|
||||
const attached = await create('attach', process.cwd())
|
||||
expect(attached.isNew).toBe(false)
|
||||
expect(attached.cwdReadableByDaemon).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { accessSync, constants as fsConstants } from 'node:fs'
|
||||
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
|
||||
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
|
||||
import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result'
|
||||
@@ -11,11 +12,14 @@ import type { TerminalHostTombstones } from './terminal-host-tombstones'
|
||||
import type { TerminalSessionTeardown } from './terminal-session-teardown'
|
||||
import { resolveDaemonSessionScrollbackRows } from './daemon-session-scrollback-window'
|
||||
import { TerminalAttachCanceledError } from './daemon-errors'
|
||||
import { rejectOnAbort } from './terminal-attach-cancellation'
|
||||
import { SessionNotFoundError } from './types'
|
||||
import { resolveWslSessionContext } from './wsl-session-context'
|
||||
|
||||
type TerminalHostSessionCreateDependencies = {
|
||||
sessions: Map<string, Session>
|
||||
/** Re-checks the host's shutdown fence and this request's cancellation after any await. */
|
||||
assertCreateAllowed: () => void
|
||||
sessionTeardown: TerminalSessionTeardown
|
||||
killedTombstones: TerminalHostTombstones
|
||||
spawnSubprocess: TerminalHostOptions['spawnSubprocess']
|
||||
@@ -30,12 +34,29 @@ export async function createOrAttachTerminalSession(
|
||||
deps: TerminalHostSessionCreateDependencies
|
||||
): Promise<CreateOrAttachResult> {
|
||||
opts.onSessionResolved?.(opts.sessionId)
|
||||
const existing = deps.sessions.get(opts.sessionId)
|
||||
let existing = deps.sessions.get(opts.sessionId)
|
||||
|
||||
// Why: descendant capture must finish before attach or recreation, or the
|
||||
// caller could receive a doomed session while teardown owns its process.
|
||||
if (deps.sessionTeardown.get(opts.sessionId) || existing?.isTerminating) {
|
||||
throw new SessionNotFoundError(opts.sessionId)
|
||||
// An attach must not adopt a doomed session; its caller retires the pane and respawns.
|
||||
if (opts.attachOnly) {
|
||||
throw new SessionNotFoundError(opts.sessionId)
|
||||
}
|
||||
// A create can wait teardown out instead, and must: a pane respawning onto its own stable id
|
||||
// reaches this a beat after the attach that retired it, and refusing surfaced the raw
|
||||
// SessionNotFoundError to the user. Windows makes it the common case, where the plain-shell
|
||||
// sweep holds the claim across an OS identity probe and taskkill (#18046).
|
||||
await Promise.race([
|
||||
deps.sessionTeardown.settle(opts.sessionId),
|
||||
rejectOnAbort(opts.cancelSignal, opts.sessionId)
|
||||
])
|
||||
deps.assertCreateAllowed()
|
||||
existing = deps.sessions.get(opts.sessionId)
|
||||
// Unkillable child, or a fresh teardown claimed it while we waited: still nobody's to recreate.
|
||||
if (existing?.isAlive && existing.isTerminating) {
|
||||
throw new SessionNotFoundError(opts.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// Why no ownership settle here: attach is synchronous by contract. A viewer
|
||||
@@ -88,6 +109,8 @@ async function spawnAndPublishSession(
|
||||
ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined }
|
||||
): Promise<CreateOrAttachResult> {
|
||||
const { size, wslDistro } = ctx
|
||||
// Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path.
|
||||
const cwdReadableByDaemon = opts.cwd && !wslDistro ? isCwdReadableByThisProcess(opts.cwd) : null
|
||||
const subprocess = await deps.spawnSubprocess({
|
||||
sessionId: opts.sessionId,
|
||||
cols: size.cols,
|
||||
@@ -184,6 +207,20 @@ async function spawnAndPublishSession(
|
||||
shellState: session.shellState,
|
||||
incarnationId: session.incarnationId,
|
||||
...getDaemonSessionResultMetadata(session),
|
||||
...(cwdReadableByDaemon !== null ? { cwdReadableByDaemon } : {}),
|
||||
attachToken: token
|
||||
}
|
||||
}
|
||||
|
||||
// Why R_OK|X_OK: listing a directory needs read, and entering it needs search — both are what
|
||||
// TCC withholds. A non-permission failure (ENOENT, ENOTDIR) reads as readable so it can never
|
||||
// masquerade as a permission denial.
|
||||
function isCwdReadableByThisProcess(cwd: string): boolean {
|
||||
try {
|
||||
accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK)
|
||||
return true
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
return code !== 'EACCES' && code !== 'EPERM'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
import { TerminalHost, type TerminalHostOptions } from './terminal-host'
|
||||
|
||||
// Why mocked: the win32 plain-shell teardown sweeps for real, and an unmocked run would put a
|
||||
// live process-table probe -- and, on a recycled pid, a taskkill /T /F -- behind these tests.
|
||||
const killWithDescendantSweepMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../pty-descendant-termination', () => ({
|
||||
killWithDescendantSweep: killWithDescendantSweepMock
|
||||
}))
|
||||
|
||||
type SpawnSubprocess = TerminalHostOptions['spawnSubprocess']
|
||||
type ExitableSubprocess = SubprocessHandle & { exit: (code: number) => void }
|
||||
|
||||
/** Shells that report their exit only after `exitDelayMs`, holding the teardown claim open the
|
||||
* way a real one does while the Windows sweep probes and taskkills its tree. Collected so a test
|
||||
* can retire an intentionally unkillable child instead of leaking its exit waiter. */
|
||||
function spawnSubprocessWithSlowExit(exitDelayMs: number): {
|
||||
spawnSubprocess: Mock<SpawnSubprocess>
|
||||
handles: ExitableSubprocess[]
|
||||
} {
|
||||
const handles: ExitableSubprocess[] = []
|
||||
const spawnSubprocess = vi.fn<SpawnSubprocess>(() => {
|
||||
let onExit: ((code: number) => void) | undefined
|
||||
const handle = {
|
||||
pid: 4242,
|
||||
exit: (code: number) => onExit?.(code),
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => {
|
||||
setTimeout(() => onExit?.(0), exitDelayMs).unref?.()
|
||||
}),
|
||||
terminateOwnedTree: () => 'unavailable' as const,
|
||||
forceKill: vi.fn(() => {
|
||||
setTimeout(() => onExit?.(137), exitDelayMs).unref?.()
|
||||
}),
|
||||
signal: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn((callback) => {
|
||||
onExit = callback
|
||||
}),
|
||||
dispose: vi.fn()
|
||||
} as unknown as ExitableSubprocess
|
||||
handles.push(handle)
|
||||
return handle
|
||||
})
|
||||
return { spawnSubprocess, handles }
|
||||
}
|
||||
|
||||
const streamClient = (): { onData: Mock; onExit: Mock } => ({
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn()
|
||||
})
|
||||
|
||||
describe('TerminalHost recreate during teardown', () => {
|
||||
it('recreates a session whose id is still being torn down', async () => {
|
||||
const { spawnSubprocess } = spawnSubprocessWithSlowExit(40)
|
||||
const host = new TerminalHost({ spawnSubprocess })
|
||||
const sessionId = 'wt-1@@respawning-pane'
|
||||
await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() })
|
||||
|
||||
// The pane closes and immediately respawns onto its own stable id (#18046).
|
||||
const killed = host.kill(sessionId, { immediate: true })
|
||||
const recreated = await host.createOrAttach({
|
||||
sessionId,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: streamClient()
|
||||
})
|
||||
|
||||
expect(recreated.isNew).toBe(true)
|
||||
expect(spawnSubprocess).toHaveBeenCalledTimes(2)
|
||||
await killed
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('still refuses an attach-only respawn onto a session being torn down', async () => {
|
||||
const { spawnSubprocess } = spawnSubprocessWithSlowExit(40)
|
||||
const host = new TerminalHost({ spawnSubprocess })
|
||||
const sessionId = 'wt-1@@attaching-pane'
|
||||
await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() })
|
||||
|
||||
const killed = host.kill(sessionId, { immediate: true })
|
||||
// Why unchanged: adopting a doomed session would hand the pane a shell teardown owns; the
|
||||
// caller retires the pane binding on this error and spawns fresh.
|
||||
await expect(
|
||||
host.createOrAttach({
|
||||
sessionId,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
attachOnly: true,
|
||||
streamClient: streamClient()
|
||||
})
|
||||
).rejects.toThrow(`Session not found: ${sessionId}`)
|
||||
expect(spawnSubprocess).toHaveBeenCalledOnce()
|
||||
await killed
|
||||
await host.dispose()
|
||||
})
|
||||
|
||||
it('refuses a create waiting on teardown once the host is shutting down', async () => {
|
||||
const { spawnSubprocess } = spawnSubprocessWithSlowExit(40)
|
||||
const host = new TerminalHost({ spawnSubprocess })
|
||||
const sessionId = 'wt-1@@shutting-down-pane'
|
||||
await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() })
|
||||
|
||||
const killed = host.kill(sessionId, { immediate: true })
|
||||
const create = host.createOrAttach({
|
||||
sessionId,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: streamClient()
|
||||
})
|
||||
// Why: dispose joins pending creations, so a create that waited out teardown must re-read the
|
||||
// fence rather than publish a session nothing will shut down.
|
||||
const disposed = host.dispose()
|
||||
|
||||
await expect(create).rejects.toThrow('Terminal host is shutting down')
|
||||
expect(spawnSubprocess).toHaveBeenCalledOnce()
|
||||
await killed
|
||||
await disposed
|
||||
})
|
||||
|
||||
it('leaves a canceled create waiting on teardown instead of the full exit budget', async () => {
|
||||
// Why a child that never exits on its own: the create must leave on its abort signal, not on
|
||||
// the teardown settling, so the teardown deliberately outlives the assertion.
|
||||
const { spawnSubprocess, handles } = spawnSubprocessWithSlowExit(30_000)
|
||||
const host = new TerminalHost({ spawnSubprocess })
|
||||
const sessionId = 'wt-1@@canceled-pane'
|
||||
await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() })
|
||||
|
||||
const killed = host.kill(sessionId, { immediate: true })
|
||||
const canceled = new AbortController()
|
||||
const create = host.createOrAttach({
|
||||
sessionId,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cancelSignal: canceled.signal,
|
||||
isCanceled: () => canceled.signal.aborted,
|
||||
streamClient: streamClient()
|
||||
})
|
||||
canceled.abort()
|
||||
|
||||
await expect(create).rejects.toThrow(`Attach canceled for session ${sessionId}`)
|
||||
expect(spawnSubprocess).toHaveBeenCalledOnce()
|
||||
|
||||
handles[0].exit(137)
|
||||
await killed
|
||||
await host.dispose()
|
||||
})
|
||||
})
|
||||
@@ -473,14 +473,17 @@ describe('TerminalHost', () => {
|
||||
expect(lastSubprocess.forceKill).toHaveBeenCalledTimes(1)
|
||||
expect(lastSubprocess.dispose).not.toHaveBeenCalled()
|
||||
expect(host.listSessions()).toHaveLength(1)
|
||||
await expect(
|
||||
host.createOrAttach({
|
||||
sessionId: 'session-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
).rejects.toThrow('Session not found')
|
||||
// An unkillable child never releases the id: the create waits out its own budget and
|
||||
// then reports absence rather than publishing a session teardown still owns.
|
||||
const recreate = host.createOrAttach({
|
||||
sessionId: 'session-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
const refused = expect(recreate).rejects.toThrow('Session not found')
|
||||
await vi.advanceTimersByTimeAsync(IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS)
|
||||
await refused
|
||||
|
||||
lastSubprocess._onExitCb?.(137)
|
||||
expect(host.listSessions()).toHaveLength(0)
|
||||
@@ -525,7 +528,7 @@ describe('TerminalHost', () => {
|
||||
expect(lastSubprocess.dispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects reattach while an agent immediate-kill snapshot is pending', async () => {
|
||||
it('defers a respawn until the agent immediate-kill snapshot completes', async () => {
|
||||
let finishSweep!: () => void
|
||||
killWithDescendantSweepMock.mockImplementation(
|
||||
(_pid: number, finish: () => void) =>
|
||||
@@ -544,22 +547,35 @@ describe('TerminalHost', () => {
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
|
||||
const retiredSubprocess = lastSubprocess
|
||||
const killing = host.kill('agent-reattach', { immediate: true })
|
||||
await expect(
|
||||
host.createOrAttach({
|
||||
let respawned = false
|
||||
const respawn = host
|
||||
.createOrAttach({
|
||||
sessionId: 'agent-reattach',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'claude',
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
).rejects.toThrow('Session not found')
|
||||
expect(lastSubprocess.forceKill).not.toHaveBeenCalled()
|
||||
.then((result) => {
|
||||
respawned = true
|
||||
return result
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// Why it must not resolve yet: capture still owns the process, so publishing here would
|
||||
// hand the caller a session teardown is about to kill.
|
||||
expect(respawned).toBe(false)
|
||||
expect(spawnFn).toHaveBeenCalledTimes(1)
|
||||
expect(retiredSubprocess.forceKill).not.toHaveBeenCalled()
|
||||
|
||||
finishSweep()
|
||||
lastSubprocess._onExitCb?.(137)
|
||||
retiredSubprocess._onExitCb?.(137)
|
||||
await killing
|
||||
expect(lastSubprocess.forceKill).toHaveBeenCalledOnce()
|
||||
await expect(respawn).resolves.toMatchObject({ isNew: true })
|
||||
expect(retiredSubprocess.forceKill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coalesces duplicate immediate kill while descendant capture is pending', async () => {
|
||||
@@ -606,29 +622,31 @@ describe('TerminalHost', () => {
|
||||
|
||||
const killing = host.kill('agent-natural-exit', { immediate: true })
|
||||
retiredSubprocess._onExitCb?.(0)
|
||||
await expect(
|
||||
host.createOrAttach({
|
||||
let respawned = false
|
||||
const respawn = host
|
||||
.createOrAttach({
|
||||
sessionId: 'agent-natural-exit',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'claude',
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
).rejects.toThrow('Session not found')
|
||||
.then((result) => {
|
||||
respawned = true
|
||||
return result
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// The root is already reaped, but the scan still holds the id.
|
||||
expect(respawned).toBe(false)
|
||||
expect(spawnFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
completeSweep()
|
||||
await killing
|
||||
expect(retiredSubprocess.forceKill).not.toHaveBeenCalled()
|
||||
|
||||
await expect(
|
||||
host.createOrAttach({
|
||||
sessionId: 'agent-natural-exit',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'claude',
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
).resolves.toEqual(expect.objectContaining({ isNew: true }))
|
||||
await expect(respawn).resolves.toEqual(expect.objectContaining({ isNew: true }))
|
||||
expect(spawnFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
|
||||
@@ -21,24 +21,10 @@ import { listLiveTerminalHostSessions } from './terminal-host-session-listing'
|
||||
import { createOrAttachTerminalSession } from './terminal-host-session-create'
|
||||
import { isShellProcess } from '../../shared/agent-detection'
|
||||
import { TerminalAttachCanceledError } from './daemon-errors'
|
||||
import { rejectOnAbort } from './terminal-attach-cancellation'
|
||||
|
||||
export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
|
||||
|
||||
/** Never resolves; only rejects, so it can bound a wait without settling it. */
|
||||
function rejectOnAbort(signal: AbortSignal | undefined, sessionId: string): Promise<never> {
|
||||
if (!signal) {
|
||||
return new Promise<never>(() => {})
|
||||
}
|
||||
return new Promise<never>((_resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new TerminalAttachCanceledError(sessionId))
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', () => reject(new TerminalAttachCanceledError(sessionId)), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
}
|
||||
export type { TerminalHostOptions } from './terminal-host-options'
|
||||
|
||||
const DEFAULT_MAX_TOMBSTONES = 1000
|
||||
@@ -106,6 +92,7 @@ export class TerminalHost {
|
||||
}
|
||||
return await createOrAttachTerminalSession(options, {
|
||||
sessions: this.sessions,
|
||||
assertCreateAllowed: () => this.assertCreateOrAttachAllowed(options),
|
||||
sessionTeardown: this.sessionTeardown,
|
||||
killedTombstones: this.killedTombstones,
|
||||
spawnSubprocess: this.spawnSubprocess,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { killWithDescendantSweep } from '../pty-descendant-termination'
|
||||
import type { Session } from './session'
|
||||
|
||||
type AgentTeardownOperation = {
|
||||
type TeardownOperation = {
|
||||
promise: Promise<void>
|
||||
immediate: boolean
|
||||
rootSignalled: boolean
|
||||
@@ -9,10 +9,10 @@ type AgentTeardownOperation = {
|
||||
session: Session
|
||||
}
|
||||
|
||||
/** Owns agent teardown by session id until descendant capture and root
|
||||
* signalling finish, even when the root exits and its Session is reaped. */
|
||||
/** Owns teardown by session id until descendant capture and root signalling
|
||||
* finish, even when the root exits and its Session is reaped. */
|
||||
export class TerminalSessionTeardown {
|
||||
private operations = new Map<string, AgentTeardownOperation>()
|
||||
private operations = new Map<string, TeardownOperation>()
|
||||
|
||||
constructor(private sessions: ReadonlyMap<string, Session>) {}
|
||||
|
||||
@@ -20,6 +20,12 @@ export class TerminalSessionTeardown {
|
||||
return this.operations.get(sessionId)?.promise
|
||||
}
|
||||
|
||||
/** Resolves once this id's tracked teardown has released the process — a rejected teardown
|
||||
* released it too. Callers re-read session state afterwards and decide for themselves. */
|
||||
async settle(sessionId: string): Promise<void> {
|
||||
await this.operations.get(sessionId)?.promise.catch(() => {})
|
||||
}
|
||||
|
||||
requestImmediate(sessionId: string): Promise<void> | undefined {
|
||||
const pending = this.operations.get(sessionId)
|
||||
if (pending) {
|
||||
@@ -38,11 +44,42 @@ export class TerminalSessionTeardown {
|
||||
return this.killAgentSession(sessionId, session, immediate)
|
||||
}
|
||||
if (immediate) {
|
||||
return this.forceKillPlainShellSession(sessionId, session)
|
||||
// Why tracked like the agent path: this claims termination on the Session and then awaits
|
||||
// an OS probe and taskkill, and a create landing inside that window must be able to wait it
|
||||
// out rather than be told the id is absent (#18046).
|
||||
return this.track(sessionId, session, immediate, () =>
|
||||
this.forceKillPlainShellSession(sessionId, session)
|
||||
)
|
||||
}
|
||||
session.kill()
|
||||
}
|
||||
|
||||
/** Publishes an operation for `sessionId` and retires it once the teardown settles. */
|
||||
private track(
|
||||
sessionId: string,
|
||||
session: Session,
|
||||
immediate: boolean,
|
||||
run: (entry: TeardownOperation) => Promise<void>
|
||||
): Promise<void> {
|
||||
const entry: TeardownOperation = {
|
||||
promise: Promise.resolve(),
|
||||
immediate,
|
||||
rootSignalled: false,
|
||||
rootCompletion: Promise.resolve(),
|
||||
session
|
||||
}
|
||||
const operation = run(entry)
|
||||
entry.promise = operation
|
||||
this.operations.set(sessionId, entry)
|
||||
const clearOperation = (): void => {
|
||||
if (this.operations.get(sessionId) === entry) {
|
||||
this.operations.delete(sessionId)
|
||||
}
|
||||
}
|
||||
void operation.then(clearOperation, clearOperation)
|
||||
return operation
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediate teardown of a non-agent shell. On Windows, closing the ConPTY does not
|
||||
* reap orphaned children (node-pty `useConptyDll` skips the console-process reap), so a
|
||||
@@ -92,48 +129,34 @@ export class TerminalSessionTeardown {
|
||||
session.scheduleForceDisposeFallback()
|
||||
}
|
||||
|
||||
const entry: AgentTeardownOperation = {
|
||||
promise: Promise.resolve(),
|
||||
immediate,
|
||||
rootSignalled: false,
|
||||
rootCompletion: Promise.resolve(),
|
||||
session
|
||||
}
|
||||
const sweep = Promise.resolve(
|
||||
killWithDescendantSweep(
|
||||
session.pid,
|
||||
() => {
|
||||
// Why: natural exit reaps the PID while ps is running. Never signal that
|
||||
// stale numeric PID after the Session no longer represents a live root.
|
||||
if (!session.isAlive) {
|
||||
return
|
||||
return this.track(sessionId, session, immediate, (entry) => {
|
||||
const sweep = Promise.resolve(
|
||||
killWithDescendantSweep(
|
||||
session.pid,
|
||||
() => {
|
||||
// Why: natural exit reaps the PID while ps is running. Never signal that
|
||||
// stale numeric PID after the Session no longer represents a live root.
|
||||
if (!session.isAlive) {
|
||||
return
|
||||
}
|
||||
entry.rootSignalled = true
|
||||
if (entry.immediate) {
|
||||
entry.rootCompletion = session.forceKillAndWaitForExit()
|
||||
} else {
|
||||
session.signalTerminationRoot()
|
||||
}
|
||||
},
|
||||
{
|
||||
// Why: the descendant rows are only authoritative while this exact
|
||||
// Session still owns the root PID captured by ps.
|
||||
ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive,
|
||||
terminateOwnedTree: () => session.terminateOwnedTree()
|
||||
}
|
||||
entry.rootSignalled = true
|
||||
if (entry.immediate) {
|
||||
entry.rootCompletion = session.forceKillAndWaitForExit()
|
||||
} else {
|
||||
session.signalTerminationRoot()
|
||||
}
|
||||
},
|
||||
{
|
||||
// Why: the descendant rows are only authoritative while this exact
|
||||
// Session still owns the root PID captured by ps.
|
||||
ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive,
|
||||
terminateOwnedTree: () => session.terminateOwnedTree()
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
// Why: descendant capture completion only proves signals were requested;
|
||||
// destructive callers must retain the native owner until OS-confirmed exit.
|
||||
const operation = sweep.then(() => entry.rootCompletion)
|
||||
entry.promise = operation
|
||||
this.operations.set(sessionId, entry)
|
||||
const clearOperation = (): void => {
|
||||
if (this.operations.get(sessionId) === entry) {
|
||||
this.operations.delete(sessionId)
|
||||
}
|
||||
}
|
||||
void operation.then(clearOperation, clearOperation)
|
||||
return operation
|
||||
// Why: descendant capture completion only proves signals were requested;
|
||||
// destructive callers must retain the native owner until OS-confirmed exit.
|
||||
return sweep.then(() => entry.rootCompletion)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./runner', async (importOriginal) => ({
|
||||
...((await importOriginal()) as Record<string, unknown>),
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
import {
|
||||
_resetCanonicalRepoKeyCacheForTests,
|
||||
getCanonicalRepoKey,
|
||||
readGitCommonDir
|
||||
} from './canonical-repo-key'
|
||||
|
||||
beforeEach(() => {
|
||||
_resetCanonicalRepoKeyCacheForTests()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('readGitCommonDir', () => {
|
||||
it('reads the absolute answer modern Git gives', () => {
|
||||
expect(readGitCommonDir('/repo/.git\n', '/repo/worktrees/a')).toBe('/repo/.git')
|
||||
})
|
||||
|
||||
it('drops the flag Git older than 2.31 echoes back, and resolves the relative answer', () => {
|
||||
// Without this every repository on such a host would answer `.git` and collide.
|
||||
expect(readGitCommonDir('--path-format=absolute\n.git\n', '/repo')).toBe('/repo/.git')
|
||||
})
|
||||
|
||||
it('resolves a WSL answer in Git execution space, not against the UNC path', () => {
|
||||
expect(readGitCommonDir('.git\n', '//wsl$/Ubuntu/home/dev/repo')).toBe('/home/dev/repo/.git')
|
||||
})
|
||||
|
||||
it('tolerates CRLF and blank lines', () => {
|
||||
expect(readGitCommonDir('\r\n/repo/.git\r\n', '/repo')).toBe('/repo/.git')
|
||||
})
|
||||
|
||||
it('returns undefined when Git printed nothing usable', () => {
|
||||
expect(readGitCommonDir('\n', '/repo')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCanonicalRepoKey', () => {
|
||||
it('gives every worktree of one repository the same key', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' })
|
||||
|
||||
await expect(getCanonicalRepoKey('/repo')).resolves.toBe('local::/repo/.git')
|
||||
await expect(getCanonicalRepoKey('/repo/worktrees/a')).resolves.toBe('local::/repo/.git')
|
||||
})
|
||||
|
||||
it('scopes the key to the execution host', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '/home/dev/repo/.git\n', stderr: '' })
|
||||
|
||||
await expect(
|
||||
getCanonicalRepoKey('//wsl$/Ubuntu/home/dev/repo', { wslDistro: 'Ubuntu' })
|
||||
).resolves.toBe('wsl:Ubuntu::/home/dev/repo/.git')
|
||||
})
|
||||
|
||||
it('caches so repeated arming costs no subprocess', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' })
|
||||
|
||||
await getCanonicalRepoKey('/repo')
|
||||
await getCanonicalRepoKey('/repo')
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to the caller path when Git cannot answer', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(new Error('not a git repository'))
|
||||
|
||||
await expect(getCanonicalRepoKey('/not-a-repo')).resolves.toBe('local::/not-a-repo')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { toWslExecutionSpace } from '../../shared/wsl-paths'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { resolveRevParsePath } from './worktree-path-comparison'
|
||||
|
||||
/**
|
||||
* One repository on one execution host, named by its Git common dir.
|
||||
*
|
||||
* Shared by the fetch controller (which serializes fetches on it) and idle ref
|
||||
* maintenance (which scopes all of its state to it), so both agree on what "the
|
||||
* same repo" means across every worktree that points at it.
|
||||
*/
|
||||
|
||||
export type CanonicalRepoKeyOptions = { wslDistro?: string }
|
||||
|
||||
const CACHE_MAX = 512
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Git < 2.31 ignores `--path-format=absolute`: it echoes the unrecognized flag,
|
||||
* exits 0, and prints a relative `.git`. Taking the raw stdout there would give
|
||||
* every repository on the host the same key.
|
||||
*/
|
||||
export function readGitCommonDir(stdout: string, repoPath: string): string | undefined {
|
||||
const commonDir = stdout
|
||||
.split('\n')
|
||||
.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
|
||||
.findLast((line) => line.length > 0 && !line.startsWith('-'))
|
||||
return commonDir ? resolveRevParsePath(toWslExecutionSpace(repoPath), commonDir) : undefined
|
||||
}
|
||||
|
||||
function remember(cacheKey: string, value: string): string {
|
||||
cache.delete(cacheKey)
|
||||
cache.set(cacheKey, value)
|
||||
while (cache.size > CACHE_MAX) {
|
||||
const oldest = cache.keys().next()
|
||||
if (oldest.done) {
|
||||
break
|
||||
}
|
||||
cache.delete(oldest.value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** `${runtimeKey}::${gitCommonDir}`, falling back to the caller's path. */
|
||||
export async function getCanonicalRepoKey(
|
||||
repoPath: string,
|
||||
options: CanonicalRepoKeyOptions = {}
|
||||
): Promise<string> {
|
||||
const runtimeKey = options.wslDistro ? `wsl:${options.wslDistro}` : 'local'
|
||||
const cacheKey = `${runtimeKey}::${repoPath}`
|
||||
const cached = cache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return remember(cacheKey, cached)
|
||||
}
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['rev-parse', '--path-format=absolute', '--git-common-dir'],
|
||||
{ cwd: repoPath, ...options }
|
||||
)
|
||||
const commonDir = readGitCommonDir(stdout, repoPath)
|
||||
if (commonDir) {
|
||||
return remember(cacheKey, `${runtimeKey}::${commonDir}`)
|
||||
}
|
||||
} catch {
|
||||
// The caller path remains a safe serialization key when canonicalization fails.
|
||||
}
|
||||
return remember(cacheKey, cacheKey)
|
||||
}
|
||||
|
||||
export function _resetCanonicalRepoKeyCacheForTests(): void {
|
||||
cache.clear()
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export type ExactRefProbeSetResult = {
|
||||
type ExactRefPresence = 'present' | 'absent' | 'unknown'
|
||||
|
||||
const EXACT_REF_PROBE_CONCURRENCY = 8
|
||||
// SHA-1 and SHA-256 repositories both report a full object id here.
|
||||
const OBJECT_ID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/
|
||||
|
||||
export function isShowRefNoMatchError(error: unknown): boolean {
|
||||
const record = error && typeof error === 'object' ? (error as Record<string, unknown>) : undefined
|
||||
@@ -126,3 +128,50 @@ export async function probeAnyExactRef(
|
||||
await Promise.all(Array.from({ length: workerCount }, () => probeNext()))
|
||||
return { found, unknown }
|
||||
}
|
||||
|
||||
/** Runs Git with a stdin payload. Only hosts that can feed a child's stdin supply one. */
|
||||
export type ExactRefProbeStdinExec = (
|
||||
argv: string[],
|
||||
options: ExactRefProbeExecOptions & { stdin: string }
|
||||
) => Promise<{ stdout: string }>
|
||||
|
||||
/** `cat-file --batch-check` reports every ref from one child, and reports a missing ref as data
|
||||
* rather than a failed exit — so a batch stays as decidable as a per-ref `show-ref --verify`.
|
||||
* A repo with many remotes otherwise pays one subprocess per remote on every conflict check. */
|
||||
export async function probeAnyExactRefBatched(
|
||||
runGit: ExactRefProbeStdinExec,
|
||||
refs: readonly string[],
|
||||
options: ExactRefProbeExecOptions = {}
|
||||
): Promise<{ found: boolean; unknown: boolean }> {
|
||||
const uniqueRefs = [...new Set(refs)]
|
||||
const safeRefs = uniqueRefs.filter((ref) => isSafeGitRefName(ref))
|
||||
if (safeRefs.length === 0) {
|
||||
return { found: false, unknown: uniqueRefs.length > 0 }
|
||||
}
|
||||
let stdout: string
|
||||
try {
|
||||
;({ stdout } = await runGit(['cat-file', '--batch-check'], {
|
||||
...options,
|
||||
stdin: `${safeRefs.join('\n')}\n`
|
||||
}))
|
||||
} catch {
|
||||
return { found: false, unknown: true }
|
||||
}
|
||||
const lines = stdout.split('\n').filter((line) => line.trim().length > 0)
|
||||
// One line per input, in order; a short read means the batch never answered for the rest.
|
||||
if (lines.length !== safeRefs.length) {
|
||||
return { found: false, unknown: true }
|
||||
}
|
||||
let unknown = safeRefs.length !== uniqueRefs.length
|
||||
for (const line of lines) {
|
||||
const [head, type] = line.split(' ')
|
||||
if (OBJECT_ID_PATTERN.test(head) && type !== undefined && type !== 'missing') {
|
||||
return { found: true, unknown: false }
|
||||
}
|
||||
if (type !== 'missing') {
|
||||
// `ambiguous`, or a spelling this Git reports differently; neither proves absence.
|
||||
unknown = true
|
||||
}
|
||||
}
|
||||
return { found: false, unknown }
|
||||
}
|
||||
|
||||
@@ -55,6 +55,30 @@ function refspecSource(refspec: string): string {
|
||||
return refspec.replace(/^\+/, '').split(':')[0]!
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `branchName`'s remote-tracking ref already exists locally under `remoteName`.
|
||||
* Used to skip a redundant fetch on the common repeat-materialize case (the ref was
|
||||
* already pulled in by an earlier mint/fetch) while still fetching it on demand the
|
||||
* first time a sibling worktree widens an existing remote onto a new branch -- a bare
|
||||
* refspec-config widen never itself imports anything (see `ensureRemoteTracksBranchNarrowly`).
|
||||
*/
|
||||
export async function forkRemoteTrackingRefExists(
|
||||
execGit: GitExecFn,
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await execGit(
|
||||
['rev-parse', '--verify', '--quiet', `refs/remotes/${remoteName}/${branchName}`],
|
||||
repoPath
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True only if `remote.<name>.url` is actually set. Deliberately plumbing (`config --get`),
|
||||
* not porcelain `git remote get-url` -- the latter falls back to echoing the remote *name*
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
|
||||
const readRepoCommonDirFromGitMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./runner', async (importOriginal) => ({
|
||||
...((await importOriginal()) as Record<string, unknown>),
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('./worktree-list-reader', async (importOriginal) => ({
|
||||
...((await importOriginal()) as Record<string, unknown>),
|
||||
readRepoCommonDirFromGit: readRepoCommonDirFromGitMock
|
||||
}))
|
||||
|
||||
import { _resetCanonicalRepoKeyCacheForTests } from './canonical-repo-key'
|
||||
import {
|
||||
_resetLocalRepoRefMaintenanceForTests,
|
||||
armLocalRepoRefMaintenance,
|
||||
createLocalRepoRefMaintenanceTarget,
|
||||
getLocalRepoRefMaintenance,
|
||||
setRepoMaintenanceActivityProbe,
|
||||
withRepoRefMaintenancePaused
|
||||
} from './local-repo-ref-maintenance'
|
||||
|
||||
const NO_ABORT = new AbortController().signal
|
||||
|
||||
function target(wslDistro?: string): ReturnType<typeof createLocalRepoRefMaintenanceTarget> {
|
||||
return createLocalRepoRefMaintenanceTarget({
|
||||
key: 'local::/repo/.git',
|
||||
repoPath: wslDistro ? '//wsl$/Ubuntu/home/dev/repo' : '/repo',
|
||||
...(wslDistro ? { wslDistro } : {})
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
readRepoCommonDirFromGitMock.mockReset()
|
||||
delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE
|
||||
_resetCanonicalRepoKeyCacheForTests()
|
||||
_resetLocalRepoRefMaintenanceForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE
|
||||
_resetLocalRepoRefMaintenanceForTests()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('local repo ref maintenance target', () => {
|
||||
it('never hands the pack child an abort signal', async () => {
|
||||
// Killing a `pack-refs` strands a `refs/**` lock about one time in five, and
|
||||
// on Windows a force-kill inside the rewrite strands `packed-refs.lock`
|
||||
// every time. The child must always be allowed to finish.
|
||||
readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git')
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await target().packRefs({ setHeld: () => {} })
|
||||
|
||||
const packCall = gitExecFileAsyncMock.mock.calls.find(
|
||||
([argv]) => (argv as string[])[0] === 'pack-refs'
|
||||
)
|
||||
expect(packCall?.[1]).not.toHaveProperty('signal')
|
||||
})
|
||||
|
||||
it('runs pack-refs at the background tier with a long deadline', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await target().packRefs({ setHeld: () => {} })
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['pack-refs', '--all', '--prune'],
|
||||
expect.objectContaining({ cwd: '/repo', admissionTier: 'background', timeout: 15 * 60_000 })
|
||||
)
|
||||
})
|
||||
|
||||
it('reads either Git auto-maintenance opt-out, and unset keys as consent', async () => {
|
||||
for (const stdout of [
|
||||
'maintenance.auto false\n',
|
||||
'gc.auto 0\n',
|
||||
'gc.auto 6700\nmaintenance.auto false\n'
|
||||
]) {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout, stderr: '' })
|
||||
await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(true)
|
||||
}
|
||||
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'maintenance.auto true\ngc.auto 6700\n',
|
||||
stderr: ''
|
||||
})
|
||||
await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false)
|
||||
|
||||
// `git config --get-regexp` exits non-zero when nothing matches.
|
||||
gitExecFileAsyncMock.mockRejectedValue(new Error('exit 1'))
|
||||
await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('walks the POSIX refs directory for a native repo', async () => {
|
||||
readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git')
|
||||
|
||||
await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBe('/repo/.git/refs')
|
||||
})
|
||||
|
||||
it('translates a WSL repo answer back to the UNC path the main process can open', async () => {
|
||||
// Git answers in its own execution space, which for WSL is a Linux path.
|
||||
readRepoCommonDirFromGitMock.mockResolvedValue('/home/dev/repo/.git')
|
||||
|
||||
await expect(target('Ubuntu').resolveRefsDirectory(NO_ABORT)).resolves.toBe(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo\\.git\\refs'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unresolvable repository rather than guessing a path', async () => {
|
||||
readRepoCommonDirFromGitMock.mockResolvedValue(undefined)
|
||||
|
||||
await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('local repo ref maintenance scheduling', () => {
|
||||
it('schedules nothing when the kill switch is set', () => {
|
||||
process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE = '1'
|
||||
const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm')
|
||||
|
||||
armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' })
|
||||
|
||||
expect(arm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('arms through the shared single-flight instance otherwise', () => {
|
||||
const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm')
|
||||
|
||||
armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' })
|
||||
|
||||
expect(arm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is free when nothing has ever been armed', async () => {
|
||||
// The common case by far: no timers, no instance, no reason to pay anything.
|
||||
await expect(withRepoRefMaintenancePaused('git-fetch', async () => 'done')).resolves.toBe(
|
||||
'done'
|
||||
)
|
||||
})
|
||||
|
||||
it('holds the window shut for the duration of ref-touching work', async () => {
|
||||
readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git')
|
||||
_resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: 1, looseRefThreshold: 0 })
|
||||
setRepoMaintenanceActivityProbe(() => false)
|
||||
const maintenance = getLocalRepoRefMaintenance()
|
||||
const packRefs = vi.fn(async () => {})
|
||||
maintenance.arm({
|
||||
key: 'local::/repo/.git',
|
||||
resolveRefsDirectory: async () => '/repo/.git/refs',
|
||||
packRefs
|
||||
})
|
||||
|
||||
await withRepoRefMaintenancePaused('branch-delete', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
expect(packRefs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(packRefs).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('routes the app activity probe into the shared instance', async () => {
|
||||
readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git')
|
||||
let busy = true
|
||||
setRepoMaintenanceActivityProbe(() => busy)
|
||||
const maintenance = getLocalRepoRefMaintenance()
|
||||
const packRefs = vi.fn(async () => {})
|
||||
|
||||
maintenance.arm({
|
||||
key: 'local::/repo/.git',
|
||||
resolveRefsDirectory: async () => '/repo/.git/refs',
|
||||
packRefs
|
||||
})
|
||||
await maintenance.whenAttemptSettled()
|
||||
|
||||
expect(packRefs).not.toHaveBeenCalled()
|
||||
busy = false
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,272 @@
|
||||
import { posix, win32 } from 'node:path'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance'
|
||||
import {
|
||||
PACK_REFS_ARGS,
|
||||
PACK_REFS_TIMEOUT_MS,
|
||||
RefMaintenanceRepoLocked,
|
||||
type PackedRefsLockReporter,
|
||||
type RepoRefMaintenanceOptions,
|
||||
type RepoRefMaintenanceTarget
|
||||
} from '../../shared/repo-ref-maintenance-policy'
|
||||
import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
|
||||
import { withSpan } from '../observability/tracer'
|
||||
import { PackRefsLockOwnership } from './pack-refs-lock-ownership'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { readRepoCommonDirFromGit } from './worktree-list-reader'
|
||||
|
||||
/**
|
||||
* Main-process wiring for idle loose-ref packing on the local execution host
|
||||
* (native and WSL).
|
||||
*
|
||||
* SSH-hosted repos are deliberately out of scope: the execution host owns
|
||||
* anything that touches execution, so maintaining them means running host-side
|
||||
* on the relay, which today has neither admission control nor spans. Keying all
|
||||
* state by execution host is what keeps this path from reaching across.
|
||||
*/
|
||||
|
||||
export type RepoMaintenanceActivityProbe = () => boolean
|
||||
|
||||
const REPO_BUSY_PROBE_MAX = 64
|
||||
|
||||
let activityProbe: RepoMaintenanceActivityProbe | null = null
|
||||
let shared: RepoRefMaintenance | null = null
|
||||
// Why keyed here rather than captured in the target: a repo can be armed from
|
||||
// the fetch controller or from a user-initiated fetch, and every arming must see
|
||||
// the same "this repo has work in flight" answer, not whichever closure was last.
|
||||
const repoBusyProbes = new Map<string, () => boolean>()
|
||||
|
||||
/** Register the owner of "this repo has a fetch in flight" for `key`. */
|
||||
export function setRepoRefMaintenanceBusyProbe(key: string, probe: () => boolean): void {
|
||||
repoBusyProbes.delete(key)
|
||||
repoBusyProbes.set(key, probe)
|
||||
while (repoBusyProbes.size > REPO_BUSY_PROBE_MAX) {
|
||||
const oldest = repoBusyProbes.keys().next()
|
||||
if (oldest.done) {
|
||||
break
|
||||
}
|
||||
repoBusyProbes.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the app-wide "do not start maintenance now" signal. Owned by the
|
||||
* main entry point because the inputs (live agents, battery, quit) are not
|
||||
* visible from the git layer.
|
||||
*/
|
||||
export function setRepoMaintenanceActivityProbe(probe: RepoMaintenanceActivityProbe | null): void {
|
||||
activityProbe = probe
|
||||
}
|
||||
|
||||
/** Support escape hatch: kills the sweep without touching the user's git config. */
|
||||
function isDisabled(): boolean {
|
||||
return process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE === '1'
|
||||
}
|
||||
|
||||
function localMaintenanceOptions(): RepoRefMaintenanceOptions {
|
||||
return {
|
||||
// Fail closed: without the app-level gate installed we cannot see agents,
|
||||
// creates, or battery, and running blind is worse than not running.
|
||||
isBusy: () => activityProbe?.() ?? true,
|
||||
observe: (attempt) =>
|
||||
withSpan('repo.ref_maintenance', (span) => attempt(span), {
|
||||
attributes: { kind: 'git', 'repo.maintenance_host': 'local' }
|
||||
}),
|
||||
onError: (error) => {
|
||||
console.warn('[repo-ref-maintenance] attempt failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalRepoRefMaintenance(): RepoRefMaintenance {
|
||||
shared ??= new RepoRefMaintenance(localMaintenanceOptions())
|
||||
return shared
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels every armed timer and waits out any `packed-refs` rewrite in progress.
|
||||
*
|
||||
* Deliberately does not kill the child. A pack orphaned by the app quitting
|
||||
* finishes on its own; a pack signalled mid-prune strands a ref lock about one
|
||||
* time in five, and on Windows a force-kill inside the rewrite strands
|
||||
* `packed-refs.lock` every time -- which blocks every later ref deletion.
|
||||
*/
|
||||
export function disposeLocalRepoRefMaintenance(): Promise<void> {
|
||||
const settling = shared?.awaitPackedRefsLockRelease() ?? Promise.resolve()
|
||||
shared?.dispose()
|
||||
shared = null
|
||||
repoBusyProbes.clear()
|
||||
return settling
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold every repository open while `run` touches refs.
|
||||
*
|
||||
* A ref deletion needs `packed-refs.lock`, which a running pack holds only while
|
||||
* it rewrites the file -- 0.03-1.37s of a 23-32s run. Waiting that out turns the
|
||||
* collision into a short pause. Cancelling the pack instead would strand a
|
||||
* `refs/**` lock about one time in five, which Git never clears, so the ref
|
||||
* stays undeletable indefinitely.
|
||||
*/
|
||||
export async function withRepoRefMaintenancePaused<T>(
|
||||
reason: string,
|
||||
run: () => Promise<T>
|
||||
): Promise<T> {
|
||||
// Taken unconditionally rather than only when something is already armed: a
|
||||
// fetch inside `run` can arm the sweep, and one counter bump against an idle
|
||||
// instance costs a microtask. This can rebuild the instance after the
|
||||
// quit-time dispose; harmless, because a fresh one has no armed timers and its
|
||||
// activity probe is gone, so it fails closed.
|
||||
const release = await getLocalRepoRefMaintenance().pause(reason)
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait out a `packed-refs` rewrite without holding the window open. For shutdown. */
|
||||
export function awaitPackedRefsLockRelease(): Promise<void> {
|
||||
return shared ? shared.awaitPackedRefsLockRelease() : Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Count user-initiated ref work as activity and restart every armed countdown.
|
||||
*
|
||||
* Deliberately not keyed to a repo: resolving one would cost a `rev-parse` on a
|
||||
* path the user is waiting on, and a manual fetch or pull says the user is at
|
||||
* the keyboard, which is a reason to defer every repository.
|
||||
*/
|
||||
export function postponeRepoRefMaintenance(): void {
|
||||
shared?.postponeAll()
|
||||
}
|
||||
|
||||
/** `overrides` preseeds the shared instance so a test can shorten the quiet period. */
|
||||
export function _resetLocalRepoRefMaintenanceForTests(
|
||||
overrides?: Partial<RepoRefMaintenanceOptions>
|
||||
): void {
|
||||
shared?.dispose()
|
||||
shared = overrides ? new RepoRefMaintenance({ ...localMaintenanceOptions(), ...overrides }) : null
|
||||
activityProbe = null
|
||||
repoBusyProbes.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Git reports the common dir in its own execution space, so a WSL repo answers
|
||||
* with a Linux path the Windows main process cannot open. Translate it back to
|
||||
* the UNC spelling for the dirent walk; the walk reads directories, not files,
|
||||
* so the handful of round trips stays cheap even over the share.
|
||||
*/
|
||||
function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | undefined): string {
|
||||
if (wslDistro && !isWslUncPath(commonDir) && !isWindowsAbsolutePathLike(commonDir)) {
|
||||
return win32.join(toWindowsWslPath(commonDir, wslDistro), 'refs')
|
||||
}
|
||||
// Decided by path syntax, not by platform: `win32.isAbsolute` accepts POSIX paths too.
|
||||
return (isWindowsAbsolutePathLike(commonDir) ? win32 : posix).join(commonDir, 'refs')
|
||||
}
|
||||
|
||||
/**
|
||||
* `maintenance.auto=false` and `gc.auto=0` are the two knobs a user reaches for
|
||||
* to tell Git to stop maintaining a repository on its own. Orca sets both on its
|
||||
* own fetches, but only as per-invocation `-c` flags, so this probe sees the
|
||||
* user's persisted config and never Orca's own suppression.
|
||||
*/
|
||||
export function isGitAutoMaintenanceDisabled(configOutput: string): boolean {
|
||||
return configOutput
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.some((line) => line === 'maintenance.auto false' || line === 'gc.auto 0')
|
||||
}
|
||||
|
||||
/**
|
||||
* The common dir in the spelling the main process can open.
|
||||
*
|
||||
* Derived from the converted refs path, not the raw one: a WSL answer arrives as
|
||||
* a Linux path but converts to a UNC path with no `/` in it, so choosing the
|
||||
* path flavour before conversion collapses the whole thing to `.`.
|
||||
*/
|
||||
function gitCommonDirForMainProcess(commonDir: string, wslDistro: string | undefined): string {
|
||||
const refs = refsDirectoryForMainProcess(commonDir, wslDistro)
|
||||
return (isWindowsAbsolutePathLike(refs) ? win32 : posix).dirname(refs)
|
||||
}
|
||||
|
||||
export type LocalRepoRefMaintenanceTargetArgs = {
|
||||
/** `${runtimeKey}::${gitCommonDir}` -- already scoped to the execution host. */
|
||||
readonly key: string
|
||||
readonly repoPath: string
|
||||
readonly wslDistro?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a write to this repo and restart its quiet-period countdown. The only
|
||||
* entry point callers need: the kill switch is honoured before anything is
|
||||
* scheduled, so a disabled build arms no timers at all.
|
||||
*/
|
||||
export function armLocalRepoRefMaintenance(args: LocalRepoRefMaintenanceTargetArgs): void {
|
||||
if (isDisabled()) {
|
||||
return
|
||||
}
|
||||
getLocalRepoRefMaintenance().arm(createLocalRepoRefMaintenanceTarget(args))
|
||||
}
|
||||
|
||||
export function createLocalRepoRefMaintenanceTarget(
|
||||
args: LocalRepoRefMaintenanceTargetArgs
|
||||
): RepoRefMaintenanceTarget {
|
||||
const gitOptions = args.wslDistro ? { wslDistro: args.wslDistro } : {}
|
||||
// The engine always probes before it packs, so the pack reuses this answer
|
||||
// rather than spending a second rev-parse on the same repository.
|
||||
let commonDir: string | undefined
|
||||
const resolveCommonDir = async (signal?: AbortSignal): Promise<string | undefined> => {
|
||||
commonDir ??= await readRepoCommonDirFromGit(args.repoPath, {
|
||||
...gitOptions,
|
||||
...(signal ? { signal } : {})
|
||||
})
|
||||
return commonDir
|
||||
}
|
||||
return {
|
||||
key: args.key,
|
||||
isBusy: () => repoBusyProbes.get(args.key)?.() ?? false,
|
||||
async resolveRefsDirectory(signal: AbortSignal) {
|
||||
const resolved = await resolveCommonDir(signal)
|
||||
return resolved ? refsDirectoryForMainProcess(resolved, args.wslDistro) : undefined
|
||||
},
|
||||
async isOptedOut(signal: AbortSignal) {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['config', '--get-regexp', '^(maintenance\\.auto|gc\\.auto)$'],
|
||||
{ cwd: args.repoPath, ...gitOptions, admissionTier: 'background', signal }
|
||||
)
|
||||
return isGitAutoMaintenanceDisabled(stdout)
|
||||
} catch {
|
||||
// Neither key set is the common case and exits non-zero; that is consent.
|
||||
return false
|
||||
}
|
||||
},
|
||||
async packRefs(lock: PackedRefsLockReporter) {
|
||||
const resolved = await resolveCommonDir()
|
||||
const owner = resolved
|
||||
? new PackRefsLockOwnership(gitCommonDirForMainProcess(resolved, args.wslDistro))
|
||||
: null
|
||||
const claim = owner ? await owner.claim() : { ok: true as const }
|
||||
if (!claim.ok) {
|
||||
throw new RefMaintenanceRepoLocked(claim.reason)
|
||||
}
|
||||
// Report the rewrite window rather than accepting a signal. A pack that is
|
||||
// killed mid-prune strands a `refs/**` lock about one time in five, and
|
||||
// Git never clears those; waiting out the window costs at most ~1.4s.
|
||||
const watch = owner?.watchLock((held) => lock.setHeld(held))
|
||||
try {
|
||||
await gitExecFileAsync([...PACK_REFS_ARGS], {
|
||||
cwd: args.repoPath,
|
||||
...gitOptions,
|
||||
admissionTier: 'background',
|
||||
timeout: PACK_REFS_TIMEOUT_MS
|
||||
})
|
||||
} finally {
|
||||
watch?.stop()
|
||||
lock.setHeld(false)
|
||||
await owner?.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { PackRefsLockOwnership } from './pack-refs-lock-ownership'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function gitCommonDir(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-pack-refs-lock-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function paths(commonDir: string): { lock: string; marker: string } {
|
||||
return {
|
||||
lock: join(commonDir, 'packed-refs.lock'),
|
||||
marker: join(commonDir, 'packed-refs.orca-owner')
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** A pid that cannot be running: the kernel rejects it outright. */
|
||||
const DEAD_PID = 0x7fffffff
|
||||
const ABANDONED_LOCK_AGE_MS = 15 * 60_000
|
||||
const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000
|
||||
|
||||
/** `claim` takes `now`, so age cases need no sleeping and no mtime forgery. */
|
||||
function laterBy(ms: number): number {
|
||||
return Date.now() + ms
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('packed-refs lock ownership', () => {
|
||||
it('claims a repository with no lock and records the owner', async () => {
|
||||
const commonDir = await gitCommonDir()
|
||||
const { marker } = paths(commonDir)
|
||||
|
||||
await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true })
|
||||
|
||||
await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid))
|
||||
})
|
||||
|
||||
it('drops the owner marker on release', async () => {
|
||||
const commonDir = await gitCommonDir()
|
||||
const ownership = new PackRefsLockOwnership(commonDir)
|
||||
await ownership.claim()
|
||||
|
||||
await ownership.release()
|
||||
|
||||
await expect(exists(paths(commonDir).marker)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a lock it cannot prove is its own', async () => {
|
||||
const commonDir = await gitCommonDir()
|
||||
// A lock with no marker belongs to the user's own git, or to another tool.
|
||||
await writeFile(paths(commonDir).lock, 'someone else')
|
||||
|
||||
await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false })
|
||||
await expect(exists(paths(commonDir).lock)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a lock whose recorded owner is still running', async () => {
|
||||
const commonDir = await gitCommonDir()
|
||||
const { lock, marker } = paths(commonDir)
|
||||
await writeFile(lock, 'in progress')
|
||||
await writeFile(marker, JSON.stringify({ pid: process.pid }))
|
||||
|
||||
await expect(
|
||||
new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1))
|
||||
).resolves.toMatchObject({ ok: false })
|
||||
await expect(exists(lock)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('reclaims the lock its own dead process left behind', async () => {
|
||||
// SIGKILL and power loss bypass git's cleanup, and git never clears this itself.
|
||||
const commonDir = await gitCommonDir()
|
||||
const { lock, marker } = paths(commonDir)
|
||||
await writeFile(lock, 'abandoned mid-rewrite')
|
||||
await writeFile(marker, JSON.stringify({ pid: DEAD_PID }))
|
||||
|
||||
const claimed = await new PackRefsLockOwnership(commonDir).claim(
|
||||
laterBy(ABANDONED_LOCK_AGE_MS + 1)
|
||||
)
|
||||
|
||||
expect(claimed).toEqual({ ok: true })
|
||||
await expect(exists(lock)).resolves.toBe(false)
|
||||
await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid))
|
||||
})
|
||||
|
||||
it('leaves a young lock alone even when the marker names a dead process', async () => {
|
||||
// A marker outlives its lock, so a foreign lock can appear after our death.
|
||||
// Age is the only thing separating our wreckage from somebody's live lock.
|
||||
const commonDir = await gitCommonDir()
|
||||
const { lock, marker } = paths(commonDir)
|
||||
await writeFile(marker, JSON.stringify({ pid: DEAD_PID }))
|
||||
await writeFile(lock, 'a different git process, started just now')
|
||||
|
||||
await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false })
|
||||
await expect(exists(lock)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('does not wedge a repository forever when the recorded pid was recycled', async () => {
|
||||
const commonDir = await gitCommonDir()
|
||||
const { lock, marker } = paths(commonDir)
|
||||
await writeFile(lock, 'abandoned mid-rewrite')
|
||||
// Our own pid stands in for a recycled one: alive, but not the process that wrote this.
|
||||
await writeFile(marker, JSON.stringify({ pid: process.pid }))
|
||||
|
||||
await expect(
|
||||
new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1))
|
||||
).resolves.toMatchObject({ ok: false })
|
||||
|
||||
await expect(
|
||||
new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1))
|
||||
).resolves.toEqual({ ok: true })
|
||||
await expect(exists(lock)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a lock whose marker is unreadable rather than guessing', async () => {
|
||||
const commonDir = await gitCommonDir()
|
||||
const { lock, marker } = paths(commonDir)
|
||||
await writeFile(lock, 'in progress')
|
||||
await writeFile(marker, 'not json')
|
||||
|
||||
await expect(
|
||||
new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1))
|
||||
).resolves.toMatchObject({ ok: false })
|
||||
await expect(exists(lock)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('claims cleanly when a marker outlived its lock', async () => {
|
||||
const commonDir = await gitCommonDir()
|
||||
await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID }))
|
||||
|
||||
await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('stranded per-ref locks', () => {
|
||||
it('clears the empty refs/**/*.lock files its own dead process left behind', async () => {
|
||||
// `tempfile.c` opens the lock O_EXCL before linking it into the list the
|
||||
// signal handler walks, so a kill in that window leaves a 0-byte file that
|
||||
// Git never clears -- and `update-ref -d` on that ref then fails forever.
|
||||
const commonDir = await gitCommonDir()
|
||||
const namespace = join(commonDir, 'refs', 'remotes', 'origin')
|
||||
await mkdir(namespace, { recursive: true })
|
||||
await writeFile(join(namespace, 'main.lock'), '')
|
||||
await writeFile(join(namespace, 'main'), 'a'.repeat(40))
|
||||
await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID }))
|
||||
|
||||
await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1))
|
||||
|
||||
await expect(exists(join(namespace, 'main.lock'))).resolves.toBe(false)
|
||||
// The ref itself is untouched.
|
||||
await expect(exists(join(namespace, 'main'))).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a non-empty ref lock alone, because a live writer is mid-write', async () => {
|
||||
const commonDir = await gitCommonDir()
|
||||
const namespace = join(commonDir, 'refs', 'heads')
|
||||
await mkdir(namespace, { recursive: true })
|
||||
await writeFile(join(namespace, 'busy.lock'), 'b'.repeat(40))
|
||||
await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID }))
|
||||
|
||||
await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1))
|
||||
|
||||
await expect(exists(join(namespace, 'busy.lock'))).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('leaves ref locks alone when there is no marker naming a dead process', async () => {
|
||||
const commonDir = await gitCommonDir()
|
||||
const namespace = join(commonDir, 'refs', 'heads')
|
||||
await mkdir(namespace, { recursive: true })
|
||||
await writeFile(join(namespace, 'other.lock'), '')
|
||||
|
||||
await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1))
|
||||
|
||||
await expect(exists(join(namespace, 'other.lock'))).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import { readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { posix, win32 } from 'node:path'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import {
|
||||
PACK_REFS_TIMEOUT_MS,
|
||||
PACKED_REFS_LOCK_POLL_MS
|
||||
} from '../../shared/repo-ref-maintenance-policy'
|
||||
|
||||
/** No legitimate `pack-refs` outlives its own deadline, so an older lock is abandoned. */
|
||||
const ABANDONED_LOCK_AGE_MS = PACK_REFS_TIMEOUT_MS
|
||||
|
||||
/** Beyond this a recorded pid may have been recycled, so it stops being evidence of life. */
|
||||
const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000
|
||||
|
||||
/** The ref tree is wide but shallow; this only stops a pathological walk. */
|
||||
const REF_LOCK_SCAN_CEILING = 4096
|
||||
|
||||
/**
|
||||
* Makes a `packed-refs.lock` Orca left behind attributable, and only that one.
|
||||
*
|
||||
* Git registers signal handlers that clean the lock up, but SIGKILL and power
|
||||
* loss bypass them, and Git never removes a stale `packed-refs.lock` on its own
|
||||
* -- every later ref deletion in that repository fails until someone deletes a
|
||||
* file they have never heard of. Recording our pid beside the lock lets a later
|
||||
* run recognise its own wreckage.
|
||||
*
|
||||
* Three independent conditions must all hold before anything is unlinked,
|
||||
* because deleting a lock somebody else is holding is far worse than declining
|
||||
* to pack: a marker must exist at all, the lock must be older than any
|
||||
* `pack-refs` could legitimately run for, and the recorded process must be gone.
|
||||
* A marker can outlive its lock, so age is what separates "our wreckage" from a
|
||||
* foreign lock that happened to appear afterwards.
|
||||
*/
|
||||
export class PackRefsLockOwnership {
|
||||
private readonly lockPath: string
|
||||
private readonly markerPath: string
|
||||
|
||||
constructor(gitCommonDir: string) {
|
||||
const path = isWindowsAbsolutePathLike(gitCommonDir) ? win32 : posix
|
||||
this.lockPath = path.join(gitCommonDir, 'packed-refs.lock')
|
||||
this.markerPath = path.join(gitCommonDir, 'packed-refs.orca-owner')
|
||||
}
|
||||
|
||||
/** Refused when the lock belongs to something we cannot prove is our own wreckage. */
|
||||
async claim(now = Date.now()): Promise<PackRefsLockClaim> {
|
||||
const reclaim = await this.reclaimAbandonedLock(now)
|
||||
if (!reclaim.ok) {
|
||||
return reclaim
|
||||
}
|
||||
// Per-ref strands outlive their pack and are invisible to Git, which never
|
||||
// clears a `refs/**\/*.lock` it did not create in this process.
|
||||
await this.reclaimStrandedRefLocks(now)
|
||||
try {
|
||||
await writeFile(this.markerPath, JSON.stringify({ pid: process.pid }), 'utf-8')
|
||||
} catch {
|
||||
// Losing the marker only costs attribution on the next run, never correctness.
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll `packed-refs.lock` so the scheduler knows when the exclusive rewrite
|
||||
* window opens and closes. Cheap: one `stat` on a fixed path.
|
||||
*/
|
||||
watchLock(report: (held: boolean) => void): { stop: () => void } {
|
||||
let stopped = false
|
||||
let last = false
|
||||
const tick = async (): Promise<void> => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
const held = (await fileAgeMs(this.lockPath, Date.now())) !== null
|
||||
if (!stopped && held !== last) {
|
||||
last = held
|
||||
report(held)
|
||||
}
|
||||
}
|
||||
const timer = setInterval(() => void tick(), PACKED_REFS_LOCK_POLL_MS)
|
||||
timer.unref?.()
|
||||
void tick()
|
||||
return {
|
||||
stop: () => {
|
||||
stopped = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async release(): Promise<void> {
|
||||
await rm(this.markerPath, { force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
private async reclaimAbandonedLock(now: number): Promise<PackRefsLockClaim> {
|
||||
const lockAgeMs = await fileAgeMs(this.lockPath, now)
|
||||
if (lockAgeMs === null) {
|
||||
return { ok: true }
|
||||
}
|
||||
// No marker means the lock is not ours to reason about, let alone remove.
|
||||
const marker = await readOwnerMarker(this.markerPath)
|
||||
if (marker === null) {
|
||||
return { ok: false, reason: 'held by another process' }
|
||||
}
|
||||
if (lockAgeMs < ABANDONED_LOCK_AGE_MS) {
|
||||
// Ours, but too young to be certain the writer is gone. Worth retrying soon.
|
||||
return { ok: false, reason: 'our own lock, not yet old enough to reclaim' }
|
||||
}
|
||||
// Past the pid-reuse horizon the pid proves nothing, and a lock this old is
|
||||
// abandoned whoever wrote it -- otherwise a recycled pid would wedge the
|
||||
// repository permanently.
|
||||
if (isProcessAlive(marker.pid) && lockAgeMs < PID_REUSE_HORIZON_MS) {
|
||||
return { ok: false, reason: 'the recorded owner is still running' }
|
||||
}
|
||||
await rm(this.lockPath, { force: true }).catch(() => {})
|
||||
await rm(this.markerPath, { force: true }).catch(() => {})
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear `refs/**\/*.lock` files a dead pack of ours left behind.
|
||||
*
|
||||
* `tempfile.c` opens the lock `O_EXCL` before `activate_tempfile()` links it
|
||||
* into the list the signal handler walks, so a kill inside that window leaves
|
||||
* a 0-byte file. Afterwards `update-ref -d` and any fetch touching that ref
|
||||
* fail with `cannot lock ref ... File exists`, forever. Same three conditions
|
||||
* as the packed-refs lock, plus a size check: a live writer's lock is not empty.
|
||||
*/
|
||||
private async reclaimStrandedRefLocks(now: number): Promise<void> {
|
||||
const marker = await readOwnerMarker(this.markerPath)
|
||||
if (marker === null || isProcessAlive(marker.pid)) {
|
||||
return
|
||||
}
|
||||
const markerAgeMs = await fileAgeMs(this.markerPath, now)
|
||||
if (markerAgeMs === null || markerAgeMs < ABANDONED_LOCK_AGE_MS) {
|
||||
return
|
||||
}
|
||||
const path = isWindowsAbsolutePathLike(this.markerPath) ? win32 : posix
|
||||
const pending = [path.join(path.dirname(this.markerPath), 'refs')]
|
||||
let visited = 0
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop()
|
||||
if (directory === undefined || (visited += 1) > REF_LOCK_SCAN_CEILING) {
|
||||
return
|
||||
}
|
||||
let entries: { name: string; isDirectory: () => boolean }[]
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(full)
|
||||
} else if (entry.name.endsWith('.lock') && (await isEmptyFile(full))) {
|
||||
await rm(full, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type PackRefsLockClaim = { ok: true } | { ok: false; reason: string }
|
||||
|
||||
/** A strand from the `O_EXCL` window is 0 bytes; a live writer's lock is not. */
|
||||
async function isEmptyFile(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(path)).size === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function readOwnerMarker(path: string): Promise<{ pid: number } | null> {
|
||||
try {
|
||||
const raw = (await readFile(path, 'utf-8')).slice(0, 256)
|
||||
const pid = (JSON.parse(raw) as { pid?: unknown }).pid
|
||||
return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 ? { pid } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Null when the file does not exist. Uses stat: the lock holds a whole packed-refs. */
|
||||
async function fileAgeMs(path: string, now: number): Promise<number | null> {
|
||||
try {
|
||||
return Math.max(0, now - (await stat(path)).mtimeMs)
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT' ? null : 0
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
if (pid === process.pid) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
|
||||
}
|
||||
}
|
||||
+36
-20
@@ -7,6 +7,10 @@ import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import type { GitRuntimeOptions } from './git-runtime-options'
|
||||
import { gitOptionsForWorktree } from './git-runtime-options'
|
||||
import {
|
||||
postponeRepoRefMaintenance,
|
||||
withRepoRefMaintenancePaused
|
||||
} from './local-repo-ref-maintenance'
|
||||
import { validateGitPushTarget } from './push-target-validation'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { fetchForkRemoteWithStaleRefspecRepair } from './fork-remote-stale-branch-refspec'
|
||||
@@ -260,8 +264,11 @@ 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 runWithGitWorktreeOperationLock(worktreePath, options.signal, () =>
|
||||
runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options))
|
||||
postponeRepoRefMaintenance()
|
||||
await withRepoRefMaintenancePaused('git-pull', () =>
|
||||
runWithGitWorktreeOperationLock(worktreePath, options.signal, () =>
|
||||
runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,9 +277,12 @@ export async function gitFastForward(
|
||||
pushTarget?: GitPushTarget,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
await runWithGitWorktreeOperationLock(worktreePath, options.signal, () =>
|
||||
runWithGitReadCacheInvalidation(() =>
|
||||
gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options)
|
||||
postponeRepoRefMaintenance()
|
||||
await withRepoRefMaintenancePaused('git-fast-forward', () =>
|
||||
runWithGitWorktreeOperationLock(worktreePath, options.signal, () =>
|
||||
runWithGitReadCacheInvalidation(() =>
|
||||
gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -282,22 +292,28 @@ export async function gitFetch(
|
||||
pushTarget?: GitPushTarget,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<void> {
|
||||
// `--prune` deletes remote-tracking refs, which needs the `packed-refs` lock a
|
||||
// running idle pack holds while it rewrites -- ~1.4s at most. This is the user
|
||||
// clicking Fetch, so wait that window out rather than letting it fail on the lock.
|
||||
postponeRepoRefMaintenance()
|
||||
try {
|
||||
if (pushTarget) {
|
||||
const target = await validateGitPushTarget(worktreePath, pushTarget, options)
|
||||
const runtimeOptions = gitOptionsForWorktree(worktreePath, options)
|
||||
await fetchForkRemoteWithStaleRefspecRepair(
|
||||
(args, cwd) => gitExecFileAsync(args, { ...runtimeOptions, cwd }),
|
||||
worktreePath,
|
||||
target.remoteName,
|
||||
() =>
|
||||
gitExecFileAsync(['fetch', '--prune', target.remoteName], runtimeOptions).then(
|
||||
() => undefined
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options))
|
||||
await withRepoRefMaintenancePaused('git-fetch', async () => {
|
||||
if (pushTarget) {
|
||||
const target = await validateGitPushTarget(worktreePath, pushTarget, options)
|
||||
const runtimeOptions = gitOptionsForWorktree(worktreePath, options)
|
||||
await fetchForkRemoteWithStaleRefspecRepair(
|
||||
(args, cwd) => gitExecFileAsync(args, { ...runtimeOptions, cwd }),
|
||||
worktreePath,
|
||||
target.remoteName,
|
||||
() =>
|
||||
gitExecFileAsync(['fetch', '--prune', target.remoteName], runtimeOptions).then(
|
||||
() => undefined
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options))
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(normalizeGitErrorMessage(error, 'fetch'))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getBranchConflictKind } from './repo-branch-conflict'
|
||||
|
||||
describe('branch conflict real Git contract', () => {
|
||||
const tempPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of tempPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('decides remote conflicts from one batched probe across many remotes', async () => {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'orca-branch-conflict-'))
|
||||
tempPaths.push(repoPath)
|
||||
const git = (...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' })
|
||||
|
||||
git('init', '--quiet')
|
||||
git('config', 'user.name', 'Orca Test')
|
||||
git('config', 'user.email', 'orca@example.test')
|
||||
git('config', 'commit.gpgSign', 'false')
|
||||
git('config', 'core.hooksPath', '.git/no-hooks')
|
||||
writeFileSync(join(repoPath, 'fixture.txt'), 'base\n')
|
||||
git('add', 'fixture.txt')
|
||||
git('commit', '--quiet', '-m', 'base')
|
||||
const head = git('rev-parse', 'HEAD').trim()
|
||||
|
||||
// Many remotes is the shape that used to cost one subprocess each.
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
git('remote', 'add', `remote${index}`, 'https://example.test/repo.git')
|
||||
}
|
||||
git('update-ref', 'refs/remotes/remote7/taken', head)
|
||||
|
||||
await expect(getBranchConflictKind(repoPath, 'taken')).resolves.toBe('remote')
|
||||
await expect(getBranchConflictKind(repoPath, 'free')).resolves.toBeNull()
|
||||
// The allowed base ref is the one remote spelling that is not a conflict.
|
||||
await expect(
|
||||
getBranchConflictKind(repoPath, 'taken', 'refs/remotes/remote7/taken')
|
||||
).resolves.toBeNull()
|
||||
|
||||
git('branch', 'local-only', head)
|
||||
await expect(getBranchConflictKind(repoPath, 'local-only')).resolves.toBe('local')
|
||||
})
|
||||
})
|
||||
@@ -134,3 +134,123 @@ describe('getBranchConflictKindViaExec', () => {
|
||||
expect(exec).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBranchConflictKindViaExec batched remote probe', () => {
|
||||
function remoteNames(count: number): string {
|
||||
return `${Array.from({ length: count }, (_, index) => `remote${index}`).join('\n')}\n`
|
||||
}
|
||||
|
||||
function baseExec(calls: string[][]): (argv: string[]) => Promise<{ stdout: string }> {
|
||||
return async (argv) => {
|
||||
calls.push(argv)
|
||||
if (argv[0] === 'rev-parse') {
|
||||
throw new Error('local branch is absent')
|
||||
}
|
||||
if (argv[0] === 'remote') {
|
||||
return { stdout: remoteNames(3) }
|
||||
}
|
||||
throw new Error(`unexpected git command: ${argv.join(' ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
it('asks one batched child instead of one probe per remote', async () => {
|
||||
const calls: string[][] = []
|
||||
const stdinPayloads: (string | undefined)[] = []
|
||||
const exec = baseExec(calls)
|
||||
const batched = async (
|
||||
argv: string[],
|
||||
options: { stdin: string }
|
||||
): Promise<{ stdout: string }> => {
|
||||
calls.push(argv)
|
||||
stdinPayloads.push(options.stdin)
|
||||
return {
|
||||
stdout: [
|
||||
'refs/remotes/remote0/feature missing',
|
||||
'refs/remotes/remote1/feature missing',
|
||||
'refs/remotes/remote2/feature missing'
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
await expect(
|
||||
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
|
||||
).resolves.toBeNull()
|
||||
expect(calls).toEqual([
|
||||
['rev-parse', '--verify', 'refs/heads/feature'],
|
||||
['remote'],
|
||||
['cat-file', '--batch-check']
|
||||
])
|
||||
expect(stdinPayloads).toEqual([
|
||||
'refs/remotes/remote0/feature\nrefs/remotes/remote1/feature\nrefs/remotes/remote2/feature\n'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a remote conflict from the batched answer', async () => {
|
||||
const calls: string[][] = []
|
||||
const exec = baseExec(calls)
|
||||
const batched = async (): Promise<{ stdout: string }> => ({
|
||||
stdout: [
|
||||
'refs/remotes/remote0/feature missing',
|
||||
`${'a'.repeat(40)} commit 214`,
|
||||
'refs/remotes/remote2/feature missing'
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
await expect(
|
||||
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
|
||||
).resolves.toBe('remote')
|
||||
})
|
||||
|
||||
it('falls back to per-ref probes when the batch cannot answer', async () => {
|
||||
const calls: string[][] = []
|
||||
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
|
||||
calls.push(argv)
|
||||
if (argv[0] === 'rev-parse') {
|
||||
throw new Error('local branch is absent')
|
||||
}
|
||||
if (argv[0] === 'remote') {
|
||||
return { stdout: remoteNames(3) }
|
||||
}
|
||||
if (argv[0] === 'show-ref') {
|
||||
if (argv[4] === 'refs/remotes/remote1/feature') {
|
||||
return { stdout: 'abc refs/remotes/remote1/feature\n' }
|
||||
}
|
||||
throw Object.assign(new Error('missing'), { code: 1, stderr: '' })
|
||||
}
|
||||
throw new Error(`unexpected git command: ${argv.join(' ')}`)
|
||||
}
|
||||
const batched = async (): Promise<{ stdout: string }> => {
|
||||
throw new Error('cat-file is unavailable')
|
||||
}
|
||||
|
||||
await expect(
|
||||
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
|
||||
).resolves.toBe('remote')
|
||||
expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('treats a short batch read as undecided rather than as absence', async () => {
|
||||
const calls: string[][] = []
|
||||
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
|
||||
calls.push(argv)
|
||||
if (argv[0] === 'rev-parse') {
|
||||
throw new Error('local branch is absent')
|
||||
}
|
||||
if (argv[0] === 'remote') {
|
||||
return { stdout: remoteNames(3) }
|
||||
}
|
||||
if (argv[0] === 'show-ref') {
|
||||
throw Object.assign(new Error('missing'), { code: 1, stderr: '' })
|
||||
}
|
||||
throw new Error(`unexpected git command: ${argv.join(' ')}`)
|
||||
}
|
||||
const batched = async (): Promise<{ stdout: string }> => ({
|
||||
stdout: 'refs/remotes/remote0/feature missing'
|
||||
})
|
||||
|
||||
await expect(
|
||||
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
|
||||
).resolves.toBeNull()
|
||||
expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,8 +4,10 @@ import { gitExecFileAsync } from './runner'
|
||||
import { isSafeGitRefName } from '../../shared/git-status-upstream-ref'
|
||||
import {
|
||||
probeAnyExactRef,
|
||||
probeAnyExactRefBatched,
|
||||
type ExactRefProbeExec,
|
||||
type ExactRefProbeExecOptions
|
||||
type ExactRefProbeExecOptions,
|
||||
type ExactRefProbeStdinExec
|
||||
} from './exact-ref-probe'
|
||||
|
||||
export type BranchConflictKind = 'local' | 'remote'
|
||||
@@ -79,12 +81,31 @@ function buildRemoteBranchConflictRefs(
|
||||
return [...refs]
|
||||
}
|
||||
|
||||
/** One batched child answers for every remote; the per-ref probes only run when the host cannot
|
||||
* feed stdin, or when the batch came back undecided. */
|
||||
async function probeAnyRemoteConflictRef(
|
||||
exec: ExactRefProbeExec,
|
||||
batchedExec: ExactRefProbeStdinExec | undefined,
|
||||
candidateRefs: readonly string[],
|
||||
probeOptions: ExactRefProbeExecOptions
|
||||
): Promise<{ found: boolean }> {
|
||||
if (batchedExec) {
|
||||
// A present ref is always decisive, so `found` never survives with `unknown` set.
|
||||
const batched = await probeAnyExactRefBatched(batchedExec, candidateRefs, probeOptions)
|
||||
if (!batched.unknown) {
|
||||
return { found: batched.found }
|
||||
}
|
||||
}
|
||||
return probeAnyExactRef(exec, candidateRefs, probeOptions)
|
||||
}
|
||||
|
||||
/** Run branch-conflict policy through the host that owns Git execution. */
|
||||
export async function getBranchConflictKindViaExec(
|
||||
exec: ExactRefProbeExec,
|
||||
branchName: string,
|
||||
allowedBaseRef?: string,
|
||||
options: ExactRefProbeExecOptions = {}
|
||||
options: ExactRefProbeExecOptions = {},
|
||||
batchedExec?: ExactRefProbeStdinExec
|
||||
): Promise<BranchConflictKind | null> {
|
||||
if (!canQueryRemoteBranchName(branchName)) {
|
||||
return null
|
||||
@@ -104,7 +125,12 @@ export async function getBranchConflictKindViaExec(
|
||||
return null
|
||||
}
|
||||
|
||||
const { found: hasRemoteConflict } = await probeAnyExactRef(exec, candidateRefs, probeOptions)
|
||||
const { found: hasRemoteConflict } = await probeAnyRemoteConflictRef(
|
||||
exec,
|
||||
batchedExec,
|
||||
candidateRefs,
|
||||
probeOptions
|
||||
)
|
||||
|
||||
return hasRemoteConflict ? 'remote' : null
|
||||
} catch {
|
||||
@@ -119,15 +145,22 @@ export function getBranchConflictKind(
|
||||
options: LocalGitExecOptions = {}
|
||||
): Promise<BranchConflictKind | null> {
|
||||
const execOptions = gitExecOptions(path, options)
|
||||
const runLocalGit = (
|
||||
argv: string[],
|
||||
commandOptions?: ExactRefProbeExecOptions & { stdin?: string }
|
||||
): Promise<{ stdout: string }> =>
|
||||
gitExecFileAsync(argv, {
|
||||
...execOptions,
|
||||
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
|
||||
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }),
|
||||
...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin })
|
||||
})
|
||||
return getBranchConflictKindViaExec(
|
||||
(argv, commandOptions) =>
|
||||
gitExecFileAsync(argv, {
|
||||
...execOptions,
|
||||
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
|
||||
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs })
|
||||
}),
|
||||
runLocalGit,
|
||||
branchName,
|
||||
allowedBaseRef
|
||||
allowedBaseRef,
|
||||
{},
|
||||
(argv, commandOptions) => runLocalGit(argv, commandOptions)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { countLooseRefs } from '../../shared/loose-ref-count'
|
||||
import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance'
|
||||
import {
|
||||
_resetLocalRepoRefMaintenanceForTests,
|
||||
createLocalRepoRefMaintenanceTarget,
|
||||
getLocalRepoRefMaintenance,
|
||||
setRepoMaintenanceActivityProbe
|
||||
} from './local-repo-ref-maintenance'
|
||||
import { forceDeleteLocalBranch } from './worktree-branch-removal'
|
||||
|
||||
const roots: string[] = []
|
||||
// Large enough that the deferral ladder (1x, 2x, 4x ... capped at 8x) outlasts
|
||||
// three real `pack-refs` runs before the deferral budget is spent.
|
||||
const QUIET_MS = 25
|
||||
const THRESHOLD = 20
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
}
|
||||
|
||||
/** A repo whose only loose-ref backlog is the one the test asks for. */
|
||||
async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-git-'))
|
||||
roots.push(root)
|
||||
const repoPath = join(root, 'repo')
|
||||
execFileSync('git', ['init', '--quiet', repoPath])
|
||||
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
|
||||
git(repoPath, ['config', 'user.email', 'test@example.com'])
|
||||
git(repoPath, ['config', 'user.name', 'Test User'])
|
||||
await writeFile(join(repoPath, 'file.txt'), 'one\n')
|
||||
git(repoPath, ['add', 'file.txt'])
|
||||
git(repoPath, ['commit', '--quiet', '-m', 'initial'])
|
||||
const head = git(repoPath, ['rev-parse', 'HEAD'])
|
||||
// Written directly: `update-ref` for thousands of refs is the slow part of the fixture.
|
||||
const namespace = join(repoPath, '.git', 'refs', 'remotes', 'origin')
|
||||
await mkdir(namespace, { recursive: true })
|
||||
for (let index = 0; index < looseRefs; index += 1) {
|
||||
await writeFile(join(namespace, `branch-${index}`), `${head}\n`)
|
||||
}
|
||||
return { repoPath, refsDir: join(repoPath, '.git', 'refs') }
|
||||
}
|
||||
|
||||
function createMaintenance(onPackRefs: () => void = () => {}): {
|
||||
maintenance: RepoRefMaintenance
|
||||
arm: (repoPath: string) => void
|
||||
} {
|
||||
const maintenance = new RepoRefMaintenance({
|
||||
quietPeriodMs: QUIET_MS,
|
||||
looseRefThreshold: THRESHOLD
|
||||
})
|
||||
return {
|
||||
maintenance,
|
||||
arm: (repoPath: string) => {
|
||||
const target = createLocalRepoRefMaintenanceTarget({
|
||||
key: `local::${repoPath}`,
|
||||
repoPath
|
||||
})
|
||||
maintenance.arm({
|
||||
...target,
|
||||
packRefs: async (signal) => {
|
||||
onPackRefs()
|
||||
await target.packRefs(signal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function settle(maintenance: RepoRefMaintenance): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4))
|
||||
await maintenance.whenAttemptSettled()
|
||||
}
|
||||
|
||||
/** Deferred repos re-arm for another quiet period, so drain rather than count rounds. */
|
||||
async function settleUntil(
|
||||
maintenance: RepoRefMaintenance,
|
||||
done: () => Promise<boolean>
|
||||
): Promise<void> {
|
||||
for (let round = 0; round < 100; round += 1) {
|
||||
if (await done()) {
|
||||
return
|
||||
}
|
||||
await settle(maintenance)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
_resetLocalRepoRefMaintenanceForTests()
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('idle ref maintenance against real Git', () => {
|
||||
it('packs a backlogged repository down to zero loose refs', async () => {
|
||||
const { repoPath, refsDir } = await createRepo(THRESHOLD + 30)
|
||||
const { maintenance, arm } = createMaintenance()
|
||||
|
||||
await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({
|
||||
count: THRESHOLD + 31
|
||||
})
|
||||
|
||||
arm(repoPath)
|
||||
await settle(maintenance)
|
||||
maintenance.dispose()
|
||||
|
||||
await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({ count: 0, saturated: false })
|
||||
// The refs survived the move into packed-refs; nothing was lost.
|
||||
expect(git(repoPath, ['for-each-ref', '--format=%(refname)']).split('\n')).toHaveLength(
|
||||
THRESHOLD + 31
|
||||
)
|
||||
expect(git(repoPath, ['rev-parse', '--verify', 'refs/remotes/origin/branch-0'])).toMatch(
|
||||
/^[0-9a-f]{40}$/
|
||||
)
|
||||
}, 30_000)
|
||||
|
||||
it('leaves a healthy repository untouched', async () => {
|
||||
const { repoPath, refsDir } = await createRepo(2)
|
||||
let packed = 0
|
||||
const { maintenance, arm } = createMaintenance(() => {
|
||||
packed += 1
|
||||
})
|
||||
|
||||
arm(repoPath)
|
||||
await settle(maintenance)
|
||||
maintenance.dispose()
|
||||
|
||||
expect(packed).toBe(0)
|
||||
await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ count: 3 })
|
||||
}, 30_000)
|
||||
|
||||
it('honours maintenance.auto=false in the repository config', async () => {
|
||||
const { repoPath, refsDir } = await createRepo(THRESHOLD + 30)
|
||||
git(repoPath, ['config', 'maintenance.auto', 'false'])
|
||||
let packed = 0
|
||||
const { maintenance, arm } = createMaintenance(() => {
|
||||
packed += 1
|
||||
})
|
||||
|
||||
arm(repoPath)
|
||||
await settle(maintenance)
|
||||
maintenance.dispose()
|
||||
|
||||
expect(packed).toBe(0)
|
||||
await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({
|
||||
count: THRESHOLD + 31
|
||||
})
|
||||
}, 30_000)
|
||||
|
||||
it('runs one repository at a time even when several go quiet together', async () => {
|
||||
const repos = await Promise.all([
|
||||
createRepo(THRESHOLD + 5),
|
||||
createRepo(THRESHOLD + 5),
|
||||
createRepo(THRESHOLD + 5)
|
||||
])
|
||||
let concurrent = 0
|
||||
let peak = 0
|
||||
const maintenance = new RepoRefMaintenance({
|
||||
quietPeriodMs: QUIET_MS,
|
||||
looseRefThreshold: THRESHOLD
|
||||
})
|
||||
for (const { repoPath } of repos) {
|
||||
const target = createLocalRepoRefMaintenanceTarget({
|
||||
key: `local::${repoPath}`,
|
||||
repoPath
|
||||
})
|
||||
maintenance.arm({
|
||||
...target,
|
||||
packRefs: async (signal) => {
|
||||
concurrent += 1
|
||||
peak = Math.max(peak, concurrent)
|
||||
try {
|
||||
await target.packRefs(signal)
|
||||
} finally {
|
||||
concurrent -= 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const allPacked = async (): Promise<boolean> => {
|
||||
const counts = await Promise.all(repos.map(({ refsDir }) => countLooseRefs(refsDir, 10_000)))
|
||||
return counts.every((scan) => scan.count === 0)
|
||||
}
|
||||
await settleUntil(maintenance, allPacked)
|
||||
maintenance.dispose()
|
||||
|
||||
expect(peak).toBe(1)
|
||||
for (const { refsDir } of repos) {
|
||||
await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({
|
||||
count: 0,
|
||||
saturated: false
|
||||
})
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
describe('yielding the repository to work that deletes refs', () => {
|
||||
it('waits for the packed-refs lock and succeeds while the prune continues', async () => {
|
||||
// The pack is never killed. `packed-refs.lock` is held for ~1.4s of a 30s
|
||||
// run; the rest is the prune, during which a concurrent `update-ref -d`
|
||||
// succeeds on its own because per-ref locks last microseconds. Signalling
|
||||
// the child there strands a `refs/**` lock Git never clears.
|
||||
const { repoPath } = await createRepo(0)
|
||||
git(repoPath, ['branch', 'doomed'])
|
||||
const head = git(repoPath, ['rev-parse', 'refs/heads/doomed'])
|
||||
|
||||
let packing = false
|
||||
let releaseLock: (() => void) | undefined
|
||||
_resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 })
|
||||
setRepoMaintenanceActivityProbe(() => false)
|
||||
getLocalRepoRefMaintenance().arm({
|
||||
key: `local::${repoPath}`,
|
||||
resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'),
|
||||
packRefs: async (lock) => {
|
||||
packing = true
|
||||
lock.setHeld(true)
|
||||
// Stands in for the rewrite window, then the long prune that follows it.
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseLock = () => {
|
||||
lock.setHeld(false)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
for (let attempt = 0; attempt < 200 && !packing; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, QUIET_MS))
|
||||
}
|
||||
expect(packing).toBe(true)
|
||||
|
||||
// The real deletion path, which routes through withRepoRefMaintenancePaused.
|
||||
let deleted = false
|
||||
const deletion = forceDeleteLocalBranch(repoPath, 'doomed', head).then(() => {
|
||||
deleted = true
|
||||
})
|
||||
|
||||
// It must still be waiting: the rewrite window is open.
|
||||
await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4))
|
||||
expect(deleted).toBe(false)
|
||||
expect(git(repoPath, ['branch', '--list', 'doomed'])).toContain('doomed')
|
||||
|
||||
// Releasing the window is enough -- the pack is never cancelled.
|
||||
releaseLock?.()
|
||||
await deletion
|
||||
expect(deleted).toBe(true)
|
||||
expect(git(repoPath, ['branch', '--list', 'doomed'])).toBe('')
|
||||
}, 30_000)
|
||||
|
||||
it('does not block the caller once the rewrite window has closed', async () => {
|
||||
// The prune phase is concurrency-safe, so a caller arriving during it pays
|
||||
// nothing at all.
|
||||
const { repoPath } = await createRepo(0)
|
||||
git(repoPath, ['branch', 'doomed'])
|
||||
const head = git(repoPath, ['rev-parse', 'refs/heads/doomed'])
|
||||
|
||||
let pruning = false
|
||||
let finishPrune: (() => void) | undefined
|
||||
_resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 })
|
||||
setRepoMaintenanceActivityProbe(() => false)
|
||||
getLocalRepoRefMaintenance().arm({
|
||||
key: `local::${repoPath}`,
|
||||
resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'),
|
||||
packRefs: async (lock) => {
|
||||
lock.setHeld(true)
|
||||
lock.setHeld(false)
|
||||
pruning = true
|
||||
await new Promise<void>((resolve) => {
|
||||
finishPrune = resolve
|
||||
})
|
||||
}
|
||||
})
|
||||
for (let attempt = 0; attempt < 200 && !pruning; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, QUIET_MS))
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
await expect(forceDeleteLocalBranch(repoPath, 'doomed', head)).resolves.toBeUndefined()
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000)
|
||||
|
||||
finishPrune?.()
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import { getUpstreamStatus } from './upstream'
|
||||
|
||||
// Why: on-demand remote materialization (#17828) defers `git remote add` for a
|
||||
// fork PR to first push/pull/fetch/fast-forward, so an unpublished review's
|
||||
// status must be read against a pushTarget whose remote was never created.
|
||||
// This exercises the real `rev-parse --verify --quiet` failure path -- a
|
||||
// fake/mocked git can't reproduce its exact exit-code/stderr shape, which is
|
||||
// exactly what `getPublishTargetStatus`'s missing-ref fallback depends on.
|
||||
describe('getUpstreamStatus with a deferred (not-yet-materialized) fork remote', () => {
|
||||
const tempPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of tempPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports the graceful "publish" state instead of 0 ahead/0 behind', async () => {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'orca-deferred-fork-remote-'))
|
||||
tempPaths.push(repoPath)
|
||||
const git = (...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' })
|
||||
|
||||
git('init', '--quiet')
|
||||
git('config', 'user.name', 'Orca Test')
|
||||
git('config', 'user.email', 'orca@example.test')
|
||||
git('config', 'commit.gpgSign', 'false')
|
||||
git('config', 'core.hooksPath', '.git/no-hooks')
|
||||
writeFileSync(join(repoPath, 'fixture.txt'), 'base\n')
|
||||
git('add', 'fixture.txt')
|
||||
git('commit', '-m', 'base')
|
||||
git('branch', '-M', 'contributor/fix')
|
||||
|
||||
// Simulates a fork-PR review worktree right after create: pushTarget
|
||||
// metadata is persisted, but `pr-contributor-orca` was never added as a
|
||||
// remote because materialization is deferred to first use.
|
||||
const pushTarget: GitPushTarget = {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'contributor/fix',
|
||||
remoteUrl: 'git@github.com:contributor/orca.git'
|
||||
}
|
||||
|
||||
const status = await getUpstreamStatus(repoPath, pushTarget)
|
||||
|
||||
expect(status).toEqual({
|
||||
hasUpstream: false,
|
||||
upstreamName: 'pr-contributor-orca/contributor/fix',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasConfiguredPushTarget: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
LocalBaseRefUpdateSuggestion
|
||||
} from '../../shared/worktree/base-ref-drift-types'
|
||||
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
|
||||
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { runWithGitReadCacheInvalidation } from './status'
|
||||
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
|
||||
@@ -149,15 +150,17 @@ export async function addWorktree(
|
||||
options: AddWorktreeOptions = {}
|
||||
): Promise<AddWorktreeResult> {
|
||||
try {
|
||||
return await runWithGitReadCacheInvalidation(() =>
|
||||
performAddWorktree(
|
||||
repoPath,
|
||||
worktreePath,
|
||||
branch,
|
||||
baseBranch,
|
||||
refreshLocalBaseRef,
|
||||
noCheckout,
|
||||
options
|
||||
return await withRepoRefMaintenancePaused('worktree-add', () =>
|
||||
runWithGitReadCacheInvalidation(() =>
|
||||
performAddWorktree(
|
||||
repoPath,
|
||||
worktreePath,
|
||||
branch,
|
||||
baseBranch,
|
||||
refreshLocalBaseRef,
|
||||
noCheckout,
|
||||
options
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
measureRetargetDivergence,
|
||||
RETARGET_MAX_COMMIT_DIVERGENCE
|
||||
} from './worktree-base-divergence'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
}
|
||||
|
||||
async function createRepo(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-base-divergence-'))
|
||||
tempRoots.push(root)
|
||||
const repoPath = join(root, 'repo')
|
||||
execFileSync('git', ['init', '--quiet', repoPath])
|
||||
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
|
||||
git(repoPath, ['config', 'user.email', 'test@example.com'])
|
||||
git(repoPath, ['config', 'user.name', 'Test User'])
|
||||
await writeFile(join(repoPath, 'version.txt'), 'one\n')
|
||||
git(repoPath, ['add', 'version.txt'])
|
||||
git(repoPath, ['commit', '--quiet', '-m', 'initial'])
|
||||
return repoPath
|
||||
}
|
||||
|
||||
function commitEmpty(repoPath: string, count: number): void {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${index}`])
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('measureRetargetDivergence with real Git', () => {
|
||||
it('allows the drift between a local branch and its remote-tracking copy', async () => {
|
||||
const repoPath = await createRepo()
|
||||
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
|
||||
commitEmpty(repoPath, 5)
|
||||
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
|
||||
git(repoPath, ['reset', '--hard', '--quiet', 'HEAD~3'])
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('within')
|
||||
})
|
||||
|
||||
it('counts drift in both directions', async () => {
|
||||
const repoPath = await createRepo()
|
||||
const forkPoint = git(repoPath, ['rev-parse', 'HEAD'])
|
||||
commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE)
|
||||
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
|
||||
git(repoPath, ['reset', '--hard', '--quiet', forkPoint])
|
||||
commitEmpty(repoPath, 1)
|
||||
|
||||
// 100 ahead + 1 behind is over the cap even though neither side alone exceeds it.
|
||||
await expect(
|
||||
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('exceeded')
|
||||
})
|
||||
|
||||
it('refuses a base that has drifted past the cap', async () => {
|
||||
const repoPath = await createRepo()
|
||||
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
|
||||
commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE + 1)
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence(repoPath, 'refs/remotes/origin/main', 'refs/heads/main')
|
||||
).resolves.toBe('exceeded')
|
||||
})
|
||||
|
||||
it('refuses unrelated histories, which share no commits at all', async () => {
|
||||
const repoPath = await createRepo()
|
||||
git(repoPath, ['checkout', '--quiet', '--orphan', 'unrelated'])
|
||||
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', 'unrelated root'])
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/unrelated')
|
||||
).resolves.toBe('exceeded')
|
||||
})
|
||||
|
||||
it('reports an unreadable ref as unverifiable, not as excess drift', async () => {
|
||||
const repoPath = await createRepo()
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/missing')
|
||||
).resolves.toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ gitExecFileAsync: vi.fn() }))
|
||||
|
||||
vi.mock('./runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync }))
|
||||
|
||||
import { GIT_READ_TIMEOUT_MS } from './command-runner/git-command-timeout'
|
||||
import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment'
|
||||
import {
|
||||
measureRetargetDivergence,
|
||||
RETARGET_DIVERGENCE_BUDGET_MS
|
||||
} from './worktree-base-divergence'
|
||||
|
||||
type ExecOptions = { cwd: string; timeout?: number; wslDistro?: string; signal?: AbortSignal }
|
||||
|
||||
function callOptions(): ExecOptions[] {
|
||||
return mocks.gitExecFileAsync.mock.calls.map((call) => call[1] as ExecOptions)
|
||||
}
|
||||
|
||||
function subcommands(): string[] {
|
||||
return mocks.gitExecFileAsync.mock.calls.map((call) => (call[0] as string[])[0]!)
|
||||
}
|
||||
|
||||
function answerProbes(count: string, mergeBase = 'abc123\n') {
|
||||
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) =>
|
||||
args[0] === 'merge-base' ? { stdout: mergeBase } : { stdout: count }
|
||||
)
|
||||
}
|
||||
|
||||
function exitCodeError(code: number): Error & { code: number } {
|
||||
return Object.assign(new Error('git exited'), { code })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.gitExecFileAsync.mockReset()
|
||||
})
|
||||
|
||||
describe('measureRetargetDivergence deadlines', () => {
|
||||
it('puts every probe under one shared budget, not a budget each', async () => {
|
||||
answerProbes('3\n')
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('within')
|
||||
|
||||
expect(subcommands()).toEqual(['rev-list', 'rev-list', 'merge-base'])
|
||||
const signals = callOptions().map((options) => options.signal)
|
||||
// One signal object across all three: the counts and merge-base are staged, so per-probe
|
||||
// budgets would let the check cost the sum of them.
|
||||
expect(new Set(signals).size).toBe(1)
|
||||
expect(signals[0]).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it('also gives each probe a command timeout well below git default read deadline', async () => {
|
||||
answerProbes('3\n')
|
||||
|
||||
await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
|
||||
// The signal covers admission queueing and the WSL environment wait, which start before a
|
||||
// command timeout exists; the timeout still covers a hung spawn.
|
||||
for (const options of callOptions()) {
|
||||
expect(options.timeout).toBe(RETARGET_DIVERGENCE_BUDGET_MS)
|
||||
}
|
||||
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('really aborts the in-flight probes when the shared budget expires', async () => {
|
||||
// A probe that behaves like a slow walk: it produces nothing on its own and only settles when
|
||||
// its signal fires. If the budget never fired, or never reached the probe, this hangs and the
|
||||
// test fails on its own timeout rather than passing on a signal that does nothing.
|
||||
mocks.gitExecFileAsync.mockImplementation(
|
||||
(_args: string[], options: ExecOptions) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
||||
),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', {
|
||||
budgetMsForTest: 25
|
||||
})
|
||||
).resolves.toBe('unknown')
|
||||
})
|
||||
|
||||
it('clears the WSL read-environment wait, which starts before any command timeout', () => {
|
||||
// Equal to it would make the first WSL-routed create of a session `unknown` by construction,
|
||||
// and the WSL numbers meaningless.
|
||||
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeGreaterThan(WSL_GIT_READ_ENVIRONMENT_WAIT_MS)
|
||||
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('stops the probes when the create itself is cancelled, without waiting for the budget', async () => {
|
||||
mocks.gitExecFileAsync.mockImplementation(
|
||||
(_args: string[], options: ExecOptions) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() =>
|
||||
reject(
|
||||
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
||||
),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const pending = measureRetargetDivergence(
|
||||
'/repo',
|
||||
'refs/heads/main',
|
||||
'refs/remotes/origin/main',
|
||||
// A budget long enough that only the caller's cancellation can end this in time.
|
||||
{ signal: controller.signal, budgetMsForTest: 60_000 }
|
||||
)
|
||||
controller.abort()
|
||||
|
||||
await expect(pending).resolves.toBe('unknown')
|
||||
})
|
||||
|
||||
it('reports a blown deadline as unverifiable rather than as excess drift', async () => {
|
||||
mocks.gitExecFileAsync.mockRejectedValue(new Error('git timed out.'))
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('unknown')
|
||||
// A count that never answered must not go on to spend a merge-base walk.
|
||||
expect(subcommands()).not.toContain('merge-base')
|
||||
})
|
||||
|
||||
it('separates merge-base saying no from merge-base failing', async () => {
|
||||
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'merge-base') {
|
||||
// Exit 1 is Git's answer for unrelated histories.
|
||||
throw exitCodeError(1)
|
||||
}
|
||||
return { stdout: '2\n' }
|
||||
})
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('exceeded')
|
||||
|
||||
mocks.gitExecFileAsync.mockReset()
|
||||
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'merge-base') {
|
||||
// A timeout carries no exit code and must not be read as "no common ancestor".
|
||||
throw new Error('git timed out.')
|
||||
}
|
||||
return { stdout: '2\n' }
|
||||
})
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('unknown')
|
||||
})
|
||||
|
||||
it('reports drift past the cap without spending a merge-base walk', async () => {
|
||||
answerProbes('101\n')
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('exceeded')
|
||||
expect(subcommands()).not.toContain('merge-base')
|
||||
})
|
||||
|
||||
it('routes every probe to the caller-named WSL distro', async () => {
|
||||
answerProbes('1\n')
|
||||
|
||||
await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', {
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
|
||||
for (const options of callOptions()) {
|
||||
expect(options).toMatchObject({ cwd: '/repo', wslDistro: 'Ubuntu' })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
|
||||
export type RetargetDivergenceOptions = {
|
||||
wslDistro?: string
|
||||
/** The create's own cancellation signal. Without it a cancelled create leaves these probes
|
||||
* running until the budget expires. */
|
||||
signal?: AbortSignal
|
||||
/** Shortens only the end-to-end budget so a test can observe a real abort; production always
|
||||
* uses the constant. Mirrors `timeoutMsForTest` on the git exec options. */
|
||||
budgetMsForTest?: number
|
||||
}
|
||||
|
||||
/** `unknown` is deliberately not folded into `exceeded`: "the bound says no" and "the bound could
|
||||
* not be evaluated" have different causes and different fixes, and only the second one means a
|
||||
* retarget that would have been cheap was skipped. `unknown` covers a blown deadline, a
|
||||
* cancelled create, and an ordinary Git failure alike — it is "no answer", not "slow". */
|
||||
export type RetargetDivergence = 'within' | 'exceeded' | 'unknown'
|
||||
|
||||
/**
|
||||
* How far two bases may drift and still be worth retargeting a prepared checkout between.
|
||||
*
|
||||
* Measured on a 21,715-file repo: a local `main` and its `origin/main` were 5 commits and 74
|
||||
* files apart, while an abandoned fork's `main` — same branch name, so the same base family —
|
||||
* was 8,173 commits and 21,708 files from `origin/main`, i.e. a whole-tree checkout. A commit
|
||||
* count separates those by three orders of magnitude, so it is the cheap proxy for the tree diff
|
||||
* the retarget reset would have to write.
|
||||
*/
|
||||
export const RETARGET_MAX_COMMIT_DIVERGENCE = 100
|
||||
|
||||
/**
|
||||
* Headroom for the walk itself, on top of the worst pre-spawn wait.
|
||||
*
|
||||
* ~3x the slowest walk measured on the 12GB/80k-ref repo (180ms to reject, 57ms to allow), so a
|
||||
* cold WSL environment probe cannot eat the whole budget and make the answer `unknown` by
|
||||
* construction.
|
||||
*/
|
||||
const RETARGET_DIVERGENCE_WALK_HEADROOM_MS = 500
|
||||
|
||||
/**
|
||||
* End-to-end deadline for the whole check, not per probe.
|
||||
*
|
||||
* Derived from the WSL read-environment wait rather than picked: `git-exec-file` awaits that probe
|
||||
* before a command timeout even exists, so a budget merely equal to it would guarantee `unknown`
|
||||
* on the first WSL-routed create of a session and make the WSL numbers meaningless. Deriving it
|
||||
* keeps that relationship explicit instead of coincidental.
|
||||
*
|
||||
* Sized against what it competes with: this exists only to decide whether to skip a ~4.1s p50 cold
|
||||
* `worktree add`, so when it expires the create pays the budget and then does that add anyway. The
|
||||
* total stays under that add even on Windows, where a killed probe also awaits `taskkill /t`.
|
||||
*
|
||||
* It must be a signal, not just a per-command timeout, because a per-command timeout starts only
|
||||
* after `git-exec-file` has awaited admission and the WSL read-environment probe, and because the
|
||||
* counts and `merge-base` are staged — two per-probe budgets in sequence would be twice the number
|
||||
* written here.
|
||||
*/
|
||||
export const RETARGET_DIVERGENCE_BUDGET_MS =
|
||||
WSL_GIT_READ_ENVIRONMENT_WAIT_MS + RETARGET_DIVERGENCE_WALK_HEADROOM_MS
|
||||
|
||||
function probeOptions(
|
||||
repoPath: string,
|
||||
options: RetargetDivergenceOptions,
|
||||
signal: AbortSignal
|
||||
): { cwd: string; wslDistro?: string; signal: AbortSignal; timeout: number } {
|
||||
// Built field by field rather than spread: the caller's bag carries a test-only key that must
|
||||
// never reach git's exec options.
|
||||
// Both bounds: the signal covers the pre-spawn waits (admission queue, WSL environment) that a
|
||||
// command timeout cannot see, and the timeout keeps the bounded tree-kill path for a hung spawn.
|
||||
return {
|
||||
cwd: repoPath,
|
||||
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
|
||||
signal,
|
||||
timeout: RETARGET_DIVERGENCE_BUDGET_MS
|
||||
}
|
||||
}
|
||||
|
||||
/** Commits reachable from `toRef` but not `fromRef`, capped; null when the probe was unusable. */
|
||||
async function countCommitsAhead(
|
||||
repoPath: string,
|
||||
fromRef: string,
|
||||
toRef: string,
|
||||
options: RetargetDivergenceOptions,
|
||||
signal: AbortSignal
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
// `--max-count` stops the walk, so an unrelated history costs a bounded number of commits
|
||||
// rather than a full traversal. Both flags predate the Git 2.25 baseline.
|
||||
// `--end-of-options` (Git 2.24) because a range whose left side began with `-` would
|
||||
// otherwise parse as an option; callers only pass `refs/`-qualified names today, and this
|
||||
// keeps that from being load-bearing.
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
[
|
||||
'rev-list',
|
||||
'--count',
|
||||
`--max-count=${RETARGET_MAX_COMMIT_DIVERGENCE + 1}`,
|
||||
'--end-of-options',
|
||||
`${fromRef}..${toRef}`
|
||||
],
|
||||
probeOptions(repoPath, options, signal)
|
||||
)
|
||||
const count = Number.parseInt(stdout.trim(), 10)
|
||||
return Number.isNaN(count) ? null : count
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** True/false when Git decided, null when the probe was unusable. */
|
||||
async function hasCommonHistory(
|
||||
repoPath: string,
|
||||
leftRef: string,
|
||||
rightRef: string,
|
||||
options: RetargetDivergenceOptions,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean | null> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['merge-base', '--end-of-options', leftRef, rightRef],
|
||||
probeOptions(repoPath, options, signal)
|
||||
)
|
||||
return stdout.trim().length > 0
|
||||
} catch (error) {
|
||||
// Exit 1 is `merge-base` reporting no common ancestor, which is an answer. A timeout or abort
|
||||
// carries no exit code and must not be read as one.
|
||||
return (error as { code?: unknown }).code === 1 ? false : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether retargeting a checkout prepared at `preparedBase` onto `targetBase` stays cheap.
|
||||
*
|
||||
* Fails closed on error, slowness, and cancellation alike: only a positive `within` authorizes
|
||||
* reusing the checkout, so every other outcome lands on the cold create path.
|
||||
*/
|
||||
export async function measureRetargetDivergence(
|
||||
repoPath: string,
|
||||
preparedBase: string,
|
||||
targetBase: string,
|
||||
options: RetargetDivergenceOptions = {}
|
||||
): Promise<RetargetDivergence> {
|
||||
const budget = AbortSignal.timeout(options.budgetMsForTest ?? RETARGET_DIVERGENCE_BUDGET_MS)
|
||||
// Combined so cancelling the create stops the probes immediately rather than at the deadline.
|
||||
const signal = options.signal ? AbortSignal.any([options.signal, budget]) : budget
|
||||
// Both directions: commits the target adds decide what the reset writes, commits only the
|
||||
// preparation has decide what it must delete.
|
||||
const [ahead, behind] = await Promise.all([
|
||||
countCommitsAhead(repoPath, preparedBase, targetBase, options, signal),
|
||||
countCommitsAhead(repoPath, targetBase, preparedBase, options, signal)
|
||||
])
|
||||
if (ahead === null || behind === null) {
|
||||
return 'unknown'
|
||||
}
|
||||
if (ahead + behind > RETARGET_MAX_COMMIT_DIVERGENCE) {
|
||||
return 'exceeded'
|
||||
}
|
||||
// Only now: `merge-base` has no `--max-count`, so on unrelated histories it would walk both of
|
||||
// them in full. Reaching here already proved neither side is more than the cap ahead of the
|
||||
// other, which bounds that walk — and unrelated histories of any size fail the counts first.
|
||||
// Required because unrelated histories replace the whole tree however few commits they carry.
|
||||
const shareHistory = await hasCommonHistory(repoPath, preparedBase, targetBase, options, signal)
|
||||
if (shareHistory === null) {
|
||||
return 'unknown'
|
||||
}
|
||||
return shareHistory ? 'within' : 'exceeded'
|
||||
}
|
||||
@@ -42,6 +42,21 @@ export async function hasWorktreeBaseCommitRef(
|
||||
return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* The qualified ref a worktree base names in this repo, or the base unchanged when nothing
|
||||
* matches. Callers that key on a base must compare this, not the raw string, or `main` and
|
||||
* `refs/heads/main` look like different bases.
|
||||
*/
|
||||
export function resolveLocalWorktreeBaseRef(
|
||||
repoPath: string,
|
||||
baseRef: string,
|
||||
options: GitExecOptions = {}
|
||||
): Promise<string> {
|
||||
return resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) =>
|
||||
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a worktree base — a qualified ref, a short branch or remote name, or a
|
||||
* full commit id — already resolves in this repo's own object/ref store.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '../../shared/git-branch-cleanup'
|
||||
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import { withLocalGitCapabilityCacheForExecution } from './git-capability-state'
|
||||
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { parseWorktreeList } from './worktree-list-parser'
|
||||
import type { GitWorktreeExecOptions, RemoveWorktreeOptions } from './worktree-operation-options'
|
||||
@@ -152,7 +153,11 @@ export async function forceDeleteLocalBranch(
|
||||
}
|
||||
// Why: stale toast actions must not delete a branch that moved; `update-ref -d` deletes only if the ref still == expectedHead.
|
||||
try {
|
||||
await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath)
|
||||
// `update-ref -d` needs the packed-refs lock a running idle pack holds while
|
||||
// it rewrites; waits it out rather than cancelling the pack.
|
||||
await withRepoRefMaintenancePaused('branch-delete', () =>
|
||||
runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath)
|
||||
)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.`
|
||||
|
||||
@@ -68,6 +68,55 @@ describe('prepared worktree creation with real Git', () => {
|
||||
expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lands a cross-base retarget on exactly the requested commit', async () => {
|
||||
const { repoPath, root } = await createRepo()
|
||||
const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY)
|
||||
const preparedPath = join(preparationRoot, `${process.pid}-retarget`)
|
||||
const finalPath = join(root, 'retargeted-worktree')
|
||||
await mkdir(preparationRoot, { recursive: true })
|
||||
|
||||
await writeFile(join(repoPath, 'shared.txt'), 'kept\n')
|
||||
git(repoPath, ['add', 'shared.txt'])
|
||||
git(repoPath, ['commit', '--quiet', '-m', 'local main'])
|
||||
const localMainHead = git(repoPath, ['rev-parse', 'HEAD'])
|
||||
|
||||
// A remote-tracking `main` that diverged: different content, an extra file, and one deletion.
|
||||
git(repoPath, ['checkout', '--quiet', '-b', 'upstream-main'])
|
||||
await writeFile(join(repoPath, 'version.txt'), 'two\n')
|
||||
await writeFile(join(repoPath, 'only-upstream.txt'), 'upstream\n')
|
||||
git(repoPath, ['rm', '--quiet', 'shared.txt'])
|
||||
git(repoPath, ['add', 'version.txt', 'only-upstream.txt'])
|
||||
git(repoPath, ['commit', '--quiet', '-m', 'upstream main'])
|
||||
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
|
||||
git(repoPath, ['checkout', '--quiet', 'main'])
|
||||
git(repoPath, ['branch', '--quiet', '-D', 'upstream-main'])
|
||||
|
||||
await prepareWorktreeCreateCheckout(
|
||||
repoPath,
|
||||
preparedPath,
|
||||
'refs/remotes/origin/main',
|
||||
createWorktreePreparationLockReason('retarget-test')
|
||||
)
|
||||
expect(git(preparedPath, ['rev-parse', 'HEAD'])).not.toBe(localMainHead)
|
||||
|
||||
await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/retargeted', 'main')
|
||||
|
||||
expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(localMainHead)
|
||||
// A retarget that left stale files behind would be a wrong checkout, not just a slow one.
|
||||
expect(git(finalPath, ['status', '--porcelain'])).toBe('')
|
||||
expect((await readFile(join(finalPath, 'version.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe(
|
||||
'one\n'
|
||||
)
|
||||
expect((await readFile(join(finalPath, 'shared.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe(
|
||||
'kept\n'
|
||||
)
|
||||
await expect(readFile(join(finalPath, 'only-upstream.txt'), 'utf8')).rejects.toThrow()
|
||||
expect(git(finalPath, ['branch', '--show-current'])).toBe('feature/retargeted')
|
||||
expect(git(finalPath, ['config', '--get', 'branch.feature/retargeted.base'])).toBe(
|
||||
'refs/heads/main'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the preparation, retargets an advanced base, and attaches the final branch', async () => {
|
||||
const { repoPath, root } = await createRepo()
|
||||
const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
|
||||
} from './worktree'
|
||||
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
|
||||
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { runWithGitReadCacheInvalidation } from './status'
|
||||
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
|
||||
@@ -69,46 +70,48 @@ export async function prepareWorktreeCreateCheckout(
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
await runWithGitReadCacheInvalidation(async () => {
|
||||
const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
|
||||
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
|
||||
)
|
||||
try {
|
||||
await gitExecFileAsync(
|
||||
[
|
||||
...windowsLongPathGitArgs(repoPath),
|
||||
'worktree',
|
||||
'add',
|
||||
'--detach',
|
||||
'--no-checkout',
|
||||
worktreePath,
|
||||
effectiveBase
|
||||
],
|
||||
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
|
||||
await withRepoRefMaintenancePaused('worktree-prepare', () =>
|
||||
runWithGitReadCacheInvalidation(async () => {
|
||||
const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
|
||||
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
|
||||
)
|
||||
// The add just wrote the marker; drop any pre-create route before the reset routes Git.
|
||||
invalidateWslLinkedWorktreeGitRouting(worktreePath)
|
||||
// Why: reset materializes files without running user post-checkout hooks before submit.
|
||||
await gitExecFileAsync(
|
||||
[...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase],
|
||||
{ ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() }
|
||||
)
|
||||
await gitExecFileAsync(
|
||||
[
|
||||
...windowsLongPathGitArgs(repoPath),
|
||||
'worktree',
|
||||
'lock',
|
||||
'--reason',
|
||||
lockReason,
|
||||
worktreePath
|
||||
],
|
||||
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
|
||||
)
|
||||
} catch (error) {
|
||||
await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
})
|
||||
try {
|
||||
await gitExecFileAsync(
|
||||
[
|
||||
...windowsLongPathGitArgs(repoPath),
|
||||
'worktree',
|
||||
'add',
|
||||
'--detach',
|
||||
'--no-checkout',
|
||||
worktreePath,
|
||||
effectiveBase
|
||||
],
|
||||
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
|
||||
)
|
||||
// The add just wrote the marker; drop any pre-create route before the reset routes Git.
|
||||
invalidateWslLinkedWorktreeGitRouting(worktreePath)
|
||||
// Why: reset materializes files without running user post-checkout hooks before submit.
|
||||
await gitExecFileAsync(
|
||||
[...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase],
|
||||
{ ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() }
|
||||
)
|
||||
await gitExecFileAsync(
|
||||
[
|
||||
...windowsLongPathGitArgs(repoPath),
|
||||
'worktree',
|
||||
'lock',
|
||||
'--reason',
|
||||
lockReason,
|
||||
worktreePath
|
||||
],
|
||||
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
|
||||
)
|
||||
} catch (error) {
|
||||
await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
notifyPreparedWorktreeMutation(repoPath)
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function listWorktreesStrict(
|
||||
return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options)
|
||||
}
|
||||
|
||||
async function annotateSparseCheckoutStatus(
|
||||
export async function annotateSparseCheckoutStatus(
|
||||
repoPath: string,
|
||||
worktrees: GitWorktreeInfo[],
|
||||
options: GitWorktreeExecOptions = {}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from './worktree-operation-options'
|
||||
import { areWorktreePathsEqual } from './worktree-path-comparison'
|
||||
import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight'
|
||||
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
|
||||
import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache'
|
||||
import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache'
|
||||
|
||||
@@ -36,8 +37,13 @@ export async function removeWorktree(
|
||||
options: RemoveWorktreeOptions = {}
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
try {
|
||||
return await runWithGitReadCacheInvalidation(() =>
|
||||
performRemoveWorktree(repoPath, worktreePath, force, options)
|
||||
// Removal deletes branches, and a ref deletion needs the packed-refs lock a
|
||||
// running idle pack holds while it rewrites. Waits that window out; the
|
||||
// prune phase that follows it is concurrency-safe and is left to finish.
|
||||
return await withRepoRefMaintenancePaused('worktree-remove', () =>
|
||||
runWithGitReadCacheInvalidation(() =>
|
||||
performRemoveWorktree(repoPath, worktreePath, force, options)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
invalidateWslLinkedWorktreeGitRouting(worktreePath)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// The annotated listing is the graph listing plus a sparse probe: callers that read only
|
||||
// `worktree.path` must skip the probe, without costing a second `git worktree list`.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
|
||||
const { detectSparseCheckoutMock, readWorktreeListMock, readTranslatedWorktreeGraphMock } =
|
||||
vi.hoisted(() => ({
|
||||
detectSparseCheckoutMock: vi.fn(),
|
||||
readWorktreeListMock: vi.fn(),
|
||||
readTranslatedWorktreeGraphMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./worktree-sparse-state', () => ({
|
||||
detectSparseCheckout: detectSparseCheckoutMock,
|
||||
resolveGitCommonDir: vi.fn()
|
||||
}))
|
||||
vi.mock('./worktree-list-reader', () => ({
|
||||
readCheckedOutBranchRef: vi.fn(),
|
||||
readRepoCommonDirFromGit: vi.fn(),
|
||||
readRepoLocation: vi.fn(),
|
||||
readTranslatedWorktreeGraph: readTranslatedWorktreeGraphMock,
|
||||
readWorktreeHeadOid: vi.fn(),
|
||||
readWorktreeList: readWorktreeListMock
|
||||
}))
|
||||
|
||||
import { _resetWorktreeScanCacheForTests, listWorktreeGraph, listWorktrees } from './worktree'
|
||||
import { __resetSparseCheckoutStateCacheForTests } from './worktree-sparse-checkout-cache'
|
||||
|
||||
const REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
|
||||
const ROW: GitWorktreeInfo = {
|
||||
path: 'C:\\wt\\x',
|
||||
head: 'a'.repeat(40),
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
|
||||
describe('graph and annotated worktree scans', () => {
|
||||
beforeEach(() => {
|
||||
detectSparseCheckoutMock.mockReset()
|
||||
detectSparseCheckoutMock.mockResolvedValue(true)
|
||||
readWorktreeListMock.mockReset()
|
||||
readWorktreeListMock.mockResolvedValue([ROW])
|
||||
readTranslatedWorktreeGraphMock.mockReset()
|
||||
readTranslatedWorktreeGraphMock.mockResolvedValue([ROW])
|
||||
_resetWorktreeScanCacheForTests()
|
||||
__resetSparseCheckoutStateCacheForTests()
|
||||
})
|
||||
|
||||
it('does not probe sparse state for a graph scan', async () => {
|
||||
const rows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(rows[0]?.path).toBe('C:\\wt\\x')
|
||||
expect(rows[0]?.isSparse).toBeUndefined()
|
||||
expect(detectSparseCheckoutMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still probes sparse state for the annotated scan', async () => {
|
||||
const rows = await listWorktrees(REPO, { wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(rows[0]?.isSparse).toBe(true)
|
||||
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads the git listing once for an overlapping graph and annotated scan', async () => {
|
||||
const [graphRows, annotatedRows] = await Promise.all([
|
||||
listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }),
|
||||
listWorktrees(REPO, { wslDistro: 'Ubuntu' })
|
||||
])
|
||||
|
||||
expect(readTranslatedWorktreeGraphMock).toHaveBeenCalledTimes(1)
|
||||
expect(graphRows[0]?.isSparse).toBeUndefined()
|
||||
expect(annotatedRows[0]?.isSparse).toBe(true)
|
||||
})
|
||||
|
||||
// Sharing the listing must not make the probe-free caller wait on the probe it opted out of.
|
||||
it('resolves a graph scan while the annotated scan is still probing', async () => {
|
||||
let releaseProbe!: () => void
|
||||
detectSparseCheckoutMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseProbe = () => resolve(true)
|
||||
})
|
||||
)
|
||||
|
||||
const annotatedScan = listWorktrees(REPO, { wslDistro: 'Ubuntu' })
|
||||
const graphRows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(graphRows[0]?.path).toBe('C:\\wt\\x')
|
||||
releaseProbe()
|
||||
expect((await annotatedScan)[0]?.isSparse).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -98,7 +98,9 @@ describe('listWorktrees in-flight sharing', () => {
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps graph and annotated scans separate despite sharing the same Git listing', async () => {
|
||||
// The annotated scan is the graph scan plus a sparse probe, so the two share one `git worktree
|
||||
// list` and only the annotated caller pays the probe. They ran Git twice before.
|
||||
it('runs one git listing for concurrent graph and annotated scans', async () => {
|
||||
const resolvers: ((value: { stdout: string }) => void)[] = []
|
||||
gitExecFileAsyncMock.mockImplementation(
|
||||
() =>
|
||||
@@ -109,13 +111,34 @@ describe('listWorktrees in-flight sharing', () => {
|
||||
|
||||
const graphScan = listWorktreeGraph('/repo')
|
||||
const annotatedScan = listWorktrees('/repo')
|
||||
expect(resolvers).toHaveLength(2)
|
||||
expect(resolvers).toHaveLength(1)
|
||||
|
||||
for (const resolve of resolvers) {
|
||||
resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' })
|
||||
}
|
||||
await Promise.all([graphScan, annotatedScan])
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Order must not matter: whichever runs first owns the listing and the other joins it.
|
||||
it('runs one git listing when the annotated scan starts first', async () => {
|
||||
const resolvers: ((value: { stdout: string }) => void)[] = []
|
||||
gitExecFileAsyncMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(resolve)
|
||||
})
|
||||
)
|
||||
|
||||
const annotatedScan = listWorktrees('/repo')
|
||||
const graphScan = listWorktreeGraph('/repo')
|
||||
expect(resolvers).toHaveLength(1)
|
||||
|
||||
for (const resolve of resolvers) {
|
||||
resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' })
|
||||
}
|
||||
await Promise.all([annotatedScan, graphScan])
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps graph scans with an AbortSignal isolated from shared callers', async () => {
|
||||
@@ -352,14 +375,15 @@ describe('listWorktrees in-flight sharing', () => {
|
||||
expect(scanResolvers).toHaveLength(1)
|
||||
|
||||
await moveWorktree('/repo', '/repo-old', '/repo-new')
|
||||
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 })
|
||||
// Two entries per annotated scan: its own, plus the graph listing it shares with probe-free callers.
|
||||
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
|
||||
|
||||
const freshScan = listWorktrees('/repo')
|
||||
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
|
||||
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 4, generations: 1 })
|
||||
|
||||
scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n')
|
||||
expect((await freshScan)[0]?.path).toBe('/repo-new')
|
||||
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 })
|
||||
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
|
||||
|
||||
scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n')
|
||||
expect((await staleScan)[0]?.path).toBe('/repo')
|
||||
@@ -396,7 +420,7 @@ describe('listWorktrees in-flight sharing', () => {
|
||||
const newestScan = listWorktrees('/repo')
|
||||
|
||||
expect(listCalls).toBe(3)
|
||||
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 3, generations: 1 })
|
||||
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 6, generations: 1 })
|
||||
|
||||
scanResolvers[0]?.()
|
||||
scanResolvers[2]?.()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import {
|
||||
annotateSparseCheckoutStatus,
|
||||
listWorktreeGraph as listWorktreeGraphUnshared,
|
||||
listWorktreesStrict as listWorktreesStrictUnshared,
|
||||
listWorktreesUnshared
|
||||
listWorktreesStrict as listWorktreesStrictUnshared
|
||||
} from './worktree-listing'
|
||||
import type { GitWorktreeExecOptions } from './worktree-operation-options'
|
||||
import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options'
|
||||
@@ -90,6 +90,22 @@ function shareWorktreeScan(
|
||||
return scan
|
||||
}
|
||||
|
||||
/**
|
||||
* Sparse annotation layered over the shared graph scan rather than its own `git worktree list`.
|
||||
*
|
||||
* Both paths soften a Git failure to `[]`, so they can share one listing; only this one pays the
|
||||
* per-worktree sparse probe. That lets a caller which reads just `worktree.path` skip the probes
|
||||
* without costing a second subprocess when it overlaps a badge reader — the two ran Git twice
|
||||
* before. Strict stays on its own scan because it must be able to reject.
|
||||
*/
|
||||
async function runAnnotatedWorktreeScan(
|
||||
repoPath: string,
|
||||
options: GitWorktreeExecOptions
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
const worktrees = await listWorktreeGraph(repoPath, options)
|
||||
return annotateSparseCheckoutStatus(repoPath, worktrees, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* List all worktrees for a git repo at the given path. Concurrent calls for
|
||||
* the same repo share one scan (unless the caller passes an AbortSignal,
|
||||
@@ -99,7 +115,7 @@ export function listWorktrees(
|
||||
repoPath: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
return shareWorktreeScan(repoPath, options, 'lenient', listWorktreesUnshared)
|
||||
return shareWorktreeScan(repoPath, options, 'lenient', runAnnotatedWorktreeScan)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-boots
|
||||
import { initializeMainProcessReady } from './startup/main-process-ready'
|
||||
import { installMainProcessQuitHandlers } from './startup/main-process-quit'
|
||||
import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock'
|
||||
import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files'
|
||||
|
||||
function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow {
|
||||
return openMainWindowController(options)
|
||||
@@ -27,6 +28,7 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
|
||||
state.skillShareDeepLinks.capture(argv, (shareId) => {
|
||||
state.mainWindow?.webContents.send('ui:openSkillShare', shareId)
|
||||
})
|
||||
state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)
|
||||
// Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935).
|
||||
if (!shouldActivateDesktopForSecondInstance(argv)) {
|
||||
return
|
||||
@@ -34,6 +36,39 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
|
||||
state.desktopActivationGate?.requestActivation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands buffered OS-opened markdown paths to a renderer that has proven it is listening.
|
||||
*
|
||||
* Until that proof arrives the paths stay buffered, because `webContents.send` to a renderer
|
||||
* with no listener attached is dropped silently and the queue would be gone.
|
||||
*/
|
||||
function publishOsOpenedMarkdownFiles(): void {
|
||||
const targetWindow = state.mainWindow
|
||||
if (!state.markdownFileOpenListenerReady || !targetWindow || targetWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
// Why consumed before the await: a renderer pull racing this resolve must not take the same
|
||||
// batch again. The restore() calls hand it back if delivery turns out to be impossible.
|
||||
const filePaths = state.osOpenedMarkdownFiles.consume()
|
||||
if (filePaths.length === 0) {
|
||||
return
|
||||
}
|
||||
void resolveOpenedMarkdownDocuments(filePaths)
|
||||
.then((documents) => {
|
||||
if (targetWindow.isDestroyed() || targetWindow.webContents.isDestroyed()) {
|
||||
state.osOpenedMarkdownFiles.restore(filePaths)
|
||||
return
|
||||
}
|
||||
if (documents.length > 0) {
|
||||
targetWindow.webContents.send('ui:openMarkdownFiles', documents)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
state.osOpenedMarkdownFiles.restore(filePaths)
|
||||
console.warn('[os-open] Failed to resolve OS-opened markdown files:', error)
|
||||
})
|
||||
}
|
||||
|
||||
const handleMacAppActivation = createMacAppActivationHandler({
|
||||
getWindow: () => state.mainWindow,
|
||||
requestActivation: requestDesktopActivation
|
||||
@@ -53,7 +88,22 @@ if (preflightReady) {
|
||||
event.preventDefault()
|
||||
requestDesktopActivation([url])
|
||||
})
|
||||
// Why: macOS delivers "Open With" as open-file, often before `ready`, and only to a handler
|
||||
// that claims the event. Non-markdown paths stay unclaimed so the OS default handler wins.
|
||||
app.on('open-file', (event, filePath) => {
|
||||
if (!state.osOpenedMarkdownFiles.captureFilePaths([filePath], publishOsOpenedMarkdownFiles)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
// Why gated on isReady: pre-ready the cold-start window is already on its way, and
|
||||
// activating the gate here would try to open one before Electron can.
|
||||
if (app.isReady()) {
|
||||
requestDesktopActivation()
|
||||
}
|
||||
})
|
||||
state.skillShareDeepLinks.capture(process.argv)
|
||||
// Why no publish: nothing is listening this early, so the first renderer pulls these on mount.
|
||||
state.osOpenedMarkdownFiles.capture(process.argv)
|
||||
registerMainProcessIpcHandlers()
|
||||
installMainProcessQuitHandlers()
|
||||
void app.whenReady().then(async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as RepoWorktrees from '../repo-worktrees'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import { listRepoWorktreeGraph } from '../repo-worktrees'
|
||||
import type { Store } from '../persistence'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ vi.mock('node:fs/promises', async () => {
|
||||
|
||||
vi.mock('../repo-worktrees', async () => {
|
||||
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
|
||||
return { ...actual, listRepoWorktrees: vi.fn() }
|
||||
return { ...actual, listRepoWorktreeGraph: vi.fn() }
|
||||
})
|
||||
|
||||
const repo: Repo = {
|
||||
@@ -56,10 +56,10 @@ describe('recovered worktree root pruning', () => {
|
||||
beforeEach(() => {
|
||||
invalidateAuthorizedRootsCache()
|
||||
__resetCreatedWorktreeRootsForTests()
|
||||
vi.mocked(listRepoWorktrees).mockReset()
|
||||
vi.mocked(listRepoWorktreeGraph).mockReset()
|
||||
// The #16520 outage itself: `listWorktrees` softens every Git failure to `[]`, so the rebuild
|
||||
// reports success with the recovered row missing and the probe is the only remaining evidence.
|
||||
vi.mocked(listRepoWorktrees).mockResolvedValue([])
|
||||
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
|
||||
statMock.mockReset()
|
||||
statMock.mockResolvedValue({})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join, resolve } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
import type * as RepoWorktrees from '../repo-worktrees'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import { listRepoWorktreeGraph } from '../repo-worktrees'
|
||||
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../shared/project-group-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
@@ -29,7 +29,7 @@ vi.mock('../repo-worktrees', async () => {
|
||||
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
|
||||
return {
|
||||
...actual,
|
||||
listRepoWorktrees: vi.fn()
|
||||
listRepoWorktreeGraph: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('filesystem auth worktree roots', () => {
|
||||
beforeEach(() => {
|
||||
invalidateAuthorizedRootsCache()
|
||||
__resetCreatedWorktreeRootsForTests()
|
||||
vi.mocked(listRepoWorktrees).mockReset()
|
||||
vi.mocked(listRepoWorktreeGraph).mockReset()
|
||||
})
|
||||
|
||||
it('rebuilds the authorized roots cache for large worktree lists', async () => {
|
||||
@@ -112,7 +112,7 @@ describe('filesystem auth worktree roots', () => {
|
||||
isMainWorktree: false
|
||||
})
|
||||
)
|
||||
vi.mocked(listRepoWorktrees).mockResolvedValue(worktrees)
|
||||
vi.mocked(listRepoWorktreeGraph).mockResolvedValue(worktrees)
|
||||
const store = makeStore()
|
||||
|
||||
await rebuildAuthorizedRootsCache(store)
|
||||
@@ -121,7 +121,7 @@ describe('filesystem auth worktree roots', () => {
|
||||
await expect(resolveRegisteredWorktreePath(lastWorktreePath, store)).resolves.toBe(
|
||||
resolve(lastWorktreePath)
|
||||
)
|
||||
expect(listRepoWorktrees).toHaveBeenCalledTimes(1)
|
||||
expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("keeps a repo's roots when its listing fails mid-rebuild", async () => {
|
||||
@@ -129,7 +129,7 @@ describe('filesystem auth worktree roots', () => {
|
||||
// a worktree a create just recovered without a listing (#16520).
|
||||
const store = makeStore()
|
||||
registerCreatedWorktreeRoot(store, repo.id, '/linked/recovered')
|
||||
vi.mocked(listRepoWorktrees).mockRejectedValue(new Error('git worktree list failed.'))
|
||||
vi.mocked(listRepoWorktreeGraph).mockRejectedValue(new Error('git worktree list failed.'))
|
||||
|
||||
await rebuildAuthorizedRootsCache(store)
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('filesystem auth worktree roots', () => {
|
||||
await mkdir(recovered)
|
||||
const store = makeStore()
|
||||
registerCreatedWorktreeRoot(store, repo.id, recovered)
|
||||
vi.mocked(listRepoWorktrees).mockResolvedValue([])
|
||||
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
|
||||
|
||||
await rebuildAuthorizedRootsCache(store)
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('filesystem auth worktree roots', () => {
|
||||
await mkdir(recovered)
|
||||
const store = makeStore()
|
||||
// Register mid-listing: the rebuild's own result was computed before this worktree existed.
|
||||
vi.mocked(listRepoWorktrees).mockImplementation(async () => {
|
||||
vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => {
|
||||
registerCreatedWorktreeRoot(store, repo.id, recovered)
|
||||
return []
|
||||
})
|
||||
@@ -174,7 +174,7 @@ describe('filesystem auth worktree roots', () => {
|
||||
it('retires a recovered root once the listing can see it again', async () => {
|
||||
const store = makeStore()
|
||||
registerCreatedWorktreeRoot(store, repo.id, '/linked/feature')
|
||||
vi.mocked(listRepoWorktrees).mockResolvedValue([
|
||||
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([
|
||||
{
|
||||
path: '/linked/feature',
|
||||
head: '',
|
||||
@@ -189,7 +189,7 @@ describe('filesystem auth worktree roots', () => {
|
||||
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).resolves.toBe(
|
||||
resolve('/linked/feature')
|
||||
)
|
||||
vi.mocked(listRepoWorktrees).mockResolvedValue([])
|
||||
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
|
||||
await rebuildAuthorizedRootsCache(store)
|
||||
|
||||
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).rejects.toThrow(
|
||||
@@ -205,7 +205,7 @@ describe('filesystem auth worktree roots', () => {
|
||||
}))
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
vi.mocked(listRepoWorktrees).mockImplementation(async () => {
|
||||
vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => {
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
@@ -215,7 +215,7 @@ describe('filesystem auth worktree roots', () => {
|
||||
|
||||
await rebuildAuthorizedRootsCache(makeStore(repos))
|
||||
|
||||
expect(listRepoWorktrees).toHaveBeenCalledTimes(repos.length)
|
||||
expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(repos.length)
|
||||
expect(maxActive).toBeLessThanOrEqual(8)
|
||||
})
|
||||
})
|
||||
@@ -392,7 +392,7 @@ describe('filesystem-auth path containment', () => {
|
||||
vi.resetModules()
|
||||
vi.doMock('../repo-worktrees', () => ({
|
||||
isRepoRoot: vi.fn(),
|
||||
listRepoWorktrees: vi.fn()
|
||||
listRepoWorktreeGraph: vi.fn()
|
||||
}))
|
||||
vi.doMock('path', async () => {
|
||||
const path = await vi.importActual<typeof NodePath>('node:path')
|
||||
|
||||
@@ -107,6 +107,7 @@ export const gitStatusModuleMock = {
|
||||
export const gitIgnoredPathsMock = { checkIgnoredPaths: checkIgnoredPathsMock }
|
||||
|
||||
export const gitWorktreeMock = {
|
||||
listWorktreeGraph: listWorktreesMock,
|
||||
listWorktrees: listWorktreesMock,
|
||||
listWorktreesStrict: listWorktreesMock
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/comm
|
||||
import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation'
|
||||
import { resolve } from 'node:path'
|
||||
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
|
||||
import { listRepoWorktrees } from '../../repo-worktrees'
|
||||
import { listRepoWorktreeGraph } from '../../repo-worktrees'
|
||||
import { resolveAuthorizedPath } from '../filesystem-auth'
|
||||
import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache'
|
||||
import { splitWorktreeId } from '../../../shared/worktree/id'
|
||||
@@ -77,7 +77,7 @@ async function localRepoOwnsWorktree(
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const worktrees = await listRepoWorktrees(repo)
|
||||
const worktrees = await listRepoWorktreeGraph(repo)
|
||||
return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path)))
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache'
|
||||
import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options'
|
||||
import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation'
|
||||
import {
|
||||
materializeWorktreePushTargetRemote,
|
||||
materializeWorktreePushTargetRemoteSsh
|
||||
} from '../../worktree-remote'
|
||||
import type { FilesystemHandlerContext } from '../filesystem-handler-context'
|
||||
|
||||
export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandlerContext): void {
|
||||
@@ -20,6 +24,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
|
||||
_event,
|
||||
args: {
|
||||
worktreePath: string
|
||||
worktreeId?: string
|
||||
publish?: boolean
|
||||
forceWithLease?: boolean
|
||||
connectionId?: string
|
||||
@@ -36,7 +41,18 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
|
||||
if (!provider) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return provider.pushBranch(args.worktreePath, publish, args.pushTarget, {
|
||||
// Why: a fork remote deferred at create time (#17828) must exist before push.
|
||||
const materializedPushTarget = args.pushTarget
|
||||
? await materializeWorktreePushTargetRemoteSsh(
|
||||
provider,
|
||||
args.worktreePath,
|
||||
args.pushTarget,
|
||||
store,
|
||||
undefined,
|
||||
args.worktreeId
|
||||
)
|
||||
: undefined
|
||||
return provider.pushBranch(args.worktreePath, publish, materializedPushTarget, {
|
||||
forceWithLease: args.forceWithLease === true
|
||||
})
|
||||
}
|
||||
@@ -46,13 +62,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
|
||||
args.worktreePath,
|
||||
worktreePath
|
||||
)
|
||||
if (args.pushTarget) {
|
||||
await validateGitPushTarget(worktreePath, args.pushTarget, {
|
||||
const materializedPushTarget = args.pushTarget
|
||||
? await materializeWorktreePushTargetRemote(
|
||||
worktreePath,
|
||||
args.pushTarget,
|
||||
store,
|
||||
undefined,
|
||||
gitOptions,
|
||||
args.worktreeId
|
||||
)
|
||||
: undefined
|
||||
if (materializedPushTarget) {
|
||||
await validateGitPushTarget(worktreePath, materializedPushTarget, {
|
||||
...gitOptions,
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
}
|
||||
await gitPush(worktreePath, publish, args.pushTarget, {
|
||||
await gitPush(worktreePath, publish, materializedPushTarget, {
|
||||
forceWithLease: args.forceWithLease === true,
|
||||
...gitOptions,
|
||||
admissionTier: 'interactive'
|
||||
@@ -64,7 +90,12 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
|
||||
'git:pull',
|
||||
async (
|
||||
_event,
|
||||
args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget }
|
||||
args: {
|
||||
worktreePath: string
|
||||
worktreeId?: string
|
||||
connectionId?: string
|
||||
pushTarget?: GitPushTarget
|
||||
}
|
||||
): Promise<void> => {
|
||||
if (args.connectionId) {
|
||||
if (args.pushTarget) {
|
||||
@@ -74,7 +105,17 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
|
||||
if (!provider) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return provider.pullBranch(args.worktreePath, args.pushTarget)
|
||||
const materializedPushTarget = args.pushTarget
|
||||
? await materializeWorktreePushTargetRemoteSsh(
|
||||
provider,
|
||||
args.worktreePath,
|
||||
args.pushTarget,
|
||||
store,
|
||||
undefined,
|
||||
args.worktreeId
|
||||
)
|
||||
: undefined
|
||||
return provider.pullBranch(args.worktreePath, materializedPushTarget)
|
||||
}
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
||||
@@ -82,13 +123,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
|
||||
args.worktreePath,
|
||||
worktreePath
|
||||
)
|
||||
if (args.pushTarget) {
|
||||
await validateGitPushTarget(worktreePath, args.pushTarget, {
|
||||
const materializedPushTarget = args.pushTarget
|
||||
? await materializeWorktreePushTargetRemote(
|
||||
worktreePath,
|
||||
args.pushTarget,
|
||||
store,
|
||||
undefined,
|
||||
gitOptions,
|
||||
args.worktreeId
|
||||
)
|
||||
: undefined
|
||||
if (materializedPushTarget) {
|
||||
await validateGitPushTarget(worktreePath, materializedPushTarget, {
|
||||
...gitOptions,
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
}
|
||||
await gitPull(worktreePath, args.pushTarget, {
|
||||
await gitPull(worktreePath, materializedPushTarget, {
|
||||
...gitOptions,
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
@@ -99,7 +150,12 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
|
||||
'git:fastForward',
|
||||
async (
|
||||
_event,
|
||||
args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget }
|
||||
args: {
|
||||
worktreePath: string
|
||||
worktreeId?: string
|
||||
connectionId?: string
|
||||
pushTarget?: GitPushTarget
|
||||
}
|
||||
): Promise<void> => {
|
||||
if (args.connectionId) {
|
||||
if (args.pushTarget) {
|
||||
@@ -109,7 +165,17 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
|
||||
if (!provider) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return provider.fastForwardBranch(args.worktreePath, args.pushTarget)
|
||||
const materializedPushTarget = args.pushTarget
|
||||
? await materializeWorktreePushTargetRemoteSsh(
|
||||
provider,
|
||||
args.worktreePath,
|
||||
args.pushTarget,
|
||||
store,
|
||||
undefined,
|
||||
args.worktreeId
|
||||
)
|
||||
: undefined
|
||||
return provider.fastForwardBranch(args.worktreePath, materializedPushTarget)
|
||||
}
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
||||
@@ -117,13 +183,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
|
||||
args.worktreePath,
|
||||
worktreePath
|
||||
)
|
||||
if (args.pushTarget) {
|
||||
await validateGitPushTarget(worktreePath, args.pushTarget, {
|
||||
const materializedPushTarget = args.pushTarget
|
||||
? await materializeWorktreePushTargetRemote(
|
||||
worktreePath,
|
||||
args.pushTarget,
|
||||
store,
|
||||
undefined,
|
||||
gitOptions,
|
||||
args.worktreeId
|
||||
)
|
||||
: undefined
|
||||
if (materializedPushTarget) {
|
||||
await validateGitPushTarget(worktreePath, materializedPushTarget, {
|
||||
...gitOptions,
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
}
|
||||
await gitFastForward(worktreePath, args.pushTarget, {
|
||||
await gitFastForward(worktreePath, materializedPushTarget, {
|
||||
...gitOptions,
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
|
||||
@@ -17,6 +17,10 @@ import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-c
|
||||
import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options'
|
||||
import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation'
|
||||
import { validateGitForkSyncExpectedUpstream } from '../../../../shared/git-fork-sync'
|
||||
import {
|
||||
materializeWorktreePushTargetRemote,
|
||||
materializeWorktreePushTargetRemoteSsh
|
||||
} from '../../worktree-remote'
|
||||
import type { FilesystemHandlerContext } from '../filesystem-handler-context'
|
||||
|
||||
export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext): void {
|
||||
@@ -52,7 +56,12 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext)
|
||||
'git:fetch',
|
||||
async (
|
||||
_event,
|
||||
args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget }
|
||||
args: {
|
||||
worktreePath: string
|
||||
worktreeId?: string
|
||||
connectionId?: string
|
||||
pushTarget?: GitPushTarget
|
||||
}
|
||||
): Promise<void> => {
|
||||
if (args.connectionId) {
|
||||
if (args.pushTarget) {
|
||||
@@ -62,7 +71,17 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext)
|
||||
if (!provider) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return provider.fetchRemote(args.worktreePath, args.pushTarget)
|
||||
const materializedPushTarget = args.pushTarget
|
||||
? await materializeWorktreePushTargetRemoteSsh(
|
||||
provider,
|
||||
args.worktreePath,
|
||||
args.pushTarget,
|
||||
store,
|
||||
undefined,
|
||||
args.worktreeId
|
||||
)
|
||||
: undefined
|
||||
return provider.fetchRemote(args.worktreePath, materializedPushTarget)
|
||||
}
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
||||
@@ -70,13 +89,23 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext)
|
||||
args.worktreePath,
|
||||
worktreePath
|
||||
)
|
||||
if (args.pushTarget) {
|
||||
await validateGitPushTarget(worktreePath, args.pushTarget, {
|
||||
const materializedPushTarget = args.pushTarget
|
||||
? await materializeWorktreePushTargetRemote(
|
||||
worktreePath,
|
||||
args.pushTarget,
|
||||
store,
|
||||
undefined,
|
||||
gitOptions,
|
||||
args.worktreeId
|
||||
)
|
||||
: undefined
|
||||
if (materializedPushTarget) {
|
||||
await validateGitPushTarget(worktreePath, materializedPushTarget, {
|
||||
...gitOptions,
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
}
|
||||
await gitFetch(worktreePath, args.pushTarget, {
|
||||
await gitFetch(worktreePath, materializedPushTarget, {
|
||||
...gitOptions,
|
||||
admissionTier: 'interactive'
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ const {
|
||||
getHostedReviewCreationEligibilityMock,
|
||||
getHostedReviewForBranchMock,
|
||||
resolveRegisteredWorktreePathMock,
|
||||
listRepoWorktreesMock
|
||||
listRepoWorktreeGraphMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
createHostedReviewMock: vi.fn(),
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
getHostedReviewCreationEligibilityMock: vi.fn(),
|
||||
getHostedReviewForBranchMock: vi.fn(),
|
||||
resolveRegisteredWorktreePathMock: vi.fn(),
|
||||
listRepoWorktreesMock: vi.fn()
|
||||
listRepoWorktreeGraphMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -52,7 +52,7 @@ vi.mock('./registered-worktree-roots-cache', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../repo-worktrees', () => ({
|
||||
listRepoWorktrees: listRepoWorktreesMock
|
||||
listRepoWorktreeGraph: listRepoWorktreeGraphMock
|
||||
}))
|
||||
|
||||
import { registerHostedReviewHandlers } from './hosted-review'
|
||||
@@ -97,7 +97,7 @@ describe('registerHostedReviewHandlers', () => {
|
||||
getHostedReviewCreationEligibilityMock.mockReset()
|
||||
getHostedReviewForBranchMock.mockReset()
|
||||
resolveRegisteredWorktreePathMock.mockReset()
|
||||
listRepoWorktreesMock.mockReset()
|
||||
listRepoWorktreeGraphMock.mockReset()
|
||||
store.getRepo.mockReset()
|
||||
store.getRepos.mockReset()
|
||||
store.getProjects.mockReset()
|
||||
@@ -114,7 +114,7 @@ describe('registerHostedReviewHandlers', () => {
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getProjects.mockReturnValue([])
|
||||
store.getSettings.mockReturnValue({ localWindowsRuntimeDefault: { kind: 'windows-host' } })
|
||||
listRepoWorktreesMock.mockResolvedValue([{ path: worktreePath }])
|
||||
listRepoWorktreeGraphMock.mockResolvedValue([{ path: worktreePath }])
|
||||
})
|
||||
|
||||
it('routes local WSL project review creation through main-process runtime options', async () => {
|
||||
@@ -143,7 +143,7 @@ describe('registerHostedReviewHandlers', () => {
|
||||
])
|
||||
const resolvedWorktreePath = resolve('/workspace/feature')
|
||||
resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath)
|
||||
listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }])
|
||||
listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }])
|
||||
createHostedReviewMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
number: 42,
|
||||
@@ -162,7 +162,7 @@ describe('registerHostedReviewHandlers', () => {
|
||||
title: 'Feature PR'
|
||||
})
|
||||
|
||||
expect(listRepoWorktreesMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' })
|
||||
expect(listRepoWorktreeGraphMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' })
|
||||
expect(createHostedReviewMock).toHaveBeenCalledWith(
|
||||
resolvedWorktreePath,
|
||||
expect.objectContaining({
|
||||
@@ -193,7 +193,7 @@ describe('registerHostedReviewHandlers', () => {
|
||||
store.getRepos.mockReturnValue([localRepo])
|
||||
const resolvedWorktreePath = resolve('/workspace/feature')
|
||||
resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath)
|
||||
listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }])
|
||||
listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }])
|
||||
createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, url: 'https://x/1' })
|
||||
|
||||
registerHostedReviewHandlers(store as never, stats as never)
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { createStackedHostedReview } from '../source-control/stacked-hosted-review-creation'
|
||||
import { getHostedReviewForBranch } from '../source-control/hosted-review'
|
||||
import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import { listRepoWorktreeGraph } from '../repo-worktrees'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
@@ -68,7 +68,7 @@ async function resolveHostedReviewWorktreePath(
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const remoteWorktreePath = normalizeRemoteHostedReviewPath(worktreePath)
|
||||
const repoWorktrees = await listRepoWorktrees(repo)
|
||||
const repoWorktrees = await listRepoWorktreeGraph(repo)
|
||||
if (
|
||||
!repoWorktrees.some(
|
||||
(worktree) => normalizeRemoteHostedReviewPath(worktree.path) === remoteWorktreePath
|
||||
@@ -82,8 +82,8 @@ async function resolveHostedReviewWorktreePath(
|
||||
const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
|
||||
const repoWorktrees =
|
||||
Object.keys(localGitOptions).length > 0
|
||||
? await listRepoWorktrees(repo, localGitOptions)
|
||||
: await listRepoWorktrees(repo)
|
||||
? await listRepoWorktreeGraph(repo, localGitOptions)
|
||||
: await listRepoWorktreeGraph(repo)
|
||||
if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) {
|
||||
throw new Error('Access denied: worktree does not belong to repository')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Real-binary coverage for #17828's remaining gap: the mocked-underlying-trigger suite in
|
||||
// `spawn-push-target-materialization.test.ts` proves the wiring/delegation logic, but not
|
||||
// that a `pty:spawn`-originated terminal -- the desktop GUI's own terminal path, previously
|
||||
// uncovered -- actually ends up with a configured upstream against real git. No mocks here:
|
||||
// this exercises the real `triggerTerminalSpawnPushTargetMaterialization` and real
|
||||
// `materializeWorktreePushTargetRemote`, driven only through `runPtyIpcSpawn`'s hook.
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitPushTarget } from '../../../../shared/worktree/types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
import type { Store } from '../../../persistence'
|
||||
import type { PtySpawnIpcDeps } from './spawn-types'
|
||||
import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const REPO_ID = 'repo-1'
|
||||
const FORK_REMOTE = 'pr-contributor-orca'
|
||||
const TRACKED_BRANCH = 'contributor/fix'
|
||||
|
||||
let scratchDir = ''
|
||||
let repoPath = ''
|
||||
let forkPath = ''
|
||||
let worktreeId = ''
|
||||
let mainBranch = ''
|
||||
|
||||
async function git(args: string[], cwd: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync('git', args, { cwd })
|
||||
return stdout
|
||||
}
|
||||
|
||||
async function setIdentity(cwd: string): Promise<void> {
|
||||
await git(['config', 'user.name', 'Orca Test'], cwd)
|
||||
await git(['config', 'user.email', 'orca@example.test'], cwd)
|
||||
await git(['config', 'commit.gpgSign', 'false'], cwd)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// realpath: macOS hands out /var/... temp paths while Git reports /private/var/...
|
||||
scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-pty-spawn-push-target-')))
|
||||
repoPath = join(scratchDir, 'repo')
|
||||
forkPath = join(scratchDir, 'fork')
|
||||
worktreeId = `${REPO_ID}::${repoPath}`
|
||||
|
||||
await mkdir(repoPath, { recursive: true })
|
||||
await git(['init', '-q'], repoPath)
|
||||
await setIdentity(repoPath)
|
||||
await writeFile(join(repoPath, 'seed.txt'), 'seed\n')
|
||||
await git(['add', '-A'], repoPath)
|
||||
await git(['commit', '-qm', 'seed'], repoPath)
|
||||
mainBranch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).trim()
|
||||
|
||||
await git(['clone', '-q', repoPath, forkPath], scratchDir)
|
||||
await setIdentity(forkPath)
|
||||
await git(['checkout', '-qb', TRACKED_BRANCH], forkPath)
|
||||
await writeFile(join(forkPath, 'fix.txt'), 'fix\n')
|
||||
await git(['add', '-A'], forkPath)
|
||||
await git(['commit', '-qm', 'fix'], forkPath)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(scratchDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function forkTarget(): GitPushTarget {
|
||||
return { remoteName: FORK_REMOTE, branchName: TRACKED_BRANCH, remoteUrl: forkPath }
|
||||
}
|
||||
|
||||
function depsFor(
|
||||
pushTarget: GitPushTarget,
|
||||
setWorktreeMeta?: Store['setWorktreeMeta']
|
||||
): {
|
||||
deps: PtySpawnIpcDeps
|
||||
meta: Record<string, WorktreeMeta>
|
||||
} {
|
||||
const meta: Record<string, WorktreeMeta> = { [worktreeId]: { pushTarget } as WorktreeMeta }
|
||||
const store = {
|
||||
getWorktreeMeta: (id: string) => meta[id],
|
||||
getRepo: (id: string) => ({ id, path: repoPath, connectionId: null }) as unknown as Repo,
|
||||
getAllWorktreeMeta: () => meta,
|
||||
...(setWorktreeMeta ? { setWorktreeMeta } : {})
|
||||
} as unknown as Store
|
||||
return { deps: { store } as unknown as PtySpawnIpcDeps, meta }
|
||||
}
|
||||
|
||||
describe('triggerPtySpawnPushTargetMaterialization (real git fixture)', () => {
|
||||
it('materializes the fork remote and configures the upstream for a pty:spawn-originated terminal', async () => {
|
||||
// Why: not just that materialization was *called* -- the coordinator's bar for closing
|
||||
// the gap is a real, git-verified configured upstream reachable from a pty:spawn arg set.
|
||||
// Pre-seeds the remote so materialize takes the short-circuit branch (worktree-remote.ts):
|
||||
// real `remote add`/`fetch` against a fabricated fork is already covered against real git by
|
||||
// worktree-push-target-refspec-real-git.test.ts; the top-level entry point this hook calls
|
||||
// additionally validates `remoteUrl` against a GitHub URL shape, which a local fixture path
|
||||
// can never satisfy. The short-circuit is also the common case in practice -- every pty:spawn
|
||||
// after the worktree's first (new tab, split, reattach) -- and still drives real
|
||||
// `ensureRemoteTracksBranchNarrowly` / narrow `fetch` / `--set-upstream-to` git calls.
|
||||
await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath)
|
||||
const { deps } = depsFor(forkTarget())
|
||||
|
||||
triggerPtySpawnPushTargetMaterialization(deps, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId
|
||||
})
|
||||
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const upstream = await git(
|
||||
['rev-parse', '--abbrev-ref', `${mainBranch}@{u}`],
|
||||
repoPath
|
||||
).catch(() => '')
|
||||
expect(upstream.trim()).toBe(`${FORK_REMOTE}/${TRACKED_BRANCH}`)
|
||||
},
|
||||
{ timeout: 5000, interval: 25 }
|
||||
)
|
||||
|
||||
const remoteUrl = (await git(['remote', 'get-url', FORK_REMOTE], repoPath)).trim()
|
||||
expect(remoteUrl).toBe(forkPath)
|
||||
|
||||
// The tracked branch's commit must actually be present -- confirms the narrow fetch ran,
|
||||
// not just that the remote config was written.
|
||||
const forkHead = (await git(['rev-parse', TRACKED_BRANCH], forkPath)).trim()
|
||||
const fetchedHead = (
|
||||
await git(['rev-parse', `${FORK_REMOTE}/${TRACKED_BRANCH}`], repoPath)
|
||||
).trim()
|
||||
expect(fetchedHead).toBe(forkHead)
|
||||
})
|
||||
|
||||
it('is a no-op once the remote was already created (repeat pty:spawn, e.g. reattach)', async () => {
|
||||
const target = { ...forkTarget(), remoteCreated: true }
|
||||
const { deps } = depsFor(target)
|
||||
await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath)
|
||||
|
||||
triggerPtySpawnPushTargetMaterialization(deps, { cols: 80, rows: 24, worktreeId })
|
||||
|
||||
// Give the fire-and-forget chain a tick; there is nothing to wait for since a
|
||||
// remoteCreated target must short-circuit before any git call.
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
const upstream = await git(['rev-parse', '--abbrev-ref', `${mainBranch}@{u}`], repoPath).catch(
|
||||
() => ''
|
||||
)
|
||||
expect(upstream.trim()).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitPushTarget } from '../../../../shared/worktree/types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
import type { Store } from '../../../persistence'
|
||||
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
|
||||
|
||||
const { triggerMock } = vi.hoisted(() => ({ triggerMock: vi.fn() }))
|
||||
vi.mock('../../../runtime/runtime-terminal-spawn-push-target-materialization', () => ({
|
||||
triggerTerminalSpawnPushTargetMaterialization: triggerMock
|
||||
}))
|
||||
|
||||
import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization'
|
||||
|
||||
const REPO_ID = 'repo-1'
|
||||
const WORKTREE_PATH = '/repo/worktree'
|
||||
const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}`
|
||||
const FORK_TARGET: GitPushTarget = {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'contributor/fix',
|
||||
remoteUrl: 'git@github.com:contributor/orca.git'
|
||||
}
|
||||
const REPO = { id: REPO_ID, path: '/repo', connectionId: null } as unknown as Repo
|
||||
|
||||
function depsWithStore(overrides: Partial<Store> = {}): PtySpawnIpcDeps {
|
||||
return {
|
||||
store: {
|
||||
getWorktreeMeta: vi.fn().mockReturnValue({ pushTarget: FORK_TARGET } as WorktreeMeta),
|
||||
getRepo: vi.fn().mockReturnValue(REPO),
|
||||
...overrides
|
||||
} as unknown as Store
|
||||
} as unknown as PtySpawnIpcDeps
|
||||
}
|
||||
|
||||
function baseArgs(overrides: Partial<PtySpawnIpcArgs> = {}): PtySpawnIpcArgs {
|
||||
return { cols: 80, rows: 24, worktreeId: WORKTREE_ID, ...overrides }
|
||||
}
|
||||
|
||||
describe('triggerPtySpawnPushTargetMaterialization', () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
triggerMock.mockReset()
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('is a no-op when args has no worktreeId', () => {
|
||||
triggerPtySpawnPushTargetMaterialization(depsWithStore(), baseArgs({ worktreeId: undefined }))
|
||||
expect(triggerMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op when deps has no store', () => {
|
||||
triggerPtySpawnPushTargetMaterialization({} as unknown as PtySpawnIpcDeps, baseArgs())
|
||||
expect(triggerMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op for a malformed worktreeId (no separator)', () => {
|
||||
triggerPtySpawnPushTargetMaterialization(
|
||||
depsWithStore(),
|
||||
baseArgs({ worktreeId: 'not-a-valid-id' })
|
||||
)
|
||||
expect(triggerMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('parses the worktreeId, looks up the push target and repo, and delegates', () => {
|
||||
const deps = depsWithStore()
|
||||
triggerPtySpawnPushTargetMaterialization(deps, baseArgs())
|
||||
|
||||
expect(deps.store!.getWorktreeMeta).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(deps.store!.getRepo).toHaveBeenCalledWith(REPO_ID)
|
||||
expect(triggerMock).toHaveBeenCalledWith(
|
||||
WORKTREE_PATH,
|
||||
FORK_TARGET,
|
||||
REPO,
|
||||
deps.store,
|
||||
REPO_ID,
|
||||
WORKTREE_ID
|
||||
)
|
||||
})
|
||||
|
||||
it('passes null when the repo lookup misses', () => {
|
||||
const deps = depsWithStore({ getRepo: vi.fn().mockReturnValue(undefined) })
|
||||
triggerPtySpawnPushTargetMaterialization(deps, baseArgs())
|
||||
|
||||
expect(triggerMock).toHaveBeenCalledWith(
|
||||
WORKTREE_PATH,
|
||||
FORK_TARGET,
|
||||
null,
|
||||
deps.store,
|
||||
REPO_ID,
|
||||
WORKTREE_ID
|
||||
)
|
||||
})
|
||||
|
||||
// Why: many pty:spawn unit tests supply a narrow fake Store missing these methods --
|
||||
// this is the actual bug the hook must guard against (#17828), not a hypothetical.
|
||||
// Optional chaining degrades the lookups to undefined/null; the underlying trigger
|
||||
// itself no-ops on an undefined push target, so this never blocks or throws on spawn.
|
||||
it('does not throw when the store lacks getWorktreeMeta/getRepo, delegating with undefined/null', () => {
|
||||
const partialStore = {} as Store
|
||||
expect(() =>
|
||||
triggerPtySpawnPushTargetMaterialization(
|
||||
{ store: partialStore } as unknown as PtySpawnIpcDeps,
|
||||
baseArgs()
|
||||
)
|
||||
).not.toThrow()
|
||||
expect(triggerMock).toHaveBeenCalledWith(
|
||||
WORKTREE_PATH,
|
||||
undefined,
|
||||
null,
|
||||
partialStore,
|
||||
REPO_ID,
|
||||
WORKTREE_ID
|
||||
)
|
||||
})
|
||||
|
||||
it('warns and swallows an error thrown by the underlying trigger', () => {
|
||||
triggerMock.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
expect(() =>
|
||||
triggerPtySpawnPushTargetMaterialization(depsWithStore(), baseArgs())
|
||||
).not.toThrow()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed to trigger push target materialization'),
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('strips a folder-workspace instance suffix from the worktree path before delegating', () => {
|
||||
const instanceId = 'a1b2c3d4-e5f6-4789-a012-b3c4d5e6f789'
|
||||
const deps = depsWithStore()
|
||||
const suffixedId = `${WORKTREE_ID}::workspace:${instanceId}`
|
||||
triggerPtySpawnPushTargetMaterialization(deps, baseArgs({ worktreeId: suffixedId }))
|
||||
|
||||
expect(triggerMock).toHaveBeenCalledWith(
|
||||
WORKTREE_PATH,
|
||||
FORK_TARGET,
|
||||
REPO,
|
||||
deps.store,
|
||||
REPO_ID,
|
||||
suffixedId
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id'
|
||||
import { triggerTerminalSpawnPushTargetMaterialization } from '../../../runtime/runtime-terminal-spawn-push-target-materialization'
|
||||
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
|
||||
|
||||
// Why (#17828): pty:spawn is the desktop GUI's own terminal path (new tab, split, reattach) --
|
||||
// raw git commands can run here before any Orca-driven sync, so a deferred fork-PR remote must
|
||||
// exist first. Mirrors the agent/background-terminal hook in
|
||||
// runtime-terminal-spawn-push-target-materialization.ts, which this delegates to; fire-and-forget
|
||||
// and a no-op once the remote already exists, so it is safe on every spawn including reattaches.
|
||||
export function triggerPtySpawnPushTargetMaterialization(
|
||||
deps: PtySpawnIpcDeps,
|
||||
args: PtySpawnIpcArgs
|
||||
): void {
|
||||
if (!args.worktreeId || !deps.store) {
|
||||
return
|
||||
}
|
||||
const parsed = splitWorktreeIdForFilesystem(args.worktreeId)
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
// Why: never let a partial/fake Store (many pty:spawn unit tests supply a narrow one) or an
|
||||
// unexpected lookup failure turn this best-effort hook into a spawn-blocking exception.
|
||||
try {
|
||||
const pushTarget = deps.store.getWorktreeMeta?.(args.worktreeId)?.pushTarget
|
||||
const repo = deps.store.getRepo?.(parsed.repoId) ?? null
|
||||
triggerTerminalSpawnPushTargetMaterialization(
|
||||
parsed.worktreePath,
|
||||
pushTarget,
|
||||
repo,
|
||||
deps.store,
|
||||
parsed.repoId,
|
||||
args.worktreeId
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[pty-spawn] failed to trigger push target materialization for ${args.worktreeId}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { buildPtyIpcSpawnOptions } from './spawn-options'
|
||||
import { executePtyIpcSpawn } from './spawn-execute'
|
||||
import { commitPtyIpcSpawn } from './spawn-commit'
|
||||
import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state'
|
||||
import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization'
|
||||
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
|
||||
|
||||
function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void {
|
||||
@@ -30,6 +31,7 @@ function restoreProvisionalPtySize(ctx: PtyIpcSpawnState): void {
|
||||
}
|
||||
|
||||
export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArgs) {
|
||||
triggerPtySpawnPushTargetMaterialization(deps, args)
|
||||
const ctx = createPtyIpcSpawnState(deps, args)
|
||||
const early = await beginPtyIpcSpawn(ctx)
|
||||
if (early) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resolve } from 'node:path'
|
||||
import { withTimeout } from '../../shared/promise-timeout-fallback'
|
||||
import { getErrorCode } from '../git/worktree-operation-options'
|
||||
import type { Store } from '../persistence'
|
||||
import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees'
|
||||
import { isRepoRoot, listRepoWorktreeGraph } from '../repo-worktrees'
|
||||
import { getLocalRepos } from './filesystem-allowed-roots'
|
||||
import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment'
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
|
||||
try {
|
||||
roots.push(resolve(repo.path))
|
||||
|
||||
for (const worktree of await listRepoWorktrees(repo)) {
|
||||
for (const worktree of await listRepoWorktreeGraph(repo)) {
|
||||
roots.push(resolve(worktree.path))
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('repos:add with git worktrees', () => {
|
||||
invalidateAuthorizedRootsCacheMock.mockReset()
|
||||
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('returns the tracked main checkout instead of adding its linked worktree', async () => {
|
||||
|
||||
@@ -61,8 +61,11 @@ vi.mock('fs/promises', () => ({
|
||||
rm: rmMock
|
||||
}))
|
||||
|
||||
// `availableParallelism` is read at module load by the git admission scheduler,
|
||||
// which this module graph reaches; a partial `os` mock breaks that import.
|
||||
vi.mock('os', () => ({
|
||||
homedir: homedirMock
|
||||
homedir: homedirMock,
|
||||
availableParallelism: () => 8
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
@@ -148,7 +151,7 @@ describe('repos:create', () => {
|
||||
gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
homedirMock.mockReset().mockReturnValue('/Users/alice')
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('registers the repos:create handler', () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('projectGroups IPC validation', () => {
|
||||
mockWindow.webContents.send.mockReset()
|
||||
resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot })
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('rejects malformed local project group create arguments before persistence', () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('repos:add + repos:clone', () => {
|
||||
resetLocalRepoMocks(reposMocks)
|
||||
mockWindow.webContents.send.mockReset()
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('defaults repos:add badgeColor to DEFAULT_REPO_BADGE_COLOR for folder repos', async () => {
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('repos:add + repos:clone', () => {
|
||||
resetLocalRepoMocks(reposMocks)
|
||||
mockWindow.webContents.send.mockReset()
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('projectGroups IPC validation', () => {
|
||||
mockWindow.webContents.send.mockReset()
|
||||
resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot })
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('uses completed scan ids as an allowlist for nested imports', async () => {
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('projectGroups IPC validation', () => {
|
||||
mockWindow.webContents.send.mockReset()
|
||||
resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot })
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('scans nested repositories over a connected SSH filesystem', async () => {
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('repos folder pickers', () => {
|
||||
removeHandlerMock.mockReset()
|
||||
showOpenDialogMock.mockReset()
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('registers the multi-folder picker with handler cleanup', () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('repos:getBaseRefDefault envelope', () => {
|
||||
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
|
||||
// Reset exec so a newly added test doesn't inherit the previous test's exec mock.
|
||||
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('returns { defaultBaseRef, remoteCount: 0 } for folder-mode repos', async () => {
|
||||
@@ -235,7 +235,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
|
||||
mockStore.getRepo.mockReset()
|
||||
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
|
||||
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('returns [] for a folder-mode repo without invoking the relay', async () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ const mainWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } }
|
||||
async function registerHandlersWithoutNotifier(): Promise<typeof ReposChangedNotificationModule> {
|
||||
vi.resetModules()
|
||||
const repos = await import('./repos')
|
||||
repos.registerRepoHandlers(mainWindow as never, mockStore as never)
|
||||
repos.registerRepoHandlers(mainWindow as never, mockStore as never, {} as never)
|
||||
return import('./repos/repos-changed-notification')
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('repos:getGitUsername', () => {
|
||||
mockWindow.webContents.send.mockReset()
|
||||
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('uses explicit SSH username config instead of remote author identity', async () => {
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('repos:addRemote', () => {
|
||||
})
|
||||
mockWindow.webContents.send.mockReset()
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('registers the repos:addRemote handler', () => {
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('sparse preset repo IPC handlers', () => {
|
||||
mockStore.saveSparsePreset.mockReset().mockImplementation((preset: SparsePreset) => preset)
|
||||
mockStore.removeSparsePreset.mockReset()
|
||||
|
||||
registerRepoHandlers(mainWindow as never, mockStore as never)
|
||||
registerRepoHandlers(mainWindow as never, mockStore as never, {} as never)
|
||||
})
|
||||
|
||||
it('normalizes and de-duplicates saved sparse preset directories', () => {
|
||||
|
||||
@@ -13,8 +13,13 @@ import { registerRepoFolderPickerHandlers } from './repos/repo-folder-picker-han
|
||||
import { registerRepoCloneHandlers } from './repos/repo-clone-lifecycle'
|
||||
import { registerRepoGitUsernameHandler } from './repos/repo-git-username-handler'
|
||||
import { registerBaseRefQueryHandlers } from './repos/base-ref-query-handlers'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void {
|
||||
export function registerRepoHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
store: Store,
|
||||
runtime: OrcaRuntimeService
|
||||
): void {
|
||||
// Remove previously registered handlers so we can re-register on macOS app re-activation (new window).
|
||||
ipcMain.removeHandler('repos:list')
|
||||
ipcMain.removeHandler('repos:listForExecutionHost')
|
||||
@@ -67,7 +72,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
registerProjectHostSetupHandlers(mainWindow, store)
|
||||
registerRepoCreationHandlers(mainWindow, store)
|
||||
registerProjectGroupHandlers(mainWindow, store)
|
||||
registerFolderWorkspaceHandlers(mainWindow, store)
|
||||
registerFolderWorkspaceHandlers(mainWindow, store, runtime)
|
||||
registerNestedRepoImportHandler(mainWindow, store)
|
||||
registerRepoUpdateHandler(mainWindow, store)
|
||||
registerSparsePresetHandlers(mainWindow, store)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getFolderWorkspacePathStatusForPath
|
||||
} from '../../project-groups/folder-workspace-path-status'
|
||||
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
|
||||
import type { OrcaRuntimeService } from '../../runtime/orca-runtime'
|
||||
import { notifyReposChanged } from './repos-changed-notification'
|
||||
import {
|
||||
FolderWorkspaceCreateArgs,
|
||||
@@ -18,7 +19,11 @@ import {
|
||||
parseProjectGroupIpcArgs
|
||||
} from './repo-ipc-arg-schemas'
|
||||
|
||||
export function registerFolderWorkspaceHandlers(mainWindow: BrowserWindow, store: Store): void {
|
||||
export function registerFolderWorkspaceHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
store: Store,
|
||||
runtime: OrcaRuntimeService
|
||||
): void {
|
||||
ipcMain.handle('folderWorkspaces:list', (): FolderWorkspace[] => store.getFolderWorkspaces())
|
||||
|
||||
ipcMain.handle('folderWorkspaces:getPathStatus', async (_event, rawArgs: unknown) => {
|
||||
@@ -107,16 +112,13 @@ export function registerFolderWorkspaceHandlers(mainWindow: BrowserWindow, store
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('folderWorkspaces:delete', (_event, rawArgs: unknown): boolean => {
|
||||
ipcMain.handle('folderWorkspaces:delete', async (_event, rawArgs: unknown): Promise<boolean> => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
FolderWorkspaceSelectorArgs,
|
||||
rawArgs,
|
||||
'invalid_folder_workspace_delete_args'
|
||||
)
|
||||
const deleted = store.removeFolderWorkspace(args.folderWorkspaceId)
|
||||
if (deleted) {
|
||||
notifyReposChanged(mainWindow)
|
||||
}
|
||||
return deleted
|
||||
// Why: the runtime owns PTY/browser/session teardown and notifies on success.
|
||||
return (await runtime.deleteFolderWorkspace(args.folderWorkspaceId)).deleted
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import type { SshTarget, SshTerminateSessionsResult } from '../../shared/ssh-types'
|
||||
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
|
||||
import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors'
|
||||
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
||||
@@ -60,14 +60,21 @@ async function doResetRelay(targetId: string, target: SshTarget): Promise<void>
|
||||
assertSshConnectsNotFenced()
|
||||
conn = await connectionManager!.connect(target)
|
||||
}
|
||||
let relayStopAcknowledged = false
|
||||
try {
|
||||
await forceStopRelayForTarget(conn, targetId)
|
||||
relayStopAcknowledged = true
|
||||
} finally {
|
||||
const ptyIds = new Set(getPtyIdsForConnection(targetId))
|
||||
for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) {
|
||||
if (lease.state !== 'terminated' && lease.state !== 'expired') {
|
||||
ptyIds.add(lease.ptyId)
|
||||
persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired')
|
||||
// Why: only a host-acknowledged force-stop may retire a lease. When it threw we never
|
||||
// observed those shells, so expiring them would record a verdict we do not hold; mirrors
|
||||
// ssh:terminateSessions, and the next connect re-attaches (or expires) them on evidence.
|
||||
if (relayStopAcknowledged) {
|
||||
persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired')
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: reset force-kills the remote relay, so every local PTY handle it owned is stale even if the reset command failed after SIGTERM.
|
||||
@@ -98,6 +105,9 @@ export function registerSshConnectionHandlers(): void {
|
||||
|
||||
ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => {
|
||||
invalidateConnectAttempt(args.targetId)
|
||||
// Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell
|
||||
// "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue.
|
||||
let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 }
|
||||
await runTargetLifecycle(args.targetId, async () => {
|
||||
const provider = getSshPtyProvider(args.targetId)
|
||||
const leases = persistedStore!.getSshRemotePtyLeases(args.targetId)
|
||||
@@ -142,6 +152,10 @@ export function registerSshConnectionHandlers(): void {
|
||||
)
|
||||
)
|
||||
: []
|
||||
if (!provider) {
|
||||
// Nothing observed these remote shells, so their state is unknown — not "nothing to do".
|
||||
outcome = { terminated: 0, unverifiable: ptyIds.length }
|
||||
}
|
||||
const shutdownFailures: string[] = []
|
||||
for (const [index, result] of shutdownResults.entries()) {
|
||||
const { appPtyId, relayPtyId } = ptyIds[index]
|
||||
@@ -154,6 +168,7 @@ export function registerSshConnectionHandlers(): void {
|
||||
clearProviderPtyState(appPtyId)
|
||||
deletePtyOwnership(appPtyId)
|
||||
persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated')
|
||||
outcome = { ...outcome, terminated: outcome.terminated + 1 }
|
||||
}
|
||||
if (shutdownFailures.length > 0) {
|
||||
// Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry.
|
||||
@@ -161,6 +176,7 @@ export function registerSshConnectionHandlers(): void {
|
||||
}
|
||||
await teardownSshTargetTransport(args.targetId, (session) => session.disposeAndPersist())
|
||||
})
|
||||
return outcome
|
||||
})
|
||||
|
||||
ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => {
|
||||
|
||||
@@ -77,6 +77,38 @@ describe('SSH IPC handlers', () => {
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
// A force-stop that threw observed nothing about the remote shells, so expiring their leases
|
||||
// would record a verdict Orca never obtained (docs/reference/ssh-execution-boundary.md).
|
||||
it('ssh:resetRelay keeps leases alive when the force-stop never reported a result', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue(conn)
|
||||
mockConnectionManager.getConnection.mockReturnValue(undefined)
|
||||
mockStore.getSshRemotePtyLeases.mockReturnValue([
|
||||
{ targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' },
|
||||
{ targetId: 'ssh-1', ptyId: 'pty-2', state: 'attached' }
|
||||
])
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
|
||||
mockForceStopRelayForTarget.mockRejectedValueOnce(new Error('channel closed'))
|
||||
|
||||
await expect(handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })).rejects.toThrow(
|
||||
'channel closed'
|
||||
)
|
||||
|
||||
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled()
|
||||
// The local handles are still stale — only the host-side verdict is withheld.
|
||||
expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-1')
|
||||
expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-2')
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
it('ssh:resetRelay clears scoped live PTYs while expiring raw leases', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
|
||||
@@ -95,7 +95,9 @@ describe('SSH IPC handlers', () => {
|
||||
mockPtyProvider.shutdown.mockResolvedValue(undefined)
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
|
||||
await expect(
|
||||
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
|
||||
).resolves.toEqual({ terminated: 2, unverifiable: 0 })
|
||||
|
||||
expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-live', {
|
||||
immediate: true,
|
||||
@@ -168,7 +170,9 @@ describe('SSH IPC handlers', () => {
|
||||
await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' })
|
||||
})
|
||||
|
||||
it('ssh:terminateSessions cannot reach expired leases without a relay', async () => {
|
||||
// Issue #12661: an offline sweep tears down local transport only. Reporting plain success would
|
||||
// read as "the remote shells are gone" when nobody asked the host.
|
||||
it('ssh:terminateSessions reports expired leases as unverifiable without a relay', async () => {
|
||||
mockStore.getSshRemotePtyLeases.mockReturnValue([
|
||||
{ targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' }
|
||||
])
|
||||
@@ -177,10 +181,26 @@ describe('SSH IPC handlers', () => {
|
||||
|
||||
await expect(
|
||||
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
|
||||
).resolves.toBeUndefined()
|
||||
).resolves.toEqual({ terminated: 0, unverifiable: 1 })
|
||||
|
||||
expect(mockPtyProvider.shutdown).not.toHaveBeenCalled()
|
||||
// Still no forced reconnect: an expired lease can name a host that is gone for good (#2626).
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'pty-expired',
|
||||
'terminated'
|
||||
)
|
||||
})
|
||||
|
||||
it('ssh:terminateSessions reports nothing unverifiable when there is nothing to reach', async () => {
|
||||
mockStore.getSshRemotePtyLeases.mockReturnValue([])
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue(undefined)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
|
||||
|
||||
await expect(
|
||||
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
|
||||
).resolves.toEqual({ terminated: 0, unverifiable: 0 })
|
||||
})
|
||||
|
||||
it('ssh:terminateSessions kills expired leases whose remote PTY may still be alive', async () => {
|
||||
|
||||
@@ -24,7 +24,9 @@ let storeRef: Store | null = null
|
||||
|
||||
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
|
||||
'app_starred_orca',
|
||||
'daemon_adopted',
|
||||
'daemon_audit_eligibility',
|
||||
'daemon_pty_cwd_denied',
|
||||
'star_nag_outcome',
|
||||
'feature_interaction_usage_bucket_reached'
|
||||
])
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { forEachWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
import type {
|
||||
WorktreeBaseRepoWatchConfig,
|
||||
WorktreeBaseWatchTarget
|
||||
} from './worktree-base-directory-event-filter'
|
||||
import type {
|
||||
WorktreeBasePollerOptions,
|
||||
WorktreeBasePollEvent,
|
||||
WorktreeBaseSubscription,
|
||||
WorktreePollerWindowVisibility
|
||||
} from './worktree-base-directory-poller'
|
||||
|
||||
// Why: the mtime gate is an optimization, not a correctness boundary — some
|
||||
// filesystems have coarse dir timestamps, and pending `.git` markers expire.
|
||||
// A periodic ungated scan guarantees eventual convergence.
|
||||
export const WORKTREE_BASE_BACKSTOP_TICKS = 15
|
||||
|
||||
// Why: a `.git` completion marker lands within moments of its worktree dir
|
||||
// (git writes it before populating the checkout). Dirs that never get one are
|
||||
// not worktrees; stop re-statting them after this many ticks and let the
|
||||
// backstop scan cover the pathological case.
|
||||
const PENDING_MARKER_MAX_TICKS = 300
|
||||
|
||||
// Why: matches the git-common poller's fan-out bound (#17828) — bounded
|
||||
// concurrency turns hundreds of serial round trips into a handful of batches
|
||||
// without dumping every candidate onto libuv's 4-thread pool at once.
|
||||
const MARKER_PROBE_CONCURRENCY = 8
|
||||
|
||||
function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string {
|
||||
return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}`
|
||||
}
|
||||
|
||||
async function dirSignature(path: string): Promise<string> {
|
||||
try {
|
||||
return statSignature(await stat(path))
|
||||
} catch {
|
||||
return 'missing'
|
||||
}
|
||||
}
|
||||
|
||||
async function hasGitMarker(dir: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(join(dir, '.git'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type BaseSnapshot = {
|
||||
// worktree-candidate dir → whether its `.git` completion marker exists
|
||||
markers: Map<string, boolean>
|
||||
// dirs whose listing determines the candidate set: the root plus any
|
||||
// nested repo containers. Their stat signatures gate the next full scan.
|
||||
gateDirs: string[]
|
||||
// index-aligned with gateDirs, each sampled *before* that dir's listing
|
||||
gateSignatures: string[]
|
||||
}
|
||||
|
||||
async function readdirSafe(path: string): Promise<Dirent[]> {
|
||||
try {
|
||||
return await readdir(path, { withFileTypes: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
|
||||
// repo's container, mirroring what worktree-base-directory-event-filter
|
||||
// matches: `<wt>/.git` completion markers and `<wt>` deletions.
|
||||
async function snapshotBase(
|
||||
rootPath: string,
|
||||
repos: ReadonlyMap<string, WorktreeBaseRepoWatchConfig>
|
||||
): Promise<BaseSnapshot> {
|
||||
const markers = new Map<string, boolean>()
|
||||
const gateDirs = [rootPath]
|
||||
// Why: sampling the signature before the listing makes a write that races the
|
||||
// scan look stale next tick (one redundant rescan) instead of invisible until
|
||||
// the backstop, which is up to 15 ticks of missed creates/deletes.
|
||||
const gateSignatures = [await dirSignature(rootPath)]
|
||||
const configs = [...repos.values()]
|
||||
const includeFlat = configs.some((config) => !config.nestWorkspaces)
|
||||
const nestedRepoNames = new Set(
|
||||
configs
|
||||
.filter((config) => config.nestWorkspaces)
|
||||
.map((config) => normalizeRuntimePathForComparison(config.repoName))
|
||||
)
|
||||
|
||||
// Root vanished or unreadable: readdirSafe yields [], producing the same
|
||||
// empty markers/candidates result as the old watcher's error path.
|
||||
const rootEntries = await readdirSafe(rootPath)
|
||||
|
||||
const candidates: string[] = []
|
||||
for (const entry of rootEntries) {
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
continue
|
||||
}
|
||||
const entryPath = join(rootPath, entry.name)
|
||||
if (includeFlat) {
|
||||
candidates.push(entryPath)
|
||||
}
|
||||
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
|
||||
gateDirs.push(entryPath)
|
||||
gateSignatures.push(await dirSignature(entryPath))
|
||||
const subEntries = await readdirSafe(entryPath)
|
||||
for (const sub of subEntries) {
|
||||
if (sub.isDirectory() || sub.isSymbolicLink()) {
|
||||
candidates.push(join(entryPath, sub.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => {
|
||||
markers.set(dir, await hasGitMarker(dir))
|
||||
})
|
||||
return { markers, gateDirs, gateSignatures }
|
||||
}
|
||||
|
||||
function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] {
|
||||
const events: WorktreeBasePollEvent[] = []
|
||||
for (const [dir, marker] of next.markers) {
|
||||
if (marker && prev.markers.get(dir) !== true) {
|
||||
events.push({ type: 'create', path: join(dir, '.git') })
|
||||
}
|
||||
}
|
||||
for (const dir of prev.markers.keys()) {
|
||||
if (!next.markers.has(dir)) {
|
||||
events.push({ type: 'delete', path: dir })
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
export async function startBasePoller(
|
||||
target: WorktreeBaseWatchTarget,
|
||||
getRepos: () => ReadonlyMap<string, WorktreeBaseRepoWatchConfig>,
|
||||
onEvents: (events: WorktreeBasePollEvent[]) => void,
|
||||
pollIntervalMs: number,
|
||||
visibility: WorktreePollerWindowVisibility,
|
||||
options: WorktreeBasePollerOptions
|
||||
): Promise<WorktreeBaseSubscription> {
|
||||
let disposed = false
|
||||
let ticking = false
|
||||
let tickCount = 0
|
||||
let snapshot = await snapshotBase(target.path, getRepos())
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let parkedWhileHidden = false
|
||||
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
|
||||
// dir → first probe tick; null means backstop scans only
|
||||
const markerProbeStartedAt = new Map<string, number | null>()
|
||||
for (const [dir, marker] of snapshot.markers) {
|
||||
if (!marker) {
|
||||
markerProbeStartedAt.set(dir, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const fullScan = async (): Promise<void> => {
|
||||
options.onFullScan?.()
|
||||
const next = await snapshotBase(target.path, getRepos())
|
||||
await options.onSnapshotTaken?.(tickCount)
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const events = diffBase(snapshot, next)
|
||||
for (const [dir, marker] of next.markers) {
|
||||
if (marker) {
|
||||
markerProbeStartedAt.delete(dir)
|
||||
} else if (!markerProbeStartedAt.has(dir)) {
|
||||
markerProbeStartedAt.set(dir, tickCount)
|
||||
}
|
||||
}
|
||||
for (const dir of markerProbeStartedAt.keys()) {
|
||||
if (!next.markers.has(dir)) {
|
||||
markerProbeStartedAt.delete(dir)
|
||||
}
|
||||
}
|
||||
snapshot = next
|
||||
if (events.length > 0) {
|
||||
onEvents(events)
|
||||
}
|
||||
}
|
||||
|
||||
const checkPendingMarkers = async (): Promise<void> => {
|
||||
const events: WorktreeBasePollEvent[] = []
|
||||
for (const [dir, firstSeenTick] of markerProbeStartedAt) {
|
||||
if (firstSeenTick === null) {
|
||||
continue
|
||||
}
|
||||
if (tickCount - firstSeenTick > pendingMarkerMaxTicks) {
|
||||
markerProbeStartedAt.set(dir, null)
|
||||
continue
|
||||
}
|
||||
options.onPendingMarkerProbe?.(join(dir, '.git'))
|
||||
if (await hasGitMarker(dir)) {
|
||||
markerProbeStartedAt.delete(dir)
|
||||
snapshot.markers.set(dir, true)
|
||||
events.push({ type: 'create', path: join(dir, '.git') })
|
||||
}
|
||||
}
|
||||
if (!disposed && events.length > 0) {
|
||||
onEvents(events)
|
||||
}
|
||||
}
|
||||
|
||||
const poll = async (forceFullScan = false): Promise<void> => {
|
||||
tickCount++
|
||||
if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) {
|
||||
await fullScan()
|
||||
return
|
||||
}
|
||||
// Idle fast path: when the dirs whose listings define the candidate set
|
||||
// are untouched, skip the readdir + per-candidate stat fan-out entirely.
|
||||
const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
|
||||
const gateChanged =
|
||||
signatures.length !== snapshot.gateSignatures.length ||
|
||||
signatures.some((sig, index) => sig !== snapshot.gateSignatures[index])
|
||||
if (gateChanged) {
|
||||
await fullScan()
|
||||
return
|
||||
}
|
||||
if (markerProbeStartedAt.size > 0) {
|
||||
await checkPendingMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
const tick = async (forceFullScan = false): Promise<void> => {
|
||||
timer = null
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (!visibility.isWindowVisible()) {
|
||||
parkedWhileHidden = true
|
||||
return
|
||||
}
|
||||
if (ticking) {
|
||||
return
|
||||
}
|
||||
ticking = true
|
||||
// Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not
|
||||
// gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick.
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
await poll(forceFullScan)
|
||||
} catch {
|
||||
// Transient fs error: keep the previous snapshot and retry next tick.
|
||||
} finally {
|
||||
ticking = false
|
||||
}
|
||||
if (!disposed) {
|
||||
// Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would
|
||||
// otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for
|
||||
// minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative.
|
||||
const nextDelay = Math.max(
|
||||
0,
|
||||
Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt))
|
||||
)
|
||||
timer = setTimeout(() => void tick(), nextDelay)
|
||||
timer.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeVisibility = visibility.onWindowBecameVisible(() => {
|
||||
if (disposed || !parkedWhileHidden) {
|
||||
return
|
||||
}
|
||||
parkedWhileHidden = false
|
||||
// Why: the ordinary dir-signature gate can miss same-granule changes made
|
||||
// while hidden; resume must diff a fresh full snapshot against the baseline.
|
||||
void tick(true)
|
||||
})
|
||||
|
||||
timer = setTimeout(() => void tick(), pollIntervalMs)
|
||||
timer.unref?.()
|
||||
|
||||
return {
|
||||
unsubscribe: async () => {
|
||||
disposed = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
unsubscribeVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
|
||||
import type {
|
||||
WorktreeBaseRepoWatchConfig,
|
||||
WorktreeBaseWatchTarget
|
||||
} from './worktree-base-directory-event-filter'
|
||||
|
||||
// Why: the backstop full scan stats a `.git` marker per candidate dir; an
|
||||
// unbounded fan-out at hundreds of worktrees would queue thousands of `stat`
|
||||
// calls on libuv's 4-thread pool (#17828).
|
||||
const { concurrency } = vi.hoisted(() => ({ concurrency: { current: 0, peak: 0 } }))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsPromises>()
|
||||
return {
|
||||
...actual,
|
||||
stat: async (...args: Parameters<typeof actual.stat>) => {
|
||||
concurrency.current += 1
|
||||
concurrency.peak = Math.max(concurrency.peak, concurrency.current)
|
||||
try {
|
||||
return await actual.stat(...args)
|
||||
} finally {
|
||||
concurrency.current -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeTarget(path: string): WorktreeBaseWatchTarget {
|
||||
const repoConfig: WorktreeBaseRepoWatchConfig = {
|
||||
repoId: 'repo-1',
|
||||
repoName: 'project',
|
||||
nestWorkspaces: false
|
||||
}
|
||||
return {
|
||||
key: `base:local:${path}`,
|
||||
kind: 'base',
|
||||
path,
|
||||
repos: new Map([[repoConfig.repoId, repoConfig]])
|
||||
}
|
||||
}
|
||||
|
||||
describe('worktree base directory poller marker fan-out (#17828)', () => {
|
||||
const cleanups: (() => Promise<void>)[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
concurrency.current = 0
|
||||
concurrency.peak = 0
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
|
||||
})
|
||||
|
||||
it('bounds concurrent `.git`-marker stats regardless of candidate count', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-base-poller-fanout-')))
|
||||
cleanups.push(() => rm(root, { recursive: true, force: true }))
|
||||
const candidateCount = 200
|
||||
for (let i = 0; i < candidateCount; i++) {
|
||||
const worktree = join(root, `wt-${i}`)
|
||||
await mkdir(worktree)
|
||||
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
|
||||
}
|
||||
|
||||
const target = makeTarget(root)
|
||||
const poller = await startWorktreeBaseDirectoryPoller(
|
||||
target,
|
||||
() => target.repos,
|
||||
() => {},
|
||||
{ pollIntervalMs: 100_000 }
|
||||
)
|
||||
cleanups.push(() => poller.unsubscribe())
|
||||
|
||||
// 200 candidates stated unbounded would peak near 200 concurrent `stat`
|
||||
// calls; bounding the marker probe keeps the peak independent of count —
|
||||
// while still overlapping requests (not serialized one-at-a-time).
|
||||
expect(concurrency.peak).toBeGreaterThan(1)
|
||||
expect(concurrency.peak).toBeLessThan(20)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,13 @@
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility'
|
||||
import type {
|
||||
WorktreeBaseRepoWatchConfig,
|
||||
WorktreeBaseWatchTarget
|
||||
} from './worktree-base-directory-event-filter'
|
||||
import { startBasePoller } from './worktree-base-directory-marker-poller'
|
||||
import { startGitCommonWatch } from './worktree-git-common-watch'
|
||||
|
||||
export { WORKTREE_BASE_BACKSTOP_TICKS } from './worktree-base-directory-marker-poller'
|
||||
|
||||
export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string }
|
||||
|
||||
export type WorktreeBaseSubscription = { unsubscribe: () => Promise<void> }
|
||||
@@ -61,6 +61,8 @@ export type WorktreeBasePollerOptions = {
|
||||
visibility?: WorktreePollerWindowVisibility
|
||||
getGitStatusRefPaths?: () => readonly string[]
|
||||
onWatchError?: (error: Error) => void
|
||||
/** Called when the watcher child dropped an event batch (git-common narrow watch only). */
|
||||
onOverflow?: () => void
|
||||
/** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */
|
||||
onFullScan?: () => void
|
||||
/** Test hook: called before a pending `.git` marker stat. */
|
||||
@@ -81,277 +83,6 @@ export type WorktreeBasePollerOptions = {
|
||||
// Orca's own worktree operations notify the renderer directly.
|
||||
export const WORKTREE_BASE_POLL_INTERVAL_MS = 2_000
|
||||
|
||||
// Why: the mtime gate is an optimization, not a correctness boundary — some
|
||||
// filesystems have coarse dir timestamps, and pending `.git` markers expire.
|
||||
// A periodic ungated scan guarantees eventual convergence.
|
||||
export const WORKTREE_BASE_BACKSTOP_TICKS = 15
|
||||
|
||||
// Why: a `.git` completion marker lands within moments of its worktree dir
|
||||
// (git writes it before populating the checkout). Dirs that never get one are
|
||||
// not worktrees; stop re-statting them after this many ticks and let the
|
||||
// backstop scan cover the pathological case.
|
||||
const PENDING_MARKER_MAX_TICKS = 300
|
||||
|
||||
function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string {
|
||||
return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}`
|
||||
}
|
||||
|
||||
async function dirSignature(path: string): Promise<string> {
|
||||
try {
|
||||
return statSignature(await stat(path))
|
||||
} catch {
|
||||
return 'missing'
|
||||
}
|
||||
}
|
||||
|
||||
async function hasGitMarker(dir: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(join(dir, '.git'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type BaseSnapshot = {
|
||||
// worktree-candidate dir → whether its `.git` completion marker exists
|
||||
markers: Map<string, boolean>
|
||||
// dirs whose listing determines the candidate set: the root plus any
|
||||
// nested repo containers. Their stat signatures gate the next full scan.
|
||||
gateDirs: string[]
|
||||
// index-aligned with gateDirs, each sampled *before* that dir's listing
|
||||
gateSignatures: string[]
|
||||
}
|
||||
|
||||
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
|
||||
// repo's container, mirroring what worktree-base-directory-event-filter
|
||||
// matches: `<wt>/.git` completion markers and `<wt>` deletions.
|
||||
async function snapshotBase(
|
||||
rootPath: string,
|
||||
repos: ReadonlyMap<string, WorktreeBaseRepoWatchConfig>
|
||||
): Promise<BaseSnapshot> {
|
||||
const markers = new Map<string, boolean>()
|
||||
const gateDirs = [rootPath]
|
||||
// Why: sampling the signature before the listing makes a write that races the
|
||||
// scan look stale next tick (one redundant rescan) instead of invisible until
|
||||
// the backstop, which is up to 15 ticks of missed creates/deletes.
|
||||
const gateSignatures = [await dirSignature(rootPath)]
|
||||
const configs = [...repos.values()]
|
||||
const includeFlat = configs.some((config) => !config.nestWorkspaces)
|
||||
const nestedRepoNames = new Set(
|
||||
configs
|
||||
.filter((config) => config.nestWorkspaces)
|
||||
.map((config) => normalizeRuntimePathForComparison(config.repoName))
|
||||
)
|
||||
|
||||
let rootEntries
|
||||
try {
|
||||
rootEntries = await readdir(rootPath, { withFileTypes: true })
|
||||
} catch {
|
||||
// Root vanished: an empty snapshot diffs into delete events for every
|
||||
// previously-known worktree dir, matching the old watcher's error path.
|
||||
return { markers, gateDirs, gateSignatures }
|
||||
}
|
||||
|
||||
const candidates: string[] = []
|
||||
for (const entry of rootEntries) {
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
continue
|
||||
}
|
||||
const entryPath = join(rootPath, entry.name)
|
||||
if (includeFlat) {
|
||||
candidates.push(entryPath)
|
||||
}
|
||||
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
|
||||
gateDirs.push(entryPath)
|
||||
gateSignatures.push(await dirSignature(entryPath))
|
||||
let subEntries
|
||||
try {
|
||||
subEntries = await readdir(entryPath, { withFileTypes: true })
|
||||
} catch {
|
||||
subEntries = []
|
||||
}
|
||||
for (const sub of subEntries) {
|
||||
if (sub.isDirectory() || sub.isSymbolicLink()) {
|
||||
candidates.push(join(entryPath, sub.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of candidates) {
|
||||
markers.set(dir, await hasGitMarker(dir))
|
||||
}
|
||||
return { markers, gateDirs, gateSignatures }
|
||||
}
|
||||
|
||||
function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] {
|
||||
const events: WorktreeBasePollEvent[] = []
|
||||
for (const [dir, marker] of next.markers) {
|
||||
if (marker && prev.markers.get(dir) !== true) {
|
||||
events.push({ type: 'create', path: join(dir, '.git') })
|
||||
}
|
||||
}
|
||||
for (const dir of prev.markers.keys()) {
|
||||
if (!next.markers.has(dir)) {
|
||||
events.push({ type: 'delete', path: dir })
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
async function startBasePoller(
|
||||
target: WorktreeBaseWatchTarget,
|
||||
getRepos: () => ReadonlyMap<string, WorktreeBaseRepoWatchConfig>,
|
||||
onEvents: (events: WorktreeBasePollEvent[]) => void,
|
||||
pollIntervalMs: number,
|
||||
visibility: WorktreePollerWindowVisibility,
|
||||
options: WorktreeBasePollerOptions
|
||||
): Promise<WorktreeBaseSubscription> {
|
||||
let disposed = false
|
||||
let ticking = false
|
||||
let tickCount = 0
|
||||
let snapshot = await snapshotBase(target.path, getRepos())
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let parkedWhileHidden = false
|
||||
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
|
||||
// dir → first probe tick; null means backstop scans only
|
||||
const markerProbeStartedAt = new Map<string, number | null>()
|
||||
for (const [dir, marker] of snapshot.markers) {
|
||||
if (!marker) {
|
||||
markerProbeStartedAt.set(dir, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const fullScan = async (): Promise<void> => {
|
||||
options.onFullScan?.()
|
||||
const next = await snapshotBase(target.path, getRepos())
|
||||
await options.onSnapshotTaken?.(tickCount)
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const events = diffBase(snapshot, next)
|
||||
for (const [dir, marker] of next.markers) {
|
||||
if (marker) {
|
||||
markerProbeStartedAt.delete(dir)
|
||||
} else if (!markerProbeStartedAt.has(dir)) {
|
||||
markerProbeStartedAt.set(dir, tickCount)
|
||||
}
|
||||
}
|
||||
for (const dir of markerProbeStartedAt.keys()) {
|
||||
if (!next.markers.has(dir)) {
|
||||
markerProbeStartedAt.delete(dir)
|
||||
}
|
||||
}
|
||||
snapshot = next
|
||||
if (events.length > 0) {
|
||||
onEvents(events)
|
||||
}
|
||||
}
|
||||
|
||||
const checkPendingMarkers = async (): Promise<void> => {
|
||||
const events: WorktreeBasePollEvent[] = []
|
||||
for (const [dir, firstSeenTick] of markerProbeStartedAt) {
|
||||
if (firstSeenTick === null) {
|
||||
continue
|
||||
}
|
||||
if (tickCount - firstSeenTick > pendingMarkerMaxTicks) {
|
||||
markerProbeStartedAt.set(dir, null)
|
||||
continue
|
||||
}
|
||||
options.onPendingMarkerProbe?.(join(dir, '.git'))
|
||||
if (await hasGitMarker(dir)) {
|
||||
markerProbeStartedAt.delete(dir)
|
||||
snapshot.markers.set(dir, true)
|
||||
events.push({ type: 'create', path: join(dir, '.git') })
|
||||
}
|
||||
}
|
||||
if (!disposed && events.length > 0) {
|
||||
onEvents(events)
|
||||
}
|
||||
}
|
||||
|
||||
const poll = async (forceFullScan = false): Promise<void> => {
|
||||
tickCount++
|
||||
if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) {
|
||||
await fullScan()
|
||||
return
|
||||
}
|
||||
// Idle fast path: when the dirs whose listings define the candidate set
|
||||
// are untouched, skip the readdir + per-candidate stat fan-out entirely.
|
||||
const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
|
||||
const gateChanged =
|
||||
signatures.length !== snapshot.gateSignatures.length ||
|
||||
signatures.some((sig, index) => sig !== snapshot.gateSignatures[index])
|
||||
if (gateChanged) {
|
||||
await fullScan()
|
||||
return
|
||||
}
|
||||
if (markerProbeStartedAt.size > 0) {
|
||||
await checkPendingMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
const tick = async (forceFullScan = false): Promise<void> => {
|
||||
timer = null
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (!visibility.isWindowVisible()) {
|
||||
parkedWhileHidden = true
|
||||
return
|
||||
}
|
||||
if (ticking) {
|
||||
return
|
||||
}
|
||||
ticking = true
|
||||
// Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not
|
||||
// gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick.
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
await poll(forceFullScan)
|
||||
} catch {
|
||||
// Transient fs error: keep the previous snapshot and retry next tick.
|
||||
} finally {
|
||||
ticking = false
|
||||
}
|
||||
if (!disposed) {
|
||||
// Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would
|
||||
// otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for
|
||||
// minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative.
|
||||
const nextDelay = Math.max(
|
||||
0,
|
||||
Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt))
|
||||
)
|
||||
timer = setTimeout(() => void tick(), nextDelay)
|
||||
timer.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeVisibility = visibility.onWindowBecameVisible(() => {
|
||||
if (disposed || !parkedWhileHidden) {
|
||||
return
|
||||
}
|
||||
parkedWhileHidden = false
|
||||
// Why: the ordinary dir-signature gate can miss same-granule changes made
|
||||
// while hidden; resume must diff a fresh full snapshot against the baseline.
|
||||
void tick(true)
|
||||
})
|
||||
|
||||
timer = setTimeout(() => void tick(), pollIntervalMs)
|
||||
timer.unref?.()
|
||||
|
||||
return {
|
||||
unsubscribe: async () => {
|
||||
disposed = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
unsubscribeVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Watches the shallow paths a worktree base target cares about and emits
|
||||
* watcher-shaped events. Resolves once the baseline (snapshot or narrow
|
||||
* native subscription) is established. */
|
||||
@@ -373,7 +104,8 @@ export async function startWorktreeBaseDirectoryPoller(
|
||||
visibility,
|
||||
options.onFullScan,
|
||||
options.getGitStatusRefPaths,
|
||||
options.onWatchError
|
||||
options.onWatchError,
|
||||
options.onOverflow
|
||||
)
|
||||
}
|
||||
return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
collectLocalWorktreeBaseChanges,
|
||||
collectRemoteWorktreeBaseChanges,
|
||||
hasCollectedWorktreeBaseChanges
|
||||
} from './worktree-base-directory-change-collector'
|
||||
import {
|
||||
scheduleWorktreeBaseNotification,
|
||||
type WorktreeBaseNotificationWatch
|
||||
} from './worktree-base-directory-notifications'
|
||||
import {
|
||||
invalidateActiveGitStatusRefResolution,
|
||||
invalidateGitStatusRefResolutionForPaths
|
||||
} from './worktree-git-status-ref-watch'
|
||||
import type { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown'
|
||||
|
||||
export type ActiveWatch = WorktreeBaseNotificationWatch & {
|
||||
subscription: { unsubscribe: () => Promise<void> }
|
||||
gitStatusRefPaths: Set<string>
|
||||
watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown
|
||||
}
|
||||
|
||||
export function handleLocalWatchEvents(
|
||||
watch: ActiveWatch,
|
||||
error: Error | null,
|
||||
events: { type: 'create' | 'update' | 'delete'; path: string }[],
|
||||
getActiveWatches: () => Iterable<ActiveWatch>
|
||||
): void {
|
||||
if (watch.disposed || watch.mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (error) {
|
||||
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
|
||||
invalidateActiveGitStatusRefResolution(watch, getActiveWatches)
|
||||
if (watch.watcherFailureRefresh.consume()) {
|
||||
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
|
||||
}
|
||||
return
|
||||
}
|
||||
watch.watcherFailureRefresh.reset()
|
||||
invalidateGitStatusRefResolutionForPaths(
|
||||
watch,
|
||||
events.map((event) => event.path),
|
||||
getActiveWatches
|
||||
)
|
||||
const changes = collectLocalWorktreeBaseChanges(watch, events)
|
||||
if (hasCollectedWorktreeBaseChanges(changes)) {
|
||||
scheduleWorktreeBaseNotification(watch, changes)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: after a dropped event batch nothing about the prior state can be
|
||||
// trusted — widen unconditionally (structural + status + head-identity),
|
||||
// same shape as the remote overflow branch below, bypassing the watcher-error
|
||||
// cooldown so a burst of overflows during one bulk op cannot suppress the
|
||||
// refresh the fleet actually needs.
|
||||
export function handleWatchOverflow(
|
||||
watch: ActiveWatch,
|
||||
getActiveWatches: () => Iterable<ActiveWatch>
|
||||
): void {
|
||||
if (watch.disposed || watch.mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
invalidateActiveGitStatusRefResolution(watch, getActiveWatches)
|
||||
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
|
||||
}
|
||||
|
||||
export function handleRemoteWatchEvents(
|
||||
watch: ActiveWatch,
|
||||
events: Parameters<typeof collectRemoteWorktreeBaseChanges>[1],
|
||||
getActiveWatches: () => Iterable<ActiveWatch>
|
||||
): void {
|
||||
if (watch.disposed || watch.mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
invalidateGitStatusRefResolutionForPaths(
|
||||
watch,
|
||||
events.flatMap((event) =>
|
||||
event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath]
|
||||
),
|
||||
getActiveWatches
|
||||
)
|
||||
const changes = collectRemoteWorktreeBaseChanges(watch, events)
|
||||
if (changes.overflow) {
|
||||
handleWatchOverflow(watch, getActiveWatches)
|
||||
return
|
||||
}
|
||||
if (hasCollectedWorktreeBaseChanges(changes)) {
|
||||
scheduleWorktreeBaseNotification(watch, changes)
|
||||
}
|
||||
}
|
||||
@@ -404,6 +404,46 @@ describe('worktree base directory watcher', () => {
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('widens an overflowed local git-common watch to a structural refresh', async () => {
|
||||
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
|
||||
const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow
|
||||
|
||||
const request = {
|
||||
worktreeId: `repo-1::${PROJECT_ROOT}`,
|
||||
worktreePath: PROJECT_ROOT,
|
||||
executionHostId: 'local',
|
||||
branch: 'refs/heads/feature',
|
||||
upstreamName: 'origin/feature'
|
||||
}
|
||||
const resolve = vi.fn(async () => 'refs/remotes/origin/feature')
|
||||
await setWorktreeGitStatusRefWatch(request, resolve)
|
||||
|
||||
onOverflow?.()
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
|
||||
// Overflow is definite proof of loss, not a possibly-transient error — it
|
||||
// invalidates the cached ref resolution unconditionally.
|
||||
await setWorktreeGitStatusRefWatch(request, resolve)
|
||||
expect(resolve).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not throttle repeated overflow refreshes the way watcher-error refreshes are throttled', async () => {
|
||||
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
|
||||
const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow
|
||||
|
||||
onOverflow?.()
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
onOverflow?.()
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
// A watcher-error burst within the 60s cooldown window collapses to one
|
||||
// refresh (see "throttles repeated structural refreshes from watcher
|
||||
// failures" above); overflow must not inherit that gate, since a bulk op
|
||||
// can legitimately overflow more than once before it settles.
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps linked HEAD and lock metadata structural', async () => {
|
||||
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
|
||||
|
||||
|
||||
@@ -6,16 +6,9 @@ import {
|
||||
disposeWorktreeHeadIdentityRefreshState,
|
||||
refreshWorktreeHeadIdentities
|
||||
} from './worktree-head-identity-refresh'
|
||||
import {
|
||||
collectLocalWorktreeBaseChanges,
|
||||
collectRemoteWorktreeBaseChanges,
|
||||
hasCollectedWorktreeBaseChanges
|
||||
} from './worktree-base-directory-change-collector'
|
||||
import {
|
||||
clearPendingWorktreeBaseNotifications,
|
||||
scheduleWorktreeBaseNotification,
|
||||
supportsWorktreeHeadIdentityRefresh,
|
||||
type WorktreeBaseNotificationWatch
|
||||
supportsWorktreeHeadIdentityRefresh
|
||||
} from './worktree-base-directory-notifications'
|
||||
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
|
||||
import { EMPTY_HEAD_IDENTITY_SCOPE } from './worktree-head-identity-scope'
|
||||
@@ -30,18 +23,16 @@ import {
|
||||
import {
|
||||
applyActiveGitStatusRefBinding,
|
||||
clearActiveGitStatusRefBinding,
|
||||
invalidateActiveGitStatusRefResolution,
|
||||
invalidateGitStatusRefResolutionForPaths,
|
||||
updateActiveGitStatusRefBinding,
|
||||
type GitStatusRefBindingRequest
|
||||
} from './worktree-git-status-ref-watch'
|
||||
import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown'
|
||||
|
||||
type ActiveWatch = WorktreeBaseNotificationWatch & {
|
||||
subscription: { unsubscribe: () => Promise<void> }
|
||||
gitStatusRefPaths: Set<string>
|
||||
watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown
|
||||
}
|
||||
import {
|
||||
handleLocalWatchEvents,
|
||||
handleRemoteWatchEvents,
|
||||
handleWatchOverflow,
|
||||
type ActiveWatch
|
||||
} from './worktree-base-directory-watch-events'
|
||||
|
||||
const activeWatches = new Map<string, ActiveWatch>()
|
||||
let syncGeneration = 0
|
||||
@@ -54,59 +45,6 @@ export function setWorktreeGitStatusRefWatch(
|
||||
return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef)
|
||||
}
|
||||
|
||||
function handleLocalWatchEvents(
|
||||
watch: ActiveWatch,
|
||||
error: Error | null,
|
||||
events: { type: 'create' | 'update' | 'delete'; path: string }[]
|
||||
): void {
|
||||
if (watch.disposed || watch.mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (error) {
|
||||
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
|
||||
invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values())
|
||||
if (watch.watcherFailureRefresh.consume()) {
|
||||
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
|
||||
}
|
||||
return
|
||||
}
|
||||
watch.watcherFailureRefresh.reset()
|
||||
invalidateGitStatusRefResolutionForPaths(
|
||||
watch,
|
||||
events.map((event) => event.path),
|
||||
() => activeWatches.values()
|
||||
)
|
||||
const changes = collectLocalWorktreeBaseChanges(watch, events)
|
||||
if (hasCollectedWorktreeBaseChanges(changes)) {
|
||||
scheduleWorktreeBaseNotification(watch, changes)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoteWatchEvents(
|
||||
watch: ActiveWatch,
|
||||
events: Parameters<typeof collectRemoteWorktreeBaseChanges>[1]
|
||||
): void {
|
||||
if (watch.disposed || watch.mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
invalidateGitStatusRefResolutionForPaths(
|
||||
watch,
|
||||
events.flatMap((event) =>
|
||||
event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath]
|
||||
),
|
||||
() => activeWatches.values()
|
||||
)
|
||||
const changes = collectRemoteWorktreeBaseChanges(watch, events)
|
||||
if (changes.overflow) {
|
||||
invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values())
|
||||
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
|
||||
return
|
||||
}
|
||||
if (hasCollectedWorktreeBaseChanges(changes)) {
|
||||
scheduleWorktreeBaseNotification(watch, changes)
|
||||
}
|
||||
}
|
||||
|
||||
function createActiveWatch(
|
||||
target: WorktreeBaseWatchTarget,
|
||||
mainWindow: BrowserWindow,
|
||||
@@ -146,7 +84,7 @@ async function subscribeTarget(
|
||||
if (!currentWatch || currentWatch.disposed) {
|
||||
return
|
||||
}
|
||||
handleRemoteWatchEvents(currentWatch, events)
|
||||
handleRemoteWatchEvents(currentWatch, events, () => activeWatches.values())
|
||||
})
|
||||
activeWatch = createActiveWatch(
|
||||
target,
|
||||
@@ -167,7 +105,7 @@ async function subscribeTarget(
|
||||
(events) => {
|
||||
const currentWatch = activeWatches.get(target.key) ?? activeWatch
|
||||
if (currentWatch && !currentWatch.disposed) {
|
||||
handleLocalWatchEvents(currentWatch, null, events)
|
||||
handleLocalWatchEvents(currentWatch, null, events, () => activeWatches.values())
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -178,7 +116,13 @@ async function subscribeTarget(
|
||||
onWatchError: (error) => {
|
||||
const currentWatch = activeWatches.get(target.key) ?? activeWatch
|
||||
if (currentWatch && !currentWatch.disposed) {
|
||||
handleLocalWatchEvents(currentWatch, error, [])
|
||||
handleLocalWatchEvents(currentWatch, error, [], () => activeWatches.values())
|
||||
}
|
||||
},
|
||||
onOverflow: () => {
|
||||
const currentWatch = activeWatches.get(target.key) ?? activeWatch
|
||||
if (currentWatch) {
|
||||
handleWatchOverflow(currentWatch, () => activeWatches.values())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +39,33 @@ export async function snapshotGitCommonEntry(
|
||||
previous: GitCommonEntrySnapshot | undefined,
|
||||
forceFullScan: boolean
|
||||
): Promise<GitCommonEntrySnapshot> {
|
||||
// Structural leaves change in place every tick; only index uses the entry-dir gate.
|
||||
// Git writes HEAD/index/config.worktree/locked via a lock file + rename inside the
|
||||
// entry dir, so the entry dir's own signature moves on every one of those writes
|
||||
// (verified against git 2.55: checkout, commit, amend, reset, ref updates, stash,
|
||||
// worktree lock/unlock, config --worktree, index writes all move it). The one
|
||||
// in-place exception is `gitdir` (worktree move/repair), which the periodic
|
||||
// forceFullScan backstop (INDEX_BACKSTOP_TICKS) below re-stats regardless of this
|
||||
// gate. Gating all of these leaves on the entry-dir signature turns an unchanged
|
||||
// entry into a single stat per tick instead of stat-ing every leaf every tick.
|
||||
const nextDirSignature = await gitCommonDirectorySignature(entryPath)
|
||||
if (nextDirSignature === 'missing') {
|
||||
return (
|
||||
previous ?? {
|
||||
dirSignature: nextDirSignature,
|
||||
structuralSignatures: new Map(),
|
||||
indexSignature: null,
|
||||
headLogSignature: null
|
||||
}
|
||||
)
|
||||
}
|
||||
const shouldRescan = forceFullScan || !previous || previous.dirSignature !== nextDirSignature
|
||||
if (!shouldRescan) {
|
||||
return previous
|
||||
}
|
||||
const structuralSignatures = new Map<string, string>()
|
||||
const [nextDirSignature, headLogSignature] = await Promise.all([
|
||||
gitCommonDirectorySignature(entryPath),
|
||||
const [headLogSignature, indexSignature] = await Promise.all([
|
||||
gitCommonFileSignature(join(entryPath, HEAD_LOG_FILE)),
|
||||
gitCommonFileSignature(join(entryPath, INDEX_FILE)),
|
||||
Promise.all(
|
||||
STRUCTURAL_METADATA_FILES.map(async (name) => {
|
||||
const signature = await gitCommonFileSignature(join(entryPath, name))
|
||||
@@ -53,20 +75,6 @@ export async function snapshotGitCommonEntry(
|
||||
})
|
||||
)
|
||||
])
|
||||
if (nextDirSignature === 'missing') {
|
||||
return (
|
||||
previous ?? {
|
||||
dirSignature: nextDirSignature,
|
||||
structuralSignatures,
|
||||
indexSignature: null,
|
||||
headLogSignature
|
||||
}
|
||||
)
|
||||
}
|
||||
const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature
|
||||
const indexSignature = shouldReadIndex
|
||||
? await gitCommonFileSignature(join(entryPath, INDEX_FILE))
|
||||
: previous.indexSignature
|
||||
return {
|
||||
dirSignature: nextDirSignature,
|
||||
structuralSignatures,
|
||||
|
||||
@@ -24,7 +24,12 @@ export async function startGitCommonNarrowWatch(
|
||||
platform: NodeJS.Platform,
|
||||
visibility: WorktreePollerWindowVisibility,
|
||||
onFullScan?: () => void,
|
||||
onWatchError?: (error: Error) => void
|
||||
onWatchError?: (error: Error) => void,
|
||||
// Why: a dropped event batch (>5,000 events, e.g. a fleet-wide bulk op) is a
|
||||
// harder loss signal than a transient error — nothing about the prior state
|
||||
// can be trusted, so this bypasses onWatchError's failure cooldown instead
|
||||
// of reusing it.
|
||||
onOverflow?: () => void
|
||||
): Promise<WorktreeBaseSubscription> {
|
||||
const worktreesDir = join(target.path, 'worktrees')
|
||||
const watcherOptions = platform === 'win32' ? { backend: 'windows' as const } : {}
|
||||
@@ -73,6 +78,11 @@ export async function startGitCommonNarrowWatch(
|
||||
.unsubscribe()
|
||||
.catch(() => {})
|
||||
.then(() =>
|
||||
// Crash fuse tripped: this poller is now the sole change signal until a
|
||||
// future existence-poll upgrade (follow-up: #17878). Its own per-entry
|
||||
// dir-signature gate (worktree-git-common-entry-snapshot.ts) already keeps
|
||||
// an unchanged entry to a single stat, so a fixed `pollIntervalMs` cadence
|
||||
// stays cheap at high worktree counts without needing to stretch itself.
|
||||
startGitCommonPolling(
|
||||
target.path,
|
||||
onEvents,
|
||||
@@ -222,6 +232,23 @@ export async function startGitCommonNarrowWatch(
|
||||
onEvents([{ type: 'update', path: worktreesDir }])
|
||||
}
|
||||
}
|
||||
},
|
||||
// Why: the watcher child drops the whole batch past 5,000 events
|
||||
// (native FSEvents overflow maps to the same op) instead of reporting
|
||||
// which paths changed. Unlike a transient error, this is definite
|
||||
// proof of loss, so it always widens rather than falling back to the
|
||||
// failure-cooldown-gated onWatchError path.
|
||||
onOverflow: () => {
|
||||
if (disposed || !active || generation !== nativeSubscriptionGeneration) {
|
||||
return
|
||||
}
|
||||
if (onOverflow) {
|
||||
onOverflow()
|
||||
} else if (onWatchError) {
|
||||
onWatchError(new Error('Git common watcher overflowed'))
|
||||
} else {
|
||||
onEvents([{ type: 'update', path: worktreesDir }])
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, sep } from 'node:path'
|
||||
import { startGitCommonPolling } from './worktree-git-common-polling'
|
||||
import type {
|
||||
WorktreeBasePollEvent,
|
||||
WorktreePollerWindowVisibility
|
||||
} from './worktree-base-directory-poller'
|
||||
|
||||
// Why: measure the fan-out this poller issues per scan (peak concurrent `stat`
|
||||
// calls, `readdir` call count as a proxy for "a tick ran") without depending on
|
||||
// real disk timing (#17828). `entryZeroStatCalls` tracks every stat under a
|
||||
// specific pre-existing entry (its dir plus every leaf), used to prove the
|
||||
// entry-dir signature gate keeps an unchanged entry to one stat per tick.
|
||||
const { statDelayMs, readdirCalls, concurrency, entryZeroStatCalls } = vi.hoisted(() => ({
|
||||
statDelayMs: { current: 0 },
|
||||
readdirCalls: { count: 0 },
|
||||
concurrency: { current: 0, peak: 0 },
|
||||
entryZeroStatCalls: { count: 0 }
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsPromises>()
|
||||
return {
|
||||
...actual,
|
||||
readdir: (...args: Parameters<typeof actual.readdir>) => {
|
||||
readdirCalls.count += 1
|
||||
return actual.readdir(...args)
|
||||
},
|
||||
stat: async (...args: Parameters<typeof actual.stat>) => {
|
||||
concurrency.current += 1
|
||||
concurrency.peak = Math.max(concurrency.peak, concurrency.current)
|
||||
const path = args[0]
|
||||
const entryZeroSegment = `${sep}wt-0`
|
||||
if (
|
||||
typeof path === 'string' &&
|
||||
(path.endsWith(entryZeroSegment) || path.includes(`${entryZeroSegment}${sep}`))
|
||||
) {
|
||||
entryZeroStatCalls.count += 1
|
||||
}
|
||||
try {
|
||||
if (statDelayMs.current > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, statDelayMs.current))
|
||||
}
|
||||
return await actual.stat(...args)
|
||||
} finally {
|
||||
concurrency.current -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const alwaysVisible: WorktreePollerWindowVisibility = {
|
||||
isWindowVisible: () => true,
|
||||
onWindowBecameVisible: () => () => {}
|
||||
}
|
||||
|
||||
async function makeCommonDir(entryCount: number): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'git-common-polling-test-'))
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
const entryPath = join(root, 'worktrees', `wt-${i}`)
|
||||
await mkdir(join(entryPath, 'logs'), { recursive: true })
|
||||
await Promise.all([
|
||||
writeFile(join(entryPath, 'HEAD'), 'ref: refs/heads/main\n'),
|
||||
writeFile(join(entryPath, 'gitdir'), `${join(root, `checkout-${i}`, '.git')}\n`),
|
||||
writeFile(join(entryPath, 'index'), Buffer.from([0])),
|
||||
writeFile(join(entryPath, 'logs', 'HEAD'), '0000 aaaa\n')
|
||||
])
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
describe('startGitCommonPolling fan-out bounds (#17828)', () => {
|
||||
const cleanups: (() => Promise<void>)[] = []
|
||||
const dirsToRemove: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
statDelayMs.current = 0
|
||||
readdirCalls.count = 0
|
||||
concurrency.current = 0
|
||||
concurrency.peak = 0
|
||||
entryZeroStatCalls.count = 0
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
|
||||
await Promise.all(
|
||||
dirsToRemove.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
|
||||
)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('bounds concurrent per-entry stat fan-out regardless of entry count', async () => {
|
||||
const commonDir = await makeCommonDir(200)
|
||||
dirsToRemove.push(commonDir)
|
||||
const sub = await startGitCommonPolling(commonDir, () => {}, 100_000, alwaysVisible)
|
||||
cleanups.push(() => sub.unsubscribe())
|
||||
// 200 entries x ~6 concurrent structural stats each would peak near 1,200
|
||||
// unbounded; bounding to 8 in-flight entries keeps the peak independent of
|
||||
// entry count instead of scaling with it.
|
||||
expect(concurrency.peak).toBeLessThan(80)
|
||||
})
|
||||
|
||||
it('never overlaps a scan with itself even when ticks fire faster than a scan completes', async () => {
|
||||
const commonDir = await makeCommonDir(10)
|
||||
dirsToRemove.push(commonDir)
|
||||
statDelayMs.current = 20
|
||||
const pollIntervalMs = 5
|
||||
const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible)
|
||||
cleanups.push(() => sub.unsubscribe())
|
||||
readdirCalls.count = 0
|
||||
// ~60 would-be 5ms ticks elapse in this window while every stat takes 20ms;
|
||||
// the ticking guard must serialize scans, not launch overlapping ones.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(readdirCalls.count).toBeLessThan(10)
|
||||
})
|
||||
|
||||
it('costs exactly one stat per tick for an unchanged entry', async () => {
|
||||
const commonDir = await makeCommonDir(1)
|
||||
dirsToRemove.push(commonDir)
|
||||
const pollIntervalMs = 20
|
||||
const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible)
|
||||
cleanups.push(() => sub.unsubscribe())
|
||||
|
||||
// Let the bootstrap snapshot (which always fully reads every entry once) settle.
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
||||
readdirCalls.count = 0
|
||||
entryZeroStatCalls.count = 0
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(readdirCalls.count).toBeGreaterThanOrEqual(5)
|
||||
},
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
// Without the entry-dir signature gate, an unchanged entry still costs ~6
|
||||
// stats every tick (HEAD/gitdir/locked/config.worktree/logs/HEAD/index).
|
||||
// With the gate, only the entry dir itself is stat'd once nothing changed —
|
||||
// one stat per tick, in lockstep with the readdir tripwire.
|
||||
expect(entryZeroStatCalls.count).toBeLessThanOrEqual(readdirCalls.count + 1)
|
||||
expect(entryZeroStatCalls.count).toBeGreaterThanOrEqual(readdirCalls.count - 1)
|
||||
})
|
||||
|
||||
it('detects a HEAD rewrite via lock+rename on the next tick', async () => {
|
||||
const commonDir = await makeCommonDir(1)
|
||||
dirsToRemove.push(commonDir)
|
||||
const events: WorktreeBasePollEvent[][] = []
|
||||
const pollIntervalMs = 20
|
||||
const sub = await startGitCommonPolling(
|
||||
commonDir,
|
||||
(batch) => events.push(batch),
|
||||
pollIntervalMs,
|
||||
alwaysVisible
|
||||
)
|
||||
cleanups.push(() => sub.unsubscribe())
|
||||
// Let the bootstrap snapshot settle before mutating.
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
||||
|
||||
const entryDir = join(commonDir, 'worktrees', 'wt-0')
|
||||
const headPath = join(entryDir, 'HEAD')
|
||||
const headLockPath = join(entryDir, 'HEAD.lock')
|
||||
// Every real git ref write goes through a lock file + rename inside the entry
|
||||
// dir (never an in-place overwrite), which moves the entry dir's own signature.
|
||||
await writeFile(headLockPath, 'ref: refs/heads/feature\n')
|
||||
await rename(headLockPath, headPath)
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(events.flat()).toContainEqual({ type: 'update', path: headPath })
|
||||
},
|
||||
{ timeout: pollIntervalMs * 10 }
|
||||
)
|
||||
})
|
||||
|
||||
it('detects an in-place gitdir rewrite only once the periodic backstop rescans it', async () => {
|
||||
const commonDir = await makeCommonDir(1)
|
||||
dirsToRemove.push(commonDir)
|
||||
const events: WorktreeBasePollEvent[][] = []
|
||||
const pollIntervalMs = 10
|
||||
const sub = await startGitCommonPolling(
|
||||
commonDir,
|
||||
(batch) => events.push(batch),
|
||||
pollIntervalMs,
|
||||
alwaysVisible
|
||||
)
|
||||
cleanups.push(() => sub.unsubscribe())
|
||||
// Let the bootstrap snapshot settle before mutating.
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
||||
|
||||
const entryDir = join(commonDir, 'worktrees', 'wt-0')
|
||||
const gitdirPath = join(entryDir, 'gitdir')
|
||||
// `gitdir` is the one structural leaf git rewrites in place (worktree move/repair),
|
||||
// so the entry dir's own signature never moves — the periodic ungated backstop
|
||||
// (INDEX_BACKSTOP_TICKS = 15) is the only thing that catches it.
|
||||
await writeFile(gitdirPath, `${join(commonDir, 'checkout-moved', '.git')}\n`)
|
||||
|
||||
// Not caught by the next several ticks: the gate stays closed since nothing
|
||||
// moved the entry dir's own signature.
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs * 5))
|
||||
expect(events.flat()).not.toContainEqual({ type: 'update', path: gitdirPath })
|
||||
|
||||
// Eventually caught regardless of the gate, once tick 15 forces the periodic backstop.
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(events.flat()).toContainEqual({ type: 'update', path: gitdirPath })
|
||||
},
|
||||
{ timeout: pollIntervalMs * 40 }
|
||||
)
|
||||
})
|
||||
|
||||
it('still detects entry add/remove correctly with bounded concurrency', async () => {
|
||||
const commonDir = await makeCommonDir(5)
|
||||
dirsToRemove.push(commonDir)
|
||||
const events: WorktreeBasePollEvent[][] = []
|
||||
const sub = await startGitCommonPolling(
|
||||
commonDir,
|
||||
(batch) => events.push(batch),
|
||||
20,
|
||||
alwaysVisible
|
||||
)
|
||||
cleanups.push(() => sub.unsubscribe())
|
||||
|
||||
const newEntry = join(commonDir, 'worktrees', 'wt-new')
|
||||
await mkdir(join(newEntry, 'logs'), { recursive: true })
|
||||
await writeFile(join(newEntry, 'HEAD'), 'ref: refs/heads/main\n')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(events.flat()).toContainEqual({ type: 'create', path: newEntry })
|
||||
})
|
||||
|
||||
await rm(newEntry, { recursive: true })
|
||||
await vi.waitFor(() => {
|
||||
expect(events.flat()).toContainEqual({ type: 'delete', path: newEntry })
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user