Merge remote-tracking branch 'origin/main' into entrypoint-ssh-openclaw-final

This commit is contained in:
Merge Sim
2026-09-01 23:08:39 -07:00
521 changed files with 27194 additions and 4163 deletions
+3 -2
View File
@@ -922,9 +922,7 @@ jobs:
run: |
$env:SKIP_BUILD = '1'
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
pnpm run --if-present test:e2e:workspace-session-golden
pnpm run --if-present test:e2e:windows-fresh-startup-golden
pnpm run --if-present test:e2e:source-control-golden
- name: Upload Playwright traces
if: failure()
@@ -940,6 +938,9 @@ jobs:
if: needs.cut.outputs.should_release == 'true'
name: skill sharing release gate ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
# The full suite is release-blocking on macOS. Windows still produces the
# same evidence, but intermittent filesystem contention cannot block signing.
continue-on-error: ${{ matrix.platform == 'windows' }}
timeout-minutes: 20
strategy:
fail-fast: false
+27 -4
View File
@@ -105,6 +105,11 @@ const winSpeechNativeResource = {
to: 'node_modules/sherpa-onnx-win-x64'
}
// Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build.
// Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with
// config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows.
const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx']
/** @type {import('electron-builder').Configuration} */
module.exports = {
appId,
@@ -376,12 +381,24 @@ module.exports = {
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
createDesktopShortcut: 'always',
// Why: on a real uninstall, stop and remove the relocated terminal daemon
// (which lives outside the install dir under LOCALAPPDATA by design). Guarded
// by ${isUpdated} inside so it never runs during an update's uninstallOldVersion.
include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh')
// Why: electron-builder allows one include, so both Windows installer hooks live in it -
// the relocated-daemon uninstall sweep (guarded by ${isUpdated} so it never runs during an
// update's uninstallOldVersion) and the additive markdown "Open with" registration.
// Windows markdown association is deliberately NOT done via `fileAssociations`; see the
// header comment in that file for why that would steal the user's default .md handler.
include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh')
},
mac: {
// Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming
// LSHandlerRank ownership, so whichever editor the user already prefers stays the default.
// Why one entry per extension: app-builder-lib globs `*.${ext}`, which an array would break.
fileAssociations: MARKDOWN_FILE_EXTENSIONS.map((ext) => ({
ext,
name: 'Markdown Document',
description: 'Markdown Document',
role: 'Editor',
rank: 'Alternate'
})),
icon: 'resources/build/icon.icns',
entitlements: 'resources/build/entitlements.mac.plist',
entitlementsInherit: 'resources/build/entitlements.mac.plist',
@@ -468,6 +485,12 @@ module.exports = {
artifactName: 'orca-macos-${arch}.${ext}'
},
linux: {
// Why mimeTypes and not fileAssociations: shared-mime-info already maps *.md/*.markdown to
// text/markdown, so reusing that type puts Orca in the Open With list without shipping a glob
// override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the
// default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to
// application/x-genesis-32x-rom, so claiming it here would need a glob override.
mimeTypes: ['text/markdown'],
// Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca.
// The Linux installer should not claim those system package/file names.
executableName: 'orca-ide',
-23
View File
@@ -1,23 +0,0 @@
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
!macroend
+79
View File
@@ -0,0 +1,79 @@
; electron-builder NSIS hooks for the Orca Windows installer.
;
; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall /
; customUnInstall hook Orca needs lives here.
; ---------------------------------------------------------------------------
; Markdown "Open with Orca" (issue #10138)
;
; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows:
; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is
; WriteRegStr SHELL_CONTEXT "Software\Classes\.md" "" "<ProgID>"
; That overwrites whichever editor currently owns .md, with no backup, for every
; existing user on their next UPDATE - and APP_UNASSOCIATE never restores it, so
; uninstalling Orca would leave .md pointing at a deleted ProgID.
;
; These writes are additive only. Registering a ProgID plus an OpenWithProgids
; hint and an Applications\<exe>\SupportedTypes entry puts Orca in Explorer's
; "Open with" list and in "Choose another app", while the default handler stays
; exactly where the user left it. Never add a `Software\Classes\.<ext>` default
; value here.
;
; MARKDOWN_PROGID must stay in sync with the extension list handled by
; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts.
; ---------------------------------------------------------------------------
!define MARKDOWN_PROGID "Orca.Markdown"
!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT
WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" ""
!macroend
!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT
DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}"
!macroend
!macro customInstall
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"'
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md"
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown"
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx"
; Why: Explorer caches the association list until told otherwise.
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
; ---------------------------------------------------------------------------
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so
; dropping them during uninstallOldVersion is correct and keeps the pair symmetric.
DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx"
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
@@ -0,0 +1,14 @@
# Files allowed to construct a `ws` server that binds a port without pinning `host`.
#
# `ws` accepts `{ port }` alone and silently binds the wildcard address. A server
# reached over 127.0.0.1 must pin `host: '127.0.0.1'`, or a foreign loopback
# listener can hold the same port and answer in its place -- which is how
# relay-control-client.test.ts came to fail with a real HTTP 401 in a test that
# was simulating silence.
#
# This list only shrinks. Adding a line also requires raising the pin in
# websocket-server-loopback-bind.test.ts, which is deliberate friction.
# Deliberate, not drift: this mock is dialled by a phone on the LAN, so it has to
# be reachable on a real interface. A loopback bind would make it unreachable.
mobile/scripts/mock-server.ts
+204
View File
@@ -0,0 +1,204 @@
/**
* Read the top-level option keys of a call's object-literal argument out of raw
* source text.
*
* Text rather than an AST because typescript@7 no longer ships the classic
* compiler API and every installed parser is a transitive dependency. The
* tradeoff is handled by refusing to guess: any shape this cannot read comes
* back as `unreadable` with a reason, and callers must treat that as a failure
* rather than as an absence of keys.
*/
export type CallOptionKeys =
| { readonly readable: true; readonly keys: readonly string[] }
| { readonly readable: false; readonly reason: string }
type ScanState = 'code' | 'line' | 'block' | 'single' | 'double' | 'template'
function closesString(state: ScanState, current: string): boolean {
return (
(state === 'single' && current === "'") ||
(state === 'double' && current === '"') ||
(state === 'template' && current === '`')
)
}
function opensNonCode(current: string, next: string | undefined): ScanState | null {
if (current === '/' && next === '/') {
return 'line'
}
if (current === '/' && next === '*') {
return 'block'
}
if (current === "'") {
return 'single'
}
if (current === '"') {
return 'double'
}
if (current === '`') {
return 'template'
}
return null
}
/**
* Text between an open paren and its match, tracking strings and comments so a
* brace inside either cannot unbalance the count. Null when it never closes.
*/
function balancedArguments(text: string, openIndex: number): string | null {
let depth = 0
let state: ScanState = 'code'
for (let index = openIndex; index < text.length; index++) {
const current = text[index]
const next = text[index + 1]
if (state === 'code') {
const opened = opensNonCode(current, next)
if (opened) {
state = opened
if (opened === 'line' || opened === 'block') {
index++
}
} else if (current === '(' || current === '{' || current === '[') {
depth++
} else if (current === ')' || current === '}' || current === ']') {
depth--
if (depth === 0) {
return text.slice(openIndex + 1, index)
}
if (depth < 0) {
return null
}
}
continue
}
if (state === 'line') {
if (current === '\n') {
state = 'code'
}
continue
}
if (state === 'block') {
if (current === '*' && next === '/') {
state = 'code'
index++
}
continue
}
if (current === '\\') {
index++
continue
}
// Brace tracking inside `${}` would need its own depth; templates never
// appear as options, so report one as unreadable instead of guessing.
if (state === 'template' && current === '$' && next === '{') {
return null
}
if (closesString(state, current)) {
state = 'code'
}
}
return null
}
/** Keys at depth 0 of an object literal body, with anything non-identifier kept verbatim. */
function objectLiteralKeys(body: string): string[] {
const keys: string[] = []
let depth = 0
let state: ScanState = 'code'
let inValue = false
let token = ''
const flush = (): void => {
const name = token.trim()
token = ''
if (name && depth === 0) {
keys.push(name)
}
}
for (let index = 0; index < body.length; index++) {
const current = body[index]
const next = body[index + 1]
if (state === 'code') {
const opened = opensNonCode(current, next)
if (opened) {
state = opened
if (opened === 'line' || opened === 'block') {
index++
}
} else if (current === '(' || current === '{' || current === '[') {
depth++
if (!inValue) {
token += current
}
} else if (current === ')' || current === '}' || current === ']') {
depth--
if (!inValue) {
token += current
}
} else if (current === ':' && depth === 0 && !inValue) {
flush()
inValue = true
} else if (current === ',' && depth === 0) {
// A shorthand or a spread ends here having never seen a colon.
if (inValue) {
inValue = false
token = ''
} else {
flush()
}
} else if (!inValue) {
token += current
}
continue
}
if (state === 'line') {
if (current === '\n') {
state = 'code'
}
continue
}
if (state === 'block') {
if (current === '*' && next === '/') {
state = 'code'
index++
}
continue
}
if (current === '\\') {
index++
continue
}
if (closesString(state, current)) {
state = 'code'
}
}
if (!inValue) {
flush()
}
return keys
}
/**
* Option keys of the call whose argument list opens at `parenIndex`, or the
* reason the shape could not be read. Spreads and computed keys land in the
* latter: either can carry a key this would otherwise report as absent.
*/
export function readCallOptionKeys(text: string, parenIndex: number): CallOptionKeys {
const args = balancedArguments(text, parenIndex)
if (args === null) {
return { readable: false, reason: 'argument list never closes' }
}
if (!args.trim()) {
return { readable: false, reason: 'called with no options argument' }
}
const trimmed = args.trim()
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
return { readable: false, reason: 'options are not an object literal' }
}
const keys = objectLiteralKeys(trimmed.slice(1, -1))
const unreadable = keys.find((key) => !/^[A-Za-z_$][\w$]*$/.test(key))
if (unreadable !== undefined) {
return { readable: false, reason: `unreadable option key \`${unreadable}\`` }
}
return { readable: true, keys }
}
@@ -0,0 +1,116 @@
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { basename } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx']
// The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("")
// value of Software\Classes\.<ext>. Additive `WriteRegNone ...\OpenWithProgids` must not
// match, or the guard below would be unfalsifiable.
const DEFAULT_HANDLER_WRITE = /WriteRegStr\s+SHELL_CONTEXT\s+"Software\\Classes\\\.[a-z]+"\s+""/i
// The hooks file documents the forbidden line in prose, so match executable script only.
const stripNsisCommentLines = (source) =>
source
.split('\n')
.filter((line) => !/^\s*[;#]/.test(line))
.join('\n')
const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8')
describe('electron-builder markdown file associations', () => {
// Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS
// packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value
// — silently taking .md from whichever editor owns it, for every existing user on their
// next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot
// prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must
// stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks.
it('never claims the Windows default markdown handler', () => {
expect(electronBuilderConfig.fileAssociations).toBeUndefined()
expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined()
})
it('joins the macOS Open With list for every markdown extension without owning it', () => {
const associations = electronBuilderConfig.mac.fileAssociations
// One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob.
expect([...associations].map((association) => association.ext).sort()).toEqual(
[...MARKDOWN_EXTENSIONS].sort()
)
for (const association of associations) {
expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' })
}
})
// Why mimeTypes and not linux.fileAssociations: shared-mime-info already maps markdown to
// text/markdown, so the desktop entry only adds a handler and mimeapps.list keeps owning
// the default. A fileAssociations entry would ship a redundant glob override instead.
it('reuses the existing shared-mime-info markdown type on Linux', () => {
expect(electronBuilderConfig.linux.mimeTypes).toContain('text/markdown')
expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined()
})
it('points the single NSIS include at the installer hooks file on disk', () => {
const includePath = electronBuilderConfig.nsis.include
expect(existsSync(includePath)).toBe(true)
expect(basename(includePath)).toBe('orca-installer-hooks.nsh')
})
// Guard for the guard: proves DEFAULT_HANDLER_WRITE really matches a takeover line, so
// the assertion below is a live check rather than a regex that can never fire.
it('recognizes an APP_ASSOCIATE-style default-handler write', () => {
for (const takeover of [
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "Orca.Markdown"',
'WriteRegStr SHELL_CONTEXT "Software\\Classes\\.markdown" "" "$0"'
]) {
expect(takeover).toMatch(DEFAULT_HANDLER_WRITE)
}
expect(
'WriteRegNone SHELL_CONTEXT "Software\\Classes\\.md\\OpenWithProgids" "Orca.Markdown"'
).not.toMatch(DEFAULT_HANDLER_WRITE)
// Comment stripping must drop prose that quotes the bad line without swallowing a real
// one that happens to carry a trailing comment.
const stripped = stripNsisCommentLines(
[
'; WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "<ProgID>"',
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "$0" ; oops'
].join('\n')
)
expect(stripped.split('\n')).toHaveLength(1)
expect(stripped).toMatch(DEFAULT_HANDLER_WRITE)
})
it('registers Windows markdown Open With additively, never as the default', async () => {
const hooks = await readInstallerHooks()
expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE)
// The additive hint that puts Orca in Explorer's "Open with" list.
expect(hooks).toMatch(
/WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/
)
expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/)
for (const ext of MARKDOWN_EXTENSIONS) {
expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
}
expect(hooks).toMatch(/!macro\s+customInstall\b/)
expect(hooks).toMatch(/!macro\s+customUnInstall\b/)
})
// Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown
// hooks too. electron-builder allows only one include, so a merge that drops the daemon
// sweep would silently orphan a running orca-terminal-daemon.exe on every uninstall.
it('keeps the daemon-host uninstall sweep across the include rename', async () => {
const hooks = await readInstallerHooks()
expect(hooks).toContain('orca-terminal-daemon.exe')
expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host')
// Without this guard, uninstallOldVersion would kill the daemon on every update —
// defeating the relocation that keeps terminals alive across updates.
expect(hooks).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/)
})
})
@@ -655,6 +655,8 @@ describe('Electron runtime package contract', () => {
expect(releaseWindowsRunStep.run).toContain(
'pnpm run --if-present test:e2e:windows-fresh-startup-golden'
)
expect(releaseWindowsRunStep.run).not.toContain('test:e2e:workspace-session-golden')
expect(releaseWindowsRunStep.run).not.toContain('test:e2e:source-control-golden')
expect(releaseEvidenceJob['continue-on-error']).toBe(true)
expect(
releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort()
@@ -31,7 +31,7 @@ describe('skill-sharing release workflow', () => {
expect(macBuild.needs).toContain('release-preflight')
})
it('blocks publication on native Windows, macOS, and the Linux floor', () => {
it('blocks on macOS and the Linux floor while keeping Windows diagnostic', () => {
const platform = workflow.jobs['skill-sharing-release-gate']
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
const publishNeeds = workflow.jobs['publish-release'].needs
@@ -40,6 +40,7 @@ describe('skill-sharing release workflow', () => {
{ os: 'macos-15', platform: 'mac' },
{ os: 'windows-2022', platform: 'windows' }
])
expect(platform['continue-on-error']).toBe("${{ matrix.platform == 'windows' }}")
expect(linux.container).toBe('ubuntu:20.04')
expect(publishNeeds).toContain('skill-sharing-release-gate')
expect(publishNeeds).toContain('skill-sharing-linux-floor-release-gate')
+39 -11
View File
@@ -110,32 +110,60 @@ export function makeTreeReadOnly(targetPath, chmod = chmodSync) {
chmod(targetPath, 0o755)
}
/**
* Restore owner write permission across a private copy.
*
* Counterpart to `makeTreeReadOnly`: clonefile, reflink and `cpSync` all carry the source's mode
* across, so a tree copied from the write-protected shared cache lands read-only and every patch
* the caller then makes -- `plutil -replace`, `codesign` -- fails with EACCES. Only the owner bit
* comes back; group and other stay as the source left them.
*/
export function makeTreeWritable(targetPath, chmod = chmodSync) {
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
const entryPath = join(targetPath, entry.name)
if (entry.isDirectory()) {
makeTreeWritable(entryPath, chmod)
} else if (!entry.isSymbolicLink()) {
const mode = statSync(entryPath, { throwIfNoEntry: false })?.mode
chmod(entryPath, mode === undefined ? 0o644 : mode | 0o200)
}
}
chmod(targetPath, 0o755)
}
/**
* Share storage when possible, otherwise copy the bytes.
*
* Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write
* through into the source.
* through into the source. The copy is unprotected on the way out for the same reason -- a private
* tree the caller cannot write to is useless to it.
*/
export function copyPrivateTree(sourcePath, destinationPath, options = {}) {
const platform = options.platform ?? process.platform
const copy = options.copy ?? copyTreeVerbatim
const unprotect = options.unprotect ?? makeTreeWritable
const privateMechanisms = new Set(['clone', 'reflink'])
let result = { mechanism: null, copyError: null }
if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) {
try {
const mechanism = shareTree(sourcePath, destinationPath, {
...options,
hardlink: () => {
throw new Error('hardlinks would not be private')
}
})
return { mechanism, copyError: null }
result = {
mechanism: shareTree(sourcePath, destinationPath, {
...options,
hardlink: () => {
throw new Error('hardlinks would not be private')
}
}),
copyError: null
}
} catch (copyError) {
copy(sourcePath, destinationPath)
return { mechanism: null, copyError }
result = { mechanism: null, copyError }
}
} else {
copy(sourcePath, destinationPath)
}
copy(sourcePath, destinationPath)
return { mechanism: null, copyError: null }
unprotect(destinationPath)
return result
}
function copyTreeVerbatim(sourcePath, destinationPath) {
+35
View File
@@ -19,6 +19,7 @@ import {
copyPrivateTree,
hardlinkTree,
makeTreeReadOnly,
makeTreeWritable,
shareTree
} from './space-sharing-copy.mjs'
@@ -170,7 +171,41 @@ describe('makeTreeReadOnly', () => {
)
})
describe('makeTreeWritable', () => {
it.runIf(process.platform !== 'win32')('undoes makeTreeReadOnly for the owner', () => {
const { source } = makeTree()
makeTreeReadOnly(source)
makeTreeWritable(source)
const file = path.join(source, 'nested', 'file')
expect(statSync(file).mode & 0o200).toBe(0o200)
expect(() => writeFileSync(file, 'mutated')).not.toThrow()
})
it.runIf(process.platform !== 'win32')('adds no write permission beyond the owner', () => {
const { source } = makeTree()
const executable = path.join(source, 'electron')
writeFileSync(executable, 'binary')
chmodSync(executable, 0o555)
makeTreeWritable(source)
expect(statSync(executable).mode & 0o777).toBe(0o755)
})
})
describe('copyPrivateTree', () => {
it.runIf(process.platform !== 'win32')(
'hands back a tree the caller can patch, even from a write-protected source',
() => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
makeTreeReadOnly(source)
copyPrivateTree(source, destination)
// The regression this guards: the shared Electron dist is read-only, clonefile/reflink/cpSync
// all carry that across, and `pn dev` then died patching the copied bundle's Info.plist.
expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'patched')).not.toThrow()
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents')
}
)
it('never hardlinks, because the caller patches what it gets back', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
@@ -0,0 +1,172 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join, relative } from 'node:path'
import { readCallOptionKeys } from './call-site-option-keys'
/**
* Locate every `new WebSocketServer(...)` in the tree and say, for each, whether
* it pins a bind address.
*
* `ws` accepts `{ port }` alone and silently binds the wildcard address, so a
* server the caller then dials on 127.0.0.1 sits at a port a foreign loopback
* listener can also hold -- and the more specific listener wins the connection,
* answering in that server's place.
*
* Anything unreadable is reported as `opaque` rather than skipped. A matcher
* that silently exempts the shapes it fails to parse is worse than no matcher,
* because it reads as coverage.
*/
export type BindSite = { path: string; line: number }
export type OpaqueSite = BindSite & { reason: string }
export type WebSocketServerBindScan = {
filesScanned: number
/** Every construction recognized, however it was then classified. */
constructions: number
/** Binds a port with no `host`: reachable at an address the dialer never named. */
wildcardBound: BindSite[]
/** Shape that could not be read; never treated as safe. */
opaque: OpaqueSite[]
/** Binds a port and pins `host`. */
loopbackBound: BindSite[]
/** No `port`: attaches to a server that owns the bind itself. */
attached: BindSite[]
}
const IGNORED_DIRECTORIES = new Set([
'node_modules',
'dist',
'out',
'build',
'.git',
'__fixtures__',
'coverage',
// Full snapshots of older releases; their bind sites are not this tree's to fix.
'.cross-version-checkouts'
])
const SCANNED_EXTENSIONS = /\.(?:ts|tsx|mts|cts)$/
const SCANNED_ROOTS = ['src', 'mobile', 'config', 'tests']
const WS_IMPORT_HINT = /from\s*['"]ws['"]/
function collectSourceFiles(root: string, found: string[] = []): string[] {
let entries: ReturnType<typeof readdirSync<{ withFileTypes: true }>>
try {
entries = readdirSync(root, { withFileTypes: true })
} catch {
return found
}
for (const entry of entries) {
if (IGNORED_DIRECTORIES.has(entry.name)) {
continue
}
const full = join(root, entry.name)
if (entry.isDirectory()) {
collectSourceFiles(full, found)
} else if (SCANNED_EXTENSIONS.test(entry.name)) {
found.push(full)
}
}
return found
}
/** Local names bound to ws's server class, following `as` aliases and namespace imports. */
function webSocketServerNames(text: string): { direct: Set<string>; namespaces: Set<string> } {
const direct = new Set<string>()
const namespaces = new Set<string>()
// One statement at a time: a pattern reaching for `from 'ws'` would swallow
// every import above it and lose the specifier names in the blob.
for (const match of text.matchAll(/\bimport\b([\s\S]*?)\bfrom\s*(['"])([^'"]+)\2/g)) {
if (match[3] !== 'ws') {
continue
}
const clause = match[1]
if (/^\s*type\b/.test(clause)) {
continue
}
const namespace = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/)
if (namespace) {
namespaces.add(namespace[1])
}
const named = clause.match(/\{([\s\S]*)\}/)
if (!named) {
continue
}
for (const specifier of named[1].split(',')) {
const trimmed = specifier.trim()
if (!trimmed || /^type\s/.test(trimmed)) {
continue
}
const parts = trimmed.split(/\s+as\s+/)
// `Server` is ws's own alias for WebSocketServer.
if (parts[0].trim() === 'WebSocketServer' || parts[0].trim() === 'Server') {
direct.add((parts[1] ?? parts[0]).trim())
}
}
}
return { direct, namespaces }
}
function classify(
scan: WebSocketServerBindScan,
site: BindSite,
text: string,
paren: number
): void {
const options = readCallOptionKeys(text, paren)
if (!options.readable) {
scan.opaque.push({ ...site, reason: options.reason })
return
}
if (!options.keys.includes('port')) {
scan.attached.push(site)
return
}
if (!options.keys.includes('host')) {
scan.wildcardBound.push(site)
return
}
scan.loopbackBound.push(site)
}
export function scanWebSocketServerBinds(repoRoot: string): WebSocketServerBindScan {
const files = SCANNED_ROOTS.flatMap((directory) => collectSourceFiles(join(repoRoot, directory)))
const scan: WebSocketServerBindScan = {
filesScanned: files.length,
constructions: 0,
wildcardBound: [],
opaque: [],
loopbackBound: [],
attached: []
}
for (const file of files) {
const text = readFileSync(file, 'utf8')
// Filter on the import, not on the class name: `Server as Wss` never spells
// WebSocketServer, and keying on that name silently skipped the whole alias.
if (!WS_IMPORT_HINT.test(text)) {
continue
}
const { direct, namespaces } = webSocketServerNames(text)
if (!direct.size && !namespaces.size) {
continue
}
const path = relative(repoRoot, file).split('\\').join('/')
const patterns = [
...[...direct].map((name) => new RegExp(`\\bnew\\s+${name}\\s*\\(`, 'g')),
...[...namespaces].map(
(name) => new RegExp(`\\bnew\\s+${name}\\.(?:WebSocketServer|Server)\\s*\\(`, 'g')
)
]
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
scan.constructions++
const line = text.slice(0, match.index).split('\n').length
classify(scan, { path, line }, text, match.index + match[0].length - 1)
}
}
}
return scan
}
export function formatSites(sites: readonly BindSite[]): string[] {
return sites.map((site) => `${site.path}:${site.line}`)
}
@@ -0,0 +1,106 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { formatSites, scanWebSocketServerBinds } from './websocket-server-bind-scan'
/**
* Hold the bind address at the tree level rather than per call site.
*
* Every one of the ~30 `.listen(0, ...)` calls in this repo already passes
* '127.0.0.1'; 7 of 7 `new WebSocketServer({ port })` calls did not. Authors know
* the convention -- `ws` just never asks, because `{ port }` alone binds the
* wildcard without a word. That silence is what this test replaces.
*
* The allowlist only shrinks. A new wildcard bind fails here even where it looks
* harmless today, because harmless-looking is exactly what the seven were.
*/
/** The ratchet, held as data so it reads as the list it is. */
const WILDCARD_BIND_ALLOWLIST: readonly string[] = readFileSync(
join(__dirname, '__fixtures__', 'websocket-server-wildcard-bind-allowlist.txt'),
'utf8'
)
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
/**
* The true count of constructions that bind a port without pinning a host.
*
* May only ever be DECREASED, and only by pinning a host. Raising it is never
* the fix.
*/
const WILDCARD_BIND_PIN = 1
/**
* A floor under the constructions the scanner still recognizes.
*
* This is the guard against the scanner going blind: an import pattern it stops
* following reports zero offenders and reads exactly like a clean tree. During
* development a single wrong regex dropped this from 24 to 3.
*/
const RECOGNIZED_CONSTRUCTION_FLOOR = 20
describe('WebSocketServer loopback bind boundary', () => {
const repoRoot = resolve(__dirname, '..', '..')
const scan = scanWebSocketServerBinds(repoRoot)
const offenders = scan.wildcardBound.map((site) => site.path)
it('scans a plausible number of files', () => {
// A broken root or extension list would make the guard silently vacuous.
expect(scan.filesScanned).toBeGreaterThan(5_000)
})
it('still recognizes the known construction sites', () => {
expect(
scan.constructions,
`Only ${scan.constructions} WebSocketServer constructions were recognized; the floor is ` +
`${RECOGNIZED_CONSTRUCTION_FLOOR}. The scanner has probably stopped following an import ` +
'shape rather than the tree having lost that many servers.'
).toBeGreaterThanOrEqual(RECOGNIZED_CONSTRUCTION_FLOOR)
})
it('can read the options of every construction it found', () => {
// An unreadable shape is never assumed safe: it could be hiding a host, or
// hiding the absence of one. Rewrite it as a plain object literal.
expect(
scan.opaque.map((site) => `${site.path}:${site.line} -- ${site.reason}`),
'WebSocketServer options that this guard cannot read.'
).toEqual([])
})
it('has no wildcard-bound server outside the allowlist', () => {
const unlisted = scan.wildcardBound.filter(
(site) => !WILDCARD_BIND_ALLOWLIST.includes(site.path)
)
expect(
formatSites(unlisted),
"New WebSocketServer that binds a port without a host. Pass host: '127.0.0.1' so a foreign " +
'loopback listener cannot claim the port and answer in its place.'
).toEqual([])
})
it('has no stale allowlist entry', () => {
// Why this direction matters too: an entry left behind after the file was
// fixed hides the next regression in that same path.
const stale = WILDCARD_BIND_ALLOWLIST.filter((path) => !offenders.includes(path))
expect(stale, 'Allowlist entry no longer binds the wildcard — delete the line.').toEqual([])
})
it('holds the wildcard-bind count at the pin', () => {
// Bounding by the allowlist's own length would prove nothing: the two move
// together, so appending a line to silence a failure would keep the bound
// satisfied. The pin is a literal so that widening takes a second edit.
expect(
scan.wildcardBound.length,
`${scan.wildcardBound.length} constructions bind the wildcard; the pin is ` +
`${WILDCARD_BIND_PIN}. Never raise the pin -- pass host: '127.0.0.1' instead.`
).toBeLessThanOrEqual(WILDCARD_BIND_PIN)
// A pin left above reality is how a ratchet rots: it re-opens room for the
// next wildcard bind to land for free.
expect(
scan.wildcardBound.length,
`Only ${scan.wildcardBound.length} constructions bind the wildcard. Lower ` +
`WILDCARD_BIND_PIN to ${scan.wildcardBound.length} to keep the ground you just took.`
).toBeGreaterThanOrEqual(WILDCARD_BIND_PIN)
})
})
+11
View File
@@ -42,6 +42,17 @@ authority.
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
### Placeholders That Fail Open
`GitCapabilityCache` records commands Git *rejects*. A `git log --format`
placeholder Git does not know is not rejected: Git echoes it verbatim and exits
zero, so there is no error to remember and no probe to cache. Ask for both forms
in one record and pick at parse time.
| Placeholder | Preferred behavior | Compatibility behavior |
| ---------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `%(decorate:…)` | Git 2.43 separates commit decorations with `\x1f`, so ref names containing commas survive | The same record also carries `%D` (Git 2.10); an unexpanded `%(decorate` placeholder selects it, at the cost of comma-splitting |
## Why Not `simple-git`
`simple-git` is a process wrapper around the installed Git binary. Its custom
+11 -4
View File
@@ -18,7 +18,7 @@
"fumadocs-mdx": "^14.3.1",
"fumadocs-ui": "^16.8.4",
"lucide-react": "^1.6.0",
"next": "16.2.1",
"next": "16.3.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0",
@@ -32,10 +32,10 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"eslint-config-next": "16.3.4",
"tailwindcss": "^4",
"typescript": "^5",
"vercel": "50.37.0"
"vercel": "59.11.1"
},
"engines": {
"node": "22.x"
@@ -46,6 +46,13 @@
"esbuild",
"sharp",
"unrs-resolver"
]
],
"overrides": {
"@vercel/fun>tar": "7.5.22",
"@vercel/fun>@tootallnate/once": "2.0.1",
"@vercel/node>undici": "5.29.0",
"@vercel/python-analysis>js-yaml": "4.3.2",
"@vercel/python-analysis>minimatch": "10.2.6"
}
}
}
+1255 -737
View File
File diff suppressed because it is too large Load Diff
+179 -152
View File
@@ -93,7 +93,7 @@ importers:
version: 55.0.27(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3)
expo-router:
specifier: ^55.0.18
version: 55.0.18(2f99795f9796def5cdb1516a493dc117)
version: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e)
expo-secure-store:
specifier: ^55.0.18
version: 55.0.18(expo@55.0.30)
@@ -178,7 +178,7 @@ importers:
version: 0.25.4
expo-module-scripts:
specifier: ^55.0.2
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
happy-dom:
specifier: ^20.11.8
version: 20.11.8
@@ -199,10 +199,10 @@ importers:
version: 6.0.3
vite:
specifier: ^8.0.16
version: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)
version: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))
version: 4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))
packages:
@@ -2897,6 +2897,9 @@ packages:
'@types/node@26.1.2':
resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==}
'@types/node@26.4.0':
resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==}
'@types/react-native@0.73.0':
resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==}
deprecated: This is a stub types definition. react-native provides its own type definitions, so you do not need this installed.
@@ -3356,8 +3359,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.27:
resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==}
baseline-browser-mapping@2.11.20:
resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -3398,8 +3401,8 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
browserslist@4.28.8:
resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -3448,8 +3451,8 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
caniuse-lite@1.0.30001810:
resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
@@ -3941,8 +3944,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
electron-to-chromium@1.5.352:
resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==}
electron-to-chromium@1.5.416:
resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==}
emittery@0.13.1:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
@@ -5228,6 +5231,10 @@ packages:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
js-yaml@4.3.2:
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
jsc-safe-url@0.2.4:
resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==}
@@ -5492,8 +5499,8 @@ packages:
resolution: {integrity: sha512-tnn0J5wzgTgTx2OJy3Cwr1y79bJz4eNgFQd+2HENOs5Vz6QOMnt05z7J+BedIo9wIbpEa0iN9U1nerxyvMRE9g==}
engines: {node: '>=20.19.4'}
metro-babel-transformer@0.84.4:
resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==}
metro-babel-transformer@0.84.5:
resolution: {integrity: sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-cache-key@0.83.7:
@@ -5504,8 +5511,8 @@ packages:
resolution: {integrity: sha512-I38PtcjT4crS5HY9UQ8i6z8S7tJ2WewtPGr/OwS6FcLKfy5T/1hlTaFw+wozUZkEtNpR6Gc0oJuvuKCbSoSN5A==}
engines: {node: '>=20.19.4'}
metro-cache-key@0.84.4:
resolution: {integrity: sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==}
metro-cache-key@0.84.5:
resolution: {integrity: sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-cache@0.83.7:
@@ -5516,8 +5523,8 @@ packages:
resolution: {integrity: sha512-aogMG5WbKzW5000otNjYrS9hIoORzkCI1faPJK+vxQLaf2BorJKBBFe/jl2Tfsi1mZUglS2EBuBt8B3JB7MYDQ==}
engines: {node: '>=20.19.4'}
metro-cache@0.84.4:
resolution: {integrity: sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==}
metro-cache@0.84.5:
resolution: {integrity: sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-config@0.83.7:
@@ -5528,8 +5535,8 @@ packages:
resolution: {integrity: sha512-crNbNy+/B4tCne2+HjUshwvC57gNBQj+V9fFSy3lHH2RlKcLHib3Mil/SfTX8Pfh2fCY5pPuSu2OKa7YiadB+Q==}
engines: {node: '>=20.19.4'}
metro-config@0.84.4:
resolution: {integrity: sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==}
metro-config@0.84.5:
resolution: {integrity: sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-core@0.83.7:
@@ -5540,8 +5547,8 @@ packages:
resolution: {integrity: sha512-NTyOUOQaQKvQgJG9VI2ymN6KTM7gHEqkVFhkPc9bK4BsHSTz/EaXuFBXtC+wtwINLeH9tbusL/jfsSmdEmuU7A==}
engines: {node: '>=20.19.4'}
metro-core@0.84.4:
resolution: {integrity: sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==}
metro-core@0.84.5:
resolution: {integrity: sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-file-map@0.83.7:
@@ -5552,8 +5559,8 @@ packages:
resolution: {integrity: sha512-+W++EUuzEXIfWQEFTWQMVThzhWbnJL4gRNJ9WSHzIAM5pT7gsprUxo9+2hrfirURm/TLvrKwhq37oCECJcDSyQ==}
engines: {node: '>=20.19.4'}
metro-file-map@0.84.4:
resolution: {integrity: sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==}
metro-file-map@0.84.5:
resolution: {integrity: sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-minify-terser@0.83.7:
@@ -5564,8 +5571,8 @@ packages:
resolution: {integrity: sha512-7tU0J5/c7LZaZJwTlOb1xq0NepTFvGzRxigDhZOq4jSE6g0BRHmBqe7XvLH3WcRiJNGy+eshcZr34RpMjb6mmg==}
engines: {node: '>=20.19.4'}
metro-minify-terser@0.84.4:
resolution: {integrity: sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==}
metro-minify-terser@0.84.5:
resolution: {integrity: sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-resolver@0.83.7:
@@ -5576,8 +5583,8 @@ packages:
resolution: {integrity: sha512-piU0NVTI9i37YztDVF5rtn9uxP3NebVT0xZM9NKJ9z0jbigrctUQksi3NoYIeJMvL6Wn2dgAehpcmmnfn+gUwA==}
engines: {node: '>=20.19.4'}
metro-resolver@0.84.4:
resolution: {integrity: sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==}
metro-resolver@0.84.5:
resolution: {integrity: sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-runtime@0.83.7:
@@ -5588,8 +5595,8 @@ packages:
resolution: {integrity: sha512-f7FfeM0pamq8vrvs8aO9KvIUabbUKe0WkHFpLt6Q9yIIIsORqNFwlgJeHGraOFPU7Cxqj5yLXkJu5bT1uwDXvw==}
engines: {node: '>=20.19.4'}
metro-runtime@0.84.4:
resolution: {integrity: sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==}
metro-runtime@0.84.5:
resolution: {integrity: sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-source-map@0.83.7:
@@ -5600,8 +5607,8 @@ packages:
resolution: {integrity: sha512-60Uor7bM+KsVewLkLCcZfkPFCbqjPDdoSmeBuT3+ye+ac80BuLWbbR8DpyxWpUqQeZPdaAT5ZqFgDIs8BNEcVA==}
engines: {node: '>=20.19.4'}
metro-source-map@0.84.4:
resolution: {integrity: sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==}
metro-source-map@0.84.5:
resolution: {integrity: sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-symbolicate@0.83.7:
@@ -5614,8 +5621,8 @@ packages:
engines: {node: '>=20.19.4'}
hasBin: true
metro-symbolicate@0.84.4:
resolution: {integrity: sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==}
metro-symbolicate@0.84.5:
resolution: {integrity: sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
hasBin: true
@@ -5627,8 +5634,8 @@ packages:
resolution: {integrity: sha512-9JRPkvi+m0QH2Y/w5RjCF9mHqOUNFpMFLDDLhKUjhcKESh8Wm3HKDdHXHVldTN1lTToCIabv81nF0zyJzKEJ5g==}
engines: {node: '>=20.19.4'}
metro-transform-plugins@0.84.4:
resolution: {integrity: sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==}
metro-transform-plugins@0.84.5:
resolution: {integrity: sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-transform-worker@0.83.7:
@@ -5639,8 +5646,8 @@ packages:
resolution: {integrity: sha512-Pa2hOfhUmWpI/dmkhsLq8uGyFHK2opoEK7j/YCiRtqNSe0YzzxYguXTNgg8AMiuZfc9LPcB1AhGENS10O5wcIw==}
engines: {node: '>=20.19.4'}
metro-transform-worker@0.84.4:
resolution: {integrity: sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==}
metro-transform-worker@0.84.5:
resolution: {integrity: sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro@0.83.7:
@@ -5653,8 +5660,8 @@ packages:
engines: {node: '>=20.19.4'}
hasBin: true
metro@0.84.4:
resolution: {integrity: sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==}
metro@0.84.5:
resolution: {integrity: sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
hasBin: true
@@ -5763,8 +5770,9 @@ packages:
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
node-releases@2.0.54:
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
engines: {node: '>=18'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
@@ -5795,8 +5803,8 @@ packages:
resolution: {integrity: sha512-pk7el+eTOzfSKMAY4QBiiwKzegXn633JQj13y+pW5E5IdS+yV2CfDJ9Hf20K/LKy8nCrP9dAmd7XOk52OJxifA==}
engines: {node: '>=20.19.4'}
ob1@0.84.4:
resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==}
ob1@0.84.5:
resolution: {integrity: sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
object-assign@4.1.1:
@@ -6677,6 +6685,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
terser@5.51.2:
resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==}
engines: {node: '>=10'}
hasBin: true
test-exclude@6.0.0:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
@@ -6862,8 +6875,8 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
update-browserslist-db@1.3.2:
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
@@ -7301,7 +7314,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.3
'@babel/helper-validator-option': 7.27.1
browserslist: 4.28.2
browserslist: 4.28.8
lru-cache: 5.1.1
semver: 6.3.1
@@ -7309,7 +7322,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.7
'@babel/helper-validator-option': 7.29.7
browserslist: 4.28.2
browserslist: 4.28.8
lru-cache: 5.1.1
semver: 6.3.1
@@ -8694,7 +8707,7 @@ snapshots:
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.3.1
js-yaml: 4.3.2
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
@@ -8773,7 +8786,7 @@ snapshots:
ws: 8.21.3
zod: 3.25.76
optionalDependencies:
expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117)
expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e)
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -8985,7 +8998,7 @@ snapshots:
'@expo/json-file': 10.0.16
'@expo/metro': 55.1.2
'@expo/spawn-async': 1.8.0
browserslist: 4.28.2
browserslist: 4.28.8
chalk: 4.1.2
debug: 4.4.3
getenv: 2.0.0
@@ -9116,7 +9129,7 @@ snapshots:
react: 19.2.8
optionalDependencies:
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117)
expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e)
react-dom: 19.2.8(react@19.2.8)
transitivePeerDependencies:
- supports-color
@@ -9201,14 +9214,14 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@26.1.2)
jest-config: 29.7.0(@types/node@26.4.0)
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -9281,7 +9294,7 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@jridgewell/trace-mapping': 0.3.31
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
collect-v8-coverage: 1.0.3
exit: 0.1.2
@@ -9975,8 +9988,8 @@ snapshots:
dependencies:
'@react-native/js-polyfills': 0.85.2
'@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.7)
metro-config: 0.84.4
metro-runtime: 0.84.4
metro-config: 0.84.5
metro-runtime: 0.84.5
transitivePeerDependencies:
- '@babel/core'
- bufferutil
@@ -10126,7 +10139,7 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)':
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
jest-matcher-utils: 30.3.0
picocolors: 1.1.1
@@ -10136,7 +10149,7 @@ snapshots:
react-test-renderer: 19.2.8(react@19.2.8)
redent: 3.0.0
optionalDependencies:
jest: 29.7.0(@types/node@26.1.2)
jest: 29.7.0(@types/node@26.4.0)
'@tootallnate/once@2.0.1': {}
@@ -10345,6 +10358,10 @@ snapshots:
dependencies:
undici-types: 8.3.0
'@types/node@26.4.0':
dependencies:
undici-types: 8.3.0
'@types/react-native@0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)':
dependencies:
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
@@ -10525,13 +10542,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))':
'@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)
vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)
'@vitest/pretty-format@4.1.11':
dependencies:
@@ -10934,7 +10951,7 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.27: {}
baseline-browser-mapping@2.11.20: {}
better-opn@3.0.2:
dependencies:
@@ -10972,13 +10989,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
browserslist@4.28.2:
browserslist@4.28.8:
dependencies:
baseline-browser-mapping: 2.10.27
caniuse-lite: 1.0.30001792
electron-to-chromium: 1.5.352
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
baseline-browser-mapping: 2.11.20
caniuse-lite: 1.0.30001810
electron-to-chromium: 1.5.416
node-releases: 2.0.54
update-browserslist-db: 1.3.2(browserslist@4.28.8)
bs-logger@0.2.6:
dependencies:
@@ -11024,7 +11041,7 @@ snapshots:
camelcase@6.3.0: {}
caniuse-lite@1.0.30001792: {}
caniuse-lite@1.0.30001810: {}
chai@6.2.2: {}
@@ -11174,7 +11191,7 @@ snapshots:
core-js-compat@3.49.0:
dependencies:
browserslist: 4.28.2
browserslist: 4.28.8
cose-base@1.0.3:
dependencies:
@@ -11184,13 +11201,13 @@ snapshots:
dependencies:
layout-base: 2.0.1
create-jest@29.7.0(@types/node@26.1.2):
create-jest@29.7.0(@types/node@26.4.0):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@26.1.2)
jest-config: 29.7.0(@types/node@26.4.0)
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@@ -11554,7 +11571,7 @@ snapshots:
ee-first@1.1.1: {}
electron-to-chromium@1.5.352: {}
electron-to-chromium@1.5.416: {}
emittery@0.13.1: {}
@@ -12169,7 +12186,7 @@ snapshots:
expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3)
expo-json-utils: 55.0.2
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8):
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@babel/cli': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
@@ -12177,7 +12194,7 @@ snapshots:
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
'@expo/npm-proofread': 1.0.1
'@expo/spawn-async': 1.7.2
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
'@tsconfig/node18': 18.2.6
'@types/jest': 29.5.14
babel-plugin-dynamic-import-node: 2.3.3
@@ -12185,11 +12202,11 @@ snapshots:
commander: 12.1.0
eslint-config-universe: 15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3)
glob: 13.0.6
jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
jest-snapshot-prettier: prettier@2.8.8
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2))
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0))
resolve-workspace-root: 2.0.1
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3)
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- '@babel/core'
@@ -12252,7 +12269,7 @@ snapshots:
- supports-color
- typescript
expo-router@55.0.18(2f99795f9796def5cdb1516a493dc117):
expo-router@55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e):
dependencies:
'@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
@@ -12289,7 +12306,7 @@ snapshots:
use-latest-callback: 0.2.6(react@19.2.8)
vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
optionalDependencies:
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
react-dom: 19.2.8(react@19.2.8)
react-native-gesture-handler: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
react-native-reanimated: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
@@ -12950,7 +12967,7 @@ snapshots:
'@jest/expect': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
co: 4.6.0
dedent: 1.7.2
@@ -12970,16 +12987,16 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-cli@29.7.0(@types/node@26.1.2):
jest-cli@29.7.0(@types/node@26.4.0):
dependencies:
'@jest/core': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@26.1.2)
create-jest: 29.7.0(@types/node@26.4.0)
exit: 0.1.2
import-local: 3.2.0
jest-config: 29.7.0(@types/node@26.1.2)
jest-config: 29.7.0(@types/node@26.4.0)
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.3
@@ -12989,7 +13006,7 @@ snapshots:
- supports-color
- ts-node
jest-config@29.7.0(@types/node@26.1.2):
jest-config@29.7.0(@types/node@26.4.0):
dependencies:
'@babel/core': 7.29.7
'@jest/test-sequencer': 29.7.0
@@ -13014,7 +13031,7 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 26.1.2
'@types/node': 26.4.0
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -13069,7 +13086,7 @@ snapshots:
jest-mock: 29.7.0
jest-util: 29.7.0
jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
dependencies:
'@expo/config': 55.0.16(typescript@5.9.3)
'@expo/json-file': 10.0.14
@@ -13080,7 +13097,7 @@ snapshots:
jest-environment-jsdom: 29.7.0
jest-snapshot: 29.7.0
jest-watch-select-projects: 2.0.0
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2))
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0))
json5: 2.2.3
lodash: 4.18.1
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
@@ -13184,7 +13201,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
emittery: 0.13.1
graceful-fs: 4.2.11
@@ -13212,7 +13229,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
cjs-module-lexer: 1.4.3
collect-v8-coverage: 1.0.3
@@ -13279,11 +13296,11 @@ snapshots:
chalk: 3.0.0
prompts: 2.4.2
jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.1.2)):
jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.4.0)):
dependencies:
ansi-escapes: 6.2.1
chalk: 4.1.2
jest: 29.7.0(@types/node@26.1.2)
jest: 29.7.0(@types/node@26.4.0)
jest-regex-util: 29.6.3
jest-watcher: 29.7.0
slash: 5.1.0
@@ -13308,12 +13325,12 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
jest@29.7.0(@types/node@26.1.2):
jest@29.7.0(@types/node@26.4.0):
dependencies:
'@jest/core': 29.7.0
'@jest/types': 29.6.3
import-local: 3.2.0
jest-cli: 29.7.0(@types/node@26.1.2)
jest-cli: 29.7.0(@types/node@26.4.0)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -13333,6 +13350,10 @@ snapshots:
dependencies:
argparse: 2.0.1
js-yaml@4.3.2:
dependencies:
argparse: 2.0.1
jsc-safe-url@0.2.4: {}
jsdom@20.0.3:
@@ -13604,12 +13625,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-babel-transformer@0.84.4:
metro-babel-transformer@0.84.5:
dependencies:
'@babel/core': 7.29.7
flow-enums-runtime: 0.0.6
hermes-parser: 0.35.0
metro-cache-key: 0.84.4
metro-cache-key: 0.84.5
nullthrows: 1.1.1
transitivePeerDependencies:
- supports-color
@@ -13622,7 +13643,7 @@ snapshots:
dependencies:
flow-enums-runtime: 0.0.6
metro-cache-key@0.84.4:
metro-cache-key@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
@@ -13644,12 +13665,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-cache@0.84.4:
metro-cache@0.84.5:
dependencies:
exponential-backoff: 3.1.3
flow-enums-runtime: 0.0.6
https-proxy-agent: 7.0.6
metro-core: 0.84.4
metro-core: 0.84.5
transitivePeerDependencies:
- supports-color
@@ -13683,15 +13704,15 @@ snapshots:
- supports-color
- utf-8-validate
metro-config@0.84.4:
metro-config@0.84.5:
dependencies:
connect: 3.7.0
flow-enums-runtime: 0.0.6
jest-validate: 29.7.0
metro: 0.84.4
metro-cache: 0.84.4
metro-core: 0.84.4
metro-runtime: 0.84.4
metro: 0.84.5
metro-cache: 0.84.5
metro-core: 0.84.5
metro-runtime: 0.84.5
yaml: 2.9.0
transitivePeerDependencies:
- bufferutil
@@ -13710,11 +13731,11 @@ snapshots:
lodash.throttle: 4.1.1
metro-resolver: 0.83.8
metro-core@0.84.4:
metro-core@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
lodash.throttle: 4.1.1
metro-resolver: 0.84.4
metro-resolver: 0.84.5
metro-file-map@0.83.7:
dependencies:
@@ -13744,7 +13765,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-file-map@0.84.4:
metro-file-map@0.84.5:
dependencies:
debug: 4.4.3
fb-watchman: 2.0.2
@@ -13768,10 +13789,10 @@ snapshots:
flow-enums-runtime: 0.0.6
terser: 5.49.1
metro-minify-terser@0.84.4:
metro-minify-terser@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
terser: 5.49.1
terser: 5.51.2
metro-resolver@0.83.7:
dependencies:
@@ -13781,7 +13802,7 @@ snapshots:
dependencies:
flow-enums-runtime: 0.0.6
metro-resolver@0.84.4:
metro-resolver@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
@@ -13795,7 +13816,7 @@ snapshots:
'@babel/runtime': 7.29.7
flow-enums-runtime: 0.0.6
metro-runtime@0.84.4:
metro-runtime@0.84.5:
dependencies:
'@babel/runtime': 7.29.7
flow-enums-runtime: 0.0.6
@@ -13828,15 +13849,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-source-map@0.84.4:
metro-source-map@0.84.5:
dependencies:
'@babel/traverse': 7.29.8
'@babel/types': 7.29.8
flow-enums-runtime: 0.0.6
invariant: 2.2.4
metro-symbolicate: 0.84.4
metro-symbolicate: 0.84.5
nullthrows: 1.1.1
ob1: 0.84.4
ob1: 0.84.5
source-map: 0.5.7
vlq: 1.0.1
transitivePeerDependencies:
@@ -13864,11 +13885,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-symbolicate@0.84.4:
metro-symbolicate@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
invariant: 2.2.4
metro-source-map: 0.84.4
metro-source-map: 0.84.5
nullthrows: 1.1.1
source-map: 0.5.7
vlq: 1.0.1
@@ -13897,7 +13918,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-transform-plugins@0.84.4:
metro-transform-plugins@0.84.5:
dependencies:
'@babel/core': 7.29.7
'@babel/generator': 7.29.8
@@ -13948,20 +13969,20 @@ snapshots:
- supports-color
- utf-8-validate
metro-transform-worker@0.84.4:
metro-transform-worker@0.84.5:
dependencies:
'@babel/core': 7.29.7
'@babel/generator': 7.29.8
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
flow-enums-runtime: 0.0.6
metro: 0.84.4
metro-babel-transformer: 0.84.4
metro-cache: 0.84.4
metro-cache-key: 0.84.4
metro-minify-terser: 0.84.4
metro-source-map: 0.84.4
metro-transform-plugins: 0.84.4
metro: 0.84.5
metro-babel-transformer: 0.84.5
metro-cache: 0.84.5
metro-cache-key: 0.84.5
metro-minify-terser: 0.84.5
metro-source-map: 0.84.5
metro-transform-plugins: 0.84.5
nullthrows: 1.1.1
transitivePeerDependencies:
- bufferutil
@@ -14059,7 +14080,7 @@ snapshots:
- supports-color
- utf-8-validate
metro@0.84.4:
metro@0.84.5:
dependencies:
'@babel/code-frame': 7.29.7
'@babel/core': 7.29.7
@@ -14076,23 +14097,22 @@ snapshots:
flow-enums-runtime: 0.0.6
graceful-fs: 4.2.11
hermes-parser: 0.35.0
image-size: 1.2.1
invariant: 2.2.4
jest-worker: 29.7.0
jsc-safe-url: 0.2.4
lodash.throttle: 4.1.1
metro-babel-transformer: 0.84.4
metro-cache: 0.84.4
metro-cache-key: 0.84.4
metro-config: 0.84.4
metro-core: 0.84.4
metro-file-map: 0.84.4
metro-resolver: 0.84.4
metro-runtime: 0.84.4
metro-source-map: 0.84.4
metro-symbolicate: 0.84.4
metro-transform-plugins: 0.84.4
metro-transform-worker: 0.84.4
metro-babel-transformer: 0.84.5
metro-cache: 0.84.5
metro-cache-key: 0.84.5
metro-config: 0.84.5
metro-core: 0.84.5
metro-file-map: 0.84.5
metro-resolver: 0.84.5
metro-runtime: 0.84.5
metro-source-map: 0.84.5
metro-symbolicate: 0.84.5
metro-transform-plugins: 0.84.5
metro-transform-worker: 0.84.5
mime-types: 3.0.2
nullthrows: 1.1.1
serialize-error: 2.1.0
@@ -14175,7 +14195,7 @@ snapshots:
node-int64@0.4.0: {}
node-releases@2.0.38: {}
node-releases@2.0.54: {}
normalize-path@3.0.0: {}
@@ -14206,7 +14226,7 @@ snapshots:
dependencies:
flow-enums-runtime: 0.0.6
ob1@0.84.4:
ob1@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
@@ -15212,6 +15232,13 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
terser@5.51.2:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
test-exclude@6.0.0:
dependencies:
'@istanbuljs/schema': 0.1.6
@@ -15267,11 +15294,11 @@ snapshots:
ts-dedent@2.3.0: {}
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3):
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
jest: 29.7.0(@types/node@26.1.2)
jest: 29.7.0(@types/node@26.4.0)
jest-util: 29.7.0
json5: 2.2.3
lodash.memoize: 4.1.2
@@ -15381,9 +15408,9 @@ snapshots:
unpipe@1.0.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.2):
update-browserslist-db@1.3.2(browserslist@4.28.8):
dependencies:
browserslist: 4.28.2
browserslist: 4.28.8
escalade: 3.2.0
picocolors: 1.1.1
@@ -15442,7 +15469,7 @@ snapshots:
- '@types/react'
- '@types/react-dom'
vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0):
vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -15450,17 +15477,17 @@ snapshots:
rolldown: 1.1.3
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.1.2
'@types/node': 26.4.0
esbuild: 0.25.4
fsevents: 2.3.3
terser: 5.49.1
terser: 5.51.2
tsx: 4.22.4
yaml: 2.9.0
vitest@4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)):
vitest@4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))
'@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
@@ -15477,10 +15504,10 @@ snapshots:
tinyexec: 1.1.2
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)
vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.1.2
'@types/node': 26.4.0
happy-dom: 20.11.8
jsdom: 20.0.3
transitivePeerDependencies:
@@ -1,24 +1,31 @@
import { readFileSync } from 'node:fs'
const SOURCE_FILES = [
'./terminal-webview-html.ts',
'./terminal-webview-html/document-shell.ts',
'./terminal-webview-html/runtime-state-and-text-scaling.ts',
'./terminal-webview-html/fit-scale-and-write-queue.ts',
'./terminal-webview-html/terminal-init-and-write.ts',
'./terminal-webview-html/host-message-router.ts',
'./terminal-webview-html/selection-state-and-eviction.ts',
'./terminal-webview-html/term-observers-and-mode-mirroring.ts',
'./terminal-webview-html/mouse-report-and-scroll-routing.ts',
'./terminal-webview-html/smooth-scroll-and-cell-geometry.ts',
'./terminal-webview-html/selection-overlay.ts',
'./terminal-webview-html/surface-touch-gestures.ts',
'./terminal-webview-html/message-bridge-and-document-close.ts'
] as const
const COMPOSER_FILE = './terminal-webview-html.ts'
const SLICE_IMPORT_RE = /^import \{[^}]*\} from '(\.\/terminal-webview-html\/[\w-]+)'$/gm
const COMPOSED_ENTRY_RE = /^ {2}TERMINAL_HTML_\w+,?$/gm
/** Reads the TypeScript source that assembles the in-WebView document. */
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
}
/**
* Reads the TypeScript source that assembles the in-WebView document.
*
* Why: the slice list is derived from the composer's own imports rather than duplicated, so a
* new slice cannot join the emitted document while staying invisible to the tests that search
* this source. The count cross-check catches an import shape the regex cannot see.
*/
export function readTerminalWebViewHtmlSource(): string {
return SOURCE_FILES.map((relativePath) =>
readFileSync(new URL(relativePath, import.meta.url), 'utf8')
).join('\n')
const composer = readSource(COMPOSER_FILE)
const slices = [...composer.matchAll(SLICE_IMPORT_RE)].map((match) => `${match[1]}.ts`)
const composedCount = [...composer.matchAll(COMPOSED_ENTRY_RE)].length
if (composedCount === 0) {
throw new Error('no composed WebView document slices found')
}
if (slices.length !== composedCount) {
throw new Error(
`WebView document slice imports (${slices.length}) do not match composed entries (${composedCount})`
)
}
return [composer, ...slices.map(readSource)].join('\n')
}
+6 -2
View File
@@ -1,6 +1,8 @@
import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell'
import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling'
import { TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE } from './terminal-webview-html/fit-scale-and-write-queue'
import { TERMINAL_HTML_FIT_SCALE } from './terminal-webview-html/terminal-fit-scale'
import { TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN } from './terminal-webview-html/mouse-mode-decset-scan'
import { TERMINAL_HTML_WRITE_QUEUE } from './terminal-webview-html/write-queue'
import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write'
import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router'
import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction'
@@ -19,7 +21,9 @@ export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme'
export const XTERM_HTML = [
TERMINAL_HTML_DOCUMENT_SHELL,
TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING,
TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE,
TERMINAL_HTML_FIT_SCALE,
TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN,
TERMINAL_HTML_WRITE_QUEUE,
TERMINAL_HTML_INIT_AND_WRITE,
TERMINAL_HTML_HOST_MESSAGE_ROUTER,
TERMINAL_HTML_SELECTION_STATE_AND_EVICTION,
@@ -1,288 +0,0 @@
import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected'
// Also carries the DECSET mouse-mode scanner: emitted-document order pins it between these two concerns.
export const TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE = `${TERMINAL_WEBVIEW_THEME_JS}
function getCellHeight() {
if (!term || !term._core) return 15;
var core = term._core;
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.height || 15;
}
return 15;
}
// Why: clamp pan so the terminal content always covers the viewport
// when zoomed in. When content is smaller than viewport in a
// dimension, pin to top-left (no floating in the middle).
function clampPan() {
if (!term || !term.element) return;
var ts = getTotalScale();
var cw = term.element.scrollWidth * ts;
var ch = term.element.scrollHeight * ts;
var vpW = window.innerWidth;
var vpH = window.innerHeight;
if (cw > vpW) {
panX = Math.min(0, Math.max(vpW - cw, panX));
} else {
panX = 0;
}
if (ch > vpH) {
panY = Math.min(0, Math.max(vpH - ch, panY));
} else {
panY = 0;
}
}
// Why: intentional no-op. Mobile replays a live PTY snapshot then applies
// live cursor-relative chunks from that same PTY; resizing only the WebView
// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or
// overlap. Kept as a no-op so its call sites stay legible.
function adjustRowsForViewport() {}
// Why: cold-start fit. After init() opens xterm, the renderer needs
// several frames before cell dimensions are computed. Reading too early
// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
// not laid out), and computeFitScale returns 1 → no zoom.
//
// Gate: cellWidth × cols is the canonical "logical width" of the grid
// and reflects xterm's layout decision, independent of buffer content.
// We commit when cellWidth becomes positive (renderer ready). Fallback:
// if cellWidth never becomes available, gate on stable positive
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
var FIT_RETRY_MAX_FRAMES = 60;
var fitRetryToken = 0;
function applyFitScale(reason) {
if (!term || !term.element) return;
var token = ++fitRetryToken;
var attempts = 0;
var lastScrollWidth = -1;
function attempt() {
if (token !== fitRetryToken) return;
if (!term || !term.element) return;
attempts++;
var cellW = getCellWidth();
if (cellW > 0 && term.cols > 0) {
commitFitScale(reason, attempts, 'cellW');
return;
}
var w = term.element.scrollWidth;
if (w > 0 && w === lastScrollWidth) {
commitFitScale(reason, attempts, 'stableSW');
return;
}
lastScrollWidth = w;
if (attempts >= FIT_RETRY_MAX_FRAMES) {
flog('commit-timeout', {
reason: reason,
attempts: attempts,
cellW: cellW,
scrollWidth: w,
cols: term.cols
});
commitFitScale(reason, attempts, 'timeout');
return;
}
requestAnimationFrame(attempt);
}
requestAnimationFrame(attempt);
}
function commitFitScale(reason, attempts, gate) {
if (!term || !term.element) return;
var preSnapScale = computeFitScale();
currentScale = preSnapScale;
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
// a second applyFitScale from observing a "no-op needed" state.
if (currentScale >= 0.95) currentScale = 1;
userScale = 1;
panX = 0;
panY = 0;
smoothScrollOffsetY = 0;
updateTransform();
adjustRowsForViewport();
var cellW = getCellWidth();
var sw = term.element.scrollWidth;
var vpW = window.innerWidth;
var expectedW = cellW * term.cols;
var suspect =
currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom
if (suspect) {
flog('commit-SUSPECT', {
reason: reason,
attempts: attempts,
gate: gate,
preSnapScale: preSnapScale,
finalScale: currentScale,
cellW: cellW,
cols: term.cols,
expectedW: expectedW,
scrollWidth: sw,
vpWidth: vpW
});
}
repositionOverlay();
}
function isAltScreenActive(data) {
if (typeof data !== 'string') return false;
var on = data.lastIndexOf(ESC + '[?1049h');
var off = data.lastIndexOf(ESC + '[?1049l');
return on !== -1 && on > off;
}
function normalizeInitialData(data) {
if (!isAltScreenActive(data)) return data;
var on = data.lastIndexOf(ESC + '[?1049h');
// Why: SerializeAddon can include normal-buffer scrollback before the
// active alternate-screen snapshot. Replaying both into a fresh mobile
// xterm duplicates TUI frames and can flatten SGR attributes.
return on > 0 ? data.slice(on) : data;
}
function updateMouseModeFromData(data) {
if (typeof data !== 'string' || data.length === 0) return;
var input = mouseModeScanTail + data;
mouseModeScanTail = extractMouseModeScanTail(input);
var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g');
var match;
while ((match = re.exec(input)) !== null) {
if (match[0] === ESC + 'c') {
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
continue;
}
var enabled = (match[2] || match[4]) === 'h';
var params = (match[1] || match[3]).split(';');
for (var i = 0; i < params.length; i++) {
if (params[i] === '') continue;
var param = Number(params[i]);
if (!Number.isInteger(param)) continue;
if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none';
if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none';
if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none';
if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none';
if (param === 1006) {
sgrMouseMode = enabled;
sgrMousePixelsMode = false;
}
if (param === 1016) {
sgrMouseMode = false;
sgrMousePixelsMode = enabled;
}
}
}
}
function resetWriteQueue() {
writeQueue = [];
writeQueueHead = 0;
}
function isStatusDotPresentationSelector(value) {
return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR;
}
function endsWithStatusDotPresentationSequence(data) {
var i = data.length - 1;
while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--;
return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT;
}
// Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph.
function normalizeStatusDotPresentation(data) {
if (typeof data !== 'string' || data.length === 0) return data;
if (statusDotPendingSelector) {
statusDotPendingSelector = false;
var strippedPendingSelectors = false;
while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1);
strippedPendingSelectors = data.length === 0;
if (strippedPendingSelectors) {
statusDotPendingSelector = true;
return '';
}
}
var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR);
statusDotPendingSelector = endsWithStatusDotPresentationSequence(data);
return normalized;
}
function enqueueWrite(data) {
writeQueue.push(normalizeStatusDotPresentation(data));
}
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
resetWriteQueue();
return undefined;
}
var next = writeQueue[writeQueueHead];
writeQueueHead++;
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
writeQueue = writeQueue.slice(writeQueueHead);
writeQueueHead = 0;
}
return next;
}
function disposeTermObservers() {
var disposables = termObserverDisposables;
termObserverDisposables = [];
for (var i = 0; i < disposables.length; i++) {
try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {}
}
}
function extractMouseModeScanTail(input) {
var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI));
if (start === -1) return '';
var tail = input.slice(start);
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return '';
if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail;
if (tail.indexOf(ESC + '[?') === 0) {
return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '';
}
if (tail.indexOf(C1_CSI + '?') === 0) {
return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '';
}
return '';
}
function pumpWrites(gen) {
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = nextQueuedWrite();
if (typeof next !== 'string') {
if (typeof next === 'function') return next(), pumpWrites(gen);
var callbacks = afterDrainCallbacks;
afterDrainCallbacks = [];
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
return;
}
writesDraining = true;
// Why: xterm.write() parses asynchronously. Row adjustment/resizing must
// wait until replayed SGR attributes have landed in the buffer.
term.write(next, function() {
if (gen !== terminalGeneration) return;
writesDraining = false;
pumpWrites(gen);
});
}
function afterWritesDrained(callback) {
afterDrainCallbacks.push(callback);
pumpWrites(terminalGeneration);
}
`
@@ -0,0 +1,52 @@
export const TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN = ` function isAltScreenActive(data) {
if (typeof data !== 'string') return false;
var on = data.lastIndexOf(ESC + '[?1049h');
var off = data.lastIndexOf(ESC + '[?1049l');
return on !== -1 && on > off;
}
function normalizeInitialData(data) {
if (!isAltScreenActive(data)) return data;
var on = data.lastIndexOf(ESC + '[?1049h');
// Why: SerializeAddon can include normal-buffer scrollback before the
// active alternate-screen snapshot. Replaying both into a fresh mobile
// xterm duplicates TUI frames and can flatten SGR attributes.
return on > 0 ? data.slice(on) : data;
}
function updateMouseModeFromData(data) {
if (typeof data !== 'string' || data.length === 0) return;
var input = mouseModeScanTail + data;
mouseModeScanTail = extractMouseModeScanTail(input);
var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g');
var match;
while ((match = re.exec(input)) !== null) {
if (match[0] === ESC + 'c') {
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
continue;
}
var enabled = (match[2] || match[4]) === 'h';
var params = (match[1] || match[3]).split(';');
for (var i = 0; i < params.length; i++) {
if (params[i] === '') continue;
var param = Number(params[i]);
if (!Number.isInteger(param)) continue;
if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none';
if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none';
if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none';
if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none';
if (param === 1006) {
sgrMouseMode = enabled;
sgrMousePixelsMode = false;
}
if (param === 1016) {
sgrMouseMode = false;
sgrMousePixelsMode = enabled;
}
}
}
}
`
@@ -0,0 +1,130 @@
import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected'
// Opens with the injected theme block: it lands at this point in the emitted document.
export const TERMINAL_HTML_FIT_SCALE = `${TERMINAL_WEBVIEW_THEME_JS}
function getCellHeight() {
if (!term || !term._core) return 15;
var core = term._core;
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.height || 15;
}
return 15;
}
// Why: clamp pan so the terminal content always covers the viewport
// when zoomed in. When content is smaller than viewport in a
// dimension, pin to top-left (no floating in the middle).
function clampPan() {
if (!term || !term.element) return;
var ts = getTotalScale();
var cw = term.element.scrollWidth * ts;
var ch = term.element.scrollHeight * ts;
var vpW = window.innerWidth;
var vpH = window.innerHeight;
if (cw > vpW) {
panX = Math.min(0, Math.max(vpW - cw, panX));
} else {
panX = 0;
}
if (ch > vpH) {
panY = Math.min(0, Math.max(vpH - ch, panY));
} else {
panY = 0;
}
}
// Why: intentional no-op. Mobile replays a live PTY snapshot then applies
// live cursor-relative chunks from that same PTY; resizing only the WebView
// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or
// overlap. Kept as a no-op so its call sites stay legible.
function adjustRowsForViewport() {}
// Why: cold-start fit. After init() opens xterm, the renderer needs
// several frames before cell dimensions are computed. Reading too early
// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
// not laid out), and computeFitScale returns 1 → no zoom.
//
// Gate: cellWidth × cols is the canonical "logical width" of the grid
// and reflects xterm's layout decision, independent of buffer content.
// We commit when cellWidth becomes positive (renderer ready). Fallback:
// if cellWidth never becomes available, gate on stable positive
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
var FIT_RETRY_MAX_FRAMES = 60;
var fitRetryToken = 0;
function applyFitScale(reason) {
if (!term || !term.element) return;
var token = ++fitRetryToken;
var attempts = 0;
var lastScrollWidth = -1;
function attempt() {
if (token !== fitRetryToken) return;
if (!term || !term.element) return;
attempts++;
var cellW = getCellWidth();
if (cellW > 0 && term.cols > 0) {
commitFitScale(reason, attempts, 'cellW');
return;
}
var w = term.element.scrollWidth;
if (w > 0 && w === lastScrollWidth) {
commitFitScale(reason, attempts, 'stableSW');
return;
}
lastScrollWidth = w;
if (attempts >= FIT_RETRY_MAX_FRAMES) {
flog('commit-timeout', {
reason: reason,
attempts: attempts,
cellW: cellW,
scrollWidth: w,
cols: term.cols
});
commitFitScale(reason, attempts, 'timeout');
return;
}
requestAnimationFrame(attempt);
}
requestAnimationFrame(attempt);
}
function commitFitScale(reason, attempts, gate) {
if (!term || !term.element) return;
var preSnapScale = computeFitScale();
currentScale = preSnapScale;
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
// a second applyFitScale from observing a "no-op needed" state.
if (currentScale >= 0.95) currentScale = 1;
userScale = 1;
panX = 0;
panY = 0;
smoothScrollOffsetY = 0;
updateTransform();
adjustRowsForViewport();
var cellW = getCellWidth();
var sw = term.element.scrollWidth;
var vpW = window.innerWidth;
var expectedW = cellW * term.cols;
var suspect =
currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom
if (suspect) {
flog('commit-SUSPECT', {
reason: reason,
attempts: attempts,
gate: gate,
preSnapScale: preSnapScale,
finalScale: currentScale,
cellW: cellW,
cols: term.cols,
expectedW: expectedW,
scrollWidth: sw,
vpWidth: vpW
});
}
repositionOverlay();
}
`
@@ -0,0 +1,110 @@
// Also carries disposeTermObservers() and extractMouseModeScanTail(): both belong to
// other concerns, but emitted-document order pins them inside this queue.
export const TERMINAL_HTML_WRITE_QUEUE = ` function resetWriteQueue() {
writeQueue = [];
writeQueueHead = 0;
}
function isStatusDotPresentationSelector(value) {
return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR;
}
function endsWithStatusDotPresentationSequence(data) {
var i = data.length - 1;
while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--;
return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT;
}
// Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph.
function normalizeStatusDotPresentation(data) {
if (typeof data !== 'string' || data.length === 0) return data;
if (statusDotPendingSelector) {
statusDotPendingSelector = false;
var strippedPendingSelectors = false;
while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1);
strippedPendingSelectors = data.length === 0;
if (strippedPendingSelectors) {
statusDotPendingSelector = true;
return '';
}
}
var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR);
statusDotPendingSelector = endsWithStatusDotPresentationSequence(data);
return normalized;
}
function enqueueWrite(data) {
writeQueue.push(normalizeStatusDotPresentation(data));
}
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
resetWriteQueue();
return undefined;
}
var next = writeQueue[writeQueueHead];
writeQueueHead++;
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
writeQueue = writeQueue.slice(writeQueueHead);
writeQueueHead = 0;
}
return next;
}
function disposeTermObservers() {
var disposables = termObserverDisposables;
termObserverDisposables = [];
for (var i = 0; i < disposables.length; i++) {
try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {}
}
}
function extractMouseModeScanTail(input) {
var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI));
if (start === -1) return '';
var tail = input.slice(start);
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return '';
if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail;
if (tail.indexOf(ESC + '[?') === 0) {
return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '';
}
if (tail.indexOf(C1_CSI + '?') === 0) {
return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '';
}
return '';
}
function pumpWrites(gen) {
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = nextQueuedWrite();
if (typeof next !== 'string') {
if (typeof next === 'function') return next(), pumpWrites(gen);
var callbacks = afterDrainCallbacks;
afterDrainCallbacks = [];
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
return;
}
writesDraining = true;
// Why: xterm.write() parses asynchronously. Row adjustment/resizing must
// wait until replayed SGR attributes have landed in the buffer.
term.write(next, function() {
if (gen !== terminalGeneration) return;
writesDraining = false;
pumpWrites(gen);
});
}
function afterWritesDrained(callback) {
afterDrainCallbacks.push(callback);
pumpWrites(terminalGeneration);
}
`
@@ -0,0 +1,17 @@
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
// Why: every other WebView test exercises one slice of the document, so an edit to an
// uncovered region ships silently. A diff here means the emitted WebView source changed —
// update these values only when that change is deliberate, and only after checking the
// document still runs. Refactors that merely move slice boundaries must leave them alone.
const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608'
const EXPECTED_LENGTH = 729776
describe('terminal WebView payload', () => {
it('composes the expected document', () => {
expect(XTERM_HTML.length).toBe(EXPECTED_LENGTH)
expect(createHash('sha256').update(XTERM_HTML, 'utf8').digest('hex')).toBe(EXPECTED_SHA256)
})
})
@@ -59,7 +59,8 @@ function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null {
// fail with EADDRINUSE; the full scenario restarts on the captured port
// because the client keeps reconnecting to its original URL.
function startServer(port = 0): Promise<WebSocketServer> {
const wss = new WebSocketServer({ port })
// host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here.
const wss = new WebSocketServer({ host: '127.0.0.1', port })
wss.on('connection', (ws: ServerSocket) => {
let sharedKey: Uint8Array | null = null
let authenticated = false
+5
View File
@@ -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
+6 -2
View File
@@ -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 {
+6 -2
View File
@@ -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.
+3 -1
View File
@@ -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: {
+1 -1
View File
@@ -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) =>
+3 -1
View File
@@ -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 () => ({
+3 -1
View File
@@ -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)
}
+31
View File
@@ -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)
}
+2
View File
@@ -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>
}
+4 -1
View File
@@ -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()
})
})
+45 -27
View File
@@ -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)
})
+2 -15
View File
@@ -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,
+69 -46
View File
@@ -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)
})
}
}
+78
View File
@@ -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')
})
})
+72
View File
@@ -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()
}
+49
View File
@@ -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 }
}
+24
View File
@@ -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
})
})
+272
View File
@@ -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)
})
})
+202
View File
@@ -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
View File
@@ -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')
})
})
+120
View File
@@ -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)
})
})
+43 -10
View File
@@ -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
})
})
})
+12 -9
View File
@@ -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' })
}
})
})
+165
View File
@@ -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'
}
+15
View File
@@ -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.
+6 -1
View File
@@ -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)
+42 -39
View File
@@ -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)
}
+1 -1
View File
@@ -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 = {}
+8 -2
View File
@@ -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]?.()
+19 -3
View File
@@ -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)
}
/**
+50
View File
@@ -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({})
})
+13 -13
View File
@@ -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')
+1
View File
@@ -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'
})
+8 -8
View File
@@ -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)
+4 -4
View File
@@ -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
)
}
}
+2
View File
@@ -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 () => {
+5 -2
View File
@@ -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', () => {

Some files were not shown because too many files have changed in this diff Show More