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