Merge remote-tracking branch 'origin/main' into adhoc/ssh-sweep-combined

This commit is contained in:
Neil
2026-09-01 20:28:28 -07:00
301 changed files with 14369 additions and 2915 deletions
+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\}/)
})
})
+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)
+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 }
: {})
}
}
@@ -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'
@@ -88,6 +89,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 +187,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'
}
}
+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 }
}
@@ -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)
})
+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
+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')
}
@@ -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) {
+4 -1
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', () => ({
+2
View File
@@ -24,7 +24,9 @@ let storeRef: Store | null = null
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
'app_starred_orca',
'daemon_adopted',
'daemon_audit_eligibility',
'daemon_pty_cwd_denied',
'star_nag_outcome',
'feature_interaction_usage_bucket_reached'
])
@@ -0,0 +1,289 @@
import { readdir, stat } from 'node:fs/promises'
import type { Dirent } from 'node:fs'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { forEachWithConcurrency } from '../../shared/map-with-concurrency'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
import type {
WorktreeBasePollerOptions,
WorktreeBasePollEvent,
WorktreeBaseSubscription,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
// Why: the mtime gate is an optimization, not a correctness boundary — some
// filesystems have coarse dir timestamps, and pending `.git` markers expire.
// A periodic ungated scan guarantees eventual convergence.
export const WORKTREE_BASE_BACKSTOP_TICKS = 15
// Why: a `.git` completion marker lands within moments of its worktree dir
// (git writes it before populating the checkout). Dirs that never get one are
// not worktrees; stop re-statting them after this many ticks and let the
// backstop scan cover the pathological case.
const PENDING_MARKER_MAX_TICKS = 300
// Why: matches the git-common poller's fan-out bound (#17828) — bounded
// concurrency turns hundreds of serial round trips into a handful of batches
// without dumping every candidate onto libuv's 4-thread pool at once.
const MARKER_PROBE_CONCURRENCY = 8
function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string {
return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}`
}
async function dirSignature(path: string): Promise<string> {
try {
return statSignature(await stat(path))
} catch {
return 'missing'
}
}
async function hasGitMarker(dir: string): Promise<boolean> {
try {
await stat(join(dir, '.git'))
return true
} catch {
return false
}
}
type BaseSnapshot = {
// worktree-candidate dir → whether its `.git` completion marker exists
markers: Map<string, boolean>
// dirs whose listing determines the candidate set: the root plus any
// nested repo containers. Their stat signatures gate the next full scan.
gateDirs: string[]
// index-aligned with gateDirs, each sampled *before* that dir's listing
gateSignatures: string[]
}
async function readdirSafe(path: string): Promise<Dirent[]> {
try {
return await readdir(path, { withFileTypes: true })
} catch {
return []
}
}
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
// repo's container, mirroring what worktree-base-directory-event-filter
// matches: `<wt>/.git` completion markers and `<wt>` deletions.
async function snapshotBase(
rootPath: string,
repos: ReadonlyMap<string, WorktreeBaseRepoWatchConfig>
): Promise<BaseSnapshot> {
const markers = new Map<string, boolean>()
const gateDirs = [rootPath]
// Why: sampling the signature before the listing makes a write that races the
// scan look stale next tick (one redundant rescan) instead of invisible until
// the backstop, which is up to 15 ticks of missed creates/deletes.
const gateSignatures = [await dirSignature(rootPath)]
const configs = [...repos.values()]
const includeFlat = configs.some((config) => !config.nestWorkspaces)
const nestedRepoNames = new Set(
configs
.filter((config) => config.nestWorkspaces)
.map((config) => normalizeRuntimePathForComparison(config.repoName))
)
// Root vanished or unreadable: readdirSafe yields [], producing the same
// empty markers/candidates result as the old watcher's error path.
const rootEntries = await readdirSafe(rootPath)
const candidates: string[] = []
for (const entry of rootEntries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
continue
}
const entryPath = join(rootPath, entry.name)
if (includeFlat) {
candidates.push(entryPath)
}
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
gateDirs.push(entryPath)
gateSignatures.push(await dirSignature(entryPath))
const subEntries = await readdirSafe(entryPath)
for (const sub of subEntries) {
if (sub.isDirectory() || sub.isSymbolicLink()) {
candidates.push(join(entryPath, sub.name))
}
}
}
}
await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => {
markers.set(dir, await hasGitMarker(dir))
})
return { markers, gateDirs, gateSignatures }
}
function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] {
const events: WorktreeBasePollEvent[] = []
for (const [dir, marker] of next.markers) {
if (marker && prev.markers.get(dir) !== true) {
events.push({ type: 'create', path: join(dir, '.git') })
}
}
for (const dir of prev.markers.keys()) {
if (!next.markers.has(dir)) {
events.push({ type: 'delete', path: dir })
}
}
return events
}
export async function startBasePoller(
target: WorktreeBaseWatchTarget,
getRepos: () => ReadonlyMap<string, WorktreeBaseRepoWatchConfig>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
options: WorktreeBasePollerOptions
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let tickCount = 0
let snapshot = await snapshotBase(target.path, getRepos())
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
// dir → first probe tick; null means backstop scans only
const markerProbeStartedAt = new Map<string, number | null>()
for (const [dir, marker] of snapshot.markers) {
if (!marker) {
markerProbeStartedAt.set(dir, 0)
}
}
const fullScan = async (): Promise<void> => {
options.onFullScan?.()
const next = await snapshotBase(target.path, getRepos())
await options.onSnapshotTaken?.(tickCount)
if (disposed) {
return
}
const events = diffBase(snapshot, next)
for (const [dir, marker] of next.markers) {
if (marker) {
markerProbeStartedAt.delete(dir)
} else if (!markerProbeStartedAt.has(dir)) {
markerProbeStartedAt.set(dir, tickCount)
}
}
for (const dir of markerProbeStartedAt.keys()) {
if (!next.markers.has(dir)) {
markerProbeStartedAt.delete(dir)
}
}
snapshot = next
if (events.length > 0) {
onEvents(events)
}
}
const checkPendingMarkers = async (): Promise<void> => {
const events: WorktreeBasePollEvent[] = []
for (const [dir, firstSeenTick] of markerProbeStartedAt) {
if (firstSeenTick === null) {
continue
}
if (tickCount - firstSeenTick > pendingMarkerMaxTicks) {
markerProbeStartedAt.set(dir, null)
continue
}
options.onPendingMarkerProbe?.(join(dir, '.git'))
if (await hasGitMarker(dir)) {
markerProbeStartedAt.delete(dir)
snapshot.markers.set(dir, true)
events.push({ type: 'create', path: join(dir, '.git') })
}
}
if (!disposed && events.length > 0) {
onEvents(events)
}
}
const poll = async (forceFullScan = false): Promise<void> => {
tickCount++
if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) {
await fullScan()
return
}
// Idle fast path: when the dirs whose listings define the candidate set
// are untouched, skip the readdir + per-candidate stat fan-out entirely.
const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
const gateChanged =
signatures.length !== snapshot.gateSignatures.length ||
signatures.some((sig, index) => sig !== snapshot.gateSignatures[index])
if (gateChanged) {
await fullScan()
return
}
if (markerProbeStartedAt.size > 0) {
await checkPendingMarkers()
}
}
const tick = async (forceFullScan = false): Promise<void> => {
timer = null
if (disposed) {
return
}
if (!visibility.isWindowVisible()) {
parkedWhileHidden = true
return
}
if (ticking) {
return
}
ticking = true
// Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not
// gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick.
const startedAt = Date.now()
try {
await poll(forceFullScan)
} catch {
// Transient fs error: keep the previous snapshot and retry next tick.
} finally {
ticking = false
}
if (!disposed) {
// Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would
// otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for
// minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative.
const nextDelay = Math.max(
0,
Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt))
)
timer = setTimeout(() => void tick(), nextDelay)
timer.unref?.()
}
}
const unsubscribeVisibility = visibility.onWindowBecameVisible(() => {
if (disposed || !parkedWhileHidden) {
return
}
parkedWhileHidden = false
// Why: the ordinary dir-signature gate can miss same-granule changes made
// while hidden; resume must diff a fresh full snapshot against the baseline.
void tick(true)
})
timer = setTimeout(() => void tick(), pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
if (timer) {
clearTimeout(timer)
}
unsubscribeVisibility()
}
}
}
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
// Why: the backstop full scan stats a `.git` marker per candidate dir; an
// unbounded fan-out at hundreds of worktrees would queue thousands of `stat`
// calls on libuv's 4-thread pool (#17828).
const { concurrency } = vi.hoisted(() => ({ concurrency: { current: 0, peak: 0 } }))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
stat: async (...args: Parameters<typeof actual.stat>) => {
concurrency.current += 1
concurrency.peak = Math.max(concurrency.peak, concurrency.current)
try {
return await actual.stat(...args)
} finally {
concurrency.current -= 1
}
}
}
})
function makeTarget(path: string): WorktreeBaseWatchTarget {
const repoConfig: WorktreeBaseRepoWatchConfig = {
repoId: 'repo-1',
repoName: 'project',
nestWorkspaces: false
}
return {
key: `base:local:${path}`,
kind: 'base',
path,
repos: new Map([[repoConfig.repoId, repoConfig]])
}
}
describe('worktree base directory poller marker fan-out (#17828)', () => {
const cleanups: (() => Promise<void>)[] = []
beforeEach(() => {
concurrency.current = 0
concurrency.peak = 0
})
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
})
it('bounds concurrent `.git`-marker stats regardless of candidate count', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-base-poller-fanout-')))
cleanups.push(() => rm(root, { recursive: true, force: true }))
const candidateCount = 200
for (let i = 0; i < candidateCount; i++) {
const worktree = join(root, `wt-${i}`)
await mkdir(worktree)
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
}
const target = makeTarget(root)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
() => {},
{ pollIntervalMs: 100_000 }
)
cleanups.push(() => poller.unsubscribe())
// 200 candidates stated unbounded would peak near 200 concurrent `stat`
// calls; bounding the marker probe keeps the peak independent of count —
// while still overlapping requests (not serialized one-at-a-time).
expect(concurrency.peak).toBeGreaterThan(1)
expect(concurrency.peak).toBeLessThan(20)
})
})
+7 -275
View File
@@ -1,13 +1,13 @@
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
import { startBasePoller } from './worktree-base-directory-marker-poller'
import { startGitCommonWatch } from './worktree-git-common-watch'
export { WORKTREE_BASE_BACKSTOP_TICKS } from './worktree-base-directory-marker-poller'
export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string }
export type WorktreeBaseSubscription = { unsubscribe: () => Promise<void> }
@@ -61,6 +61,8 @@ export type WorktreeBasePollerOptions = {
visibility?: WorktreePollerWindowVisibility
getGitStatusRefPaths?: () => readonly string[]
onWatchError?: (error: Error) => void
/** Called when the watcher child dropped an event batch (git-common narrow watch only). */
onOverflow?: () => void
/** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */
onFullScan?: () => void
/** Test hook: called before a pending `.git` marker stat. */
@@ -81,277 +83,6 @@ export type WorktreeBasePollerOptions = {
// Orca's own worktree operations notify the renderer directly.
export const WORKTREE_BASE_POLL_INTERVAL_MS = 2_000
// Why: the mtime gate is an optimization, not a correctness boundary — some
// filesystems have coarse dir timestamps, and pending `.git` markers expire.
// A periodic ungated scan guarantees eventual convergence.
export const WORKTREE_BASE_BACKSTOP_TICKS = 15
// Why: a `.git` completion marker lands within moments of its worktree dir
// (git writes it before populating the checkout). Dirs that never get one are
// not worktrees; stop re-statting them after this many ticks and let the
// backstop scan cover the pathological case.
const PENDING_MARKER_MAX_TICKS = 300
function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string {
return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}`
}
async function dirSignature(path: string): Promise<string> {
try {
return statSignature(await stat(path))
} catch {
return 'missing'
}
}
async function hasGitMarker(dir: string): Promise<boolean> {
try {
await stat(join(dir, '.git'))
return true
} catch {
return false
}
}
type BaseSnapshot = {
// worktree-candidate dir → whether its `.git` completion marker exists
markers: Map<string, boolean>
// dirs whose listing determines the candidate set: the root plus any
// nested repo containers. Their stat signatures gate the next full scan.
gateDirs: string[]
// index-aligned with gateDirs, each sampled *before* that dir's listing
gateSignatures: string[]
}
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
// repo's container, mirroring what worktree-base-directory-event-filter
// matches: `<wt>/.git` completion markers and `<wt>` deletions.
async function snapshotBase(
rootPath: string,
repos: ReadonlyMap<string, WorktreeBaseRepoWatchConfig>
): Promise<BaseSnapshot> {
const markers = new Map<string, boolean>()
const gateDirs = [rootPath]
// Why: sampling the signature before the listing makes a write that races the
// scan look stale next tick (one redundant rescan) instead of invisible until
// the backstop, which is up to 15 ticks of missed creates/deletes.
const gateSignatures = [await dirSignature(rootPath)]
const configs = [...repos.values()]
const includeFlat = configs.some((config) => !config.nestWorkspaces)
const nestedRepoNames = new Set(
configs
.filter((config) => config.nestWorkspaces)
.map((config) => normalizeRuntimePathForComparison(config.repoName))
)
let rootEntries
try {
rootEntries = await readdir(rootPath, { withFileTypes: true })
} catch {
// Root vanished: an empty snapshot diffs into delete events for every
// previously-known worktree dir, matching the old watcher's error path.
return { markers, gateDirs, gateSignatures }
}
const candidates: string[] = []
for (const entry of rootEntries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
continue
}
const entryPath = join(rootPath, entry.name)
if (includeFlat) {
candidates.push(entryPath)
}
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
gateDirs.push(entryPath)
gateSignatures.push(await dirSignature(entryPath))
let subEntries
try {
subEntries = await readdir(entryPath, { withFileTypes: true })
} catch {
subEntries = []
}
for (const sub of subEntries) {
if (sub.isDirectory() || sub.isSymbolicLink()) {
candidates.push(join(entryPath, sub.name))
}
}
}
}
for (const dir of candidates) {
markers.set(dir, await hasGitMarker(dir))
}
return { markers, gateDirs, gateSignatures }
}
function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] {
const events: WorktreeBasePollEvent[] = []
for (const [dir, marker] of next.markers) {
if (marker && prev.markers.get(dir) !== true) {
events.push({ type: 'create', path: join(dir, '.git') })
}
}
for (const dir of prev.markers.keys()) {
if (!next.markers.has(dir)) {
events.push({ type: 'delete', path: dir })
}
}
return events
}
async function startBasePoller(
target: WorktreeBaseWatchTarget,
getRepos: () => ReadonlyMap<string, WorktreeBaseRepoWatchConfig>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
options: WorktreeBasePollerOptions
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let tickCount = 0
let snapshot = await snapshotBase(target.path, getRepos())
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
// dir → first probe tick; null means backstop scans only
const markerProbeStartedAt = new Map<string, number | null>()
for (const [dir, marker] of snapshot.markers) {
if (!marker) {
markerProbeStartedAt.set(dir, 0)
}
}
const fullScan = async (): Promise<void> => {
options.onFullScan?.()
const next = await snapshotBase(target.path, getRepos())
await options.onSnapshotTaken?.(tickCount)
if (disposed) {
return
}
const events = diffBase(snapshot, next)
for (const [dir, marker] of next.markers) {
if (marker) {
markerProbeStartedAt.delete(dir)
} else if (!markerProbeStartedAt.has(dir)) {
markerProbeStartedAt.set(dir, tickCount)
}
}
for (const dir of markerProbeStartedAt.keys()) {
if (!next.markers.has(dir)) {
markerProbeStartedAt.delete(dir)
}
}
snapshot = next
if (events.length > 0) {
onEvents(events)
}
}
const checkPendingMarkers = async (): Promise<void> => {
const events: WorktreeBasePollEvent[] = []
for (const [dir, firstSeenTick] of markerProbeStartedAt) {
if (firstSeenTick === null) {
continue
}
if (tickCount - firstSeenTick > pendingMarkerMaxTicks) {
markerProbeStartedAt.set(dir, null)
continue
}
options.onPendingMarkerProbe?.(join(dir, '.git'))
if (await hasGitMarker(dir)) {
markerProbeStartedAt.delete(dir)
snapshot.markers.set(dir, true)
events.push({ type: 'create', path: join(dir, '.git') })
}
}
if (!disposed && events.length > 0) {
onEvents(events)
}
}
const poll = async (forceFullScan = false): Promise<void> => {
tickCount++
if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) {
await fullScan()
return
}
// Idle fast path: when the dirs whose listings define the candidate set
// are untouched, skip the readdir + per-candidate stat fan-out entirely.
const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
const gateChanged =
signatures.length !== snapshot.gateSignatures.length ||
signatures.some((sig, index) => sig !== snapshot.gateSignatures[index])
if (gateChanged) {
await fullScan()
return
}
if (markerProbeStartedAt.size > 0) {
await checkPendingMarkers()
}
}
const tick = async (forceFullScan = false): Promise<void> => {
timer = null
if (disposed) {
return
}
if (!visibility.isWindowVisible()) {
parkedWhileHidden = true
return
}
if (ticking) {
return
}
ticking = true
// Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not
// gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick.
const startedAt = Date.now()
try {
await poll(forceFullScan)
} catch {
// Transient fs error: keep the previous snapshot and retry next tick.
} finally {
ticking = false
}
if (!disposed) {
// Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would
// otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for
// minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative.
const nextDelay = Math.max(
0,
Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt))
)
timer = setTimeout(() => void tick(), nextDelay)
timer.unref?.()
}
}
const unsubscribeVisibility = visibility.onWindowBecameVisible(() => {
if (disposed || !parkedWhileHidden) {
return
}
parkedWhileHidden = false
// Why: the ordinary dir-signature gate can miss same-granule changes made
// while hidden; resume must diff a fresh full snapshot against the baseline.
void tick(true)
})
timer = setTimeout(() => void tick(), pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
if (timer) {
clearTimeout(timer)
}
unsubscribeVisibility()
}
}
}
/** Watches the shallow paths a worktree base target cares about and emits
* watcher-shaped events. Resolves once the baseline (snapshot or narrow
* native subscription) is established. */
@@ -373,7 +104,8 @@ export async function startWorktreeBaseDirectoryPoller(
visibility,
options.onFullScan,
options.getGitStatusRefPaths,
options.onWatchError
options.onWatchError,
options.onOverflow
)
}
return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options)
@@ -0,0 +1,90 @@
import {
collectLocalWorktreeBaseChanges,
collectRemoteWorktreeBaseChanges,
hasCollectedWorktreeBaseChanges
} from './worktree-base-directory-change-collector'
import {
scheduleWorktreeBaseNotification,
type WorktreeBaseNotificationWatch
} from './worktree-base-directory-notifications'
import {
invalidateActiveGitStatusRefResolution,
invalidateGitStatusRefResolutionForPaths
} from './worktree-git-status-ref-watch'
import type { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown'
export type ActiveWatch = WorktreeBaseNotificationWatch & {
subscription: { unsubscribe: () => Promise<void> }
gitStatusRefPaths: Set<string>
watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown
}
export function handleLocalWatchEvents(
watch: ActiveWatch,
error: Error | null,
events: { type: 'create' | 'update' | 'delete'; path: string }[],
getActiveWatches: () => Iterable<ActiveWatch>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
if (error) {
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
invalidateActiveGitStatusRefResolution(watch, getActiveWatches)
if (watch.watcherFailureRefresh.consume()) {
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
}
return
}
watch.watcherFailureRefresh.reset()
invalidateGitStatusRefResolutionForPaths(
watch,
events.map((event) => event.path),
getActiveWatches
)
const changes = collectLocalWorktreeBaseChanges(watch, events)
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
// Why: after a dropped event batch nothing about the prior state can be
// trusted — widen unconditionally (structural + status + head-identity),
// same shape as the remote overflow branch below, bypassing the watcher-error
// cooldown so a burst of overflows during one bulk op cannot suppress the
// refresh the fleet actually needs.
export function handleWatchOverflow(
watch: ActiveWatch,
getActiveWatches: () => Iterable<ActiveWatch>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
invalidateActiveGitStatusRefResolution(watch, getActiveWatches)
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
}
export function handleRemoteWatchEvents(
watch: ActiveWatch,
events: Parameters<typeof collectRemoteWorktreeBaseChanges>[1],
getActiveWatches: () => Iterable<ActiveWatch>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
invalidateGitStatusRefResolutionForPaths(
watch,
events.flatMap((event) =>
event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath]
),
getActiveWatches
)
const changes = collectRemoteWorktreeBaseChanges(watch, events)
if (changes.overflow) {
handleWatchOverflow(watch, getActiveWatches)
return
}
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
@@ -404,6 +404,46 @@ describe('worktree base directory watcher', () => {
expect(notifyWorktreesChanged).toHaveBeenCalledOnce()
})
it('widens an overflowed local git-common watch to a structural refresh', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow
const request = {
worktreeId: `repo-1::${PROJECT_ROOT}`,
worktreePath: PROJECT_ROOT,
executionHostId: 'local',
branch: 'refs/heads/feature',
upstreamName: 'origin/feature'
}
const resolve = vi.fn(async () => 'refs/remotes/origin/feature')
await setWorktreeGitStatusRefWatch(request, resolve)
onOverflow?.()
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
// Overflow is definite proof of loss, not a possibly-transient error — it
// invalidates the cached ref resolution unconditionally.
await setWorktreeGitStatusRefWatch(request, resolve)
expect(resolve).toHaveBeenCalledTimes(2)
})
it('does not throttle repeated overflow refreshes the way watcher-error refreshes are throttled', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow
onOverflow?.()
await vi.advanceTimersByTimeAsync(300)
onOverflow?.()
await vi.advanceTimersByTimeAsync(300)
// A watcher-error burst within the 60s cooldown window collapses to one
// refresh (see "throttles repeated structural refreshes from watcher
// failures" above); overflow must not inherit that gate, since a bulk op
// can legitimately overflow more than once before it settles.
expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2)
})
it('keeps linked HEAD and lock metadata structural', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
+16 -72
View File
@@ -6,16 +6,9 @@ import {
disposeWorktreeHeadIdentityRefreshState,
refreshWorktreeHeadIdentities
} from './worktree-head-identity-refresh'
import {
collectLocalWorktreeBaseChanges,
collectRemoteWorktreeBaseChanges,
hasCollectedWorktreeBaseChanges
} from './worktree-base-directory-change-collector'
import {
clearPendingWorktreeBaseNotifications,
scheduleWorktreeBaseNotification,
supportsWorktreeHeadIdentityRefresh,
type WorktreeBaseNotificationWatch
supportsWorktreeHeadIdentityRefresh
} from './worktree-base-directory-notifications'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import { EMPTY_HEAD_IDENTITY_SCOPE } from './worktree-head-identity-scope'
@@ -30,18 +23,16 @@ import {
import {
applyActiveGitStatusRefBinding,
clearActiveGitStatusRefBinding,
invalidateActiveGitStatusRefResolution,
invalidateGitStatusRefResolutionForPaths,
updateActiveGitStatusRefBinding,
type GitStatusRefBindingRequest
} from './worktree-git-status-ref-watch'
import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown'
type ActiveWatch = WorktreeBaseNotificationWatch & {
subscription: { unsubscribe: () => Promise<void> }
gitStatusRefPaths: Set<string>
watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown
}
import {
handleLocalWatchEvents,
handleRemoteWatchEvents,
handleWatchOverflow,
type ActiveWatch
} from './worktree-base-directory-watch-events'
const activeWatches = new Map<string, ActiveWatch>()
let syncGeneration = 0
@@ -54,59 +45,6 @@ export function setWorktreeGitStatusRefWatch(
return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef)
}
function handleLocalWatchEvents(
watch: ActiveWatch,
error: Error | null,
events: { type: 'create' | 'update' | 'delete'; path: string }[]
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
if (error) {
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values())
if (watch.watcherFailureRefresh.consume()) {
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
}
return
}
watch.watcherFailureRefresh.reset()
invalidateGitStatusRefResolutionForPaths(
watch,
events.map((event) => event.path),
() => activeWatches.values()
)
const changes = collectLocalWorktreeBaseChanges(watch, events)
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
function handleRemoteWatchEvents(
watch: ActiveWatch,
events: Parameters<typeof collectRemoteWorktreeBaseChanges>[1]
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
invalidateGitStatusRefResolutionForPaths(
watch,
events.flatMap((event) =>
event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath]
),
() => activeWatches.values()
)
const changes = collectRemoteWorktreeBaseChanges(watch, events)
if (changes.overflow) {
invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values())
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
return
}
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
function createActiveWatch(
target: WorktreeBaseWatchTarget,
mainWindow: BrowserWindow,
@@ -146,7 +84,7 @@ async function subscribeTarget(
if (!currentWatch || currentWatch.disposed) {
return
}
handleRemoteWatchEvents(currentWatch, events)
handleRemoteWatchEvents(currentWatch, events, () => activeWatches.values())
})
activeWatch = createActiveWatch(
target,
@@ -167,7 +105,7 @@ async function subscribeTarget(
(events) => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (currentWatch && !currentWatch.disposed) {
handleLocalWatchEvents(currentWatch, null, events)
handleLocalWatchEvents(currentWatch, null, events, () => activeWatches.values())
}
},
{
@@ -178,7 +116,13 @@ async function subscribeTarget(
onWatchError: (error) => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (currentWatch && !currentWatch.disposed) {
handleLocalWatchEvents(currentWatch, error, [])
handleLocalWatchEvents(currentWatch, error, [], () => activeWatches.values())
}
},
onOverflow: () => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (currentWatch) {
handleWatchOverflow(currentWatch, () => activeWatches.values())
}
}
}
@@ -39,11 +39,33 @@ export async function snapshotGitCommonEntry(
previous: GitCommonEntrySnapshot | undefined,
forceFullScan: boolean
): Promise<GitCommonEntrySnapshot> {
// Structural leaves change in place every tick; only index uses the entry-dir gate.
// Git writes HEAD/index/config.worktree/locked via a lock file + rename inside the
// entry dir, so the entry dir's own signature moves on every one of those writes
// (verified against git 2.55: checkout, commit, amend, reset, ref updates, stash,
// worktree lock/unlock, config --worktree, index writes all move it). The one
// in-place exception is `gitdir` (worktree move/repair), which the periodic
// forceFullScan backstop (INDEX_BACKSTOP_TICKS) below re-stats regardless of this
// gate. Gating all of these leaves on the entry-dir signature turns an unchanged
// entry into a single stat per tick instead of stat-ing every leaf every tick.
const nextDirSignature = await gitCommonDirectorySignature(entryPath)
if (nextDirSignature === 'missing') {
return (
previous ?? {
dirSignature: nextDirSignature,
structuralSignatures: new Map(),
indexSignature: null,
headLogSignature: null
}
)
}
const shouldRescan = forceFullScan || !previous || previous.dirSignature !== nextDirSignature
if (!shouldRescan) {
return previous
}
const structuralSignatures = new Map<string, string>()
const [nextDirSignature, headLogSignature] = await Promise.all([
gitCommonDirectorySignature(entryPath),
const [headLogSignature, indexSignature] = await Promise.all([
gitCommonFileSignature(join(entryPath, HEAD_LOG_FILE)),
gitCommonFileSignature(join(entryPath, INDEX_FILE)),
Promise.all(
STRUCTURAL_METADATA_FILES.map(async (name) => {
const signature = await gitCommonFileSignature(join(entryPath, name))
@@ -53,20 +75,6 @@ export async function snapshotGitCommonEntry(
})
)
])
if (nextDirSignature === 'missing') {
return (
previous ?? {
dirSignature: nextDirSignature,
structuralSignatures,
indexSignature: null,
headLogSignature
}
)
}
const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature
const indexSignature = shouldReadIndex
? await gitCommonFileSignature(join(entryPath, INDEX_FILE))
: previous.indexSignature
return {
dirSignature: nextDirSignature,
structuralSignatures,
@@ -24,7 +24,12 @@ export async function startGitCommonNarrowWatch(
platform: NodeJS.Platform,
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void,
onWatchError?: (error: Error) => void
onWatchError?: (error: Error) => void,
// Why: a dropped event batch (>5,000 events, e.g. a fleet-wide bulk op) is a
// harder loss signal than a transient error — nothing about the prior state
// can be trusted, so this bypasses onWatchError's failure cooldown instead
// of reusing it.
onOverflow?: () => void
): Promise<WorktreeBaseSubscription> {
const worktreesDir = join(target.path, 'worktrees')
const watcherOptions = platform === 'win32' ? { backend: 'windows' as const } : {}
@@ -73,6 +78,11 @@ export async function startGitCommonNarrowWatch(
.unsubscribe()
.catch(() => {})
.then(() =>
// Crash fuse tripped: this poller is now the sole change signal until a
// future existence-poll upgrade (follow-up: #17878). Its own per-entry
// dir-signature gate (worktree-git-common-entry-snapshot.ts) already keeps
// an unchanged entry to a single stat, so a fixed `pollIntervalMs` cadence
// stays cheap at high worktree counts without needing to stretch itself.
startGitCommonPolling(
target.path,
onEvents,
@@ -222,6 +232,23 @@ export async function startGitCommonNarrowWatch(
onEvents([{ type: 'update', path: worktreesDir }])
}
}
},
// Why: the watcher child drops the whole batch past 5,000 events
// (native FSEvents overflow maps to the same op) instead of reporting
// which paths changed. Unlike a transient error, this is definite
// proof of loss, so it always widens rather than falling back to the
// failure-cooldown-gated onWatchError path.
onOverflow: () => {
if (disposed || !active || generation !== nativeSubscriptionGeneration) {
return
}
if (onOverflow) {
onOverflow()
} else if (onWatchError) {
onWatchError(new Error('Git common watcher overflowed'))
} else {
onEvents([{ type: 'update', path: worktreesDir }])
}
}
}
)
@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { startGitCommonPolling } from './worktree-git-common-polling'
import type {
WorktreeBasePollEvent,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
// Why: measure the fan-out this poller issues per scan (peak concurrent `stat`
// calls, `readdir` call count as a proxy for "a tick ran") without depending on
// real disk timing (#17828). `entryZeroStatCalls` tracks every stat under a
// specific pre-existing entry (its dir plus every leaf), used to prove the
// entry-dir signature gate keeps an unchanged entry to one stat per tick.
const { statDelayMs, readdirCalls, concurrency, entryZeroStatCalls } = vi.hoisted(() => ({
statDelayMs: { current: 0 },
readdirCalls: { count: 0 },
concurrency: { current: 0, peak: 0 },
entryZeroStatCalls: { count: 0 }
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
readdir: (...args: Parameters<typeof actual.readdir>) => {
readdirCalls.count += 1
return actual.readdir(...args)
},
stat: async (...args: Parameters<typeof actual.stat>) => {
concurrency.current += 1
concurrency.peak = Math.max(concurrency.peak, concurrency.current)
const path = args[0]
const entryZeroSegment = `${sep}wt-0`
if (
typeof path === 'string' &&
(path.endsWith(entryZeroSegment) || path.includes(`${entryZeroSegment}${sep}`))
) {
entryZeroStatCalls.count += 1
}
try {
if (statDelayMs.current > 0) {
await new Promise((resolve) => setTimeout(resolve, statDelayMs.current))
}
return await actual.stat(...args)
} finally {
concurrency.current -= 1
}
}
}
})
const alwaysVisible: WorktreePollerWindowVisibility = {
isWindowVisible: () => true,
onWindowBecameVisible: () => () => {}
}
async function makeCommonDir(entryCount: number): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'git-common-polling-test-'))
for (let i = 0; i < entryCount; i++) {
const entryPath = join(root, 'worktrees', `wt-${i}`)
await mkdir(join(entryPath, 'logs'), { recursive: true })
await Promise.all([
writeFile(join(entryPath, 'HEAD'), 'ref: refs/heads/main\n'),
writeFile(join(entryPath, 'gitdir'), `${join(root, `checkout-${i}`, '.git')}\n`),
writeFile(join(entryPath, 'index'), Buffer.from([0])),
writeFile(join(entryPath, 'logs', 'HEAD'), '0000 aaaa\n')
])
}
return root
}
describe('startGitCommonPolling fan-out bounds (#17828)', () => {
const cleanups: (() => Promise<void>)[] = []
const dirsToRemove: string[] = []
beforeEach(() => {
statDelayMs.current = 0
readdirCalls.count = 0
concurrency.current = 0
concurrency.peak = 0
entryZeroStatCalls.count = 0
})
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
await Promise.all(
dirsToRemove.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
)
vi.useRealTimers()
})
it('bounds concurrent per-entry stat fan-out regardless of entry count', async () => {
const commonDir = await makeCommonDir(200)
dirsToRemove.push(commonDir)
const sub = await startGitCommonPolling(commonDir, () => {}, 100_000, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
// 200 entries x ~6 concurrent structural stats each would peak near 1,200
// unbounded; bounding to 8 in-flight entries keeps the peak independent of
// entry count instead of scaling with it.
expect(concurrency.peak).toBeLessThan(80)
})
it('never overlaps a scan with itself even when ticks fire faster than a scan completes', async () => {
const commonDir = await makeCommonDir(10)
dirsToRemove.push(commonDir)
statDelayMs.current = 20
const pollIntervalMs = 5
const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
readdirCalls.count = 0
// ~60 would-be 5ms ticks elapse in this window while every stat takes 20ms;
// the ticking guard must serialize scans, not launch overlapping ones.
await new Promise((resolve) => setTimeout(resolve, 300))
expect(readdirCalls.count).toBeLessThan(10)
})
it('costs exactly one stat per tick for an unchanged entry', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const pollIntervalMs = 20
const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot (which always fully reads every entry once) settle.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
readdirCalls.count = 0
entryZeroStatCalls.count = 0
await vi.waitFor(
() => {
expect(readdirCalls.count).toBeGreaterThanOrEqual(5)
},
{ timeout: 2_000 }
)
// Without the entry-dir signature gate, an unchanged entry still costs ~6
// stats every tick (HEAD/gitdir/locked/config.worktree/logs/HEAD/index).
// With the gate, only the entry dir itself is stat'd once nothing changed —
// one stat per tick, in lockstep with the readdir tripwire.
expect(entryZeroStatCalls.count).toBeLessThanOrEqual(readdirCalls.count + 1)
expect(entryZeroStatCalls.count).toBeGreaterThanOrEqual(readdirCalls.count - 1)
})
it('detects a HEAD rewrite via lock+rename on the next tick', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const pollIntervalMs = 20
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
pollIntervalMs,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot settle before mutating.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
const entryDir = join(commonDir, 'worktrees', 'wt-0')
const headPath = join(entryDir, 'HEAD')
const headLockPath = join(entryDir, 'HEAD.lock')
// Every real git ref write goes through a lock file + rename inside the entry
// dir (never an in-place overwrite), which moves the entry dir's own signature.
await writeFile(headLockPath, 'ref: refs/heads/feature\n')
await rename(headLockPath, headPath)
await vi.waitFor(
() => {
expect(events.flat()).toContainEqual({ type: 'update', path: headPath })
},
{ timeout: pollIntervalMs * 10 }
)
})
it('detects an in-place gitdir rewrite only once the periodic backstop rescans it', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const pollIntervalMs = 10
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
pollIntervalMs,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot settle before mutating.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
const entryDir = join(commonDir, 'worktrees', 'wt-0')
const gitdirPath = join(entryDir, 'gitdir')
// `gitdir` is the one structural leaf git rewrites in place (worktree move/repair),
// so the entry dir's own signature never moves — the periodic ungated backstop
// (INDEX_BACKSTOP_TICKS = 15) is the only thing that catches it.
await writeFile(gitdirPath, `${join(commonDir, 'checkout-moved', '.git')}\n`)
// Not caught by the next several ticks: the gate stays closed since nothing
// moved the entry dir's own signature.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs * 5))
expect(events.flat()).not.toContainEqual({ type: 'update', path: gitdirPath })
// Eventually caught regardless of the gate, once tick 15 forces the periodic backstop.
await vi.waitFor(
() => {
expect(events.flat()).toContainEqual({ type: 'update', path: gitdirPath })
},
{ timeout: pollIntervalMs * 40 }
)
})
it('still detects entry add/remove correctly with bounded concurrency', async () => {
const commonDir = await makeCommonDir(5)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
20,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
const newEntry = join(commonDir, 'worktrees', 'wt-new')
await mkdir(join(newEntry, 'logs'), { recursive: true })
await writeFile(join(newEntry, 'HEAD'), 'ref: refs/heads/main\n')
await vi.waitFor(() => {
expect(events.flat()).toContainEqual({ type: 'create', path: newEntry })
})
await rm(newEntry, { recursive: true })
await vi.waitFor(() => {
expect(events.flat()).toContainEqual({ type: 'delete', path: newEntry })
})
})
})
+21 -14
View File
@@ -1,5 +1,6 @@
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { forEachWithConcurrency } from '../../shared/map-with-concurrency'
import { PRIMARY_CHECKOUT_METADATA_FILES } from './worktree-git-common-metadata-files'
import {
diffGitCommon,
@@ -23,18 +24,26 @@ import {
// same way the base poller's backstop rescan does.
const INDEX_BACKSTOP_TICKS = 15
// Why: an unbounded fan-out across every worktree admin entry queues thousands
// of ops on libuv's 4-thread default pool, starving every other main-process
// fs call for the scan's duration (#17828). 8 mirrors the existing
// head-identity/exact-ref-probe pools — enough to saturate typical local
// disks without monopolizing the pool. Since snapshotGitCommonEntry's own
// entry-dir gate (see worktree-git-common-entry-snapshot.ts) keeps most ticks
// down to 1 stat per unchanged entry, real in-flight is now bounded by this
// limit rather than limit × per-entry stat count.
const GIT_COMMON_SNAPSHOT_CONCURRENCY = 8
async function snapshotStatusRefSignatures(
paths: ReadonlySet<string>
): Promise<Map<string, string>> {
const signatures = new Map<string, string>()
await Promise.all(
[...paths].map(async (path) => {
const signature = await gitCommonFileSignature(path)
if (signature !== null) {
signatures.set(path, signature)
}
})
)
await forEachWithConcurrency([...paths], GIT_COMMON_SNAPSHOT_CONCURRENCY, async (path) => {
const signature = await gitCommonFileSignature(path)
if (signature !== null) {
signatures.set(path, signature)
}
})
return signatures
}
@@ -91,12 +100,10 @@ async function snapshotGitCommon(
}
const entries = new Map<string, GitCommonEntrySnapshot>()
await Promise.all(
entryPaths.map(async (entryPath) => {
const previousEntry = previous?.entries.get(entryPath)
entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan))
})
)
await forEachWithConcurrency(entryPaths, GIT_COMMON_SNAPSHOT_CONCURRENCY, async (entryPath) => {
const previousEntry = previous?.entries.get(entryPath)
entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan))
})
// Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan
// now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost —
// rather than the always-run worktrees-dir readdir.
@@ -526,6 +526,45 @@ describe('worktree git-common narrow watch (local native platforms)', () => {
expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled()
})
it('routes a dropped event batch through the dedicated overflow callback', async () => {
installSubscribeMock()
const commonDir = await makeCommonDir(true)
const received: WorktreeBasePollEvent[][] = []
const onOverflow = vi.fn()
const watch = await startGitCommonWatch(
makeTarget(commonDir),
(events) => received.push(events),
POLL_MS,
'darwin',
alwaysVisible,
undefined,
() => [],
undefined,
onOverflow
)
cleanups.push(() => watch.unsubscribe())
narrowSubscription().hooks.onOverflow?.()
expect(onOverflow).toHaveBeenCalledOnce()
// The dedicated callback owns the refresh; the generic event/error paths
// must not also fire so the caller cannot double-count the same loss.
expect(received).toEqual([])
expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled()
})
it('falls back to a structural change when no overflow callback is wired', async () => {
installSubscribeMock()
const commonDir = await makeCommonDir(true)
const worktreesDir = join(commonDir, 'worktrees')
const received: WorktreeBasePollEvent[][] = []
await startWatch(commonDir, received)
narrowSubscription().hooks.onOverflow?.()
expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir })
})
it('arms via existence polling when the worktrees dir appears later', async () => {
installSubscribeMock()
const commonDir = await makeCommonDir(false)
+6 -2
View File
@@ -31,7 +31,8 @@ export async function startGitCommonWatch(
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void,
getStatusRefPaths: () => readonly string[] = () => [],
onWatchError?: (error: Error) => void
onWatchError?: (error: Error) => void,
onOverflow?: () => void
): Promise<WorktreeBaseSubscription> {
if (supportsNarrowWatch(platform)) {
const [narrowWatch, primaryWatch] = await Promise.all([
@@ -42,7 +43,8 @@ export async function startGitCommonWatch(
platform,
visibility,
onFullScan,
onWatchError
onWatchError,
onOverflow
),
startGitCommonPrimaryWatch(
target.path,
@@ -60,6 +62,8 @@ export async function startGitCommonWatch(
}
}
}
// Why: Electron only ships darwin/linux/win32, all covered by NARROW_WATCH_PLATFORMS
// above, so this branch is defensive dead code in production, not a reachable fallback.
return startGitCommonPolling(
target.path,
onEvents,
+144 -122
View File
@@ -2189,130 +2189,139 @@ export async function createLocalWorktree(
let lastExistingReviewNumber: number | null = null
const shouldRetireGeneratedName =
args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName)
const retiredNameRegistry = shouldRetireGeneratedName
? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings)
: null
const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null
// Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user.
for (let suffix = 1, attempts = 0; attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) {
effectiveSanitizedName = shouldRetireGeneratedName
? getGeneratedWorktreeCreateCandidate(
sanitizedName,
suffix,
retiredNameRegistry?.exhaustedTiers
)
: getWorktreeCreateCandidate(sanitizedName, suffix)
effectiveRequestedName = shouldRetireGeneratedName
? effectiveSanitizedName
: requestedName.trim()
? getWorktreeCreateCandidate(requestedName, suffix)
: effectiveSanitizedName
if (isRetiredName?.(effectiveSanitizedName)) {
continue
}
attempts += 1
lastExistingReviewNumber = null
await timing.time('resolve_name', async () => {
const retiredNameRegistry = shouldRetireGeneratedName
? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings)
: null
const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null
// Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user.
for (
let suffix = 1, attempts = 0;
attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS;
suffix += 1
) {
effectiveSanitizedName = shouldRetireGeneratedName
? getGeneratedWorktreeCreateCandidate(
sanitizedName,
suffix,
retiredNameRegistry?.exhaustedTiers
)
: getWorktreeCreateCandidate(sanitizedName, suffix)
effectiveRequestedName = shouldRetireGeneratedName
? effectiveSanitizedName
: requestedName.trim()
? getWorktreeCreateCandidate(requestedName, suffix)
: effectiveSanitizedName
if (isRetiredName?.(effectiveSanitizedName)) {
continue
}
attempts += 1
lastExistingReviewNumber = null
branchName = await resolveCreateBranchName(
repo.path,
selectedExistingLocalBranchName
? selectedExistingLocalBranchName
: getBranchNameOverrideCandidate(args.branchNameOverride, suffix),
effectiveSanitizedName,
settings,
username,
localWorktreeGitOptions
)
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
repo.path,
branchName,
baseBranch,
localWorktreeGitOptions
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling.
selectedExistingLocalBranchName = branchName
}
lastBranchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions)
const allowedPushTargetRemoteConflict =
lastBranchConflictKind &&
isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args)
if (lastBranchConflictKind) {
if (allowedPushTargetRemoteConflict) {
lastExistingPR = null
let lookupFailed = false
const selectedReview = getSelectedReviewBranch(args)
if (selectedReview?.provider === 'github') {
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
lookupFailed = true
}
if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastBranchConflictKind = null
} else if (lastExistingPR) {
lastExistingReviewNumber = lastExistingPR.number
}
} else if (selectedReview) {
let hostedReview: Awaited<ReturnType<typeof getSelectedHostedReviewForBranch>> = null
try {
hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args)
} catch {
lookupFailed = true
}
if (!lookupFailed && hostedReview?.matchesSelected) {
lastBranchConflictKind = null
} else if (hostedReview) {
lastExistingReviewNumber = hostedReview.number
branchName = await resolveCreateBranchName(
repo.path,
selectedExistingLocalBranchName
? selectedExistingLocalBranchName
: getBranchNameOverrideCandidate(args.branchNameOverride, suffix),
effectiveSanitizedName,
settings,
username,
localWorktreeGitOptions
)
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
repo.path,
branchName,
baseBranch,
localWorktreeGitOptions
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling.
selectedExistingLocalBranchName = branchName
}
lastBranchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions)
const allowedPushTargetRemoteConflict =
lastBranchConflictKind &&
isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args)
if (lastBranchConflictKind) {
if (allowedPushTargetRemoteConflict) {
lastExistingPR = null
let lookupFailed = false
const selectedReview = getSelectedReviewBranch(args)
if (selectedReview?.provider === 'github') {
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
lookupFailed = true
}
if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastBranchConflictKind = null
} else if (lastExistingPR) {
lastExistingReviewNumber = lastExistingPR.number
}
} else if (selectedReview) {
let hostedReview: Awaited<ReturnType<typeof getSelectedHostedReviewForBranch>> = null
try {
hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args)
} catch {
lookupFailed = true
}
if (!lookupFailed && hostedReview?.matchesSelected) {
lastBranchConflictKind = null
} else if (hostedReview) {
lastExistingReviewNumber = hostedReview.number
}
}
}
}
}
if (lastBranchConflictKind) {
continue
}
// Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it.
if (suffix > 1 && !checkoutExistingBranch) {
lastExistingPR = null
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
// GitHub API may be unreachable, rate-limited, or token missing
}
if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastExistingReviewNumber = lastExistingPR.number
if (lastBranchConflictKind) {
continue
}
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings),
workspaceRoot
)
if (existsSync(worktreePath)) {
continue
}
// Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it.
if (suffix > 1 && !checkoutExistingBranch) {
lastExistingPR = null
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
// GitHub API may be unreachable, rate-limited, or token missing
}
if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastExistingReviewNumber = lastExistingPR.number
continue
}
}
resolved = true
break
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings),
workspaceRoot
)
if (existsSync(worktreePath)) {
continue
}
resolved = true
break
}
})
if (!resolved) {
// Why: every suffix collided; reject with a specific reason so the user sees why create failed instead of a generic error or hung spinner.
if (lastExistingReviewNumber !== null) {
// Read once and format eagerly: the suffix loop assigns this from a callback, so the `let`'s
// narrowing does not reach the message.
const existingReviewNumber = lastExistingReviewNumber
if (existingReviewNumber !== null) {
throw new Error(
`Branch "${branchName}" already has PR #${lastExistingReviewNumber}. Pick a different ${branchConflictSubject}.`
`Branch "${branchName}" already has PR #${String(existingReviewNumber)}. Pick a different ${branchConflictSubject}.`
)
}
if (lastBranchConflictKind) {
@@ -2361,14 +2370,17 @@ export async function createLocalWorktree(
emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId)
let preparedPushTarget: GitPushTarget | undefined
if (args.pushTarget) {
const requestedPushTarget = args.pushTarget
if (requestedPushTarget) {
// Why: validate/fetch the contributor remote before create so a failure doesn't leave a half-created worktree with conflicts on retry.
preparedPushTarget = await prepareWorktreePushTarget(
repo.path,
args.pushTarget,
store,
repo.id,
localWorktreeGitOptions
preparedPushTarget = await timing.time('prepare_push_target', () =>
prepareWorktreePushTarget(
repo.path,
requestedPushTarget,
store,
repo.id,
localWorktreeGitOptions
)
)
}
@@ -2390,7 +2402,7 @@ export async function createLocalWorktree(
addResult =
(await timing.time('git_worktree_add', async () => {
if (sparseDirectories.length === 0 && !checkoutExistingBranch) {
const preparedResult = await consumePreparedWorktreeCreate({
const prepared = await consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot,
worktreePath,
@@ -2399,9 +2411,19 @@ export async function createLocalWorktree(
refreshLocalBaseRef: settings.refreshLocalBaseRefOnWorktreeCreate,
...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {})
})
if (preparedResult) {
return preparedResult
timing.recordPreparedCheckout(
prepared.status === 'hit'
? { status: 'hit', retargeted: prepared.retargeted }
: { status: 'miss', reason: prepared.reason }
)
if (prepared.status === 'hit') {
return prepared.result
}
} else {
timing.recordPreparedCheckout({
status: 'miss',
reason: sparseDirectories.length > 0 ? 'sparse_checkout' : 'checkout_existing_branch'
})
}
if (sparseDirectories.length > 0) {
if (checkoutExistingBranch) {
@@ -633,7 +633,10 @@ describe('registerWorktreeHandlers', () => {
})) as {
setup?: unknown
startupTerminal?: { spawned: boolean; surface?: string }
timing?: { phases: { phase: string }[] }
timing?: {
phases: { phase: string }[]
preparedCheckout?: { status: string; reason?: string }
}
}
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'repo-1' }),
@@ -695,6 +698,8 @@ describe('registerWorktreeHandlers', () => {
'spawn_startup_terminal'
])
)
// Nothing warmed this repo, so the create must report the cold path rather than stay silent.
expect(result.timing?.preparedCheckout).toEqual({ status: 'miss', reason: 'none_armed' })
})
it('returns the wrapped setup command when startup spawned but setup creation failed', async () => {
@@ -205,14 +205,14 @@ describe('registerWorktreeHandlers', () => {
}
])
const result = await handlers['worktrees:create'](null, {
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
sparseCheckout: {
directories: [' packages/web ', 'apps\\api\\', 'packages/web/'],
presetId: 'preset-1'
}
})
})) as { timing?: { preparedCheckout?: { status: string; reason?: string } } }
expect(addWorktreeMock).not.toHaveBeenCalled()
expect(addSparseWorktreeMock).toHaveBeenCalledWith(
@@ -240,6 +240,11 @@ describe('registerWorktreeHandlers', () => {
sparsePresetId: 'preset-1'
})
})
// A sparse create can never claim a prepared checkout; say so rather than looking like a miss.
expect(result.timing?.preparedCheckout).toEqual({
status: 'miss',
reason: 'sparse_checkout'
})
})
it('retires a generated sparse name when creation rollback also fails', async () => {
@@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId,
toSshExecutionHostId
} from '../../shared/execution-host'
import { getSshProviderAuthority } from '../ssh/ssh-provider-authority'
import {
listWorktreesMock,
@@ -443,7 +447,76 @@ describe('registerWorktreeHandlers', () => {
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('refuses to retire metadata for non-SSH hosts and unowned repos', async () => {
// Runtime-host rows are exempt from gcStaleWorktreeMeta exactly as SSH ones are, so a paired
// client needs this path to ever drop them (#17776).
it('retires runtime-host metadata an authoritative scan proved gone', async () => {
const runtimeHostId = toRuntimeExecutionHostId('env-1')
const runtimeRepo = {
id: 'repo-1',
path: '/home/orca/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: runtimeHostId
}
const metaById: Record<string, ReturnType<typeof makeWorktreeMeta>> = {
'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId }),
'repo-1::/home/orca/other-host': makeWorktreeMeta({
hostId: toSshExecutionHostId('target-a')
})
}
store.getRepos.mockReturnValue([runtimeRepo])
store.getProjectHostSetups.mockReturnValue([])
store.getAllWorktreeMeta.mockReturnValue(metaById)
store.removeWorktreeMeta.mockImplementation((worktreeId: string) => {
delete metaById[worktreeId]
})
const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, {
repoId: runtimeRepo.id,
executionHostId: runtimeHostId,
worktreeIds: ['repo-1::/home/orca/deleted', 'repo-1::/home/orca/other-host']
})
// The row stamped to another host needs that host's own scan, not this one's.
expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/home/orca/deleted'] })
expect(store.removeWorktreeMeta).toHaveBeenCalledExactlyOnceWith(
'repo-1::/home/orca/deleted',
runtimeHostId
)
})
// A repo that reaches its checkouts over SSH is not the runtime host's to condemn. The refusal
// comes from `findExactRepoOwner`: a runtime `executionHostId` beside a `connectionId` is
// contradictory ownership evidence, so no owner resolves at all.
it('refuses to retire a connection-backed repo under a runtime host id', async () => {
const runtimeHostId = toRuntimeExecutionHostId('env-1')
store.getRepos.mockReturnValue([
{
id: 'repo-1',
path: '/home/orca/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: runtimeHostId,
connectionId: 'target-a'
}
])
store.getAllWorktreeMeta.mockReturnValue({
'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId })
})
expect(
await handlers['worktrees:forgetRemovedForExecutionHost'](null, {
repoId: 'repo-1',
executionHostId: runtimeHostId,
worktreeIds: ['repo-1::/home/orca/deleted']
})
).toEqual({ forgottenWorktreeIds: [] })
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('refuses to retire metadata for non-executing hosts and unowned repos', async () => {
const sshRepo = {
id: 'repo-1',
path: '/remote/repo-a',
+5 -2
View File
@@ -17,7 +17,10 @@ import { registerSparseCheckoutCacheInvalidation } from './worktrees/listing/reg
import { registerWorktreeMetadataHandlers } from './worktrees/metadata/register-worktree-metadata-handlers'
import { registerWorktreeForgetHandlers } from './worktrees/removal/register-worktree-forget-handlers'
import { registerWorktreeRemovalHandlers } from './worktrees/removal/register-worktree-removal-handlers'
import type { WorktreeIpcContext } from './worktrees/worktree-ipc-context'
import {
createWorktreeRemovalRegistry,
type WorktreeIpcContext
} from './worktrees/worktree-ipc-context'
registerDetectedWorktreeScanInvalidation()
@@ -66,7 +69,7 @@ export function registerWorktreeHandlers(
runtime,
...(options ? { options } : {}),
detectedWorktreeCancellations: createSenderScopedRequestCancellations(),
worktreeRemovalsInFlight: new Map()
worktreeRemovalsInFlight: createWorktreeRemovalRegistry()
}
// Remove all stale registrations before installing any replacement handler.
@@ -4,7 +4,10 @@ import type {
CreateWorktreeResult,
AdoptProvisionedRootArgs
} from '../../../../shared/worktree/create-types'
import { withWorktreeSpan } from '../../../observability/instrumentation'
import {
addWorktreeCreatePhaseAttributes,
withWorktreeSpan
} from '../../../observability/instrumentation'
import { workspaceSourceSchema } from '../../../../shared/telemetry-events'
import type { WorkspaceSource } from '../../../../shared/telemetry-events'
import {
@@ -36,7 +39,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
async (_event, rawArgs: CreateWorktreeArgs): Promise<CreateWorktreeResult> => {
const args = normalizeLinkedWorkItemFields(rawArgs)
// Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator.
return withWorktreeSpan({ stage: 'create' }, async () => {
return withWorktreeSpan({ stage: 'create' }, async (span) => {
const repo = store.getRepo(args.repoId)
if (!repo) {
throw new Error(`Repo not found: ${args.repoId}`)
@@ -74,6 +77,9 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
throw error
}
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
if (result.timing) {
addWorktreeCreatePhaseAttributes(span, result.timing)
}
// Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name.
track('workspace_created', {
@@ -103,11 +103,21 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void {
const requestedExecutionHostId = args?.executionHostId ?? 'ssh:'
const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : []
const parsedHost = parseExecutionHostId(requestedExecutionHostId)
if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) {
// Runtime hosts belong here for the same reason SSH ones do: their rows are exempt from
// gcStaleWorktreeMeta, so a scan-proven removal is the only thing that ever retires them.
if (
(parsedHost?.kind !== 'ssh' && parsedHost?.kind !== 'runtime') ||
worktreeIds.length === 0
) {
return nothingForgotten
}
// No runtime arm in the check below: `findExactRepoOwner` already refuses a repo carrying both
// a runtime `executionHostId` and a `connectionId`, because `resolveRepoOwnershipEvidence`
// calls that pair contradictory and one non-owned candidate voids the whole lookup. A second
// check would be unreachable, and unreachable code on a destructive path reads as a guarantee
// it is not making.
const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId)
if (!repo || repo.connectionId !== parsedHost.targetId) {
if (!repo || (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId)) {
return nothingForgotten
}
// Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips
@@ -14,3 +14,18 @@ export type WorktreeIpcContext = {
detectedWorktreeCancellations: SenderScopedRequestCancellations
worktreeRemovalsInFlight: Map<string, WorktreeRemovalInFlight>
}
// Why: removal and forget both delete refs, and a ref deletion has to take the
// `packed-refs` lock. Idle ref maintenance needs a process-wide view of that
// registry so it never packs while one is running.
let activeWorktreeRemovals: ReadonlyMap<string, WorktreeRemovalInFlight> | null = null
export function createWorktreeRemovalRegistry(): Map<string, WorktreeRemovalInFlight> {
const registry = new Map<string, WorktreeRemovalInFlight>()
activeWorktreeRemovals = registry
return registry
}
export function hasWorktreeRemovalsInFlight(): boolean {
return (activeWorktreeRemovals?.size ?? 0) > 0
}
@@ -3,6 +3,7 @@ import { _resetTracerForTests, setActiveSink, type TracerSink } from './tracer'
import {
_gitSpanSamplingBucketCountForTests,
_resetGitSpanSamplingForTests,
addWorktreeCreatePhaseAttributes,
withGitSpan
} from './instrumentation'
@@ -167,3 +168,84 @@ describe('withGitSpan sampling', () => {
expect(_gitSpanSamplingBucketCountForTests()).toBe(1)
})
})
describe('addWorktreeCreatePhaseAttributes', () => {
function capture(): {
attributes: Record<string, unknown>
span: Parameters<typeof addWorktreeCreatePhaseAttributes>[0]
} {
const attributes: Record<string, unknown> = {}
const span = {
setAttribute: (key: string, value: unknown) => {
attributes[key] = value
}
} as unknown as Parameters<typeof addWorktreeCreatePhaseAttributes>[0]
return { attributes, span }
}
it('counts concurrent phases once when measuring unattributed time', () => {
const { attributes, span } = capture()
// Create resolves shared directories and .worktreeinclude concurrently; summing their
// durations would claim 400ms of coverage for a 200ms window.
addWorktreeCreatePhaseAttributes(span, {
totalDurationMs: 1000,
phases: [
{ phase: 'resolve_shared_directories', startedAtMs: 100, durationMs: 200 },
{ phase: 'resolve_worktreeinclude', startedAtMs: 150, durationMs: 150 }
]
})
expect(attributes['worktree.create.phase.resolve_shared_directories_ms']).toBe(200)
expect(attributes['worktree.create.phase.resolve_worktreeinclude_ms']).toBe(150)
// Covered wall clock is 100..300, so 800ms is genuinely unaccounted for.
expect(attributes['worktree.create.unattributed_ms']).toBe(800)
})
it('sums disjoint phases and never reports negative unattributed time', () => {
const { attributes, span } = capture()
addWorktreeCreatePhaseAttributes(span, {
totalDurationMs: 500,
phases: [
{ phase: 'resolve_name', startedAtMs: 0, durationMs: 100 },
{ phase: 'git_worktree_add', startedAtMs: 300, durationMs: 200 }
]
})
expect(attributes['worktree.create.total_ms']).toBe(500)
expect(attributes['worktree.create.unattributed_ms']).toBe(200)
})
it('records a prepared-checkout hit and whether it had to be retargeted', () => {
const { attributes, span } = capture()
addWorktreeCreatePhaseAttributes(span, {
totalDurationMs: 900,
phases: [{ phase: 'git_worktree_add', startedAtMs: 0, durationMs: 400 }],
preparedCheckout: { status: 'hit', retargeted: true }
})
expect(attributes['worktree.create.prepared_checkout']).toBe('hit')
expect(attributes['worktree.create.prepared_checkout_retargeted']).toBe(true)
expect(attributes['worktree.create.prepared_checkout_miss']).toBeUndefined()
expect(attributes['worktree.create.unattributed_ms']).toBe(500)
})
it('records why a create missed the prepared checkout', () => {
const { attributes, span } = capture()
addWorktreeCreatePhaseAttributes(span, {
totalDurationMs: 8_000,
phases: [],
preparedCheckout: { status: 'miss', reason: 'base_mismatch' }
})
expect(attributes['worktree.create.prepared_checkout']).toBe('miss')
expect(attributes['worktree.create.prepared_checkout_miss']).toBe('base_mismatch')
expect(attributes['worktree.create.prepared_checkout_retargeted']).toBeUndefined()
})
it('stays silent on paths that never consult the prepared checkout', () => {
const { attributes, span } = capture()
addWorktreeCreatePhaseAttributes(span, { totalDurationMs: 10, phases: [] })
expect(attributes['worktree.create.prepared_checkout']).toBeUndefined()
})
})
+73 -3
View File
@@ -21,6 +21,7 @@
// itself becomes a `noopSpan` that swallows all calls — call sites do not
// need to branch on whether tracing is on.
import type { PreparedCheckoutOutcome } from '../../shared/worktree/create-types'
import { startSpan, withSpan, type ActiveSpan } from './tracer'
const GIT_FAST_SUCCESS_THRESHOLD_MS = 250
@@ -202,10 +203,11 @@ export type WorktreeSpanArgs = {
readonly path?: string
}
/** Wrap a worktree-setup phase in a `worktree.<stage>` span. */
/** Wrap a worktree-setup phase in a `worktree.<stage>` span. The callback receives the span so a
* create can attach its own phase breakdown; the git children alone leave the waits invisible. */
export async function withWorktreeSpan<T>(
meta: WorktreeSpanArgs,
fn: () => Promise<T>
fn: (span: ActiveSpan) => Promise<T>
): Promise<T> {
return withSpan(
`worktree.${meta.stage}`,
@@ -214,12 +216,80 @@ export async function withWorktreeSpan<T>(
if (meta.path) {
span.setAttribute('worktree.path', meta.path)
}
return await fn()
return await fn(span)
},
{ attributes: { kind: 'worktree' } }
)
}
type WorktreeCreatePhaseTiming = {
readonly phase: string
readonly startedAtMs: number
readonly durationMs: number
}
/** Wall-clock span covered by at least one phase. Create runs some phases concurrently, so summing
* durations double-counts and would report overlap as coverage the phases never had. */
function measuredWallClockMs(phases: readonly WorktreePhaseInterval[]): number {
const intervals = [...phases]
.map((phase) => [phase.startedAtMs, phase.startedAtMs + phase.durationMs] as const)
.sort((left, right) => left[0] - right[0])
let covered = 0
let openedAt: number | null = null
let closesAt = 0
for (const [start, end] of intervals) {
if (openedAt === null) {
openedAt = start
closesAt = end
continue
}
if (start <= closesAt) {
closesAt = Math.max(closesAt, end)
continue
}
covered += closesAt - openedAt
openedAt = start
closesAt = end
}
return openedAt === null ? 0 : covered + (closesAt - openedAt)
}
type WorktreePhaseInterval = Pick<WorktreeCreatePhaseTiming, 'startedAtMs' | 'durationMs'>
/** Records a create's phase breakdown on its span. Phase names are already a closed vocabulary in
* the recorder, so they are safe to key on; nothing here carries a branch name or a path. */
export function addWorktreeCreatePhaseAttributes(
span: ActiveSpan,
timing: {
totalDurationMs: number
phases: readonly WorktreeCreatePhaseTiming[]
preparedCheckout?: PreparedCheckoutOutcome
}
): void {
span.setAttribute('worktree.create.total_ms', Math.round(timing.totalDurationMs))
if (timing.preparedCheckout) {
span.setAttribute('worktree.create.prepared_checkout', timing.preparedCheckout.status)
if (timing.preparedCheckout.status === 'hit') {
// A retargeted hit still pays a reset, so it must not be read as a free hit.
span.setAttribute(
'worktree.create.prepared_checkout_retargeted',
timing.preparedCheckout.retargeted
)
} else {
span.setAttribute('worktree.create.prepared_checkout_miss', timing.preparedCheckout.reason)
}
}
for (const phase of timing.phases) {
span.setAttribute(`worktree.create.phase.${phase.phase}_ms`, Math.round(phase.durationMs))
}
// What the phases do not cover is the number that matters when create feels slow for no visible
// reason, so name it rather than leaving it to subtraction.
span.setAttribute(
'worktree.create.unattributed_ms',
Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases)))
)
}
/** Closed set so a typo can't silently mint an orphan span name. */
export type WorktreeRemoveStage =
| 'archive_hook'

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