Merge remote-tracking branch 'origin/main' into mobile-rearch

This commit is contained in:
Jinwoo-H
2026-09-01 22:08:48 -04:00
184 changed files with 6241 additions and 1352 deletions
+27 -4
View File
@@ -115,6 +115,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,
@@ -387,12 +392,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',
@@ -479,6 +496,12 @@ module.exports = {
artifactName: 'orca-macos-${arch}.${ext}'
},
linux: {
// Why mimeTypes and not fileAssociations: shared-mime-info already maps *.md/*.markdown to
// text/markdown, so reusing that type puts Orca in the Open With list without shipping a glob
// override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the
// default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to
// application/x-genesis-32x-rom, so claiming it here would need a glob override.
mimeTypes: ['text/markdown'],
// Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca.
// The Linux installer should not claim those system package/file names.
executableName: 'orca-ide',
-23
View File
@@ -1,23 +0,0 @@
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
!macroend
+79
View File
@@ -0,0 +1,79 @@
; electron-builder NSIS hooks for the Orca Windows installer.
;
; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall /
; customUnInstall hook Orca needs lives here.
; ---------------------------------------------------------------------------
; Markdown "Open with Orca" (issue #10138)
;
; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows:
; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is
; WriteRegStr SHELL_CONTEXT "Software\Classes\.md" "" "<ProgID>"
; That overwrites whichever editor currently owns .md, with no backup, for every
; existing user on their next UPDATE - and APP_UNASSOCIATE never restores it, so
; uninstalling Orca would leave .md pointing at a deleted ProgID.
;
; These writes are additive only. Registering a ProgID plus an OpenWithProgids
; hint and an Applications\<exe>\SupportedTypes entry puts Orca in Explorer's
; "Open with" list and in "Choose another app", while the default handler stays
; exactly where the user left it. Never add a `Software\Classes\.<ext>` default
; value here.
;
; MARKDOWN_PROGID must stay in sync with the extension list handled by
; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts.
; ---------------------------------------------------------------------------
!define MARKDOWN_PROGID "Orca.Markdown"
!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT
WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" ""
!macroend
!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT
DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}"
!macroend
!macro customInstall
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"'
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md"
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown"
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx"
; Why: Explorer caches the association list until told otherwise.
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
; ---------------------------------------------------------------------------
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so
; dropping them during uninstallOldVersion is correct and keeps the pair symmetric.
DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx"
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
@@ -0,0 +1,116 @@
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { basename } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx']
// The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("")
// value of Software\Classes\.<ext>. Additive `WriteRegNone ...\OpenWithProgids` must not
// match, or the guard below would be unfalsifiable.
const DEFAULT_HANDLER_WRITE = /WriteRegStr\s+SHELL_CONTEXT\s+"Software\\Classes\\\.[a-z]+"\s+""/i
// The hooks file documents the forbidden line in prose, so match executable script only.
const stripNsisCommentLines = (source) =>
source
.split('\n')
.filter((line) => !/^\s*[;#]/.test(line))
.join('\n')
const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8')
describe('electron-builder markdown file associations', () => {
// Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS
// packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value
// — silently taking .md from whichever editor owns it, for every existing user on their
// next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot
// prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must
// stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks.
it('never claims the Windows default markdown handler', () => {
expect(electronBuilderConfig.fileAssociations).toBeUndefined()
expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined()
})
it('joins the macOS Open With list for every markdown extension without owning it', () => {
const associations = electronBuilderConfig.mac.fileAssociations
// One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob.
expect([...associations].map((association) => association.ext).sort()).toEqual(
[...MARKDOWN_EXTENSIONS].sort()
)
for (const association of associations) {
expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' })
}
})
// Why mimeTypes and not linux.fileAssociations: shared-mime-info already maps markdown to
// text/markdown, so the desktop entry only adds a handler and mimeapps.list keeps owning
// the default. A fileAssociations entry would ship a redundant glob override instead.
it('reuses the existing shared-mime-info markdown type on Linux', () => {
expect(electronBuilderConfig.linux.mimeTypes).toContain('text/markdown')
expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined()
})
it('points the single NSIS include at the installer hooks file on disk', () => {
const includePath = electronBuilderConfig.nsis.include
expect(existsSync(includePath)).toBe(true)
expect(basename(includePath)).toBe('orca-installer-hooks.nsh')
})
// Guard for the guard: proves DEFAULT_HANDLER_WRITE really matches a takeover line, so
// the assertion below is a live check rather than a regex that can never fire.
it('recognizes an APP_ASSOCIATE-style default-handler write', () => {
for (const takeover of [
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "Orca.Markdown"',
'WriteRegStr SHELL_CONTEXT "Software\\Classes\\.markdown" "" "$0"'
]) {
expect(takeover).toMatch(DEFAULT_HANDLER_WRITE)
}
expect(
'WriteRegNone SHELL_CONTEXT "Software\\Classes\\.md\\OpenWithProgids" "Orca.Markdown"'
).not.toMatch(DEFAULT_HANDLER_WRITE)
// Comment stripping must drop prose that quotes the bad line without swallowing a real
// one that happens to carry a trailing comment.
const stripped = stripNsisCommentLines(
[
'; WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "<ProgID>"',
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "$0" ; oops'
].join('\n')
)
expect(stripped.split('\n')).toHaveLength(1)
expect(stripped).toMatch(DEFAULT_HANDLER_WRITE)
})
it('registers Windows markdown Open With additively, never as the default', async () => {
const hooks = await readInstallerHooks()
expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE)
// The additive hint that puts Orca in Explorer's "Open with" list.
expect(hooks).toMatch(
/WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/
)
expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/)
for (const ext of MARKDOWN_EXTENSIONS) {
expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
}
expect(hooks).toMatch(/!macro\s+customInstall\b/)
expect(hooks).toMatch(/!macro\s+customUnInstall\b/)
})
// Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown
// hooks too. electron-builder allows only one include, so a merge that drops the daemon
// sweep would silently orphan a running orca-terminal-daemon.exe on every uninstall.
it('keeps the daemon-host uninstall sweep across the include rename', async () => {
const hooks = await readInstallerHooks()
expect(hooks).toContain('orca-terminal-daemon.exe')
expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host')
// Without this guard, uninstallOldVersion would kill the daemon on every update —
// defeating the relocation that keeps terminals alive across updates.
expect(hooks).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/)
})
})
+39 -11
View File
@@ -110,32 +110,60 @@ export function makeTreeReadOnly(targetPath, chmod = chmodSync) {
chmod(targetPath, 0o755)
}
/**
* Restore owner write permission across a private copy.
*
* Counterpart to `makeTreeReadOnly`: clonefile, reflink and `cpSync` all carry the source's mode
* across, so a tree copied from the write-protected shared cache lands read-only and every patch
* the caller then makes -- `plutil -replace`, `codesign` -- fails with EACCES. Only the owner bit
* comes back; group and other stay as the source left them.
*/
export function makeTreeWritable(targetPath, chmod = chmodSync) {
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
const entryPath = join(targetPath, entry.name)
if (entry.isDirectory()) {
makeTreeWritable(entryPath, chmod)
} else if (!entry.isSymbolicLink()) {
const mode = statSync(entryPath, { throwIfNoEntry: false })?.mode
chmod(entryPath, mode === undefined ? 0o644 : mode | 0o200)
}
}
chmod(targetPath, 0o755)
}
/**
* Share storage when possible, otherwise copy the bytes.
*
* Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write
* through into the source.
* through into the source. The copy is unprotected on the way out for the same reason -- a private
* tree the caller cannot write to is useless to it.
*/
export function copyPrivateTree(sourcePath, destinationPath, options = {}) {
const platform = options.platform ?? process.platform
const copy = options.copy ?? copyTreeVerbatim
const unprotect = options.unprotect ?? makeTreeWritable
const privateMechanisms = new Set(['clone', 'reflink'])
let result = { mechanism: null, copyError: null }
if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) {
try {
const mechanism = shareTree(sourcePath, destinationPath, {
...options,
hardlink: () => {
throw new Error('hardlinks would not be private')
}
})
return { mechanism, copyError: null }
result = {
mechanism: shareTree(sourcePath, destinationPath, {
...options,
hardlink: () => {
throw new Error('hardlinks would not be private')
}
}),
copyError: null
}
} catch (copyError) {
copy(sourcePath, destinationPath)
return { mechanism: null, copyError }
result = { mechanism: null, copyError }
}
} else {
copy(sourcePath, destinationPath)
}
copy(sourcePath, destinationPath)
return { mechanism: null, copyError: null }
unprotect(destinationPath)
return result
}
function copyTreeVerbatim(sourcePath, destinationPath) {
+35
View File
@@ -19,6 +19,7 @@ import {
copyPrivateTree,
hardlinkTree,
makeTreeReadOnly,
makeTreeWritable,
shareTree
} from './space-sharing-copy.mjs'
@@ -170,7 +171,41 @@ describe('makeTreeReadOnly', () => {
)
})
describe('makeTreeWritable', () => {
it.runIf(process.platform !== 'win32')('undoes makeTreeReadOnly for the owner', () => {
const { source } = makeTree()
makeTreeReadOnly(source)
makeTreeWritable(source)
const file = path.join(source, 'nested', 'file')
expect(statSync(file).mode & 0o200).toBe(0o200)
expect(() => writeFileSync(file, 'mutated')).not.toThrow()
})
it.runIf(process.platform !== 'win32')('adds no write permission beyond the owner', () => {
const { source } = makeTree()
const executable = path.join(source, 'electron')
writeFileSync(executable, 'binary')
chmodSync(executable, 0o555)
makeTreeWritable(source)
expect(statSync(executable).mode & 0o777).toBe(0o755)
})
})
describe('copyPrivateTree', () => {
it.runIf(process.platform !== 'win32')(
'hands back a tree the caller can patch, even from a write-protected source',
() => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
makeTreeReadOnly(source)
copyPrivateTree(source, destination)
// The regression this guards: the shared Electron dist is read-only, clonefile/reflink/cpSync
// all carry that across, and `pn dev` then died patching the copied bundle's Info.plist.
expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'patched')).not.toThrow()
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents')
}
)
it('never hardlinks, because the caller patches what it gets back', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
+11
View File
@@ -42,6 +42,17 @@ authority.
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
### Placeholders That Fail Open
`GitCapabilityCache` records commands Git *rejects*. A `git log --format`
placeholder Git does not know is not rejected: Git echoes it verbatim and exits
zero, so there is no error to remember and no probe to cache. Ask for both forms
in one record and pick at parse time.
| Placeholder | Preferred behavior | Compatibility behavior |
| ---------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `%(decorate:…)` | Git 2.43 separates commit decorations with `\x1f`, so ref names containing commas survive | The same record also carries `%D` (Git 2.10); an unexpanded `%(decorate` placeholder selects it, at the cost of comma-splitting |
## Why Not `simple-git`
`simple-git` is a process wrapper around the installed Git binary. Its custom
@@ -1,24 +1,31 @@
import { readFileSync } from 'node:fs'
const SOURCE_FILES = [
'./terminal-webview-html.ts',
'./terminal-webview-html/document-shell.ts',
'./terminal-webview-html/runtime-state-and-text-scaling.ts',
'./terminal-webview-html/fit-scale-and-write-queue.ts',
'./terminal-webview-html/terminal-init-and-write.ts',
'./terminal-webview-html/host-message-router.ts',
'./terminal-webview-html/selection-state-and-eviction.ts',
'./terminal-webview-html/term-observers-and-mode-mirroring.ts',
'./terminal-webview-html/mouse-report-and-scroll-routing.ts',
'./terminal-webview-html/smooth-scroll-and-cell-geometry.ts',
'./terminal-webview-html/selection-overlay.ts',
'./terminal-webview-html/surface-touch-gestures.ts',
'./terminal-webview-html/message-bridge-and-document-close.ts'
] as const
const COMPOSER_FILE = './terminal-webview-html.ts'
const SLICE_IMPORT_RE = /^import \{[^}]*\} from '(\.\/terminal-webview-html\/[\w-]+)'$/gm
const COMPOSED_ENTRY_RE = /^ {2}TERMINAL_HTML_\w+,?$/gm
/** Reads the TypeScript source that assembles the in-WebView document. */
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
}
/**
* Reads the TypeScript source that assembles the in-WebView document.
*
* Why: the slice list is derived from the composer's own imports rather than duplicated, so a
* new slice cannot join the emitted document while staying invisible to the tests that search
* this source. The count cross-check catches an import shape the regex cannot see.
*/
export function readTerminalWebViewHtmlSource(): string {
return SOURCE_FILES.map((relativePath) =>
readFileSync(new URL(relativePath, import.meta.url), 'utf8')
).join('\n')
const composer = readSource(COMPOSER_FILE)
const slices = [...composer.matchAll(SLICE_IMPORT_RE)].map((match) => `${match[1]}.ts`)
const composedCount = [...composer.matchAll(COMPOSED_ENTRY_RE)].length
if (composedCount === 0) {
throw new Error('no composed WebView document slices found')
}
if (slices.length !== composedCount) {
throw new Error(
`WebView document slice imports (${slices.length}) do not match composed entries (${composedCount})`
)
}
return [composer, ...slices.map(readSource)].join('\n')
}
+6 -2
View File
@@ -1,6 +1,8 @@
import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell'
import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling'
import { TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE } from './terminal-webview-html/fit-scale-and-write-queue'
import { TERMINAL_HTML_FIT_SCALE } from './terminal-webview-html/terminal-fit-scale'
import { TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN } from './terminal-webview-html/mouse-mode-decset-scan'
import { TERMINAL_HTML_WRITE_QUEUE } from './terminal-webview-html/write-queue'
import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write'
import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router'
import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction'
@@ -19,7 +21,9 @@ export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme'
export const XTERM_HTML = [
TERMINAL_HTML_DOCUMENT_SHELL,
TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING,
TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE,
TERMINAL_HTML_FIT_SCALE,
TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN,
TERMINAL_HTML_WRITE_QUEUE,
TERMINAL_HTML_INIT_AND_WRITE,
TERMINAL_HTML_HOST_MESSAGE_ROUTER,
TERMINAL_HTML_SELECTION_STATE_AND_EVICTION,
@@ -1,288 +0,0 @@
import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected'
// Also carries the DECSET mouse-mode scanner: emitted-document order pins it between these two concerns.
export const TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE = `${TERMINAL_WEBVIEW_THEME_JS}
function getCellHeight() {
if (!term || !term._core) return 15;
var core = term._core;
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.height || 15;
}
return 15;
}
// Why: clamp pan so the terminal content always covers the viewport
// when zoomed in. When content is smaller than viewport in a
// dimension, pin to top-left (no floating in the middle).
function clampPan() {
if (!term || !term.element) return;
var ts = getTotalScale();
var cw = term.element.scrollWidth * ts;
var ch = term.element.scrollHeight * ts;
var vpW = window.innerWidth;
var vpH = window.innerHeight;
if (cw > vpW) {
panX = Math.min(0, Math.max(vpW - cw, panX));
} else {
panX = 0;
}
if (ch > vpH) {
panY = Math.min(0, Math.max(vpH - ch, panY));
} else {
panY = 0;
}
}
// Why: intentional no-op. Mobile replays a live PTY snapshot then applies
// live cursor-relative chunks from that same PTY; resizing only the WebView
// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or
// overlap. Kept as a no-op so its call sites stay legible.
function adjustRowsForViewport() {}
// Why: cold-start fit. After init() opens xterm, the renderer needs
// several frames before cell dimensions are computed. Reading too early
// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
// not laid out), and computeFitScale returns 1 → no zoom.
//
// Gate: cellWidth × cols is the canonical "logical width" of the grid
// and reflects xterm's layout decision, independent of buffer content.
// We commit when cellWidth becomes positive (renderer ready). Fallback:
// if cellWidth never becomes available, gate on stable positive
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
var FIT_RETRY_MAX_FRAMES = 60;
var fitRetryToken = 0;
function applyFitScale(reason) {
if (!term || !term.element) return;
var token = ++fitRetryToken;
var attempts = 0;
var lastScrollWidth = -1;
function attempt() {
if (token !== fitRetryToken) return;
if (!term || !term.element) return;
attempts++;
var cellW = getCellWidth();
if (cellW > 0 && term.cols > 0) {
commitFitScale(reason, attempts, 'cellW');
return;
}
var w = term.element.scrollWidth;
if (w > 0 && w === lastScrollWidth) {
commitFitScale(reason, attempts, 'stableSW');
return;
}
lastScrollWidth = w;
if (attempts >= FIT_RETRY_MAX_FRAMES) {
flog('commit-timeout', {
reason: reason,
attempts: attempts,
cellW: cellW,
scrollWidth: w,
cols: term.cols
});
commitFitScale(reason, attempts, 'timeout');
return;
}
requestAnimationFrame(attempt);
}
requestAnimationFrame(attempt);
}
function commitFitScale(reason, attempts, gate) {
if (!term || !term.element) return;
var preSnapScale = computeFitScale();
currentScale = preSnapScale;
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
// a second applyFitScale from observing a "no-op needed" state.
if (currentScale >= 0.95) currentScale = 1;
userScale = 1;
panX = 0;
panY = 0;
smoothScrollOffsetY = 0;
updateTransform();
adjustRowsForViewport();
var cellW = getCellWidth();
var sw = term.element.scrollWidth;
var vpW = window.innerWidth;
var expectedW = cellW * term.cols;
var suspect =
currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom
if (suspect) {
flog('commit-SUSPECT', {
reason: reason,
attempts: attempts,
gate: gate,
preSnapScale: preSnapScale,
finalScale: currentScale,
cellW: cellW,
cols: term.cols,
expectedW: expectedW,
scrollWidth: sw,
vpWidth: vpW
});
}
repositionOverlay();
}
function isAltScreenActive(data) {
if (typeof data !== 'string') return false;
var on = data.lastIndexOf(ESC + '[?1049h');
var off = data.lastIndexOf(ESC + '[?1049l');
return on !== -1 && on > off;
}
function normalizeInitialData(data) {
if (!isAltScreenActive(data)) return data;
var on = data.lastIndexOf(ESC + '[?1049h');
// Why: SerializeAddon can include normal-buffer scrollback before the
// active alternate-screen snapshot. Replaying both into a fresh mobile
// xterm duplicates TUI frames and can flatten SGR attributes.
return on > 0 ? data.slice(on) : data;
}
function updateMouseModeFromData(data) {
if (typeof data !== 'string' || data.length === 0) return;
var input = mouseModeScanTail + data;
mouseModeScanTail = extractMouseModeScanTail(input);
var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g');
var match;
while ((match = re.exec(input)) !== null) {
if (match[0] === ESC + 'c') {
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
continue;
}
var enabled = (match[2] || match[4]) === 'h';
var params = (match[1] || match[3]).split(';');
for (var i = 0; i < params.length; i++) {
if (params[i] === '') continue;
var param = Number(params[i]);
if (!Number.isInteger(param)) continue;
if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none';
if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none';
if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none';
if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none';
if (param === 1006) {
sgrMouseMode = enabled;
sgrMousePixelsMode = false;
}
if (param === 1016) {
sgrMouseMode = false;
sgrMousePixelsMode = enabled;
}
}
}
}
function resetWriteQueue() {
writeQueue = [];
writeQueueHead = 0;
}
function isStatusDotPresentationSelector(value) {
return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR;
}
function endsWithStatusDotPresentationSequence(data) {
var i = data.length - 1;
while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--;
return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT;
}
// Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph.
function normalizeStatusDotPresentation(data) {
if (typeof data !== 'string' || data.length === 0) return data;
if (statusDotPendingSelector) {
statusDotPendingSelector = false;
var strippedPendingSelectors = false;
while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1);
strippedPendingSelectors = data.length === 0;
if (strippedPendingSelectors) {
statusDotPendingSelector = true;
return '';
}
}
var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR);
statusDotPendingSelector = endsWithStatusDotPresentationSequence(data);
return normalized;
}
function enqueueWrite(data) {
writeQueue.push(normalizeStatusDotPresentation(data));
}
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
resetWriteQueue();
return undefined;
}
var next = writeQueue[writeQueueHead];
writeQueueHead++;
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
writeQueue = writeQueue.slice(writeQueueHead);
writeQueueHead = 0;
}
return next;
}
function disposeTermObservers() {
var disposables = termObserverDisposables;
termObserverDisposables = [];
for (var i = 0; i < disposables.length; i++) {
try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {}
}
}
function extractMouseModeScanTail(input) {
var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI));
if (start === -1) return '';
var tail = input.slice(start);
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return '';
if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail;
if (tail.indexOf(ESC + '[?') === 0) {
return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '';
}
if (tail.indexOf(C1_CSI + '?') === 0) {
return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '';
}
return '';
}
function pumpWrites(gen) {
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = nextQueuedWrite();
if (typeof next !== 'string') {
if (typeof next === 'function') return next(), pumpWrites(gen);
var callbacks = afterDrainCallbacks;
afterDrainCallbacks = [];
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
return;
}
writesDraining = true;
// Why: xterm.write() parses asynchronously. Row adjustment/resizing must
// wait until replayed SGR attributes have landed in the buffer.
term.write(next, function() {
if (gen !== terminalGeneration) return;
writesDraining = false;
pumpWrites(gen);
});
}
function afterWritesDrained(callback) {
afterDrainCallbacks.push(callback);
pumpWrites(terminalGeneration);
}
`
@@ -0,0 +1,52 @@
export const TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN = ` function isAltScreenActive(data) {
if (typeof data !== 'string') return false;
var on = data.lastIndexOf(ESC + '[?1049h');
var off = data.lastIndexOf(ESC + '[?1049l');
return on !== -1 && on > off;
}
function normalizeInitialData(data) {
if (!isAltScreenActive(data)) return data;
var on = data.lastIndexOf(ESC + '[?1049h');
// Why: SerializeAddon can include normal-buffer scrollback before the
// active alternate-screen snapshot. Replaying both into a fresh mobile
// xterm duplicates TUI frames and can flatten SGR attributes.
return on > 0 ? data.slice(on) : data;
}
function updateMouseModeFromData(data) {
if (typeof data !== 'string' || data.length === 0) return;
var input = mouseModeScanTail + data;
mouseModeScanTail = extractMouseModeScanTail(input);
var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g');
var match;
while ((match = re.exec(input)) !== null) {
if (match[0] === ESC + 'c') {
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
continue;
}
var enabled = (match[2] || match[4]) === 'h';
var params = (match[1] || match[3]).split(';');
for (var i = 0; i < params.length; i++) {
if (params[i] === '') continue;
var param = Number(params[i]);
if (!Number.isInteger(param)) continue;
if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none';
if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none';
if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none';
if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none';
if (param === 1006) {
sgrMouseMode = enabled;
sgrMousePixelsMode = false;
}
if (param === 1016) {
sgrMouseMode = false;
sgrMousePixelsMode = enabled;
}
}
}
}
`
@@ -0,0 +1,130 @@
import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected'
// Opens with the injected theme block: it lands at this point in the emitted document.
export const TERMINAL_HTML_FIT_SCALE = `${TERMINAL_WEBVIEW_THEME_JS}
function getCellHeight() {
if (!term || !term._core) return 15;
var core = term._core;
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.height || 15;
}
return 15;
}
// Why: clamp pan so the terminal content always covers the viewport
// when zoomed in. When content is smaller than viewport in a
// dimension, pin to top-left (no floating in the middle).
function clampPan() {
if (!term || !term.element) return;
var ts = getTotalScale();
var cw = term.element.scrollWidth * ts;
var ch = term.element.scrollHeight * ts;
var vpW = window.innerWidth;
var vpH = window.innerHeight;
if (cw > vpW) {
panX = Math.min(0, Math.max(vpW - cw, panX));
} else {
panX = 0;
}
if (ch > vpH) {
panY = Math.min(0, Math.max(vpH - ch, panY));
} else {
panY = 0;
}
}
// Why: intentional no-op. Mobile replays a live PTY snapshot then applies
// live cursor-relative chunks from that same PTY; resizing only the WebView
// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or
// overlap. Kept as a no-op so its call sites stay legible.
function adjustRowsForViewport() {}
// Why: cold-start fit. After init() opens xterm, the renderer needs
// several frames before cell dimensions are computed. Reading too early
// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
// not laid out), and computeFitScale returns 1 → no zoom.
//
// Gate: cellWidth × cols is the canonical "logical width" of the grid
// and reflects xterm's layout decision, independent of buffer content.
// We commit when cellWidth becomes positive (renderer ready). Fallback:
// if cellWidth never becomes available, gate on stable positive
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
var FIT_RETRY_MAX_FRAMES = 60;
var fitRetryToken = 0;
function applyFitScale(reason) {
if (!term || !term.element) return;
var token = ++fitRetryToken;
var attempts = 0;
var lastScrollWidth = -1;
function attempt() {
if (token !== fitRetryToken) return;
if (!term || !term.element) return;
attempts++;
var cellW = getCellWidth();
if (cellW > 0 && term.cols > 0) {
commitFitScale(reason, attempts, 'cellW');
return;
}
var w = term.element.scrollWidth;
if (w > 0 && w === lastScrollWidth) {
commitFitScale(reason, attempts, 'stableSW');
return;
}
lastScrollWidth = w;
if (attempts >= FIT_RETRY_MAX_FRAMES) {
flog('commit-timeout', {
reason: reason,
attempts: attempts,
cellW: cellW,
scrollWidth: w,
cols: term.cols
});
commitFitScale(reason, attempts, 'timeout');
return;
}
requestAnimationFrame(attempt);
}
requestAnimationFrame(attempt);
}
function commitFitScale(reason, attempts, gate) {
if (!term || !term.element) return;
var preSnapScale = computeFitScale();
currentScale = preSnapScale;
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
// a second applyFitScale from observing a "no-op needed" state.
if (currentScale >= 0.95) currentScale = 1;
userScale = 1;
panX = 0;
panY = 0;
smoothScrollOffsetY = 0;
updateTransform();
adjustRowsForViewport();
var cellW = getCellWidth();
var sw = term.element.scrollWidth;
var vpW = window.innerWidth;
var expectedW = cellW * term.cols;
var suspect =
currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom
if (suspect) {
flog('commit-SUSPECT', {
reason: reason,
attempts: attempts,
gate: gate,
preSnapScale: preSnapScale,
finalScale: currentScale,
cellW: cellW,
cols: term.cols,
expectedW: expectedW,
scrollWidth: sw,
vpWidth: vpW
});
}
repositionOverlay();
}
`
@@ -0,0 +1,110 @@
// Also carries disposeTermObservers() and extractMouseModeScanTail(): both belong to
// other concerns, but emitted-document order pins them inside this queue.
export const TERMINAL_HTML_WRITE_QUEUE = ` function resetWriteQueue() {
writeQueue = [];
writeQueueHead = 0;
}
function isStatusDotPresentationSelector(value) {
return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR;
}
function endsWithStatusDotPresentationSequence(data) {
var i = data.length - 1;
while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--;
return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT;
}
// Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph.
function normalizeStatusDotPresentation(data) {
if (typeof data !== 'string' || data.length === 0) return data;
if (statusDotPendingSelector) {
statusDotPendingSelector = false;
var strippedPendingSelectors = false;
while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1);
strippedPendingSelectors = data.length === 0;
if (strippedPendingSelectors) {
statusDotPendingSelector = true;
return '';
}
}
var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR);
statusDotPendingSelector = endsWithStatusDotPresentationSequence(data);
return normalized;
}
function enqueueWrite(data) {
writeQueue.push(normalizeStatusDotPresentation(data));
}
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
resetWriteQueue();
return undefined;
}
var next = writeQueue[writeQueueHead];
writeQueueHead++;
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
writeQueue = writeQueue.slice(writeQueueHead);
writeQueueHead = 0;
}
return next;
}
function disposeTermObservers() {
var disposables = termObserverDisposables;
termObserverDisposables = [];
for (var i = 0; i < disposables.length; i++) {
try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {}
}
}
function extractMouseModeScanTail(input) {
var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI));
if (start === -1) return '';
var tail = input.slice(start);
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return '';
if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail;
if (tail.indexOf(ESC + '[?') === 0) {
return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '';
}
if (tail.indexOf(C1_CSI + '?') === 0) {
return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '';
}
return '';
}
function pumpWrites(gen) {
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = nextQueuedWrite();
if (typeof next !== 'string') {
if (typeof next === 'function') return next(), pumpWrites(gen);
var callbacks = afterDrainCallbacks;
afterDrainCallbacks = [];
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
return;
}
writesDraining = true;
// Why: xterm.write() parses asynchronously. Row adjustment/resizing must
// wait until replayed SGR attributes have landed in the buffer.
term.write(next, function() {
if (gen !== terminalGeneration) return;
writesDraining = false;
pumpWrites(gen);
});
}
function afterWritesDrained(callback) {
afterDrainCallbacks.push(callback);
pumpWrites(terminalGeneration);
}
`
@@ -0,0 +1,17 @@
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
// Why: every other WebView test exercises one slice of the document, so an edit to an
// uncovered region ships silently. A diff here means the emitted WebView source changed —
// update these values only when that change is deliberate, and only after checking the
// document still runs. Refactors that merely move slice boundaries must leave them alone.
const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608'
const EXPECTED_LENGTH = 729776
describe('terminal WebView payload', () => {
it('composes the expected document', () => {
expect(XTERM_HTML.length).toBe(EXPECTED_LENGTH)
expect(createHash('sha256').update(XTERM_HTML, 'utf8').digest('hex')).toBe(EXPECTED_SHA256)
})
})
+1 -1
View File
@@ -34,7 +34,7 @@ export type RelocatedDaemonHost = {
const HOST_SUBDIR = 'daemon-host'
const MARKER_NAME = '.materialized.json'
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync.
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync.
const LOCAL_HOST_ROOT_NAME = 'Orca'
// Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it.
+49
View File
@@ -19,6 +19,8 @@ export type ExactRefProbeSetResult = {
type ExactRefPresence = 'present' | 'absent' | 'unknown'
const EXACT_REF_PROBE_CONCURRENCY = 8
// SHA-1 and SHA-256 repositories both report a full object id here.
const OBJECT_ID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/
export function isShowRefNoMatchError(error: unknown): boolean {
const record = error && typeof error === 'object' ? (error as Record<string, unknown>) : undefined
@@ -126,3 +128,50 @@ export async function probeAnyExactRef(
await Promise.all(Array.from({ length: workerCount }, () => probeNext()))
return { found, unknown }
}
/** Runs Git with a stdin payload. Only hosts that can feed a child's stdin supply one. */
export type ExactRefProbeStdinExec = (
argv: string[],
options: ExactRefProbeExecOptions & { stdin: string }
) => Promise<{ stdout: string }>
/** `cat-file --batch-check` reports every ref from one child, and reports a missing ref as data
* rather than a failed exit — so a batch stays as decidable as a per-ref `show-ref --verify`.
* A repo with many remotes otherwise pays one subprocess per remote on every conflict check. */
export async function probeAnyExactRefBatched(
runGit: ExactRefProbeStdinExec,
refs: readonly string[],
options: ExactRefProbeExecOptions = {}
): Promise<{ found: boolean; unknown: boolean }> {
const uniqueRefs = [...new Set(refs)]
const safeRefs = uniqueRefs.filter((ref) => isSafeGitRefName(ref))
if (safeRefs.length === 0) {
return { found: false, unknown: uniqueRefs.length > 0 }
}
let stdout: string
try {
;({ stdout } = await runGit(['cat-file', '--batch-check'], {
...options,
stdin: `${safeRefs.join('\n')}\n`
}))
} catch {
return { found: false, unknown: true }
}
const lines = stdout.split('\n').filter((line) => line.trim().length > 0)
// One line per input, in order; a short read means the batch never answered for the rest.
if (lines.length !== safeRefs.length) {
return { found: false, unknown: true }
}
let unknown = safeRefs.length !== uniqueRefs.length
for (const line of lines) {
const [head, type] = line.split(' ')
if (OBJECT_ID_PATTERN.test(head) && type !== undefined && type !== 'missing') {
return { found: true, unknown: false }
}
if (type !== 'missing') {
// `ambiguous`, or a spelling this Git reports differently; neither proves absence.
unknown = true
}
}
return { found: false, unknown }
}
@@ -0,0 +1,49 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { getBranchConflictKind } from './repo-branch-conflict'
describe('branch conflict real Git contract', () => {
const tempPaths: string[] = []
afterEach(() => {
for (const path of tempPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
it('decides remote conflicts from one batched probe across many remotes', async () => {
const repoPath = mkdtempSync(join(tmpdir(), 'orca-branch-conflict-'))
tempPaths.push(repoPath)
const git = (...args: string[]): string =>
execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' })
git('init', '--quiet')
git('config', 'user.name', 'Orca Test')
git('config', 'user.email', 'orca@example.test')
git('config', 'commit.gpgSign', 'false')
git('config', 'core.hooksPath', '.git/no-hooks')
writeFileSync(join(repoPath, 'fixture.txt'), 'base\n')
git('add', 'fixture.txt')
git('commit', '--quiet', '-m', 'base')
const head = git('rev-parse', 'HEAD').trim()
// Many remotes is the shape that used to cost one subprocess each.
for (let index = 0; index < 12; index += 1) {
git('remote', 'add', `remote${index}`, 'https://example.test/repo.git')
}
git('update-ref', 'refs/remotes/remote7/taken', head)
await expect(getBranchConflictKind(repoPath, 'taken')).resolves.toBe('remote')
await expect(getBranchConflictKind(repoPath, 'free')).resolves.toBeNull()
// The allowed base ref is the one remote spelling that is not a conflict.
await expect(
getBranchConflictKind(repoPath, 'taken', 'refs/remotes/remote7/taken')
).resolves.toBeNull()
git('branch', 'local-only', head)
await expect(getBranchConflictKind(repoPath, 'local-only')).resolves.toBe('local')
})
})
+120
View File
@@ -134,3 +134,123 @@ describe('getBranchConflictKindViaExec', () => {
expect(exec).not.toHaveBeenCalled()
})
})
describe('getBranchConflictKindViaExec batched remote probe', () => {
function remoteNames(count: number): string {
return `${Array.from({ length: count }, (_, index) => `remote${index}`).join('\n')}\n`
}
function baseExec(calls: string[][]): (argv: string[]) => Promise<{ stdout: string }> {
return async (argv) => {
calls.push(argv)
if (argv[0] === 'rev-parse') {
throw new Error('local branch is absent')
}
if (argv[0] === 'remote') {
return { stdout: remoteNames(3) }
}
throw new Error(`unexpected git command: ${argv.join(' ')}`)
}
}
it('asks one batched child instead of one probe per remote', async () => {
const calls: string[][] = []
const stdinPayloads: (string | undefined)[] = []
const exec = baseExec(calls)
const batched = async (
argv: string[],
options: { stdin: string }
): Promise<{ stdout: string }> => {
calls.push(argv)
stdinPayloads.push(options.stdin)
return {
stdout: [
'refs/remotes/remote0/feature missing',
'refs/remotes/remote1/feature missing',
'refs/remotes/remote2/feature missing'
].join('\n')
}
}
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBeNull()
expect(calls).toEqual([
['rev-parse', '--verify', 'refs/heads/feature'],
['remote'],
['cat-file', '--batch-check']
])
expect(stdinPayloads).toEqual([
'refs/remotes/remote0/feature\nrefs/remotes/remote1/feature\nrefs/remotes/remote2/feature\n'
])
})
it('reports a remote conflict from the batched answer', async () => {
const calls: string[][] = []
const exec = baseExec(calls)
const batched = async (): Promise<{ stdout: string }> => ({
stdout: [
'refs/remotes/remote0/feature missing',
`${'a'.repeat(40)} commit 214`,
'refs/remotes/remote2/feature missing'
].join('\n')
})
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBe('remote')
})
it('falls back to per-ref probes when the batch cannot answer', async () => {
const calls: string[][] = []
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
calls.push(argv)
if (argv[0] === 'rev-parse') {
throw new Error('local branch is absent')
}
if (argv[0] === 'remote') {
return { stdout: remoteNames(3) }
}
if (argv[0] === 'show-ref') {
if (argv[4] === 'refs/remotes/remote1/feature') {
return { stdout: 'abc refs/remotes/remote1/feature\n' }
}
throw Object.assign(new Error('missing'), { code: 1, stderr: '' })
}
throw new Error(`unexpected git command: ${argv.join(' ')}`)
}
const batched = async (): Promise<{ stdout: string }> => {
throw new Error('cat-file is unavailable')
}
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBe('remote')
expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3)
})
it('treats a short batch read as undecided rather than as absence', async () => {
const calls: string[][] = []
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
calls.push(argv)
if (argv[0] === 'rev-parse') {
throw new Error('local branch is absent')
}
if (argv[0] === 'remote') {
return { stdout: remoteNames(3) }
}
if (argv[0] === 'show-ref') {
throw Object.assign(new Error('missing'), { code: 1, stderr: '' })
}
throw new Error(`unexpected git command: ${argv.join(' ')}`)
}
const batched = async (): Promise<{ stdout: string }> => ({
stdout: 'refs/remotes/remote0/feature missing'
})
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBeNull()
expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3)
})
})
+43 -10
View File
@@ -4,8 +4,10 @@ import { gitExecFileAsync } from './runner'
import { isSafeGitRefName } from '../../shared/git-status-upstream-ref'
import {
probeAnyExactRef,
probeAnyExactRefBatched,
type ExactRefProbeExec,
type ExactRefProbeExecOptions
type ExactRefProbeExecOptions,
type ExactRefProbeStdinExec
} from './exact-ref-probe'
export type BranchConflictKind = 'local' | 'remote'
@@ -79,12 +81,31 @@ function buildRemoteBranchConflictRefs(
return [...refs]
}
/** One batched child answers for every remote; the per-ref probes only run when the host cannot
* feed stdin, or when the batch came back undecided. */
async function probeAnyRemoteConflictRef(
exec: ExactRefProbeExec,
batchedExec: ExactRefProbeStdinExec | undefined,
candidateRefs: readonly string[],
probeOptions: ExactRefProbeExecOptions
): Promise<{ found: boolean }> {
if (batchedExec) {
// A present ref is always decisive, so `found` never survives with `unknown` set.
const batched = await probeAnyExactRefBatched(batchedExec, candidateRefs, probeOptions)
if (!batched.unknown) {
return { found: batched.found }
}
}
return probeAnyExactRef(exec, candidateRefs, probeOptions)
}
/** Run branch-conflict policy through the host that owns Git execution. */
export async function getBranchConflictKindViaExec(
exec: ExactRefProbeExec,
branchName: string,
allowedBaseRef?: string,
options: ExactRefProbeExecOptions = {}
options: ExactRefProbeExecOptions = {},
batchedExec?: ExactRefProbeStdinExec
): Promise<BranchConflictKind | null> {
if (!canQueryRemoteBranchName(branchName)) {
return null
@@ -104,7 +125,12 @@ export async function getBranchConflictKindViaExec(
return null
}
const { found: hasRemoteConflict } = await probeAnyExactRef(exec, candidateRefs, probeOptions)
const { found: hasRemoteConflict } = await probeAnyRemoteConflictRef(
exec,
batchedExec,
candidateRefs,
probeOptions
)
return hasRemoteConflict ? 'remote' : null
} catch {
@@ -119,15 +145,22 @@ export function getBranchConflictKind(
options: LocalGitExecOptions = {}
): Promise<BranchConflictKind | null> {
const execOptions = gitExecOptions(path, options)
const runLocalGit = (
argv: string[],
commandOptions?: ExactRefProbeExecOptions & { stdin?: string }
): Promise<{ stdout: string }> =>
gitExecFileAsync(argv, {
...execOptions,
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }),
...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin })
})
return getBranchConflictKindViaExec(
(argv, commandOptions) =>
gitExecFileAsync(argv, {
...execOptions,
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs })
}),
runLocalGit,
branchName,
allowedBaseRef
allowedBaseRef,
{},
(argv, commandOptions) => runLocalGit(argv, commandOptions)
)
}
+50
View File
@@ -12,6 +12,7 @@ import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-boots
import { initializeMainProcessReady } from './startup/main-process-ready'
import { installMainProcessQuitHandlers } from './startup/main-process-quit'
import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock'
import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files'
function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow {
return openMainWindowController(options)
@@ -27,6 +28,7 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.skillShareDeepLinks.capture(argv, (shareId) => {
state.mainWindow?.webContents.send('ui:openSkillShare', shareId)
})
state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)
// Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935).
if (!shouldActivateDesktopForSecondInstance(argv)) {
return
@@ -34,6 +36,39 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.desktopActivationGate?.requestActivation()
}
/**
* Hands buffered OS-opened markdown paths to a renderer that has proven it is listening.
*
* Until that proof arrives the paths stay buffered, because `webContents.send` to a renderer
* with no listener attached is dropped silently and the queue would be gone.
*/
function publishOsOpenedMarkdownFiles(): void {
const targetWindow = state.mainWindow
if (!state.markdownFileOpenListenerReady || !targetWindow || targetWindow.isDestroyed()) {
return
}
// Why consumed before the await: a renderer pull racing this resolve must not take the same
// batch again. The restore() calls hand it back if delivery turns out to be impossible.
const filePaths = state.osOpenedMarkdownFiles.consume()
if (filePaths.length === 0) {
return
}
void resolveOpenedMarkdownDocuments(filePaths)
.then((documents) => {
if (targetWindow.isDestroyed() || targetWindow.webContents.isDestroyed()) {
state.osOpenedMarkdownFiles.restore(filePaths)
return
}
if (documents.length > 0) {
targetWindow.webContents.send('ui:openMarkdownFiles', documents)
}
})
.catch((error) => {
state.osOpenedMarkdownFiles.restore(filePaths)
console.warn('[os-open] Failed to resolve OS-opened markdown files:', error)
})
}
const handleMacAppActivation = createMacAppActivationHandler({
getWindow: () => state.mainWindow,
requestActivation: requestDesktopActivation
@@ -53,7 +88,22 @@ if (preflightReady) {
event.preventDefault()
requestDesktopActivation([url])
})
// Why: macOS delivers "Open With" as open-file, often before `ready`, and only to a handler
// that claims the event. Non-markdown paths stay unclaimed so the OS default handler wins.
app.on('open-file', (event, filePath) => {
if (!state.osOpenedMarkdownFiles.captureFilePaths([filePath], publishOsOpenedMarkdownFiles)) {
return
}
event.preventDefault()
// Why gated on isReady: pre-ready the cold-start window is already on its way, and
// activating the gate here would try to open one before Electron can.
if (app.isReady()) {
requestDesktopActivation()
}
})
state.skillShareDeepLinks.capture(process.argv)
// Why no publish: nothing is listening this early, so the first renderer pulls these on mount.
state.osOpenedMarkdownFiles.capture(process.argv)
registerMainProcessIpcHandlers()
installMainProcessQuitHandlers()
void app.whenReady().then(async () => {
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
// Why: the backstop full scan stats a `.git` marker per candidate dir; an
// unbounded fan-out at hundreds of worktrees would queue thousands of `stat`
// calls on libuv's 4-thread pool (#17828).
const { concurrency } = vi.hoisted(() => ({ concurrency: { current: 0, peak: 0 } }))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
stat: async (...args: Parameters<typeof actual.stat>) => {
concurrency.current += 1
concurrency.peak = Math.max(concurrency.peak, concurrency.current)
try {
return await actual.stat(...args)
} finally {
concurrency.current -= 1
}
}
}
})
function makeTarget(path: string): WorktreeBaseWatchTarget {
const repoConfig: WorktreeBaseRepoWatchConfig = {
repoId: 'repo-1',
repoName: 'project',
nestWorkspaces: false
}
return {
key: `base:local:${path}`,
kind: 'base',
path,
repos: new Map([[repoConfig.repoId, repoConfig]])
}
}
describe('worktree base directory poller marker fan-out (#17828)', () => {
const cleanups: (() => Promise<void>)[] = []
beforeEach(() => {
concurrency.current = 0
concurrency.peak = 0
})
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
})
it('bounds concurrent `.git`-marker stats regardless of candidate count', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-base-poller-fanout-')))
cleanups.push(() => rm(root, { recursive: true, force: true }))
const candidateCount = 200
for (let i = 0; i < candidateCount; i++) {
const worktree = join(root, `wt-${i}`)
await mkdir(worktree)
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
}
const target = makeTarget(root)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
() => {},
{ pollIntervalMs: 100_000 }
)
cleanups.push(() => poller.unsubscribe())
// 200 candidates stated unbounded would peak near 200 concurrent `stat`
// calls; bounding the marker probe keeps the peak independent of count —
// while still overlapping requests (not serialized one-at-a-time).
expect(concurrency.peak).toBeGreaterThan(1)
expect(concurrency.peak).toBeLessThan(20)
})
})
+21 -16
View File
@@ -1,6 +1,8 @@
import { readdir, stat } from 'node:fs/promises'
import type { Dirent } from 'node:fs'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { forEachWithConcurrency } from '../../shared/map-with-concurrency'
import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility'
import type {
WorktreeBaseRepoWatchConfig,
@@ -92,6 +94,11 @@ export const WORKTREE_BASE_BACKSTOP_TICKS = 15
// backstop scan cover the pathological case.
const PENDING_MARKER_MAX_TICKS = 300
// Why: matches the git-common poller's fan-out bound (#17828) — bounded
// concurrency turns hundreds of serial round trips into a handful of batches
// without dumping every candidate onto libuv's 4-thread pool at once.
const MARKER_PROBE_CONCURRENCY = 8
function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string {
return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}`
}
@@ -123,6 +130,14 @@ type BaseSnapshot = {
gateSignatures: string[]
}
async function readdirSafe(path: string): Promise<Dirent[]> {
try {
return await readdir(path, { withFileTypes: true })
} catch {
return []
}
}
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
// repo's container, mirroring what worktree-base-directory-event-filter
// matches: `<wt>/.git` completion markers and `<wt>` deletions.
@@ -144,14 +159,9 @@ async function snapshotBase(
.map((config) => normalizeRuntimePathForComparison(config.repoName))
)
let rootEntries
try {
rootEntries = await readdir(rootPath, { withFileTypes: true })
} catch {
// Root vanished: an empty snapshot diffs into delete events for every
// previously-known worktree dir, matching the old watcher's error path.
return { markers, gateDirs, gateSignatures }
}
// Root vanished or unreadable: readdirSafe yields [], producing the same
// empty markers/candidates result as the old watcher's error path.
const rootEntries = await readdirSafe(rootPath)
const candidates: string[] = []
for (const entry of rootEntries) {
@@ -165,12 +175,7 @@ async function snapshotBase(
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
gateDirs.push(entryPath)
gateSignatures.push(await dirSignature(entryPath))
let subEntries
try {
subEntries = await readdir(entryPath, { withFileTypes: true })
} catch {
subEntries = []
}
const subEntries = await readdirSafe(entryPath)
for (const sub of subEntries) {
if (sub.isDirectory() || sub.isSymbolicLink()) {
candidates.push(join(entryPath, sub.name))
@@ -179,9 +184,9 @@ async function snapshotBase(
}
}
for (const dir of candidates) {
await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => {
markers.set(dir, await hasGitMarker(dir))
}
})
return { markers, gateDirs, gateSignatures }
}
@@ -39,11 +39,33 @@ export async function snapshotGitCommonEntry(
previous: GitCommonEntrySnapshot | undefined,
forceFullScan: boolean
): Promise<GitCommonEntrySnapshot> {
// Structural leaves change in place every tick; only index uses the entry-dir gate.
// Git writes HEAD/index/config.worktree/locked via a lock file + rename inside the
// entry dir, so the entry dir's own signature moves on every one of those writes
// (verified against git 2.55: checkout, commit, amend, reset, ref updates, stash,
// worktree lock/unlock, config --worktree, index writes all move it). The one
// in-place exception is `gitdir` (worktree move/repair), which the periodic
// forceFullScan backstop (INDEX_BACKSTOP_TICKS) below re-stats regardless of this
// gate. Gating all of these leaves on the entry-dir signature turns an unchanged
// entry into a single stat per tick instead of stat-ing every leaf every tick.
const nextDirSignature = await gitCommonDirectorySignature(entryPath)
if (nextDirSignature === 'missing') {
return (
previous ?? {
dirSignature: nextDirSignature,
structuralSignatures: new Map(),
indexSignature: null,
headLogSignature: null
}
)
}
const shouldRescan = forceFullScan || !previous || previous.dirSignature !== nextDirSignature
if (!shouldRescan) {
return previous
}
const structuralSignatures = new Map<string, string>()
const [nextDirSignature, headLogSignature] = await Promise.all([
gitCommonDirectorySignature(entryPath),
const [headLogSignature, indexSignature] = await Promise.all([
gitCommonFileSignature(join(entryPath, HEAD_LOG_FILE)),
gitCommonFileSignature(join(entryPath, INDEX_FILE)),
Promise.all(
STRUCTURAL_METADATA_FILES.map(async (name) => {
const signature = await gitCommonFileSignature(join(entryPath, name))
@@ -53,20 +75,6 @@ export async function snapshotGitCommonEntry(
})
)
])
if (nextDirSignature === 'missing') {
return (
previous ?? {
dirSignature: nextDirSignature,
structuralSignatures,
indexSignature: null,
headLogSignature
}
)
}
const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature
const indexSignature = shouldReadIndex
? await gitCommonFileSignature(join(entryPath, INDEX_FILE))
: previous.indexSignature
return {
dirSignature: nextDirSignature,
structuralSignatures,
@@ -73,6 +73,11 @@ export async function startGitCommonNarrowWatch(
.unsubscribe()
.catch(() => {})
.then(() =>
// Crash fuse tripped: this poller is now the sole change signal until a
// future existence-poll upgrade (follow-up: #17878). Its own per-entry
// dir-signature gate (worktree-git-common-entry-snapshot.ts) already keeps
// an unchanged entry to a single stat, so a fixed `pollIntervalMs` cadence
// stays cheap at high worktree counts without needing to stretch itself.
startGitCommonPolling(
target.path,
onEvents,
@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { startGitCommonPolling } from './worktree-git-common-polling'
import type {
WorktreeBasePollEvent,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
// Why: measure the fan-out this poller issues per scan (peak concurrent `stat`
// calls, `readdir` call count as a proxy for "a tick ran") without depending on
// real disk timing (#17828). `entryZeroStatCalls` tracks every stat under a
// specific pre-existing entry (its dir plus every leaf), used to prove the
// entry-dir signature gate keeps an unchanged entry to one stat per tick.
const { statDelayMs, readdirCalls, concurrency, entryZeroStatCalls } = vi.hoisted(() => ({
statDelayMs: { current: 0 },
readdirCalls: { count: 0 },
concurrency: { current: 0, peak: 0 },
entryZeroStatCalls: { count: 0 }
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
readdir: (...args: Parameters<typeof actual.readdir>) => {
readdirCalls.count += 1
return actual.readdir(...args)
},
stat: async (...args: Parameters<typeof actual.stat>) => {
concurrency.current += 1
concurrency.peak = Math.max(concurrency.peak, concurrency.current)
const path = args[0]
const entryZeroSegment = `${sep}wt-0`
if (
typeof path === 'string' &&
(path.endsWith(entryZeroSegment) || path.includes(`${entryZeroSegment}${sep}`))
) {
entryZeroStatCalls.count += 1
}
try {
if (statDelayMs.current > 0) {
await new Promise((resolve) => setTimeout(resolve, statDelayMs.current))
}
return await actual.stat(...args)
} finally {
concurrency.current -= 1
}
}
}
})
const alwaysVisible: WorktreePollerWindowVisibility = {
isWindowVisible: () => true,
onWindowBecameVisible: () => () => {}
}
async function makeCommonDir(entryCount: number): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'git-common-polling-test-'))
for (let i = 0; i < entryCount; i++) {
const entryPath = join(root, 'worktrees', `wt-${i}`)
await mkdir(join(entryPath, 'logs'), { recursive: true })
await Promise.all([
writeFile(join(entryPath, 'HEAD'), 'ref: refs/heads/main\n'),
writeFile(join(entryPath, 'gitdir'), `${join(root, `checkout-${i}`, '.git')}\n`),
writeFile(join(entryPath, 'index'), Buffer.from([0])),
writeFile(join(entryPath, 'logs', 'HEAD'), '0000 aaaa\n')
])
}
return root
}
describe('startGitCommonPolling fan-out bounds (#17828)', () => {
const cleanups: (() => Promise<void>)[] = []
const dirsToRemove: string[] = []
beforeEach(() => {
statDelayMs.current = 0
readdirCalls.count = 0
concurrency.current = 0
concurrency.peak = 0
entryZeroStatCalls.count = 0
})
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
await Promise.all(
dirsToRemove.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
)
vi.useRealTimers()
})
it('bounds concurrent per-entry stat fan-out regardless of entry count', async () => {
const commonDir = await makeCommonDir(200)
dirsToRemove.push(commonDir)
const sub = await startGitCommonPolling(commonDir, () => {}, 100_000, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
// 200 entries x ~6 concurrent structural stats each would peak near 1,200
// unbounded; bounding to 8 in-flight entries keeps the peak independent of
// entry count instead of scaling with it.
expect(concurrency.peak).toBeLessThan(80)
})
it('never overlaps a scan with itself even when ticks fire faster than a scan completes', async () => {
const commonDir = await makeCommonDir(10)
dirsToRemove.push(commonDir)
statDelayMs.current = 20
const pollIntervalMs = 5
const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
readdirCalls.count = 0
// ~60 would-be 5ms ticks elapse in this window while every stat takes 20ms;
// the ticking guard must serialize scans, not launch overlapping ones.
await new Promise((resolve) => setTimeout(resolve, 300))
expect(readdirCalls.count).toBeLessThan(10)
})
it('costs exactly one stat per tick for an unchanged entry', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const pollIntervalMs = 20
const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot (which always fully reads every entry once) settle.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
readdirCalls.count = 0
entryZeroStatCalls.count = 0
await vi.waitFor(
() => {
expect(readdirCalls.count).toBeGreaterThanOrEqual(5)
},
{ timeout: 2_000 }
)
// Without the entry-dir signature gate, an unchanged entry still costs ~6
// stats every tick (HEAD/gitdir/locked/config.worktree/logs/HEAD/index).
// With the gate, only the entry dir itself is stat'd once nothing changed —
// one stat per tick, in lockstep with the readdir tripwire.
expect(entryZeroStatCalls.count).toBeLessThanOrEqual(readdirCalls.count + 1)
expect(entryZeroStatCalls.count).toBeGreaterThanOrEqual(readdirCalls.count - 1)
})
it('detects a HEAD rewrite via lock+rename on the next tick', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const pollIntervalMs = 20
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
pollIntervalMs,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot settle before mutating.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
const entryDir = join(commonDir, 'worktrees', 'wt-0')
const headPath = join(entryDir, 'HEAD')
const headLockPath = join(entryDir, 'HEAD.lock')
// Every real git ref write goes through a lock file + rename inside the entry
// dir (never an in-place overwrite), which moves the entry dir's own signature.
await writeFile(headLockPath, 'ref: refs/heads/feature\n')
await rename(headLockPath, headPath)
await vi.waitFor(
() => {
expect(events.flat()).toContainEqual({ type: 'update', path: headPath })
},
{ timeout: pollIntervalMs * 10 }
)
})
it('detects an in-place gitdir rewrite only once the periodic backstop rescans it', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const pollIntervalMs = 10
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
pollIntervalMs,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot settle before mutating.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
const entryDir = join(commonDir, 'worktrees', 'wt-0')
const gitdirPath = join(entryDir, 'gitdir')
// `gitdir` is the one structural leaf git rewrites in place (worktree move/repair),
// so the entry dir's own signature never moves — the periodic ungated backstop
// (INDEX_BACKSTOP_TICKS = 15) is the only thing that catches it.
await writeFile(gitdirPath, `${join(commonDir, 'checkout-moved', '.git')}\n`)
// Not caught by the next several ticks: the gate stays closed since nothing
// moved the entry dir's own signature.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs * 5))
expect(events.flat()).not.toContainEqual({ type: 'update', path: gitdirPath })
// Eventually caught regardless of the gate, once tick 15 forces the periodic backstop.
await vi.waitFor(
() => {
expect(events.flat()).toContainEqual({ type: 'update', path: gitdirPath })
},
{ timeout: pollIntervalMs * 40 }
)
})
it('still detects entry add/remove correctly with bounded concurrency', async () => {
const commonDir = await makeCommonDir(5)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
20,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
const newEntry = join(commonDir, 'worktrees', 'wt-new')
await mkdir(join(newEntry, 'logs'), { recursive: true })
await writeFile(join(newEntry, 'HEAD'), 'ref: refs/heads/main\n')
await vi.waitFor(() => {
expect(events.flat()).toContainEqual({ type: 'create', path: newEntry })
})
await rm(newEntry, { recursive: true })
await vi.waitFor(() => {
expect(events.flat()).toContainEqual({ type: 'delete', path: newEntry })
})
})
})
+21 -14
View File
@@ -1,5 +1,6 @@
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { forEachWithConcurrency } from '../../shared/map-with-concurrency'
import { PRIMARY_CHECKOUT_METADATA_FILES } from './worktree-git-common-metadata-files'
import {
diffGitCommon,
@@ -23,18 +24,26 @@ import {
// same way the base poller's backstop rescan does.
const INDEX_BACKSTOP_TICKS = 15
// Why: an unbounded fan-out across every worktree admin entry queues thousands
// of ops on libuv's 4-thread default pool, starving every other main-process
// fs call for the scan's duration (#17828). 8 mirrors the existing
// head-identity/exact-ref-probe pools — enough to saturate typical local
// disks without monopolizing the pool. Since snapshotGitCommonEntry's own
// entry-dir gate (see worktree-git-common-entry-snapshot.ts) keeps most ticks
// down to 1 stat per unchanged entry, real in-flight is now bounded by this
// limit rather than limit × per-entry stat count.
const GIT_COMMON_SNAPSHOT_CONCURRENCY = 8
async function snapshotStatusRefSignatures(
paths: ReadonlySet<string>
): Promise<Map<string, string>> {
const signatures = new Map<string, string>()
await Promise.all(
[...paths].map(async (path) => {
const signature = await gitCommonFileSignature(path)
if (signature !== null) {
signatures.set(path, signature)
}
})
)
await forEachWithConcurrency([...paths], GIT_COMMON_SNAPSHOT_CONCURRENCY, async (path) => {
const signature = await gitCommonFileSignature(path)
if (signature !== null) {
signatures.set(path, signature)
}
})
return signatures
}
@@ -91,12 +100,10 @@ async function snapshotGitCommon(
}
const entries = new Map<string, GitCommonEntrySnapshot>()
await Promise.all(
entryPaths.map(async (entryPath) => {
const previousEntry = previous?.entries.get(entryPath)
entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan))
})
)
await forEachWithConcurrency(entryPaths, GIT_COMMON_SNAPSHOT_CONCURRENCY, async (entryPath) => {
const previousEntry = previous?.entries.get(entryPath)
entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan))
})
// Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan
// now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost —
// rather than the always-run worktrees-dir readdir.
@@ -60,6 +60,8 @@ export async function startGitCommonWatch(
}
}
}
// Why: Electron only ships darwin/linux/win32, all covered by NARROW_WATCH_PLATFORMS
// above, so this branch is defensive dead code in production, not a reachable fallback.
return startGitCommonPolling(
target.path,
onEvents,
@@ -4,6 +4,8 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import {
cleanupUnusedWorktreePushTargetRemoteWithExec,
findWorktreeMetaReferencingRemote,
hasBranchConfigUsingRemote,
sameGitHubRemoteUrl,
type GitRemoteExec,
type WorktreePushTargetStore
@@ -253,6 +255,70 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => {
})
})
describe('hasBranchConfigUsingRemote', () => {
it('requireExistingBranch: false (default) protects on config alone, even if the branch is gone', async () => {
const exec = makeExec({ branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}` })
await expect(hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget())).resolves.toBe(true)
})
it('requireExistingBranch: true only protects when the referencing branch still exists', async () => {
const exec = vi.fn<GitRemoteExec>(async (args: string[]) => {
if (args[0] === 'config') {
return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' }
}
if (args[0] === 'for-each-ref') {
return { stdout: 'main\ncontributor/fix\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await expect(
hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true })
).resolves.toBe(true)
})
it('requireExistingBranch: true does not protect on a stale config entry from a deleted branch', async () => {
const exec = vi.fn<GitRemoteExec>(async (args: string[]) => {
if (args[0] === 'config') {
return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' }
}
if (args[0] === 'for-each-ref') {
return { stdout: 'main\n', stderr: '' } // contributor/fix no longer exists
}
return { stdout: '', stderr: '' }
})
await expect(
hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true })
).resolves.toBe(false)
})
it('extracts branch names containing dots correctly', async () => {
const exec = vi.fn<GitRemoteExec>(async (args: string[]) => {
if (args[0] === 'config') {
return { stdout: `branch.release/1.2.3.remote ${FORK_REMOTE}`, stderr: '' }
}
if (args[0] === 'for-each-ref') {
return { stdout: 'release/1.2.3\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await expect(
hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true })
).resolves.toBe(true)
})
})
describe('findWorktreeMetaReferencingRemote', () => {
it('scopes matches to the given repo id and excludes worktrees without a pushTarget', () => {
const store = storeOf({
'repo-1::/wt/a': forkTarget(),
'repo-1::/wt/b': undefined,
'repo-2::/wt/c': forkTarget()
})
const matches = findWorktreeMetaReferencingRemote(store, 'repo-1', forkTarget())
expect(matches.map((match) => match.worktreeId)).toEqual(['repo-1::/wt/a'])
})
})
describe('sameGitHubRemoteUrl', () => {
it('matches SSH and HTTPS forms of the same GitHub fork', () => {
expect(
+108 -30
View File
@@ -1,9 +1,12 @@
// Why: fork-PR worktrees can add a contributor's fork as a git remote. When such
// a worktree is deleted we prune that remote, but only when it's truly unused.
// This module holds that decision logic behind an injectable `execGit` boundary so
// the multi-fork cleanup matrix is unit-testable without a real repo.
// the multi-fork cleanup matrix is unit-testable without a real repo. The same
// predicates back the periodic sweep in `worktree-push-target-reconciliation.ts`,
// which inverts them over every `pr-*` remote instead of one removed worktree.
import type { Store } from '../persistence'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import { parseGitHubOwnerRepo } from '../github/gh-utils'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
@@ -29,61 +32,113 @@ export function sameGitHubRemoteUrl(left: string, right: string): boolean {
)
}
/** A worktree metadata entry, in the same repo as `target`, whose pushTarget references it. */
export type WorktreeMetaReferencingRemote = { worktreeId: string; meta: WorktreeMeta }
// Exported so the reconciliation sweep can inspect *which* worktrees reference a remote
// (to check liveness/provenance) instead of only the single-target yes/no this file needs.
export function findWorktreeMetaReferencingRemote(
store: WorktreePushTargetStore,
repoId: string,
target: Pick<GitPushTarget, 'remoteName' | 'remoteUrl'>
): WorktreeMetaReferencingRemote[] {
return Object.entries(store.getAllWorktreeMeta())
.filter(([worktreeId, meta]) => {
// Why: git remotes are repo-local; matching metadata from another repo
// must not pin this repo's fork remote forever.
if (getRepoIdFromWorktreeId(worktreeId) !== repoId || !meta.pushTarget) {
return false
}
const otherRemoteUrl = meta.pushTarget.remoteUrl
const targetRemoteUrl = target.remoteUrl
return (
meta.pushTarget.remoteName === target.remoteName ||
(typeof otherRemoteUrl === 'string' &&
typeof targetRemoteUrl === 'string' &&
sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl))
)
})
.map(([worktreeId, meta]) => ({ worktreeId, meta }))
}
function isPushTargetUsedByAnotherWorktree(
store: WorktreePushTargetStore,
removedWorktreeId: string,
target: GitPushTarget
): boolean {
const removedRepoId = getRepoIdFromWorktreeId(removedWorktreeId)
return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => {
// Why: git remotes are repo-local; matching metadata from another repo
// must not pin this repo's fork remote forever.
const belongsToSameRepo = getRepoIdFromWorktreeId(worktreeId) === removedRepoId
if (worktreeId === removedWorktreeId || !belongsToSameRepo || !meta.pushTarget) {
return false
}
const otherRemoteUrl = meta.pushTarget.remoteUrl
const targetRemoteUrl = target.remoteUrl
return (
meta.pushTarget.remoteName === target.remoteName ||
(typeof otherRemoteUrl === 'string' &&
typeof targetRemoteUrl === 'string' &&
sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl))
)
})
return findWorktreeMetaReferencingRemote(store, removedRepoId, target).some(
({ worktreeId }) => worktreeId !== removedWorktreeId
)
}
async function hasBranchConfigUsingRemote(
export type BranchConfigMatch = { branchName: string }
// Exported for the sweep, which additionally verifies each matched branch still exists
// before treating it as a reason to keep the remote (`requireExistingBranch`).
export async function hasBranchConfigUsingRemote(
execGit: GitRemoteExec,
repoPath: string,
target: GitPushTarget
target: Pick<GitPushTarget, 'remoteName' | 'remoteUrl'>,
options: { requireExistingBranch?: boolean } = {}
): Promise<boolean> {
let stdout: string
try {
;({ stdout } = await execGit(
['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'],
repoPath
))
} catch {
return false
}
const matches: BranchConfigMatch[] = []
// Why: git config output can be large; avoid materializing line/split arrays here.
for (const line of iterateProcessOutputLines(stdout)) {
const parsed = parseBranchRemoteConfigLine(line)
if (parsed && (parsed.value === target.remoteName || parsed.value === target.remoteUrl)) {
matches.push({ branchName: parsed.branchName })
}
}
if (matches.length === 0) {
return false
}
if (!options.requireExistingBranch) {
return true
}
return branchesExist(
execGit,
repoPath,
matches.map((match) => match.branchName)
)
}
async function branchesExist(
execGit: GitRemoteExec,
repoPath: string,
branchNames: string[]
): Promise<boolean> {
try {
const { stdout } = await execGit(
['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'],
['for-each-ref', '--format=%(refname:short)', 'refs/heads/'],
repoPath
)
// Why: git config output can be large; avoid materializing line/split arrays here.
for (const line of iterateProcessOutputLines(stdout)) {
const value = readBranchRemoteConfigValue(line)
if (value === target.remoteName || value === target.remoteUrl) {
return true
}
}
return false
const existingBranches = new Set(iterateProcessOutputLines(stdout))
return branchNames.some((branchName) => existingBranches.has(branchName))
} catch {
return false
}
}
function readBranchRemoteConfigValue(line: string): string | null {
function parseBranchRemoteConfigLine(line: string): { branchName: string; value: string } | null {
let index = 0
while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) {
index += 1
}
const keyStart = index
while (index < line.length && !isBranchConfigSeparator(line.charCodeAt(index))) {
index += 1
}
const key = line.slice(keyStart, index)
while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) {
index += 1
}
@@ -96,7 +151,30 @@ function readBranchRemoteConfigValue(line: string): string | null {
while (valueEnd > valueStart && isBranchConfigSeparator(line.charCodeAt(valueEnd - 1))) {
valueEnd -= 1
}
return valueStart < valueEnd ? line.slice(valueStart, valueEnd) : null
if (valueStart >= valueEnd) {
return null
}
const branchName = extractBranchNameFromConfigKey(key)
return branchName ? { branchName, value: line.slice(valueStart, valueEnd) } : null
}
// `branch.<name>.remote` / `branch.<name>.pushRemote`; `<name>` may itself contain dots
// (e.g. `release/1.2.3`), so only the known trailing suffix is stripped.
function extractBranchNameFromConfigKey(key: string): string | null {
const prefix = 'branch.'
if (!key.startsWith(prefix)) {
return null
}
const rest = key.slice(prefix.length)
const lastDot = rest.lastIndexOf('.')
if (lastDot <= 0) {
return null
}
const suffix = rest.slice(lastDot + 1)
if (suffix !== 'remote' && suffix !== 'pushRemote') {
return null
}
return rest.slice(0, lastDot)
}
function isBranchConfigSeparator(code: number): boolean {
@@ -0,0 +1,173 @@
// Real-binary coverage for the pr-* remote reconciliation sweep (#17828): the mocked-exec suite
// proves the decision matrix, but not that `git remote -v`, `git config --get-regexp`, and
// `git for-each-ref` are parsed correctly against real Git output.
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 } from 'vitest'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup'
import { reconcileOrphanedPrRemotesWithExec } from './worktree-push-target-reconciliation'
const execFileAsync = promisify(execFile)
const REPO_ID = 'repo-1'
const FORK_REMOTE = 'pr-contributor-orca'
let scratchDir = ''
let repoPath = ''
let forkPath = ''
async function git(args: string[], cwd: string): Promise<string> {
const { stdout } = await execFileAsync('git', args, { cwd })
return stdout
}
const execGit: GitRemoteExec = (args, cwd) => execFileAsync('git', args, { cwd })
function worktreeId(suffix: string): string {
return `${REPO_ID}::${suffix}`
}
function forkTarget(overrides: Partial<GitPushTarget> = {}): GitPushTarget {
return {
remoteName: FORK_REMOTE,
branchName: 'contributor/fix',
remoteUrl: forkPath,
remoteCreated: true,
...overrides
}
}
function storeOf(entries: Record<string, GitPushTarget | undefined>): WorktreePushTargetStore {
const meta: Record<string, WorktreeMeta> = {}
for (const [id, pushTarget] of Object.entries(entries)) {
meta[id] = { pushTarget } as unknown as WorktreeMeta
}
return { getAllWorktreeMeta: () => meta }
}
beforeEach(async () => {
// realpath: macOS hands out /var/... temp paths while Git reports /private/var/...
scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-pr-remote-reconcile-')))
repoPath = join(scratchDir, 'repo')
forkPath = join(scratchDir, 'fork')
await mkdir(repoPath, { recursive: true })
await git(['init', '-q'], repoPath)
await git(['config', 'user.name', 'Orca Test'], repoPath)
await git(['config', 'user.email', 'orca@example.test'], repoPath)
await git(['config', 'commit.gpgSign', 'false'], repoPath)
await git(['config', 'core.hooksPath', '.git/no-hooks'], repoPath)
await writeFile(join(repoPath, 'seed.txt'), 'seed\n')
await git(['add', '-A'], repoPath)
await git(['commit', '-qm', 'seed'], repoPath)
// A second local "fork" repo the pr-* remote points at, so `remote add`/fetch behave normally.
await git(['clone', '-q', repoPath, forkPath], scratchDir)
await git(['config', 'user.name', 'Orca Test'], forkPath)
await git(['config', 'user.email', 'orca@example.test'], forkPath)
await git(['config', 'commit.gpgSign', 'false'], forkPath)
await git(['config', 'core.hooksPath', '.git/no-hooks'], forkPath)
await git(['checkout', '-qb', 'contributor/fix'], forkPath)
await writeFile(join(forkPath, 'fork.txt'), 'fork change\n')
await git(['add', '-A'], forkPath)
await git(['commit', '-qm', 'fork change'], forkPath)
await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath)
await git(
[
'fetch',
FORK_REMOTE,
`+refs/heads/contributor/fix:refs/remotes/${FORK_REMOTE}/contributor/fix`
],
repoPath
)
})
afterEach(async () => {
await rm(scratchDir, { recursive: true, force: true })
})
describe('reconcileOrphanedPrRemotesWithExec against the real Git binary', () => {
it('leaves a user-created remote alone: naming/URL shape is not proof of provenance', async () => {
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
repoPath,
REPO_ID,
storeOf({}), // no worktree metadata anywhere claims this remote
execGit,
[]
)
expect(reclaimed).toEqual([])
await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE)
})
it('leaves the remote alone while a live worktree still references it', async () => {
const worktreePath = join(scratchDir, 'wt-live')
await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local'], repoPath)
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
repoPath,
REPO_ID,
storeOf({ [worktreeId(worktreePath)]: forkTarget() }),
execGit,
[worktreePath]
)
expect(reclaimed).toEqual([])
await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE)
})
it('leaves the remote alone while its branch still exists (preserve-on-delete kept alive)', async () => {
await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath)
await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath)
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
repoPath,
REPO_ID,
storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }),
execGit,
[] // the worktree that created it is gone, but the branch it preserved is not
)
expect(reclaimed).toEqual([])
await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE)
})
it('reclaims the remote once the branch that pinned it is deleted (path 2)', async () => {
await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath)
await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath)
// Delete only the ref, leaving the config behind, exactly as `update-ref -d` alone would --
// proving the sweep checks branch existence rather than trusting stale config.
await rm(join(repoPath, '.git', 'refs', 'heads', 'contributor', 'fix'))
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
repoPath,
REPO_ID,
storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }),
execGit,
[]
)
expect(reclaimed).toEqual([FORK_REMOTE])
await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE)
})
it('reclaims a remote orphaned by a worktree removed outside Orca (path 3)', async () => {
const worktreePath = join(scratchDir, 'wt-externally-removed')
await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local-2'], repoPath)
// Simulate a plain `git worktree remove` the user ran outside Orca: Orca's metadata for
// that worktree is still sitting in the store (nothing told it to clean up), but the
// worktree itself is gone.
await git(['worktree', 'remove', '--force', worktreePath], repoPath)
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
repoPath,
REPO_ID,
storeOf({ [worktreeId(worktreePath)]: forkTarget() }),
execGit,
[] // listWorktrees no longer reports it
)
expect(reclaimed).toEqual([FORK_REMOTE])
await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE)
})
})
@@ -0,0 +1,262 @@
import { describe, expect, it, vi, type Mock } from 'vitest'
import { validateGitExecArgs } from '../../relay/git-exec-validator'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup'
import {
_resetPrRemoteReconciliationRateLimitForTests,
isOrcaGeneratedPrRemoteName,
reconcileOrphanedPrRemotesWithExec
} from './worktree-push-target-reconciliation'
type ExecMock = Mock<GitRemoteExec>
const REPO_PATH = '/repo-root'
const REPO_ID = 'repo-1'
const FORK_URL = 'git@github.com:contributor/orca.git'
const FORK_REMOTE = 'pr-contributor-orca'
function worktreeId(suffix: string): string {
return `${REPO_ID}::${suffix}`
}
function forkTarget(overrides: Partial<GitPushTarget> = {}): GitPushTarget {
return {
remoteName: FORK_REMOTE,
branchName: 'contributor/fix',
remoteUrl: FORK_URL,
remoteCreated: true,
...overrides
}
}
function metaWith(pushTarget: GitPushTarget | undefined): WorktreeMeta {
return { pushTarget } as unknown as WorktreeMeta
}
function storeOf(entries: Record<string, GitPushTarget | undefined>): WorktreePushTargetStore {
const meta: Record<string, WorktreeMeta> = {}
for (const [id, pushTarget] of Object.entries(entries)) {
meta[id] = metaWith(pushTarget)
}
return { getAllWorktreeMeta: () => meta }
}
type ExecScript = {
remotes?: string
branchConfig?: string
localBranches?: string
}
function makeExec(script: ExecScript = {}): ExecMock {
const { remotes = '', branchConfig = '', localBranches = '' } = script
return vi.fn<GitRemoteExec>(async (args: string[]) => {
if (args[0] === 'remote' && args[1] === '-v') {
return { stdout: remotes, stderr: '' }
}
if (args[0] === 'config') {
return { stdout: branchConfig, stderr: '' }
}
if (args[0] === 'for-each-ref') {
return { stdout: localBranches, stderr: '' }
}
if (args[0] === 'remote' && args[1] === 'remove') {
return { stdout: '', stderr: '' }
}
return { stdout: '', stderr: '' }
})
}
function remoteLines(entries: { name: string; url: string }[]): string {
return entries
.flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
.join('\n')
}
function removeCalls(exec: ExecMock): string[][] {
return exec.mock.calls
.map(([args]) => args)
.filter((args) => args[0] === 'remote' && args[1] === 'remove')
}
describe('isOrcaGeneratedPrRemoteName', () => {
it('matches Orca-generated names, including disambiguated ones', () => {
expect(isOrcaGeneratedPrRemoteName('pr-head')).toBe(true)
expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca')).toBe(true)
expect(isOrcaGeneratedPrRemoteName('pr-head-2')).toBe(true)
expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca-3')).toBe(true)
})
it('does not match unrelated remote names', () => {
expect(isOrcaGeneratedPrRemoteName('origin')).toBe(false)
expect(isOrcaGeneratedPrRemoteName('upstream')).toBe(false)
expect(isOrcaGeneratedPrRemoteName('project-remote')).toBe(false)
})
})
describe('reconcileOrphanedPrRemotesWithExec', () => {
it('leaves a remote alone when no worktree metadata ever proves Orca created it (user-created, ambiguous)', async () => {
// Same naming shape a user could coincidentally pick; no pushTarget anywhere claims it.
const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) })
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({}),
exec,
[]
)
expect(reclaimed).toEqual([])
expect(removeCalls(exec)).toEqual([])
})
it('leaves a remote alone that is not shaped like an Orca-generated pr-* remote', async () => {
const exec = makeExec({
remotes: remoteLines([{ name: 'my-fork', url: FORK_URL }])
})
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/a')]: { ...forkTarget(), remoteName: 'my-fork' } }),
exec,
[]
)
expect(reclaimed).toEqual([])
expect(removeCalls(exec)).toEqual([])
})
it('leaves a remote alone that a live worktree still references (path guard)', async () => {
const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) })
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/a')]: forkTarget() }),
exec,
['/wt/a']
)
expect(reclaimed).toEqual([])
expect(removeCalls(exec)).toEqual([])
})
it('leaves a remote alone that is referenced by an existing branch (path 2, branch still kept)', async () => {
const exec = makeExec({
remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]),
branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`,
localBranches: 'contributor/fix\nmain'
})
// Metadata for the worktree that created it is gone, but the branch it preserved lives on.
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/gone')]: forkTarget() }),
exec,
[]
)
expect(reclaimed).toEqual([])
expect(removeCalls(exec)).toEqual([])
})
it('reclaims a remote whose protecting branch config is stale (path 2, branch since deleted)', async () => {
const exec = makeExec({
remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]),
// Config line survives even though `contributor/fix` no longer exists.
branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`,
localBranches: 'main'
})
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/gone')]: forkTarget() }),
exec,
[]
)
expect(reclaimed).toEqual([FORK_REMOTE])
expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]])
})
it('reclaims a remote left behind by a worktree removed outside Orca (path 3)', async () => {
const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) })
// Metadata still records the (now-vanished) worktree's Orca-created pushTarget.
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/gone')]: forkTarget() }),
exec,
[] // no live worktrees at all
)
expect(reclaimed).toEqual([FORK_REMOTE])
expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]])
})
it('reclaims a remote even when the only referencing metadata lacks remoteCreated, as long as another entry proves provenance (path 1)', async () => {
const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) })
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({
// The worktree whose removal originally bailed (legacy metadata, no remoteCreated flag)...
[worktreeId('/wt/legacy')]: forkTarget({ remoteCreated: false }),
// ...but a sibling that reused the remote correctly inherited ownership, and is also gone.
[worktreeId('/wt/sibling-gone')]: forkTarget({ remoteCreated: true })
}),
exec,
[]
)
expect(reclaimed).toEqual([FORK_REMOTE])
})
it('never touches origin or upstream even if metadata is malformed', async () => {
const exec = makeExec({
remotes: remoteLines([
{ name: 'origin', url: FORK_URL },
{ name: 'upstream', url: FORK_URL }
])
})
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/a')]: forkTarget({ remoteName: 'origin' }) }),
exec,
[]
)
expect(reclaimed).toEqual([])
expect(removeCalls(exec)).toEqual([])
})
it('scopes provenance and liveness to the same repo (remotes are repo-local)', async () => {
const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) })
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ 'repo-2::/wt/other-repo': forkTarget() }),
exec,
[]
)
expect(reclaimed).toEqual([])
})
it('sends only argv the relay accepts, so the sweep is not silently skipped over SSH', async () => {
// Exercise every branch (config probe, for-each-ref probe, and the reclaim itself).
const exec = makeExec({
remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]),
branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`,
localBranches: 'main'
})
await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/gone')]: forkTarget() }),
exec,
[]
)
expect(exec.mock.calls.length).toBeGreaterThan(0)
for (const [args] of exec.mock.calls) {
expect(() => validateGitExecArgs(args)).not.toThrow()
}
})
})
describe('reconcileOrphanedPrRemotes rate limiting', () => {
it('exposes a test reset so repeated test runs are not affected by prior cooldowns', () => {
expect(() => _resetPrRemoteReconciliationRateLimitForTests()).not.toThrow()
})
})
@@ -0,0 +1,204 @@
// Why: `pr-*` remotes Orca adds for fork-PR review are only ever pruned by
// `worktree-push-target-cleanup.ts`, and only when a *single* worktree removal
// triggers it. Three things escape that: (1) legacy/reused metadata missing the
// `remoteCreated` flag, (2) a "preserve branch on delete" pinning its remote via
// `branch.*.remote` config long after the worktree is gone, and (3) a worktree
// removed outside Orca entirely (no removal event ever fires). This sweep
// inverts the same safety predicates over every `pr-*` remote in the repo
// instead of one removal, so all three eventually get reclaimed. It never adds
// new safety logic — see `worktree-push-target-cleanup.ts` for the predicates.
import { gitExecFileAsync } from '../git/runner'
import { listWorktrees } from '../git/worktree'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import type { GitPushTarget } from '../../shared/worktree/types'
import { WORKTREE_ID_SEPARATOR, worktreeIdComparisonKey } from '../../shared/worktree/id'
import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner'
import {
findWorktreeMetaReferencingRemote,
hasBranchConfigUsingRemote,
type GitRemoteExec,
type WorktreePushTargetStore
} from './worktree-push-target-cleanup'
// Orca only ever mints `pr-head` or `pr-<owner>-<repo>` (see `sanitizeRemoteName`), optionally
// disambiguated with `-2`..`-99` (see `ensureUniqueRemoteName`). The naming convention alone is
// not proof of provenance -- a user could name a remote `pr-foo` -- so this only narrows which
// remotes are even considered; `hasOrcaCreatedProvenance` below is the actual safety gate.
const ORCA_PR_REMOTE_NAME_PATTERN =
/^pr-(?:head|[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)(?:-[0-9]{1,2})?$/
export function isOrcaGeneratedPrRemoteName(name: string): boolean {
return ORCA_PR_REMOTE_NAME_PATTERN.test(name)
}
type PrRemoteCandidate = { name: string; url: string }
async function listPrRemoteCandidates(
execGit: GitRemoteExec,
repoPath: string
): Promise<PrRemoteCandidate[]> {
let stdout: string
try {
;({ stdout } = await execGit(['remote', '-v'], repoPath))
} catch {
return []
}
const candidates = new Map<string, string>()
for (const line of iterateProcessOutputLines(stdout)) {
const parsed = parseRemoteVerboseLine(line)
if (parsed?.direction === 'fetch' && isOrcaGeneratedPrRemoteName(parsed.name)) {
candidates.set(parsed.name, parsed.url)
}
}
return [...candidates.entries()].map(([name, url]) => ({ name, url }))
}
function parseRemoteVerboseLine(
line: string
): { name: string; url: string; direction: 'fetch' | 'push' } | null {
const tabIndex = line.indexOf('\t')
if (tabIndex === -1) {
return null
}
const name = line.slice(0, tabIndex)
const match = /^(.*) \((fetch|push)\)$/.exec(line.slice(tabIndex + 1).trim())
return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null
}
async function shouldReclaimPrRemote(
execGit: GitRemoteExec,
repoPath: string,
repoId: string,
store: WorktreePushTargetStore,
remote: PrRemoteCandidate,
liveWorktreeKeys: ReadonlySet<string>
): Promise<boolean> {
const target: Pick<GitPushTarget, 'remoteName' | 'remoteUrl'> = {
remoteName: remote.name,
remoteUrl: remote.url
}
const referencingEntries = findWorktreeMetaReferencingRemote(store, repoId, target)
// Provenance gate: only touch a remote some worktree's persisted pushTarget explicitly
// recorded Orca creating. Naming and URL shape are necessary but not sufficient proof.
if (!referencingEntries.some(({ meta }) => meta.pushTarget?.remoteCreated === true)) {
return false
}
const stillClaimedByLiveWorktree = referencingEntries.some(({ worktreeId }) => {
const key = worktreeIdComparisonKey(worktreeId)
return key !== null && liveWorktreeKeys.has(key)
})
if (stillClaimedByLiveWorktree) {
return false
}
// A branch that still exists may push to this fork again later; only a branch that's
// actually gone (force-deleted, or deleted outside the "preserve on delete" flow) frees it.
if (
await hasBranchConfigUsingRemote(execGit, repoPath, target, { requireExistingBranch: true })
) {
return false
}
return true
}
// Exported for unit/real-git tests: the `execGit` seam and injected live-worktree paths let
// tests drive the sweep without a real repo (or with one, for the real-git coverage).
export async function reconcileOrphanedPrRemotesWithExec(
repoPath: string,
repoId: string,
store: WorktreePushTargetStore,
execGit: GitRemoteExec,
liveWorktreePaths: readonly string[]
): Promise<string[]> {
const liveWorktreeKeys = new Set(
liveWorktreePaths
.map((path) => worktreeIdComparisonKey(`${repoId}${WORKTREE_ID_SEPARATOR}${path}`))
.filter((key): key is string => key !== null)
)
const reclaimed: string[] = []
for (const remote of await listPrRemoteCandidates(execGit, repoPath)) {
if (await shouldReclaimPrRemote(execGit, repoPath, repoId, store, remote, liveWorktreeKeys)) {
await execGit(['remote', 'remove', remote.name], repoPath)
reclaimed.push(remote.name)
}
}
return reclaimed
}
// Why: the sweep costs a handful of git subprocesses (remote -v, worktree list, per-candidate
// config/for-each-ref); bound to once per repo per cooldown so bursts of removals don't repeat it.
const RECONCILE_COOLDOWN_MS = 60 * 60 * 1000
const lastReconciledAtByRepoId = new Map<string, number>()
function shouldReconcileNow(repoId: string): boolean {
const last = lastReconciledAtByRepoId.get(repoId)
return last === undefined || Date.now() - last >= RECONCILE_COOLDOWN_MS
}
export function _resetPrRemoteReconciliationRateLimitForTests(): void {
lastReconciledAtByRepoId.clear()
}
function logReclaimed(repoPath: string, reclaimed: string[]): void {
if (reclaimed.length > 0) {
console.log(
`[worktrees] Reclaimed ${reclaimed.length} orphaned PR remote(s) in ${repoPath}: ${reclaimed.join(', ')}`
)
}
}
/** Best-effort, rate-limited sweep run alongside single-target cleanup (see call sites). */
export async function reconcileOrphanedPrRemotes(
repoPath: string,
repoId: string,
store: WorktreePushTargetStore,
gitOptions: { wslDistro?: string } = {}
): Promise<void> {
if (!shouldReconcileNow(repoId)) {
return
}
lastReconciledAtByRepoId.set(repoId, Date.now())
try {
const liveWorktrees = await listWorktrees(repoPath, gitOptions)
logReclaimed(
repoPath,
await reconcileOrphanedPrRemotesWithExec(
repoPath,
repoId,
store,
(args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }),
liveWorktrees.map((worktree) => worktree.path)
)
)
} catch (error) {
console.warn(`[worktrees] Failed to reconcile orphaned PR remotes for ${repoPath}`, error)
}
}
/** SSH counterpart of {@link reconcileOrphanedPrRemotes}; the execution host owns the remotes. */
export async function reconcileOrphanedPrRemotesSsh(
provider: SshGitProvider,
repoPath: string,
repoId: string,
store: WorktreePushTargetStore
): Promise<void> {
if (!shouldReconcileNow(repoId)) {
return
}
lastReconciledAtByRepoId.set(repoId, Date.now())
try {
const liveWorktrees = await provider.listWorktrees(repoPath)
logReclaimed(
repoPath,
await reconcileOrphanedPrRemotesWithExec(
repoPath,
repoId,
store,
(args, cwd) => provider.exec(args, cwd),
liveWorktrees.map((worktree) => worktree.path)
)
)
} catch (error) {
console.warn(`[worktrees] Failed to reconcile orphaned PR remotes (SSH) for ${repoPath}`, error)
}
}
+155 -119
View File
@@ -104,6 +104,10 @@ import {
type GitRemoteExec,
type WorktreePushTargetStore
} from './worktree-push-target-cleanup'
import {
reconcileOrphanedPrRemotes,
reconcileOrphanedPrRemotesSsh
} from './worktree-push-target-reconciliation'
import {
configureCreatedWorktreePushTargetWithExec,
ensureUniqueRemoteName,
@@ -1030,6 +1034,18 @@ export async function cleanupUnusedWorktreePushTargetRemote(
} catch (error) {
console.warn(`[worktrees] Failed to clean up fork PR remote for ${removedWorktreeId}`, error)
}
// Why: also catches remotes this specific removal couldn't reclaim (legacy metadata,
// a preserved branch since deleted, a worktree removed outside Orca) -- see
// worktree-push-target-reconciliation.ts. Rate-limited internally; safe to call every removal.
// Not awaited: a repo with a large backlog (the scenario this exists for) can have dozens of
// candidate remotes, each probed with a couple of git subprocesses -- that must never add
// latency to the worktree-removal call the user is waiting on. It catches its own errors.
void reconcileOrphanedPrRemotes(
repoPath,
getRepoIdFromWorktreeId(removedWorktreeId),
store,
gitOptions
)
}
export async function configureCreatedWorktreePushTarget(
@@ -1134,6 +1150,14 @@ export async function cleanupUnusedWorktreePushTargetRemoteSsh(
error
)
}
// Why: SSH counterpart of the sweep above -- the execution host owns these remotes.
// Not awaited for the same reason as the local path: never add sweep latency to removal.
void reconcileOrphanedPrRemotesSsh(
provider,
repoPath,
getRepoIdFromWorktreeId(removedWorktreeId),
store
)
}
async function readRemoteEffectiveHooks(
@@ -2165,130 +2189,139 @@ export async function createLocalWorktree(
let lastExistingReviewNumber: number | null = null
const shouldRetireGeneratedName =
args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName)
const retiredNameRegistry = shouldRetireGeneratedName
? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings)
: null
const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null
// Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user.
for (let suffix = 1, attempts = 0; attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) {
effectiveSanitizedName = shouldRetireGeneratedName
? getGeneratedWorktreeCreateCandidate(
sanitizedName,
suffix,
retiredNameRegistry?.exhaustedTiers
)
: getWorktreeCreateCandidate(sanitizedName, suffix)
effectiveRequestedName = shouldRetireGeneratedName
? effectiveSanitizedName
: requestedName.trim()
? getWorktreeCreateCandidate(requestedName, suffix)
: effectiveSanitizedName
if (isRetiredName?.(effectiveSanitizedName)) {
continue
}
attempts += 1
lastExistingReviewNumber = null
await timing.time('resolve_name', async () => {
const retiredNameRegistry = shouldRetireGeneratedName
? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings)
: null
const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null
// Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user.
for (
let suffix = 1, attempts = 0;
attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS;
suffix += 1
) {
effectiveSanitizedName = shouldRetireGeneratedName
? getGeneratedWorktreeCreateCandidate(
sanitizedName,
suffix,
retiredNameRegistry?.exhaustedTiers
)
: getWorktreeCreateCandidate(sanitizedName, suffix)
effectiveRequestedName = shouldRetireGeneratedName
? effectiveSanitizedName
: requestedName.trim()
? getWorktreeCreateCandidate(requestedName, suffix)
: effectiveSanitizedName
if (isRetiredName?.(effectiveSanitizedName)) {
continue
}
attempts += 1
lastExistingReviewNumber = null
branchName = await resolveCreateBranchName(
repo.path,
selectedExistingLocalBranchName
? selectedExistingLocalBranchName
: getBranchNameOverrideCandidate(args.branchNameOverride, suffix),
effectiveSanitizedName,
settings,
username,
localWorktreeGitOptions
)
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
repo.path,
branchName,
baseBranch,
localWorktreeGitOptions
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling.
selectedExistingLocalBranchName = branchName
}
lastBranchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions)
const allowedPushTargetRemoteConflict =
lastBranchConflictKind &&
isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args)
if (lastBranchConflictKind) {
if (allowedPushTargetRemoteConflict) {
lastExistingPR = null
let lookupFailed = false
const selectedReview = getSelectedReviewBranch(args)
if (selectedReview?.provider === 'github') {
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
lookupFailed = true
}
if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastBranchConflictKind = null
} else if (lastExistingPR) {
lastExistingReviewNumber = lastExistingPR.number
}
} else if (selectedReview) {
let hostedReview: Awaited<ReturnType<typeof getSelectedHostedReviewForBranch>> = null
try {
hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args)
} catch {
lookupFailed = true
}
if (!lookupFailed && hostedReview?.matchesSelected) {
lastBranchConflictKind = null
} else if (hostedReview) {
lastExistingReviewNumber = hostedReview.number
branchName = await resolveCreateBranchName(
repo.path,
selectedExistingLocalBranchName
? selectedExistingLocalBranchName
: getBranchNameOverrideCandidate(args.branchNameOverride, suffix),
effectiveSanitizedName,
settings,
username,
localWorktreeGitOptions
)
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
repo.path,
branchName,
baseBranch,
localWorktreeGitOptions
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling.
selectedExistingLocalBranchName = branchName
}
lastBranchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions)
const allowedPushTargetRemoteConflict =
lastBranchConflictKind &&
isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args)
if (lastBranchConflictKind) {
if (allowedPushTargetRemoteConflict) {
lastExistingPR = null
let lookupFailed = false
const selectedReview = getSelectedReviewBranch(args)
if (selectedReview?.provider === 'github') {
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
lookupFailed = true
}
if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastBranchConflictKind = null
} else if (lastExistingPR) {
lastExistingReviewNumber = lastExistingPR.number
}
} else if (selectedReview) {
let hostedReview: Awaited<ReturnType<typeof getSelectedHostedReviewForBranch>> = null
try {
hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args)
} catch {
lookupFailed = true
}
if (!lookupFailed && hostedReview?.matchesSelected) {
lastBranchConflictKind = null
} else if (hostedReview) {
lastExistingReviewNumber = hostedReview.number
}
}
}
}
}
if (lastBranchConflictKind) {
continue
}
// Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it.
if (suffix > 1 && !checkoutExistingBranch) {
lastExistingPR = null
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
// GitHub API may be unreachable, rate-limited, or token missing
}
if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastExistingReviewNumber = lastExistingPR.number
if (lastBranchConflictKind) {
continue
}
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings),
workspaceRoot
)
if (existsSync(worktreePath)) {
continue
}
// Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it.
if (suffix > 1 && !checkoutExistingBranch) {
lastExistingPR = null
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
// GitHub API may be unreachable, rate-limited, or token missing
}
if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastExistingReviewNumber = lastExistingPR.number
continue
}
}
resolved = true
break
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings),
workspaceRoot
)
if (existsSync(worktreePath)) {
continue
}
resolved = true
break
}
})
if (!resolved) {
// Why: every suffix collided; reject with a specific reason so the user sees why create failed instead of a generic error or hung spinner.
if (lastExistingReviewNumber !== null) {
// Read once and format eagerly: the suffix loop assigns this from a callback, so the `let`'s
// narrowing does not reach the message.
const existingReviewNumber = lastExistingReviewNumber
if (existingReviewNumber !== null) {
throw new Error(
`Branch "${branchName}" already has PR #${lastExistingReviewNumber}. Pick a different ${branchConflictSubject}.`
`Branch "${branchName}" already has PR #${String(existingReviewNumber)}. Pick a different ${branchConflictSubject}.`
)
}
if (lastBranchConflictKind) {
@@ -2337,14 +2370,17 @@ export async function createLocalWorktree(
emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId)
let preparedPushTarget: GitPushTarget | undefined
if (args.pushTarget) {
const requestedPushTarget = args.pushTarget
if (requestedPushTarget) {
// Why: validate/fetch the contributor remote before create so a failure doesn't leave a half-created worktree with conflicts on retry.
preparedPushTarget = await prepareWorktreePushTarget(
repo.path,
args.pushTarget,
store,
repo.id,
localWorktreeGitOptions
preparedPushTarget = await timing.time('prepare_push_target', () =>
prepareWorktreePushTarget(
repo.path,
requestedPushTarget,
store,
repo.id,
localWorktreeGitOptions
)
)
}
@@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId,
toSshExecutionHostId
} from '../../shared/execution-host'
import { getSshProviderAuthority } from '../ssh/ssh-provider-authority'
import {
listWorktreesMock,
@@ -443,7 +447,76 @@ describe('registerWorktreeHandlers', () => {
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('refuses to retire metadata for non-SSH hosts and unowned repos', async () => {
// Runtime-host rows are exempt from gcStaleWorktreeMeta exactly as SSH ones are, so a paired
// client needs this path to ever drop them (#17776).
it('retires runtime-host metadata an authoritative scan proved gone', async () => {
const runtimeHostId = toRuntimeExecutionHostId('env-1')
const runtimeRepo = {
id: 'repo-1',
path: '/home/orca/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: runtimeHostId
}
const metaById: Record<string, ReturnType<typeof makeWorktreeMeta>> = {
'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId }),
'repo-1::/home/orca/other-host': makeWorktreeMeta({
hostId: toSshExecutionHostId('target-a')
})
}
store.getRepos.mockReturnValue([runtimeRepo])
store.getProjectHostSetups.mockReturnValue([])
store.getAllWorktreeMeta.mockReturnValue(metaById)
store.removeWorktreeMeta.mockImplementation((worktreeId: string) => {
delete metaById[worktreeId]
})
const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, {
repoId: runtimeRepo.id,
executionHostId: runtimeHostId,
worktreeIds: ['repo-1::/home/orca/deleted', 'repo-1::/home/orca/other-host']
})
// The row stamped to another host needs that host's own scan, not this one's.
expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/home/orca/deleted'] })
expect(store.removeWorktreeMeta).toHaveBeenCalledExactlyOnceWith(
'repo-1::/home/orca/deleted',
runtimeHostId
)
})
// A repo that reaches its checkouts over SSH is not the runtime host's to condemn. The refusal
// comes from `findExactRepoOwner`: a runtime `executionHostId` beside a `connectionId` is
// contradictory ownership evidence, so no owner resolves at all.
it('refuses to retire a connection-backed repo under a runtime host id', async () => {
const runtimeHostId = toRuntimeExecutionHostId('env-1')
store.getRepos.mockReturnValue([
{
id: 'repo-1',
path: '/home/orca/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: runtimeHostId,
connectionId: 'target-a'
}
])
store.getAllWorktreeMeta.mockReturnValue({
'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId })
})
expect(
await handlers['worktrees:forgetRemovedForExecutionHost'](null, {
repoId: 'repo-1',
executionHostId: runtimeHostId,
worktreeIds: ['repo-1::/home/orca/deleted']
})
).toEqual({ forgottenWorktreeIds: [] })
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('refuses to retire metadata for non-executing hosts and unowned repos', async () => {
const sshRepo = {
id: 'repo-1',
path: '/remote/repo-a',
@@ -4,7 +4,10 @@ import type {
CreateWorktreeResult,
AdoptProvisionedRootArgs
} from '../../../../shared/worktree/create-types'
import { withWorktreeSpan } from '../../../observability/instrumentation'
import {
addWorktreeCreatePhaseAttributes,
withWorktreeSpan
} from '../../../observability/instrumentation'
import { workspaceSourceSchema } from '../../../../shared/telemetry-events'
import type { WorkspaceSource } from '../../../../shared/telemetry-events'
import {
@@ -36,7 +39,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
async (_event, rawArgs: CreateWorktreeArgs): Promise<CreateWorktreeResult> => {
const args = normalizeLinkedWorkItemFields(rawArgs)
// Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator.
return withWorktreeSpan({ stage: 'create' }, async () => {
return withWorktreeSpan({ stage: 'create' }, async (span) => {
const repo = store.getRepo(args.repoId)
if (!repo) {
throw new Error(`Repo not found: ${args.repoId}`)
@@ -74,6 +77,9 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
throw error
}
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
if (result.timing) {
addWorktreeCreatePhaseAttributes(span, result.timing)
}
// Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name.
track('workspace_created', {
@@ -103,11 +103,21 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void {
const requestedExecutionHostId = args?.executionHostId ?? 'ssh:'
const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : []
const parsedHost = parseExecutionHostId(requestedExecutionHostId)
if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) {
// Runtime hosts belong here for the same reason SSH ones do: their rows are exempt from
// gcStaleWorktreeMeta, so a scan-proven removal is the only thing that ever retires them.
if (
(parsedHost?.kind !== 'ssh' && parsedHost?.kind !== 'runtime') ||
worktreeIds.length === 0
) {
return nothingForgotten
}
// No runtime arm in the check below: `findExactRepoOwner` already refuses a repo carrying both
// a runtime `executionHostId` and a `connectionId`, because `resolveRepoOwnershipEvidence`
// calls that pair contradictory and one non-owned candidate voids the whole lookup. A second
// check would be unreachable, and unreachable code on a destructive path reads as a guarantee
// it is not making.
const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId)
if (!repo || repo.connectionId !== parsedHost.targetId) {
if (!repo || (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId)) {
return nothingForgotten
}
// Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips
@@ -3,6 +3,7 @@ import { _resetTracerForTests, setActiveSink, type TracerSink } from './tracer'
import {
_gitSpanSamplingBucketCountForTests,
_resetGitSpanSamplingForTests,
addWorktreeCreatePhaseAttributes,
withGitSpan
} from './instrumentation'
@@ -167,3 +168,50 @@ describe('withGitSpan sampling', () => {
expect(_gitSpanSamplingBucketCountForTests()).toBe(1)
})
})
describe('addWorktreeCreatePhaseAttributes', () => {
function capture(): {
attributes: Record<string, unknown>
span: Parameters<typeof addWorktreeCreatePhaseAttributes>[0]
} {
const attributes: Record<string, unknown> = {}
const span = {
setAttribute: (key: string, value: unknown) => {
attributes[key] = value
}
} as unknown as Parameters<typeof addWorktreeCreatePhaseAttributes>[0]
return { attributes, span }
}
it('counts concurrent phases once when measuring unattributed time', () => {
const { attributes, span } = capture()
// Create resolves shared directories and .worktreeinclude concurrently; summing their
// durations would claim 400ms of coverage for a 200ms window.
addWorktreeCreatePhaseAttributes(span, {
totalDurationMs: 1000,
phases: [
{ phase: 'resolve_shared_directories', startedAtMs: 100, durationMs: 200 },
{ phase: 'resolve_worktreeinclude', startedAtMs: 150, durationMs: 150 }
]
})
expect(attributes['worktree.create.phase.resolve_shared_directories_ms']).toBe(200)
expect(attributes['worktree.create.phase.resolve_worktreeinclude_ms']).toBe(150)
// Covered wall clock is 100..300, so 800ms is genuinely unaccounted for.
expect(attributes['worktree.create.unattributed_ms']).toBe(800)
})
it('sums disjoint phases and never reports negative unattributed time', () => {
const { attributes, span } = capture()
addWorktreeCreatePhaseAttributes(span, {
totalDurationMs: 500,
phases: [
{ phase: 'resolve_name', startedAtMs: 0, durationMs: 100 },
{ phase: 'git_worktree_add', startedAtMs: 300, durationMs: 200 }
]
})
expect(attributes['worktree.create.total_ms']).toBe(500)
expect(attributes['worktree.create.unattributed_ms']).toBe(200)
})
})
+56 -3
View File
@@ -202,10 +202,11 @@ export type WorktreeSpanArgs = {
readonly path?: string
}
/** Wrap a worktree-setup phase in a `worktree.<stage>` span. */
/** Wrap a worktree-setup phase in a `worktree.<stage>` span. The callback receives the span so a
* create can attach its own phase breakdown; the git children alone leave the waits invisible. */
export async function withWorktreeSpan<T>(
meta: WorktreeSpanArgs,
fn: () => Promise<T>
fn: (span: ActiveSpan) => Promise<T>
): Promise<T> {
return withSpan(
`worktree.${meta.stage}`,
@@ -214,12 +215,64 @@ export async function withWorktreeSpan<T>(
if (meta.path) {
span.setAttribute('worktree.path', meta.path)
}
return await fn()
return await fn(span)
},
{ attributes: { kind: 'worktree' } }
)
}
type WorktreeCreatePhaseTiming = {
readonly phase: string
readonly startedAtMs: number
readonly durationMs: number
}
/** Wall-clock span covered by at least one phase. Create runs some phases concurrently, so summing
* durations double-counts and would report overlap as coverage the phases never had. */
function measuredWallClockMs(phases: readonly WorktreePhaseInterval[]): number {
const intervals = [...phases]
.map((phase) => [phase.startedAtMs, phase.startedAtMs + phase.durationMs] as const)
.sort((left, right) => left[0] - right[0])
let covered = 0
let openedAt: number | null = null
let closesAt = 0
for (const [start, end] of intervals) {
if (openedAt === null) {
openedAt = start
closesAt = end
continue
}
if (start <= closesAt) {
closesAt = Math.max(closesAt, end)
continue
}
covered += closesAt - openedAt
openedAt = start
closesAt = end
}
return openedAt === null ? 0 : covered + (closesAt - openedAt)
}
type WorktreePhaseInterval = Pick<WorktreeCreatePhaseTiming, 'startedAtMs' | 'durationMs'>
/** Records a create's phase breakdown on its span. Phase names are already a closed vocabulary in
* the recorder, so they are safe to key on; nothing here carries a branch name or a path. */
export function addWorktreeCreatePhaseAttributes(
span: ActiveSpan,
timing: { totalDurationMs: number; phases: readonly WorktreeCreatePhaseTiming[] }
): void {
span.setAttribute('worktree.create.total_ms', Math.round(timing.totalDurationMs))
for (const phase of timing.phases) {
span.setAttribute(`worktree.create.phase.${phase.phase}_ms`, Math.round(phase.durationMs))
}
// What the phases do not cover is the number that matters when create feels slow for no visible
// reason, so name it rather than leaving it to subtraction.
span.setAttribute(
'worktree.create.unattributed_ms',
Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases)))
)
}
/** Closed set so a typo can't silently mint an orphan span name. */
export type WorktreeRemoveStage =
| 'archive_hook'
@@ -66,6 +66,24 @@ export function rekeyOwnerKey(
return null
}
/**
* Every worktree locator an owner key could name.
*
* Two readings, because one key can be both: with a repo literally named `worktree`,
* `worktree::/p` is a `<repoId>::<path>` locator AND parses as a `worktree:` workspace key naming
* repo `` (empty). `ownerKeyBelongsToRepo` accepts either, so a caller that reasons about a key
* without a repo id in hand has to consider both or it will disagree with the predicate.
*/
export function ownerKeyWorktreeIds(ownerKey: string): string[] {
const rawOwnerKey = isWorktreeHostIdentity(ownerKey)
? getWorktreeIdFromHostIdentity(ownerKey)
: ownerKey
const scope = parseWorkspaceKey(ownerKey)
return scope?.type === 'worktree' && scope.worktreeId !== rawOwnerKey
? [rawOwnerKey, scope.worktreeId]
: [rawOwnerKey]
}
export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean {
const rawOwnerKey = isWorktreeHostIdentity(ownerKey)
? getWorktreeIdFromHostIdentity(ownerKey)
@@ -397,6 +397,8 @@ describe('Store.migrateWorktreeIdentity', () => {
it('moves persisted mobile selections across reloads', async () => {
const store = await createStore()
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
store.addRepo(makeRepo({ id: 'repo1', path: '/repo1' }))
store.setMobileClientTabSelections({
'device-a': {
[OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} }
@@ -10,6 +10,7 @@ import {
createStore,
writeDataFile,
readDataFile,
makeRepo,
makeTerminalTab
} from './persistence-test-harness'
@@ -53,6 +54,11 @@ describe('cross-host pane identity migration', () => {
it('refuses hostless alias and acknowledgement rewrites for a tab id two partitions share', async () => {
writeDataFile({
schemaVersion: 1,
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
repos: [
makeRepo({ id: 'repo-local', path: '/repo-local' }),
makeRepo({ id: 'repo-a', path: '/repo-a' })
],
workspaceSession: makeLegacyPaneSession('repo-local', 'local-pty'),
workspaceSessionsByHostId: {
'ssh:host-a': makeLegacyPaneSession('repo-a', 'pty-a')
@@ -0,0 +1,240 @@
// Why this file exists: deregistering a project used to strand every row it owned. No sweeper could
// reach them -- the missing-directory prune is gated on the repo still being registered, and a
// paired client's mirror of a remote host's rows is keyed by ids that client never registers, so the
// owning host's removal never reached it (#17776).
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { getDefaultWorkspaceSession } from '../shared/constants'
import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope'
import type { PersistedState } from '../shared/persisted-state-types'
import {
testState,
createStore,
writeDataFile,
readDataFile,
makeRepo,
makeTerminalTab
} from './persistence-test-harness'
vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(),
sshConfigHostsToTargets: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) }))
const LIVE_REPO = 'live-repo'
const GONE_REPO = 'gone-repo'
const LIVE_WORKTREE = `${LIVE_REPO}::/workspace/live`
const GONE_WORKTREE = `${GONE_REPO}::/workspace/orphan`
const RUNTIME_HOST = 'runtime:env-a'
const sleepingAgentFor = (worktreeId: string, tabId = 'tab-1') => ({
[`${tabId}:leaf-1`]: {
paneKey: `${tabId}:leaf-1`,
tabId,
worktreeId,
agent: 'codex' as const,
providerSession: { key: 'session_id' as const, id: 'sess-1' },
prompt: 'sleeping',
state: 'waiting' as const,
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep' as const
}
})
const sessionFor = (worktreeId: string, tabId = 'tab-1') => ({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeId]: [makeTerminalTab({ id: tabId, worktreeId })]
},
activeTabTypeByWorktree: { [worktreeId]: 'terminal' as const },
lastVisitedAtByWorktreeId: { [worktreeId]: 123 },
// The residue `profile-project-session-field-disposition` flags as leaking on repo removal.
sleepingAgentSessionsByPaneKey: sleepingAgentFor(worktreeId, tabId)
})
describe('deregistered repo residue', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-orphan-sweep-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('drops metadata, identity rows and sessions owned by an unregistered repo id', async () => {
const seed = await createStore()
seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' }))
seed.addRepo(makeRepo({ id: GONE_REPO, path: '/workspace/orphan' }))
seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' })
seed.setWorktreeMetaForHost(GONE_WORKTREE, 'local', { displayName: 'Orphan' })
seed.setWorkspaceSession(sessionFor(GONE_WORKTREE), 'local')
seed.flush()
// Deregister by hand: the point is that a row can outlive its repo however that happened.
const persisted = readDataFile() as PersistedState
persisted.repos = persisted.repos.filter((repo) => repo.id !== GONE_REPO)
writeDataFile(persisted)
const reloaded = await createStore()
reloaded.flush()
const swept = readDataFile() as PersistedState
expect(Object.keys(swept.worktreeMeta)).toEqual([LIVE_WORKTREE])
expect(swept.worktreeIdentityAliases).not.toHaveProperty(
composeWorktreeHostIdentity('local', GONE_WORKTREE)
)
expect(Object.keys(swept.worktreeMetaByIdentity ?? {})).toHaveLength(1)
const session = swept.workspaceSession
expect(session.tabsByWorktree).toEqual({})
expect(session.lastVisitedAtByWorktreeId).toEqual({})
expect(session.activeTabTypeByWorktree).toEqual({})
expect(session.sleepingAgentSessionsByPaneKey ?? {}).toEqual({})
})
it("sweeps a remote host's session partition the owning host's removal can never reach", async () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })],
worktreeMeta: {},
workspaceSessionsByHostId: {
[RUNTIME_HOST]: sessionFor(GONE_WORKTREE)
}
})
const store = await createStore()
store.flush()
const partition = store.getWorkspaceSession(RUNTIME_HOST)
expect(partition.tabsByWorktree).toEqual({})
expect(partition.activeTabTypeByWorktree).toEqual({})
})
it('keeps rows for every registered repo, on any execution host', async () => {
const remoteWorktree = `${LIVE_REPO}::/home/user/remote`
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/home/user/live', executionHostId: RUNTIME_HOST })],
worktreeMeta: { [remoteWorktree]: { hostId: RUNTIME_HOST, status: 'active' } },
workspaceSessionsByHostId: { [RUNTIME_HOST]: sessionFor(remoteWorktree) }
})
const store = await createStore()
expect(store.getWorktreeMeta(remoteWorktree)).toBeDefined()
const partition = store.getWorkspaceSession(RUNTIME_HOST)
expect(partition.tabsByWorktree[remoteWorktree]).toHaveLength(1)
// Also proves the sleeping-agent fixture is well-formed, so the sweep assertions above bite.
expect(Object.keys(partition.sleepingAgentSessionsByPaneKey ?? {})).toHaveLength(1)
})
it('leaves folder-workspace session rows alone: their keys name no repo', async () => {
const workspaceKey = folderWorkspaceKey('folder-1')
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
workspaceSession: {
...getDefaultWorkspaceSession(),
lastVisitedAtByWorktreeId: { [workspaceKey]: 7 }
}
})
const store = await createStore()
expect(store.getWorkspaceSession('local').lastVisitedAtByWorktreeId).toEqual({
[workspaceKey]: 7
})
})
// Regression: the pane-keyed records are pruned by the worktreeId they name, not by their own key,
// so an orphan whose ONLY residue is a sleeping agent survived -- and re-seeded the sweep on every
// launch, so the store never self-cleared and every load scheduled another save.
it("drops a sleeping agent that is the orphan repo's only residue, and self-clears", async () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })],
worktreeMeta: {},
workspaceSession: {
...getDefaultWorkspaceSession(),
sleepingAgentSessionsByPaneKey: sleepingAgentFor(GONE_WORKTREE)
}
})
const store = await createStore()
store.flush()
expect(store.getWorkspaceSession('local').sleepingAgentSessionsByPaneKey ?? {}).toEqual({})
// On disk, not just in memory: if the flush had not persisted the cleanup, the next load would
// silently redo it and the self-clearing assertion below would pass without meaning anything.
const persisted = readDataFile() as PersistedState
expect(persisted.workspaceSession.sleepingAgentSessionsByPaneKey ?? {}).toEqual({})
// Self-clearing: with the residue gone nothing re-seeds the orphan id, so the next launch has
// no work. Before the fix this stayed non-empty forever and every load scheduled another save.
const reloaded = await createStore()
expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([])
})
// The session scalars are pruned by bespoke rules, not by owner key, so no owner-key loop reaches
// them. Each has to be able to seed the sweep on its own or an orphan named only there is stuck.
it.each([
{ label: 'activeWorktreeId', session: { activeWorktreeId: GONE_WORKTREE } },
// Canonical `worktree:<id>` form, which needs unwrapping before the repo id is visible.
{
label: 'activeWorkspaceKey',
session: { activeWorkspaceKey: worktreeWorkspaceKey(GONE_WORKTREE) }
},
{
label: 'activeWorktreeIdsOnShutdown',
session: { activeWorktreeIdsOnShutdown: [GONE_WORKTREE] }
}
])("clears $label when it is the orphan repo's only residue", async ({ session }) => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })],
worktreeMeta: {},
workspaceSessionsByHostId: {
[RUNTIME_HOST]: { ...getDefaultWorkspaceSession(), ...session }
}
})
const store = await createStore()
store.flush()
const partition = store.getWorkspaceSession(RUNTIME_HOST)
expect(partition.activeWorktreeId ?? null).toBeNull()
expect(partition.activeWorkspaceKey ?? null).toBeNull()
expect(partition.activeWorktreeIdsOnShutdown ?? []).toEqual([])
const reloaded = await createStore()
expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([])
})
// Why: a sweep that dirtied every launch would rewrite the profile forever and mask real changes.
it('leaves a profile with no orphans byte-identical across reloads', async () => {
const seed = await createStore()
seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' }))
seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' })
seed.setWorkspaceSession(sessionFor(LIVE_WORKTREE), 'local')
seed.flush()
const canonicalizing = await createStore()
canonicalizing.flush()
const canonical = JSON.stringify(readDataFile())
const reloaded = await createStore()
reloaded.flush()
expect(JSON.stringify(readDataFile())).toBe(canonical)
})
})
@@ -123,6 +123,9 @@ describe('Store host-partitioned workspace sessions', () => {
}
})
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
const makeRepos = (...repoIds: string[]) => repoIds.map((id) => makeRepo({ id, path: `/${id}` }))
it('migrates a legacy workspaceSession blob into the local partition', async () => {
writeDataFile({
schemaVersion: 1,
@@ -194,6 +197,7 @@ describe('Store host-partitioned workspace sessions', () => {
writeDataFile({
schemaVersion: 1,
workspaceSession: makeHostSession('local-repo'),
repos: makeRepos('repo-ssh'),
workspaceSessionsByHostId: {
'ssh:ssh-1': makeLegacyPaneHostSession('repo-ssh', 'remote-pty')
},
@@ -224,6 +228,7 @@ describe('Store host-partitioned workspace sessions', () => {
writeDataFile({
schemaVersion: 1,
workspaceSession: makeHostSession('local-repo'),
repos: makeRepos('repo-a', 'repo-b'),
workspaceSessionsByHostId: {
'ssh:host-a': makeLegacyPaneHostSession('repo-a', 'pty-a'),
'ssh:host-b': makeLegacyPaneHostSession('repo-b', 'pty-b')
@@ -488,6 +493,7 @@ describe('Store host-partitioned workspace sessions', () => {
it('removes one orphaned worktree with a host-scoped topology fence', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'repo-gone', path: '/repo-gone' }))
const worktreeId = 'repo-gone::/workspace/stale'
const session = {
...makeHostSession('repo-gone'),
@@ -728,6 +734,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
writeDataFile({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSessionsByHostId: {
'runtime:good': makeHostSession('good-repo'),
// activeRepoId must be string|null; a number fails the zod parse.
@@ -753,6 +760,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
writeDataFile({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSession: {
...makeHostSession('local-repo'),
// A projected/truncated write can leave a top-level field the wrong type;
@@ -813,6 +821,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
const profile = await canonicalize({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSession: {
...makeHostSession('local-repo'),
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] }
@@ -845,6 +854,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
const profile = await canonicalize({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSessionsByHostId: {
'runtime:env-a': {
...makeHostSession('runtime-repo'),
@@ -150,6 +150,8 @@ describe('Store', () => {
it('does not restore a terminal tab after its durable close flush returns', async () => {
const store = await createStore()
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' }))
const worktreeId = 'repo-1::/tmp/worktree-1'
const tabId = 'terminal-1'
const session: WorkspaceSessionState = {
@@ -58,7 +58,7 @@ describe('Store native-chat tab viewMode persistence', () => {
const WORKTREE = 'repo1::/worktree'
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {},
settings: {},
ui: {},
+5 -1
View File
@@ -737,7 +737,10 @@ describe('Store', () => {
it('reassignSshTargetId persists a worktree-meta-only re-point (no matching repo)', async () => {
const store = await createStore()
// A meta on the old SSH host with no repo row — the re-point must still be persisted, not memory-only.
// A meta on the old SSH host with no repo row for that host — the re-point must still be
// persisted, not memory-only. The repo id stays registered so the load-time orphan sweep,
// which only reads repo ids, leaves the row alone.
store.addRepo(makeRepo({ id: 'r1', path: '/r1' }))
store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' })
const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new')
@@ -787,6 +790,7 @@ describe('Store', () => {
it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'r1', path: '/r1' }))
store.setWorkspaceSession(
{
activeRepoId: null,
+1 -1
View File
@@ -708,7 +708,7 @@ describe('Store', () => {
}
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {
'repo1::/worktree-a': { status: 'active' },
'repo1::/worktree-b': { status: 'active' }
@@ -346,7 +346,7 @@ describe('Store', () => {
const acknowledgedAt = 1_700_000_000_000
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {},
settings: {},
ui: {
@@ -408,7 +408,7 @@ describe('Store', () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {},
settings: {},
ui: {
@@ -166,6 +166,8 @@ describe('Store', () => {
describe('mobileClientTabSelectionsByDeviceId', () => {
it('persists device tab selections across reloads and drops malformed payloads', async () => {
const store = await createStore()
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' }))
store.setMobileClientTabSelections({
'device-a': {
'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} }
@@ -188,6 +190,7 @@ describe('Store', () => {
it('prunes selections for a removed repo worktree', async () => {
const store = await createStore()
store.addRepo(makeRepo())
store.addRepo(makeRepo({ id: 'other-repo', path: '/other-repo' }))
store.setMobileClientTabSelections({
'device-a': {
'r1::/tmp/wt': {
@@ -12,10 +12,13 @@ import {
import { mergeProjectHostSetupCompatibilityState } from '../tracking-repos/project-host-compatibility'
import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations'
import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning'
import { collectDeregisteredRepoIds } from '../tracking-repos/deregistered-repo-residue'
import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration'
import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations'
import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update'
import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
@@ -129,6 +132,33 @@ export class RepoLifecycleOperations {
scheduleSave(this[repoLifecycleOperationsContext].scheduling)
}
/**
* Drop every persisted row owned by a repo id that is no longer registered.
*
* Runs at load because no removal path can: `removeProject` only fires while the repo is still in
* `state.repos`, and a paired client's mirror of a remote host's rows is keyed by ids that client
* never registers, so the owning host's removal never reaches it (#17776). An orphan has no owner
* that could object, so this ignores the session-ownership and local-execution-host gates the
* missing-directory sweeper needs.
*/
sweepDeregisteredRepoResidue(): string[] {
const state = this[repoLifecycleOperationsContext].runtime.state
const orphanRepoIds = collectDeregisteredRepoIds(state)
if (orphanRepoIds.size === 0) {
return []
}
for (const repoId of orphanRepoIds) {
pruneWorktreeStateForRepo(this, repoId, null)
state.workspaceSession = removeRepoFromWorkspaceSession(state.workspaceSession, repoId)
state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions(
state.workspaceSessionsByHostId,
repoId
)
}
pruneDeregisteredRepoUiResidue(state.ui, orphanRepoIds)
return [...orphanRepoIds]
}
updateRepo(
id: string,
updates: Partial<
@@ -212,6 +242,26 @@ export function pruneMobileClientTabSelections(
}
}
function pruneDeregisteredRepoUiResidue(
ui: PersistedState['ui'],
orphanRepoIds: ReadonlySet<string>
): void {
const isOrphanWorktree = (worktreeId: string): boolean =>
orphanRepoIds.has(getRepoIdFromWorktreeId(worktreeId))
if (ui.lastActiveRepoId && orphanRepoIds.has(ui.lastActiveRepoId)) {
ui.lastActiveRepoId = null
}
if (ui.lastActiveWorktreeId && isOrphanWorktree(ui.lastActiveWorktreeId)) {
ui.lastActiveWorktreeId = null
}
ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? []
for (const worktreeId of Object.keys(ui.showDotfilesByWorktree ?? {})) {
if (isOrphanWorktree(worktreeId)) {
delete ui.showDotfilesByWorktree?.[worktreeId]
}
}
}
export function getRepoUpdateOperations(
owner: RepoLifecycleOperations
): RepoUpdatePersistenceOperations {
+9 -1
View File
@@ -64,6 +64,9 @@ export class Store {
)
const adaptedProjectGroups = this.domains.adaptation.adaptFlatFolderScanProjectGroups()
this.domains.adaptation.hydrateFolderWorkspaceDiffComments()
// Load is the only place an orphaned repo id can be swept: every removal path needs the repo to
// still be registered, so rows outlive their owner without one (#17776).
const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue()
for (const entry of normalized.migrationUnsupportedEntries) {
setMigrationUnsupportedPty(entry)
}
@@ -78,7 +81,12 @@ export class Store {
this.state.legacyPaneKeyAliasEntries = entries
scheduleSave(this.domains.scheduling)
})
if (normalized.changed || this.runtime.loadNeedsSave || adaptedProjectGroups) {
if (
normalized.changed ||
this.runtime.loadNeedsSave ||
adaptedProjectGroups ||
sweptRepoIds.length > 0
) {
scheduleSave(this.domains.scheduling)
}
}
@@ -0,0 +1,108 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qualified-identity'
import { splitWorktreeId } from '../../../shared/worktree/id'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition'
import { ownerKeyWorktreeIds } from '../../orca-profiles/profile-project-worktree-identity'
/**
* Repo ids that still own persisted rows but no longer appear in `state.repos`.
*
* Why nothing else finds them: every other sweeper is gated on the repo still being registered, so
* deregistering a project stranded the rows it owned permanently — including a paired client's
* mirror of a remote host's session partition, which no local repo removal can reach (#17776).
*/
export function collectDeregisteredRepoIds(state: PersistedState): Set<string> {
const liveRepoIds = new Set(state.repos.map((repo) => repo.id))
const orphanRepoIds = new Set<string>()
// Only a full `<repoId>::<path>` locator seeds the set. A bare key -- a folder workspace id, a
// repo-keyed topology revision, a test-shaped locator -- cannot be told apart from a repo id, and
// guessing wrong here deletes live session state.
const addWorktreeId = (worktreeId: string | null | undefined): void => {
const repoId = worktreeId ? splitWorktreeId(worktreeId)?.repoId : undefined
if (repoId && !liveRepoIds.has(repoId)) {
orphanRepoIds.add(repoId)
}
}
/**
* Seed from an owner key, which can read as two different locators (see `ownerKeyWorktreeIds`).
* All or nothing: if either reading names a live repo the key is that repo's, and seeding the
* other reading would hand the removal pass -- which accepts either -- a live row to delete.
*/
const addOwnerKey = (ownerKey: string): void => {
const repoIds = ownerKeyWorktreeIds(ownerKey).flatMap((worktreeId) => {
const repoId = splitWorktreeId(worktreeId)?.repoId
return repoId ? [repoId] : []
})
if (repoIds.length > 0 && repoIds.every((repoId) => !liveRepoIds.has(repoId))) {
for (const repoId of repoIds) {
orphanRepoIds.add(repoId)
}
}
}
// Deliberately not seeded from `sparsePresetsByRepo` or `retiredWorktreeNamesByRepo`: both are
// bounded, and dropping a retired-name row would let a re-added repo reissue a name onto a cwd
// that still holds a prior occupant's agent state.
for (const worktreeId of Object.keys(state.worktreeMeta)) {
addWorktreeId(worktreeId)
}
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
addWorktreeId(getWorktreeIdFromHostIdentity(alias))
}
for (const [childId, lineage] of Object.entries(state.worktreeLineageById)) {
addWorktreeId(childId)
addWorktreeId(lineage.parentWorktreeId)
}
for (const [childKey, lineage] of Object.entries(state.workspaceLineageByChildKey)) {
addOwnerKey(childKey)
addOwnerKey(lineage.parentWorkspaceKey)
}
for (const selections of Object.values(state.mobileClientTabSelectionsByDeviceId ?? {})) {
for (const worktreeId of Object.keys(selections)) {
addWorktreeId(worktreeId)
}
}
const sessions: (WorkspaceSessionState | undefined)[] = [
state.workspaceSession,
...Object.values(state.workspaceSessionsByHostId ?? {})
]
for (const session of sessions) {
if (!session) {
continue
}
for (const field of SESSION_FIELDS_PRUNED_BY_OWNER_KEY) {
for (const ownerKey of Object.keys(
(session[field] as Record<string, unknown> | undefined) ?? {}
)) {
addOwnerKey(ownerKey)
}
}
for (const ownerKey of Object.keys(session.tabsByWorktree ?? {})) {
addOwnerKey(ownerKey)
}
for (const ownerKey of Object.keys(session.browserTabsByWorktree ?? {})) {
addOwnerKey(ownerKey)
}
// Pruned by bespoke rules rather than by owner key, so the loop above never reaches them.
for (const ownerKey of [
session.activeWorktreeId,
session.activeWorkspaceKey,
...(session.activeWorktreeIdsOnShutdown ?? [])
]) {
if (ownerKey) {
addOwnerKey(ownerKey)
}
}
// Not seeded from `terminalTopologyRevisionByRepoId`: its keys are bare repo ids by contract,
// and a bare key is exactly what `addWorktreeId` refuses to trust. Rows there are removed once
// any locator seeds their repo id, which every repo that ever opened a terminal has.
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
addWorktreeId(record.worktreeId)
}
for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) {
addWorktreeId(tombstone.worktreeId)
}
}
return orphanRepoIds
}
@@ -2,6 +2,7 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal'
import {
getExecutionHostIdFromWorktreeHostIdentity,
@@ -58,10 +59,26 @@ export function pruneWorktreeStateForRepo(
}
}
}
collectPrefixedKeys(Object.keys(state.worktreeMeta))
collectPrefixedKeys(Object.keys(state.workspaceSession?.lastVisitedAtByWorktreeId ?? {}))
for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) {
// Why the pane-keyed records contribute owner keys: they are pruned by the worktreeId they name,
// not by their own key, so a worktree with no meta and no visit row would otherwise keep its
// sleeping agents and tombstones forever -- and keep re-seeding the orphan sweep every load.
const collectScannedRecordOwners = (session: WorkspaceSessionState | undefined): void => {
collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {}))
collectPrefixedKeys(
Object.values(session?.sleepingAgentSessionsByPaneKey ?? {}).map(
(record) => record.worktreeId
)
)
collectPrefixedKeys(
Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).map(
(tombstone) => tombstone.worktreeId
)
)
}
collectPrefixedKeys(Object.keys(state.worktreeMeta))
collectScannedRecordOwners(state.workspaceSession)
for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) {
collectScannedRecordOwners(session)
}
for (const key of Object.keys(state.worktreeMeta)) {
@@ -656,9 +656,21 @@ describe('legacy coordinator takeover races', () => {
const detectionStarted = new Promise<void>((resolve) => {
signalDetectionStarted = resolve
})
let detectionCalls = 0
vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockImplementation(
() =>
new Promise<boolean>((resolve) => {
new Promise<boolean>((resolve, reject) => {
detectionCalls += 1
// Why reject instead of re-arming: a second call would overwrite resolveDetection and
// strand the first promise, hanging to a timeout instead of naming what changed.
if (detectionCalls > 1) {
reject(
new Error(
`isTerminalRunningAgent was called ${detectionCalls} times; this test drives exactly one detection.`
)
)
return
}
resolveDetection = resolve
signalDetectionStarted?.()
})
@@ -0,0 +1,123 @@
// Why this file exists: the authoritative missing-metadata prune had exactly one caller,
// `ipcMain.handle('worktrees:listAll')`. A headless runtime host has no renderer, so it never swept
// its own repos and their `worktreeMeta` rows grew without bound (#17776).
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import type { Repo } from '../../shared/repo-types'
import { testState, createStore, makeRepo } from '../persistence-test-harness'
import type { Store } from '../persistence/loading-store/store'
import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries'
import type { RuntimeStore } from './runtime-store-contract'
vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(),
sshConfigHostsToTargets: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) }))
const gitWorktree = (path: string): GitWorktreeInfo => ({
path,
branch: 'main',
head: 'abc1234',
isBare: false,
isMainWorktree: true
})
function queries(
store: Store,
repo: Repo,
worktrees: readonly GitWorktreeInfo[],
ok = true
): RuntimeManagedWorktreeQueries {
return new RuntimeManagedWorktreeQueries({
getStore: () => store as unknown as RuntimeStore,
listResolved: async () => [],
resolveRepo: async () => repo,
selectRepos: () => [repo],
scanRepo: async () => ({ ok, worktrees: [...worktrees] })
})
}
describe('runtime detected-worktree listing sweeps missing local metadata', () => {
let repoPath = ''
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-runtime-sweep-'))
repoPath = join(testState.dir, 'repo')
mkdirSync(repoPath, { recursive: true })
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
// Paired with the off-host case below: same fixture, no `connectionId`.
it('drops a metadata row whose directory is gone and the scan does not list', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath })
store.addRepo(repo)
const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}`
store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' })
expect(store.getWorktreeMeta(missingId)).toBeDefined()
await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo)
expect(store.getWorktreeMeta(missingId)).toBeUndefined()
})
it('keeps a row whose directory still exists', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath })
store.addRepo(repo)
const livePath = join(testState.dir, 'live-worktree')
mkdirSync(livePath, { recursive: true })
const liveId = `${repo.id}::${livePath}`
store.setWorktreeMetaForHost(liveId, 'local', { displayName: 'Live' })
await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo)
expect(store.getWorktreeMeta(liveId)).toBeDefined()
})
// A non-authoritative scan is a failed listing, which is no evidence any checkout is gone.
it('keeps every row when the scan is not authoritative', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath })
store.addRepo(repo)
const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}`
store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' })
await queries(store, repo, [], false).listDetected(repo)
expect(store.getWorktreeMeta(missingId)).toBeDefined()
})
// The execution host owns this verdict: this host cannot stat a checkout that lives behind an SSH
// connection, so a local miss is not evidence of absence. See docs/reference/ssh-execution-boundary.md.
//
// Deliberately identical to the first case except for `connectionId`, and the row is stamped
// `local` so it is a real prune candidate. That pairing is the proof: the same fixture without a
// connection loses the row, so the connection is the only reason this one keeps it. Removing any
// single gate would not show that -- four independent checks derive from `connectionId` here.
it('never sweeps a repo whose git runs off-host', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath, connectionId: 'build-box' })
store.addRepo(repo)
const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}`
store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' })
await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo)
expect(store.getWorktreeMeta(missingId)).toBeDefined()
})
})
@@ -20,6 +20,10 @@ import {
} from '../../shared/worktree/visibility-sources'
import { mergeWorktree } from '../ipc/worktree-logic'
import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning'
import { pruneMetadataMissingFromAuthoritativeLocalScan } from '../ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning'
import type { NativeLocalWorktreeMetadataScanExpectation } from '../persistence/tracking-repos/missing-local-worktree-metadata-pruning'
import { getLocalWorktreeScanGeneration } from '../local-worktree-scan-generation'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import type { Store } from '../persistence'
import type { RuntimeStore } from './runtime-store-contract'
import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan'
@@ -36,6 +40,31 @@ type Dependencies = {
scanRepo(repo: Repo): Promise<RuntimeWorktreeScanResult>
}
/**
* The destructive scan expectation for one repo, or undefined when this repo must not carry one.
*
* WSL-routed repos are excluded for the same reason the desktop listing excludes them: the listing
* runs in the distro and reports Linux paths while metadata can hold UNC ones, and v1 cannot prove
* those aliases equivalent. A runtime that needs repair throws rather than resolving routing, which
* is likewise no basis for deleting rows.
*/
function captureLocalMetadataPruneExpectation(
store: RuntimeStore,
repo: Repo
): NativeLocalWorktreeMetadataScanExpectation | undefined {
if (typeof store.captureNativeLocalWorktreeMetadataScanExpectation !== 'function') {
return undefined
}
try {
if (getLocalProjectWorktreeGitOptions(store as unknown as Store, repo).wslDistro) {
return undefined
}
} catch {
return undefined
}
return store.captureNativeLocalWorktreeMetadataScanExpectation(repo)
}
export class RuntimeManagedWorktreeQueries {
constructor(private readonly deps: Dependencies) {}
@@ -129,6 +158,10 @@ export class RuntimeManagedWorktreeQueries {
worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
}
}
// Why capture before the scan: listing can mutate metadata synchronously before its first
// await, and the prune revalidates against the rows as they stood when the scan was issued.
const metadataScanGeneration = getLocalWorktreeScanGeneration(repo.id)
const metadataPruneExpectation = captureLocalMetadataPruneExpectation(store, repo)
let scan: RuntimeWorktreeScanResult
try {
scan = await this.deps.scanRepo(repo)
@@ -136,6 +169,17 @@ export class RuntimeManagedWorktreeQueries {
scan = { ok: false, worktrees: [] }
}
if (scan.ok) {
// Why the runtime sweeps too: the desktop listing that used to own this runs off `ipcMain`,
// so a headless host -- which has no renderer -- never pruned its own repos' rows (#17776).
if (metadataPruneExpectation) {
await pruneMetadataMissingFromAuthoritativeLocalScan({
store: store as unknown as Store,
repo,
gitWorktrees: scan.worktrees,
scan: metadataPruneExpectation,
scanGeneration: metadataScanGeneration
})
}
pruneLineageForMissingRepoWorktrees(store as unknown as Store, repo, scan.worktrees)
}
const matcher = createWorktreeVisibilitySourceMatcher(
@@ -30,6 +30,9 @@ export type RuntimeStore = {
removeProjectForHost?: Store['removeProjectForHost']
reorderRepos?: Store['reorderRepos']
getAllWorktreeMeta: Store['getAllWorktreeMeta']
captureNativeLocalWorktreeMetadataScanExpectation?: Store['captureNativeLocalWorktreeMetadataScanExpectation']
pruneSessionlessMissingLocalWorktreeMetadataForRepo?: Store['pruneSessionlessMissingLocalWorktreeMetadataForRepo']
getProfileStorageDirectory?: Store['getProfileStorageDirectory']
getWorktreeMeta: Store['getWorktreeMeta']
setWorktreeMeta: Store['setWorktreeMeta']
setWorktreeMetaForHost?: Store['setWorktreeMetaForHost']
@@ -2,7 +2,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { testState, createStore, makeTerminalTab, writeDataFile } from './persistence-test-harness'
import {
testState,
createStore,
makeRepo,
makeTerminalTab,
writeDataFile
} from './persistence-test-harness'
import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures'
import { getDefaultPersistedState } from '../shared/constants'
@@ -196,6 +202,8 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', ()
it('does not clear and rebind a retired surface loaded from an older profile', async () => {
const paneKey = `${TAB}:${TEST_LEAF_1}`
const persisted = getDefaultPersistedState(testState.dir)
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
persisted.repos = [makeRepo({ id: 'repo1', path: '/repo1' })]
persisted.workspaceSession = {
...persisted.workspaceSession,
...sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }),
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery'
import { logStartupMilestone } from './startup-diagnostics'
import { mainProcessState as state } from './main-process-state'
import { resolveOpenedMarkdownDocuments } from './os-opened-markdown-files'
export function registerMainProcessIpcHandlers(): void {
ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
@@ -36,6 +37,20 @@ export function registerMainProcessIpcHandlers(): void {
state.pendingOpenSettings.matches(event.sender.id, { consume: true })
)
ipcMain.handle('ui:consumePendingSkillShare', () => state.skillShareDeepLinks.consume())
// Why: the renderer pulls this once its ui:openMarkdownFiles listener attaches, so a
// cold-start "Open With" queued before mount still opens. The pull doubles as the proof
// that the listener is live, which is what lets main start pushing.
ipcMain.handle('ui:consumePendingMarkdownFileOpens', async () => {
state.markdownFileOpenListenerReady = true
const filePaths = state.osOpenedMarkdownFiles.consume()
try {
return await resolveOpenedMarkdownDocuments(filePaths)
} catch (error) {
// Why restored: the renderer never received these, so a later mount must still get them.
state.osOpenedMarkdownFiles.restore(filePaths)
throw error
}
})
ipcMain.handle(
'app:startupDiagnostic',
(_event, event: string, details?: Record<string, unknown>) => {
+7
View File
@@ -36,6 +36,7 @@ import type { ServeOptions } from './main-process-serve'
import type { HangDetectionMarker } from '../hang-watchdog/hang-detection-marker'
import { ServeReadinessPublisher } from '../server/serve-readiness'
import { SkillShareDeepLinkState } from './skill-share-deep-link-state'
import { OsOpenedMarkdownFileState } from './os-opened-markdown-files'
import {
DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD,
DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
@@ -90,6 +91,12 @@ export const mainProcessState = {
// Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount.
pendingOpenSettings: createWebContentsTimedFlag(),
skillShareDeepLinks: new SkillShareDeepLinkState(),
// Why: a Finder/Explorer "Open With" can land before any window exists; the renderer pulls this buffer on mount.
osOpenedMarkdownFiles: new OsOpenedMarkdownFileState(),
// Why a latch and not just "a window exists": a window can be up while its renderer has not
// attached the ui:openMarkdownFiles listener yet, and a push into that gap is dropped by
// Electron with no error. Only the renderer's own pull proves the listener is live.
markdownFileOpenListenerReady: false,
firstWindowStartupServicesReady: Promise.resolve(),
managedWslCliReconciliationReady: Promise.resolve(),
managedWslCliStartupBarrierReady: Promise.resolve(),
@@ -145,6 +145,9 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}
clearExpectedRendererReload(rendererWebContentsId)
recordCrashBreadcrumb('main_window_loaded')
logStartupMilestone('did-finish-load')
// Why cleared here: a reload drops the old ui:openMarkdownFiles listener, and the fresh
// renderer re-attaches by pulling. Pushing into the gap between would be silently lost.
state.markdownFileOpenListenerReady = false
const currentStore = state.store
if (currentStore && resolveConsent(currentStore.getSettings()).effective === 'enabled') {
trackAppOpenedOnce()
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import { OsOpenedMarkdownFileState } from './os-opened-markdown-files'
/**
* The two ways a queued "Open With" can be lost between main and the renderer. Both are
* about ownership: main must not drop paths it has not proven the renderer received.
*/
describe('os-opened markdown delivery ownership', () => {
it('keeps the batch when resolution rejects on the pull path', async () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths(['/notes/a.md'])
const resolve = vi.fn().mockRejectedValue(new Error('floating root unavailable'))
// Mirrors the ipcMain.handle('ui:consumePendingMarkdownFileOpens') body.
const pull = async (): Promise<unknown> => {
const filePaths = state.consume()
try {
return await resolve(filePaths)
} catch (error) {
state.restore(filePaths)
throw error
}
}
await expect(pull()).rejects.toThrow('floating root unavailable')
// Without the restore the file would be gone and no later mount could ever open it.
expect(state.consume()).toEqual(['/notes/a.md'])
})
it('holds the batch while the renderer listener is not yet attached', () => {
const state = new OsOpenedMarkdownFileState()
const send = vi.fn()
let listenerReady = false
// Mirrors publishOsOpenedMarkdownFiles()'s guard.
const publish = (): void => {
if (!listenerReady) {
return
}
const filePaths = state.consume()
if (filePaths.length > 0) {
send(filePaths)
}
}
// A window exists, but the renderer has not mounted its bridge yet: send() here would be
// dropped by Electron with no error, and consuming would destroy the queue.
state.captureFilePaths(['/notes/a.md'], publish)
expect(send).not.toHaveBeenCalled()
// The renderer's pull is what proves the listener is live.
listenerReady = true
state.captureFilePaths(['/notes/b.md'], publish)
expect(send).toHaveBeenCalledExactlyOnceWith(['/notes/a.md', '/notes/b.md'])
expect(state.consume()).toEqual([])
})
it('restores a batch the window could no longer receive', () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths(['/notes/a.md'])
const filePaths = state.consume()
// Window died between consume and send.
state.restore(filePaths)
expect(state.consume()).toEqual(['/notes/a.md'])
})
})
@@ -0,0 +1,306 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isMarkdownDocumentName } from '../ipc/markdown-documents'
import {
MAX_PENDING_OS_OPENED_MARKDOWN_FILES,
OsOpenedMarkdownFileState,
markdownPathsFromArguments,
resolveOpenedMarkdownDocuments
} from './os-opened-markdown-files'
vi.mock('../ipc/filesystem-auth', () => ({
authorizeExternalPath: vi.fn()
}))
vi.mock('../ipc/floating-workspace-directory', () => ({
ensureDefaultFloatingWorkspacePath: vi.fn()
}))
const { authorizeExternalPath } = await import('../ipc/filesystem-auth')
const { ensureDefaultFloatingWorkspacePath } = await import('../ipc/floating-workspace-directory')
describe('markdownPathsFromArguments', () => {
it('keeps absolute markdown paths and drops other extensions', () => {
expect(
markdownPathsFromArguments(
[
'/Users/dev/notes/a.md',
'/Users/dev/notes/b.markdown',
'/Users/dev/notes/c.mdx',
'/Users/dev/notes/d.txt',
'/Users/dev/src/e.tsx',
'/Users/dev/notes/README'
],
'darwin'
)
).toEqual(['/Users/dev/notes/a.md', '/Users/dev/notes/b.markdown', '/Users/dev/notes/c.mdx'])
})
it('drops switches, including Chromium-style ones that would otherwise look like values', () => {
expect(
markdownPathsFromArguments(
['--serve', '-v', '--allow-file-access-from-files', '/Users/dev/notes/a.md'],
'darwin'
)
).toEqual(['/Users/dev/notes/a.md'])
})
it('drops the executable and dev entries because none of them end in a markdown extension', () => {
const nonDocumentEntries = [
'/Applications/Orca.app/Contents/MacOS/Orca',
'/Users/dev/orca/out/main/index.js',
'/Applications/Orca.app/Contents/Resources/app.asar'
]
// The module documents that the extension check alone excludes these; hold it to that.
for (const entry of nonDocumentEntries) {
expect(isMarkdownDocumentName(entry), entry).toBe(false)
}
expect(
markdownPathsFromArguments([...nonDocumentEntries, '/Users/dev/notes/a.md'], 'darwin')
).toEqual(['/Users/dev/notes/a.md'])
})
it('drops relative paths because a second instance has no meaningful cwd', () => {
expect(
markdownPathsFromArguments(['readme.md', './docs/a.md', '../up.md', ''], 'darwin')
).toEqual([])
})
it('accepts win32 drive-letter and UNC paths', () => {
expect(
markdownPathsFromArguments(
['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md', 'C:\\Users\\dev\\todo.txt'],
'win32'
)
).toEqual(['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md'])
})
it('dedupes case-insensitively on win32 and keeps the first spelling', () => {
expect(markdownPathsFromArguments(['C:\\notes\\A.md', 'c:\\notes\\a.md'], 'win32')).toEqual([
'C:\\notes\\A.md'
])
})
it('normalizes parent segments before deduping', () => {
expect(
markdownPathsFromArguments(['C:\\notes\\sub\\..\\a.md', 'C:\\notes\\a.md'], 'win32')
).toEqual(['C:\\notes\\a.md'])
expect(markdownPathsFromArguments(['/docs/../notes/a.md', '/notes/a.md'], 'darwin')).toEqual([
'/notes/a.md'
])
})
it('does not dedupe case-insensitively on posix, where casing is a different file', () => {
expect(markdownPathsFromArguments(['/a/A.md', '/a/a.md'], 'linux')).toEqual([
'/a/A.md',
'/a/a.md'
])
})
it('accepts a file:// URI, which the desktop entry %U field code permits', () => {
// Why defensive rather than load-bearing: GLib decodes a local file:// URI to a plain
// path before spawning (measured on Ubuntu 24.04), so Linux hits the plain-path branch
// today. The %U spec still allows a URI, and a launcher that passes one literally would
// otherwise be dropped without a trace.
expect(
markdownPathsFromArguments(
['file:///home/me/notes/a.md', 'file:///home/me/notes/b.txt'],
'linux'
)
).toEqual(['/home/me/notes/a.md'])
})
it('percent-decodes a file:// URI so a path with spaces still opens', () => {
expect(markdownPathsFromArguments(['file:///home/me/design%20notes.md'], 'linux')).toEqual([
'/home/me/design notes.md'
])
})
it('decodes win32 file:// URIs, including UNC authority form', () => {
expect(
markdownPathsFromArguments(
['file:///C:/Users/me/todo.md', 'file://server/share/a.md'],
'win32'
)
).toEqual(['C:\\Users\\me\\todo.md', '\\\\server\\share\\a.md'])
})
it('dedupes a path delivered as both a URI and a bare path', () => {
expect(markdownPathsFromArguments(['file:///home/me/a.md', '/home/me/a.md'], 'linux')).toEqual([
'/home/me/a.md'
])
})
it('drops a malformed or non-file URL instead of throwing', () => {
expect(() =>
markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux')
).not.toThrow()
expect(
markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux')
).toEqual([])
})
it('honours the platform argument rather than the host OS', () => {
const argv = ['C:\\notes\\a.md', '/notes/b.md']
// Same argv, two platforms: a win32 path is not absolute to posix, and posix input is
// renormalized to backslashes on win32. Neither result may depend on where the suite runs.
expect(markdownPathsFromArguments(argv, 'darwin')).toEqual(['/notes/b.md'])
expect(markdownPathsFromArguments(argv, 'win32')).toEqual(['C:\\notes\\a.md', '\\notes\\b.md'])
})
})
// Why resolve(): the state uses the host platform by default, so fixture paths must already be
// spelled the way the host's path module normalizes them (`\n\a.md` and a drive on Windows).
const hostPath = (name: string): string => resolve(sep, 'notes', name)
describe('OsOpenedMarkdownFileState', () => {
it('reports no capture and does not publish when argv carries no markdown', () => {
const state = new OsOpenedMarkdownFileState()
const publish = vi.fn()
expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', '--serve'], publish)).toBe(
false
)
expect(publish).not.toHaveBeenCalled()
expect(state.consume()).toEqual([])
})
it('buffers and publishes when argv carries markdown', () => {
const state = new OsOpenedMarkdownFileState()
const publish = vi.fn()
const filePath = hostPath('a.md')
expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', filePath], publish)).toBe(
true
)
expect(publish).toHaveBeenCalledTimes(1)
expect(state.consume()).toEqual([filePath])
})
it('captures a single macOS open-file path', () => {
const state = new OsOpenedMarkdownFileState()
const publish = vi.fn()
const filePath = hostPath('a.md')
expect(state.captureFilePaths([filePath], publish)).toBe(true)
expect(state.captureFilePaths([hostPath('a.png')], publish)).toBe(false)
expect(publish).toHaveBeenCalledTimes(1)
expect(state.consume()).toEqual([filePath])
})
it('does not duplicate a path captured twice', () => {
const state = new OsOpenedMarkdownFileState()
const filePath = hostPath('a.md')
state.captureFilePaths([filePath])
state.captureFilePaths([filePath])
state.capture(['orca', filePath])
expect(state.consume()).toEqual([filePath])
})
it('drains the buffer on consume', () => {
const state = new OsOpenedMarkdownFileState()
const paths = [hostPath('a.md'), hostPath('b.md')]
state.captureFilePaths(paths)
expect(state.consume()).toEqual(paths)
expect(state.consume()).toEqual([])
})
it('restores an undelivered batch at the front of the buffer', () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths([hostPath('later.md')])
state.restore([hostPath('undelivered.md')])
expect(state.consume()).toEqual([hostPath('undelivered.md'), hostPath('later.md')])
})
it('caps the buffer when captures overflow it', () => {
const state = new OsOpenedMarkdownFileState()
const overflow = MAX_PENDING_OS_OPENED_MARKDOWN_FILES + 5
const paths = Array.from({ length: overflow }, (_, index) => hostPath(`file-${index}.md`))
expect(state.captureFilePaths(paths)).toBe(true)
expect(state.consume()).toEqual(paths.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES))
})
it('caps the buffer when a restore overflows it', () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths([hostPath('pending.md')])
const restored = Array.from({ length: MAX_PENDING_OS_OPENED_MARKDOWN_FILES }, (_, index) =>
hostPath(`restored-${index}.md`)
)
state.restore(restored)
const pending = state.consume()
expect(pending).toHaveLength(MAX_PENDING_OS_OPENED_MARKDOWN_FILES)
expect(pending).toEqual(restored)
})
})
describe('resolveOpenedMarkdownDocuments', () => {
let floatingRoot: string
let fileRoot: string
beforeEach(async () => {
vi.mocked(authorizeExternalPath).mockClear()
vi.mocked(ensureDefaultFloatingWorkspacePath).mockClear()
floatingRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-root-'))
fileRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-files-'))
vi.mocked(ensureDefaultFloatingWorkspacePath).mockResolvedValue(floatingRoot)
})
afterEach(async () => {
await rm(floatingRoot, { recursive: true, force: true })
await rm(fileRoot, { recursive: true, force: true })
})
it('resolves a real file outside the floating root to a basename-relative document', async () => {
const filePath = join(fileRoot, 'design notes.md')
await writeFile(filePath, '# hi\n', 'utf8')
const documents = await resolveOpenedMarkdownDocuments([filePath])
expect(documents).toEqual([
{
filePath,
relativePath: 'design notes.md',
basename: 'design notes.md',
name: 'design notes'
}
])
expect(authorizeExternalPath).toHaveBeenCalledWith(filePath)
})
it('drops a directory that merely looks like a markdown file', async () => {
const bundlePath = join(fileRoot, 'bundle.md')
await mkdir(bundlePath)
const filePath = join(fileRoot, 'real.md')
await writeFile(filePath, '# hi\n', 'utf8')
const documents = await resolveOpenedMarkdownDocuments([bundlePath, filePath])
expect(documents.map((document) => document.filePath)).toEqual([filePath])
// Security contract: a path we never validated must never be authorized for renderer reads.
expect(authorizeExternalPath).toHaveBeenCalledTimes(1)
expect(authorizeExternalPath).toHaveBeenCalledWith(filePath)
})
it('drops a path that no longer exists without authorizing it', async () => {
const missingPath = join(fileRoot, 'gone.md')
expect(await resolveOpenedMarkdownDocuments([missingPath])).toEqual([])
expect(authorizeExternalPath).not.toHaveBeenCalled()
})
it('returns nothing for an empty input without touching the filesystem', async () => {
expect(await resolveOpenedMarkdownDocuments([])).toEqual([])
expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled()
expect(authorizeExternalPath).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,149 @@
import { stat } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import { authorizeExternalPath } from '../ipc/filesystem-auth'
import { ensureDefaultFloatingWorkspacePath } from '../ipc/floating-workspace-directory'
import { isMarkdownDocumentName, markdownDocumentFromFilePath } from '../ipc/markdown-documents'
// Why: a shell can only ever hand over the files the user selected; anything past this is a
// runaway argv, and buffering it unbounded would pin the paths for the whole session.
export const MAX_PENDING_OS_OPENED_MARKDOWN_FILES = 32
/**
* Resolves one argv entry to a local absolute path, or null if it is not one.
*
* Why file:// is accepted defensively: electron-builder appends the `%U` field code to the
* generated Linux `Exec=` line, and `%U` is specified as "URLs". GLib turns out to decode a
* local `file://` URI back to a plain path before spawning (measured on Ubuntu 24.04, via
* the same `launch_uris` call a file manager makes), so the branch below is not what fires
* there today — but the spec permits a URI, and a launcher that honours it literally would
* otherwise be silently dropped. macOS `open-file` and the Windows shell `%1` pass paths.
*/
function localPathFromArgument(argument: string, platform: NodeJS.Platform): string | null {
const pathApi = platform === 'win32' ? path.win32 : path.posix
if (argument.startsWith('file://')) {
try {
// Why the explicit windows flag: this must decode the same way on any host so the
// behaviour is testable, and it is what turns `file://server/share` back into a UNC path.
return fileURLToPath(argument, { windows: platform === 'win32' })
} catch {
return null
}
}
return pathApi.isAbsolute(argument) ? argument : null
}
/**
* Absolute markdown paths an OS "Open With" put on a launch or second-instance argv.
*
* Why no executable/asar/dev-entry filtering: none of those argv entries end in a markdown
* extension, so the extension check already excludes them. Relative entries are dropped
* because the shell always passes absolute paths and `cwd` is meaningless for a second instance.
*/
export function markdownPathsFromArguments(
argv: readonly string[],
platform: NodeJS.Platform = process.platform
): string[] {
const pathApi = platform === 'win32' ? path.win32 : path.posix
const seen = new Set<string>()
const paths: string[] = []
for (const rawArgument of argv) {
if (!rawArgument || rawArgument.startsWith('-')) {
continue
}
const argument = localPathFromArgument(rawArgument, platform)
if (!argument || !isMarkdownDocumentName(argument)) {
continue
}
const normalized = pathApi.normalize(argument)
// Why lowercased on win32: the shell round-trips drive letters and 8.3 casing
// inconsistently, and two spellings of one path must not open two tabs.
const key = platform === 'win32' ? normalized.toLowerCase() : normalized
if (seen.has(key)) {
continue
}
seen.add(key)
paths.push(normalized)
}
return paths
}
/**
* Buffers markdown paths the OS handed us until a renderer can receive them.
*
* Mirrors SkillShareDeepLinkState: main pushes when a window is already live, and the
* renderer pulls the same buffer when its listener attaches, so a cold-start "Open With"
* that lands before mount is not dropped.
*/
export class OsOpenedMarkdownFileState {
private pending: string[] = []
/** Returns true when argv carried at least one markdown path. */
capture(argv: readonly string[], publish?: () => void): boolean {
return this.add(markdownPathsFromArguments(argv), publish)
}
/** Returns true when at least one path was a markdown document. */
captureFilePaths(filePaths: readonly string[], publish?: () => void): boolean {
return this.add(markdownPathsFromArguments(filePaths), publish)
}
consume(): string[] {
const pending = this.pending
this.pending = []
return pending
}
/** Puts an undelivered batch back at the front so the next renderer still receives it. */
restore(filePaths: readonly string[]): void {
this.pending = [...filePaths, ...this.pending].slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES)
}
private add(filePaths: readonly string[], publish?: () => void): boolean {
if (filePaths.length === 0) {
return false
}
const merged = [...this.pending]
for (const filePath of filePaths) {
if (!merged.includes(filePath)) {
merged.push(filePath)
}
}
this.pending = merged.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES)
publish?.()
return true
}
}
/**
* Turns OS-handed paths into the same `MarkdownDocument` shape the floating workspace's own
* file picker produces, authorizing each one for the renderer's later read.
*/
export async function resolveOpenedMarkdownDocuments(
filePaths: readonly string[]
): Promise<MarkdownDocument[]> {
if (filePaths.length === 0) {
return []
}
const floatingRoot = await ensureDefaultFloatingWorkspacePath()
const documents: MarkdownDocument[] = []
for (const filePath of filePaths) {
try {
// Why: the shell can hand over a bundle directory named `*.md`, or a path already
// deleted by the time we resolve. Authorize only something that is really a file.
if (!(await stat(filePath)).isFile()) {
continue
}
} catch {
continue
}
authorizeExternalPath(filePath)
documents.push(
markdownDocumentFromFilePath(floatingRoot, filePath, {
outsideRootRelativePath: 'basename'
})
)
}
return documents
}
@@ -0,0 +1,57 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const read = (relativePath: string): string =>
// Why source text: this wiring is module-scope side effects in the entry point, which no
// unit test can import without booting Electron. These guards pin the call shapes instead.
readFileSync(join(process.cwd(), relativePath), 'utf8').replaceAll('"', "'")
describe('os-opened markdown wiring', () => {
const index = read('src/main/index.ts')
const bootstrap = read('src/main/startup/main-process-ipc-bootstrap.ts')
const controller = read('src/main/startup/main-window-controller.ts')
it('captures argv before the serve-duplicate early return', () => {
const captureIndex = index.indexOf(
'state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)'
)
const serveGuardIndex = index.indexOf('if (!shouldActivateDesktopForSecondInstance(argv)) {')
expect(captureIndex).toBeGreaterThanOrEqual(0)
expect(serveGuardIndex).toBeGreaterThanOrEqual(0)
// A duplicate `orca serve` returns early; capturing after that would drop the user's files.
expect(captureIndex).toBeLessThan(serveGuardIndex)
})
it('claims the macOS open-file event so the default handler does not win it', () => {
const handlerIndex = index.indexOf("app.on('open-file'")
expect(handlerIndex).toBeGreaterThanOrEqual(0)
const preventDefaultIndex = index.indexOf('event.preventDefault()', handlerIndex)
const nextRegistrationIndex = index.indexOf('app.on(', handlerIndex + 1)
expect(preventDefaultIndex).toBeGreaterThan(handlerIndex)
if (nextRegistrationIndex !== -1) {
expect(preventDefaultIndex).toBeLessThan(nextRegistrationIndex)
}
})
it('captures the cold-start argv and lets the renderer pull it after mount', () => {
expect(index).toContain('state.osOpenedMarkdownFiles.capture(process.argv)')
expect(bootstrap).toContain("ipcMain.handle('ui:consumePendingMarkdownFileOpens'")
})
// Why: `webContents.send` to a renderer that has not attached the listener is dropped with no
// error, so publishing on "a window exists" alone would consume the queue into a void.
it('only pushes once the renderer has proven its listener is attached', () => {
expect(index).toContain('!state.markdownFileOpenListenerReady')
expect(bootstrap).toContain('state.markdownFileOpenListenerReady = true')
// A reload drops the listener; the fresh renderer re-proves itself by pulling again.
expect(controller).toContain('state.markdownFileOpenListenerReady = false')
})
it('restores an undelivered batch on both the push and the pull path', () => {
expect(index).toContain('state.osOpenedMarkdownFiles.restore(filePaths)')
expect(bootstrap).toContain('state.osOpenedMarkdownFiles.restore(filePaths)')
})
})
+65 -28
View File
@@ -105,6 +105,56 @@ function devWrapperTestEnv(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return { ...env, ...extra }
}
/**
* What the two cases below wait on: a ~280MB clone of Electron.app, two swiftc
* helper builds, and `codesign --deep` over the result. Six seconds on an idle
* machine; the swiftc builds alone pass fifteen when this file runs inside the
* full suite and every core is taken. The generous ceiling only costs time on a
* run that is already failing.
*/
const PREPARE_TIMEOUT_MS = 90_000
/**
* Spawns the wrapper with its output retained.
*
* Why retained: the wrapper reports its own failures on stderr, and discarding
* them turned a crash in prepare into a bare "Timed out waiting for condition"
* with nothing to act on.
*/
function spawnDevWrapper(
args: string[],
env: NodeJS.ProcessEnv
): { wrapper: ChildProcess; readOutput: () => string } {
const wrapper = spawn(process.execPath, args, {
cwd: resolve('.'),
env,
stdio: ['ignore', 'pipe', 'pipe']
})
let output = ''
const collect = (chunk: Buffer): void => {
output += chunk.toString()
}
wrapper.stdout?.on('data', collect)
wrapper.stderr?.on('data', collect)
return { wrapper, readOutput: () => output }
}
async function waitForEnvFile(envFile: string, readOutput: () => string): Promise<void> {
try {
await waitFor(() => {
try {
return readFileSync(envFile, 'utf8').trim().length > 0
} catch {
return false
}
}, PREPARE_TIMEOUT_MS)
} catch (error) {
throw new Error(
`${(error as Error).message}: the dev wrapper never wrote ${envFile}. Wrapper output:\n${readOutput() || '(none)'}`
)
}
}
describe('run-electron-vite-dev', () => {
afterEach(async () => {
for (const pid of processesToCleanUp) {
@@ -351,26 +401,19 @@ describe('run-electron-vite-dev', () => {
async function runWrapper(runId: string): Promise<{ electronExecPath: string }> {
const pidFile = join(tempDir, `${runId}.pid`)
const envFile = join(tempDir, `${runId}.json`)
const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], {
cwd: resolve('.'),
env: {
const { wrapper, readOutput } = spawnDevWrapper(
[wrapperPath, '--remote-debugging-port=9448'],
{
...baseEnv,
ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile,
ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile
},
stdio: 'ignore'
})
}
)
expect(wrapper.pid).toBeTypeOf('number')
processesToCleanUp.add(wrapper.pid!)
await waitFor(() => {
try {
return readFileSync(envFile, 'utf8').trim().length > 0
} catch {
return false
}
}, 20000)
await waitForEnvFile(envFile, readOutput)
const trackedPids = trackPidFile(pidFile)
@@ -409,7 +452,8 @@ describe('run-electron-vite-dev', () => {
}
}
},
30000
// Two full prepares, each budgeted at PREPARE_TIMEOUT_MS.
PREPARE_TIMEOUT_MS * 2 + 30_000
)
it.skipIf(process.platform !== 'darwin')(
@@ -421,9 +465,9 @@ describe('run-electron-vite-dev', () => {
const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs')
const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs')
const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], {
cwd: resolve('.'),
env: devWrapperTestEnv({
const { wrapper, readOutput } = spawnDevWrapper(
[wrapperPath, '--remote-debugging-port=9448'],
devWrapperTestEnv({
ORCA_ELECTRON_VITE_CLI: fakeCliPath,
ORCA_SKIP_DEV_CLI_PREPARE: '1',
ORCA_SKIP_DEV_WEB_PREPARE: '1',
@@ -431,20 +475,13 @@ describe('run-electron-vite-dev', () => {
ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile,
ORCA_DEV_BRANCH: 'feature/framework-symlinks',
ORCA_DEV_WORKTREE_NAME: 'symlink-ui'
}),
stdio: 'ignore'
})
})
)
expect(wrapper.pid).toBeTypeOf('number')
processesToCleanUp.add(wrapper.pid!)
await waitFor(() => {
try {
return readFileSync(envFile, 'utf8').trim().length > 0
} catch {
return false
}
}, 20000)
await waitForEnvFile(envFile, readOutput)
const trackedPids = trackPidFile(pidFile)
@@ -464,6 +501,6 @@ describe('run-electron-vite-dev', () => {
await stopWrapperAndTrackedPids(wrapper, trackedPids)
},
30000
PREPARE_TIMEOUT_MS + 30_000
)
})
@@ -0,0 +1,21 @@
import { setBoundedMapEntry } from './runtime/runtime-async-boundaries'
/** Two creates this close together mean more are likely; an isolated create earns no replacement. */
export const WORKTREE_CREATE_BURST_MS = 5 * 60_000
const WORKTREE_CREATE_PREPARATION_CONSUME_MAX = 64
/** When each preparation key was last consumed, so a burst can be told from an isolated create. */
const lastConsumedAt = new Map<string, number>()
/** Records this consume and reports whether it continues a burst. A replacement checkout costs a
* full tree and holds disk until its TTL, so only a user who is already creating repeatedly earns
* one; the first create of a session pays nothing for a spare nobody claims. */
export function recordPreparationConsume(key: string, now = Date.now()): boolean {
const previous = lastConsumedAt.get(key)
setBoundedMapEntry(lastConsumedAt, key, now, WORKTREE_CREATE_PREPARATION_CONSUME_MAX)
return previous !== undefined && now - previous <= WORKTREE_CREATE_BURST_MS
}
export function resetPreparationConsumeHistoryForTests(): void {
lastConsumedAt.clear()
}
@@ -0,0 +1,79 @@
import {
isWorktreeCreatePreparation,
parseWorktreePreparationOwnerPid,
parseWorktreePreparationPathOwnerPid
} from '../shared/worktree/create-preparation'
import type { AddWorktreeOptions } from './git/worktree'
import { listWorktreeGraph } from './git/worktree'
import { discardPreparedWorktree, unlockPreparedWorktree } from './git/worktree-create-preparation'
import { retryPendingPreparationDiscards } from './worktree-preparation-discard-retry'
const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4
const staleCleanupInFlight = new Map<string, Promise<void>>()
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
/** Reclaims preparations a crashed process left registered. Single-flighted per host key so a burst
* of arming calls shares one worktree listing. */
export async function cleanupStalePreparations(
cleanupKey: string,
repoPath: string,
options: AddWorktreeOptions
): Promise<void> {
const existing = staleCleanupInFlight.get(cleanupKey)
if (existing) {
await existing.catch(() => {})
return
}
const cleanup = (async () => {
// Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus
// a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create.
void retryPendingPreparationDiscards(cleanupKey)
const worktrees = await listWorktreeGraph(repoPath, {
...options,
includeCreatePreparations: true
})
const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation)
let nextIndex = 0
async function discardNextStalePreparation(): Promise<void> {
while (nextIndex < staleWorktrees.length) {
const worktree = staleWorktrees[nextIndex]
nextIndex += 1
const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason)
const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path)
if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) {
continue
}
// Preserve a branch-attached final path after a crash; only detached or
// still-hidden preparations are safe to discard automatically.
if (worktree.branch && pathOwnerPid === null) {
await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {})
} else if (pathOwnerPid === lockOwnerPid) {
await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {})
}
}
}
const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length)
await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation()))
})()
staleCleanupInFlight.set(cleanupKey, cleanup)
try {
await cleanup.catch(() => {})
} finally {
if (staleCleanupInFlight.get(cleanupKey) === cleanup) {
staleCleanupInFlight.delete(cleanupKey)
}
}
}
export function resetStalePreparationCleanupForTests(): void {
staleCleanupInFlight.clear()
}
@@ -466,4 +466,51 @@ describe('worktree create preparation registry', () => {
expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true })
expect(mocks.discard).toHaveBeenCalledTimes(1)
})
function consumeOnce(name: string): ReturnType<typeof consumePreparedWorktreeCreate> {
return consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: `/workspace/${name}`,
branch: `feature/${name}`,
baseBranch: 'origin/main'
})
}
it('does not re-arm after an isolated create', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(consumeOnce('only')).resolves.toEqual({})
// Why: a lone create would otherwise leave a full spare checkout on disk for the whole TTL.
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1)
})
it('re-arms a preparation once creates arrive in a burst', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(consumeOnce('first')).resolves.toEqual({})
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1)
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2)
// No arming call follows this consume: the third checkout can only come from the re-arm.
await expect(consumeOnce('second')).resolves.toEqual({})
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3)
// The replacement is claimable, so a third create still skips the cold add.
await expect(consumeOnce('third')).resolves.toEqual({})
expect(mocks.finalize).toHaveBeenCalledTimes(3)
})
it('does not re-arm when finalization failed', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(consumeOnce('first')).resolves.toEqual({})
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
mocks.prepareCheckout.mockClear()
mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move'))
await expect(consumeOnce('second')).resolves.toBeNull()
expect(mocks.prepareCheckout).not.toHaveBeenCalled()
})
})
+51 -74
View File
@@ -7,17 +7,12 @@ import { isFolderRepo } from '../shared/repo-kind'
import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path'
import {
WORKTREE_CREATE_PREPARATION_DIRECTORY,
createWorktreePreparationLockReason,
isWorktreeCreatePreparation,
parseWorktreePreparationOwnerPid,
parseWorktreePreparationPathOwnerPid
createWorktreePreparationLockReason
} from '../shared/worktree/create-preparation'
import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree'
import { listWorktreeGraph } from './git/worktree'
import {
discardPreparedWorktree,
finalizePreparedWorktree,
unlockPreparedWorktree,
prepareWorktreeCreateCheckout
} from './git/worktree-create-preparation'
import {
@@ -25,17 +20,23 @@ import {
getWorktreeMirrorDistro
} from './project-runtime-git-options'
import { computeWorkspaceRootAsync, getWorktreePathSettings } from './ipc/worktree-logic'
import {
recordPreparationConsume,
resetPreparationConsumeHistoryForTests
} from './worktree-create-preparation-burst'
import {
cleanupStalePreparations,
resetStalePreparationCleanupForTests
} from './worktree-create-preparation-stale-cleanup'
import { toHostFilesystemPath } from './host-tree-removal'
import {
discardPreparationWithRetry,
resetPendingPreparationDiscardsForTests,
retryPendingPreparationDiscards,
trackPreparationDiscard
} from './worktree-preparation-discard-retry'
export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000
export const WORKTREE_CREATE_PREPARATION_LIMIT = 3
const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4
type PreparationEntry = {
key: string
@@ -59,7 +60,6 @@ type ConsumePreparedWorktreeArgs = {
}
const preparations = new Map<string, PreparationEntry>()
const staleCleanupInFlight = new Map<string, Promise<void>>()
function pathOps(path: string): Pick<typeof posix, 'dirname' | 'join' | 'normalize'> {
return isWindowsAbsolutePathLike(path) ? win32 : posix
@@ -79,15 +79,6 @@ function preparationKey(
return `${pathKey(repoPath)}\0${pathKey(workspaceRoot)}\0${baseBranch}\0${options.wslDistro ?? ''}`
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
function preparationHostKey(repoPath: string, options: AddWorktreeOptions): string {
return `${pathKey(repoPath)}\0${options.wslDistro ?? ''}`
}
@@ -131,57 +122,6 @@ function enforcePreparationLimit(): void {
}
}
async function cleanupStalePreparations(
repoPath: string,
options: AddWorktreeOptions
): Promise<void> {
const cleanupKey = preparationHostKey(repoPath, options)
const existing = staleCleanupInFlight.get(cleanupKey)
if (existing) {
await existing.catch(() => {})
return
}
const cleanup = (async () => {
// Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus
// a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create.
void retryPendingPreparationDiscards(cleanupKey)
const worktrees = await listWorktreeGraph(repoPath, {
...options,
includeCreatePreparations: true
})
const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation)
let nextIndex = 0
async function discardNextStalePreparation(): Promise<void> {
while (nextIndex < staleWorktrees.length) {
const worktree = staleWorktrees[nextIndex]
nextIndex += 1
const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason)
const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path)
if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) {
continue
}
// Preserve a branch-attached final path after a crash; only detached or
// still-hidden preparations are safe to discard automatically.
if (worktree.branch && pathOwnerPid === null) {
await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {})
} else if (pathOwnerPid === lockOwnerPid) {
await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {})
}
}
}
const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length)
await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation()))
})()
staleCleanupInFlight.set(cleanupKey, cleanup)
try {
await cleanup.catch(() => {})
} finally {
if (staleCleanupInFlight.get(cleanupKey) === cleanup) {
staleCleanupInFlight.delete(cleanupKey)
}
}
}
export async function prepareWorktreeCreateForRepo(
store: Store,
repo: Repo,
@@ -205,6 +145,16 @@ export async function prepareWorktreeCreateForRepo(
return existing.ready
}
return startPreparation(key, repo.path, workspaceRoot, baseBranch, options)
}
function startPreparation(
key: string,
repoPath: string,
workspaceRoot: string,
baseBranch: string,
options: AddWorktreeOptions
): Promise<void> {
enforcePreparationLimit()
const preparationId = `${process.pid}-${randomUUID()}`
const lockReason = createWorktreePreparationLockReason(preparationId)
@@ -218,21 +168,21 @@ export async function prepareWorktreeCreateForRepo(
expiration.unref()
Object.assign(entry, {
key,
repoPath: repo.path,
repoPath,
workspaceRoot,
preparedPath,
options,
createdAt: Date.now(),
expiration,
ready: (async () => {
await cleanupStalePreparations(repo.path, options)
await cleanupStalePreparations(preparationHostKey(repoPath, options), repoPath, options)
await mkdir(
toHostFilesystemPath(
pathOps(workspaceRoot).join(workspaceRoot, WORKTREE_CREATE_PREPARATION_DIRECTORY)
),
{ recursive: true }
)
await prepareWorktreeCreateCheckout(repo.path, preparedPath, baseBranch, lockReason, options)
await prepareWorktreeCreateCheckout(repoPath, preparedPath, baseBranch, lockReason, options)
})()
} satisfies PreparationEntry)
preparations.set(key, entry)
@@ -266,6 +216,28 @@ async function claimPreparedWorktree(
}
}
/** Replaces a just-consumed preparation, but only once the user has shown they are creating in a
* burst. A replacement costs a full checkout and ~5 minutes of disk until its TTL, so arming one
* after an isolated create spends that on nobody. Never awaited: create has already returned by
* the time the replacement checkout finishes. */
function rearmPreparation(entry: PreparationEntry, baseBranch: string): void {
// Record first: a prefetch that re-armed this key while we finalized would otherwise swallow the
// consume, and the next create would look isolated when it is really the middle of a burst.
const continuesBurst = recordPreparationConsume(entry.key)
if (preparations.has(entry.key) || !continuesBurst) {
return
}
void startPreparation(
entry.key,
entry.repoPath,
entry.workspaceRoot,
baseBranch,
entry.options
).catch(() => {
// Why: a warm-up failure is recovered by the normal add on the next create.
})
}
export async function consumePreparedWorktreeCreate(
args: ConsumePreparedWorktreeArgs
): Promise<AddWorktreeResult | null> {
@@ -283,7 +255,7 @@ export async function consumePreparedWorktreeCreate(
await mkdir(toHostFilesystemPath(pathOps(args.worktreePath).dirname(args.worktreePath)), {
recursive: true
})
return await finalizePreparedWorktree(
const result = await finalizePreparedWorktree(
args.repoPath,
entry.preparedPath,
args.worktreePath,
@@ -292,6 +264,10 @@ export async function consumePreparedWorktreeCreate(
args.refreshLocalBaseRef,
options
)
// Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is
// creating in a burst; the TTL and the preparation limit still bound an unused replacement.
rearmPreparation(entry, args.baseBranch)
return result
} catch (error) {
await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {})
console.warn(
@@ -305,7 +281,8 @@ export async function consumePreparedWorktreeCreate(
export async function _resetWorktreeCreatePreparationsForTests(): Promise<void> {
const entries = [...preparations.values()]
preparations.clear()
staleCleanupInFlight.clear()
resetPreparationConsumeHistoryForTests()
resetStalePreparationCleanupForTests()
await Promise.all(
entries.map(async (entry) => {
clearTimeout(entry.expiration)
+20 -6
View File
@@ -5,12 +5,26 @@ import { tmpdir } from 'node:os'
import type { PersistedState } from '../shared/persisted-state-types'
import { canonicalWorktreeIdentity } from '../shared/worktree/identity'
import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity'
import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness'
import type { Store } from './persistence/loading-store/store'
import {
createStore,
makeRepo,
readDataFile,
testState,
writeDataFile
} from './persistence-test-harness'
describe('host-qualified worktree metadata', () => {
const worktreeId = 'repo-1::/workspace/feature'
const ROTATED_INSTANCE_ID = '44444444-4444-4444-8444-444444444444'
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
const createStoreWithRepo = (): Store => {
const store = createStore()
store.addRepo(makeRepo({ id: 'repo-1', path: '/workspace' }))
return store
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-identity-'))
})
@@ -52,7 +66,7 @@ describe('host-qualified worktree metadata', () => {
})
})
it('reloads host-specific metadata without collapsing it to the legacy locator', () => {
const store = createStore()
const store = createStoreWithRepo()
store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' })
store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' })
store.flush()
@@ -72,7 +86,7 @@ describe('host-qualified worktree metadata', () => {
expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after')
})
it('backfills one stable instance for legacy metadata that omitted it', () => {
const seed = createStore()
const seed = createStoreWithRepo()
seed.setWorktreeMeta(worktreeId, { displayName: 'Legacy feature' })
seed.flush()
const legacy = readDataFile() as PersistedState
@@ -97,7 +111,7 @@ describe('host-qualified worktree metadata', () => {
// Fails open on purpose: an ambiguous alias used to brick reads and throw out of the worktree
// listing loop, taking every workspace in the repo down with it and never self-healing.
it('collapses an ambiguous locator onto its most recently active instance', () => {
const seed = createStore()
const seed = createStoreWithRepo()
const first = seed.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'First' })
seed.flush()
const persisted = readDataFile() as PersistedState
@@ -262,7 +276,7 @@ describe('host-qualified worktree metadata', () => {
it('repairs a missing canonical instance id while re-adopting an SSH target', () => {
const oldHostId = 'ssh:old-target' as const
const newHostId = 'ssh:new-target' as const
const seed = createStore()
const seed = createStoreWithRepo()
seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' })
seed.flush()
const persisted = readDataFile() as PersistedState
@@ -318,7 +332,7 @@ describe('host-qualified worktree metadata', () => {
it('deduplicates an equivalent destination during SSH target re-adoption', () => {
const oldHostId = 'ssh:old-target' as const
const newHostId = 'ssh:new-target' as const
const seed = createStore()
const seed = createStoreWithRepo()
seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' })
seed.flush()
const persisted = readDataFile() as PersistedState
@@ -16,7 +16,7 @@
*/
import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { getShellLaunchConfig } from './providers/local-pty-shell-ready'
import { selectShellStartupFeatures } from './shell-startup-features'
@@ -382,9 +382,12 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui
// value this wrapper cannot use degrades to $HOME, where zsh itself looks.
const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' })
try {
// Unique per run: a fixed name here shares one path with every other run in
// the system temp dir, so a killed run leaves a stale directory behind and
// every later rename onto it fails with ENOTEMPTY.
const { values } = await runFromRelocatedRoot(
home,
join(dirname(userDataPath), '홍길동-wsl-view')
join(dirname(userDataPath), `홍길동-${basename(userDataPath)}`)
)
expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1')
+2 -1
View File
@@ -6,6 +6,7 @@ import type {
} from '../../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent'
import type { PreloadApi } from '../api-types'
export const agentStatusApi = {
/** Listen for agent status updates forwarded from native hook receivers. */
@@ -85,4 +86,4 @@ export const agentStatusApi = {
}): void => {
ipcRenderer.send('agentStatus:transferPaneAuthority', args)
}
}
} satisfies PreloadApi['agentStatus']
+2 -1
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const agentTrustApi = {
markTrusted: (args: {
@@ -6,4 +7,4 @@ export const agentTrustApi = {
workspacePath: string
connectionId?: string
}): Promise<void> => ipcRenderer.invoke('agentTrust:markTrusted', args)
}
} satisfies PreloadApi['agentTrust']
+7 -7
View File
@@ -10,19 +10,19 @@ import type {
} from '../../shared/ai-vault-types'
import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault-session-title'
import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation'
import type { PreloadApi } from '../api-types'
export const aiVaultApi = {
listSessions: (args?: AiVaultListArgs): Promise<unknown> =>
ipcRenderer.invoke('aiVault:listSessions', args),
resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise<unknown> =>
listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args),
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) =>
ipcRenderer.invoke('aiVault:resolveSessionTitles', args),
cancelListSessions: (args: { requestToken: string }): Promise<void> =>
ipcRenderer.invoke('aiVault:cancelListSessions', args),
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise<unknown> =>
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) =>
ipcRenderer.invoke('aiVault:prepareSessionResume', args),
listSubagentSessions: (args: AiVaultSubagentListArgs): Promise<unknown> =>
listSubagentSessions: (args: AiVaultSubagentListArgs) =>
ipcRenderer.invoke('aiVault:listSubagentSessions', args),
getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise<unknown> =>
getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) =>
ipcRenderer.invoke('aiVault:getFirstUserPrompt', args),
deleteSession: (args: AiVaultDeleteSessionArgs): Promise<AiVaultDeleteSessionResult> =>
ipcRenderer.invoke('aiVault:deleteSession', args),
@@ -31,4 +31,4 @@ export const aiVaultApi = {
ipcRenderer.on('aiVault:windowFocused', listener)
return () => ipcRenderer.removeListener('aiVault:windowFocused', listener)
}
}
} satisfies PreloadApi['aiVault']
+2 -1
View File
@@ -40,6 +40,7 @@ export const appApi = {
throw new Error('Failed to stage renderer state before unload.')
}
},
awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(),
awaitFirstWindowStartupServices: (): Promise<void> =>
ipcRenderer.invoke('app:awaitFirstWindowStartupServices'),
prepareTerminalStartupRestoration: (): Promise<void> =>
@@ -73,4 +74,4 @@ export const appApi = {
ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'),
writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) =>
ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args)
}
} satisfies PreloadApi['app']
+2 -2
View File
@@ -1,5 +1,5 @@
import { ipcRenderer } from 'electron'
import type { ExternalAutomationManagerResult } from '../api-types'
import type { ExternalAutomationManagerResult, PreloadApi } from '../api-types'
import type {
AutomationDispatchRequest,
AutomationDispatchResult,
@@ -56,4 +56,4 @@ export const automationsApi = {
ipcRenderer.on('automations:changed', listener)
return () => ipcRenderer.removeListener('automations:changed', listener)
}
}
} satisfies PreloadApi['automations']
+3 -2
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const bitbucketApi = {
connect: (args: {
@@ -12,5 +13,5 @@ export const bitbucketApi = {
disconnect: (): Promise<void> => ipcRenderer.invoke('bitbucket:disconnect'),
status: (): Promise<unknown> => ipcRenderer.invoke('bitbucket:status')
}
status: () => ipcRenderer.invoke('bitbucket:status')
} satisfies PreloadApi['bitbucket']
@@ -6,6 +6,7 @@ import type {
} from '../../shared/browser-webauthn-account'
import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument'
import { browserClientPageRendererRequests } from '../preload-runtime-support'
import type { PreloadApi } from '../api-types'
export const browserGuestRegistrationAndDownloadsApi = {
onClientPageRendererRequest: browserClientPageRendererRequests.subscribe,
@@ -194,4 +195,4 @@ export const browserGuestRegistrationAndDownloadsApi = {
ipcRenderer.on('browser:download-finished', listener)
return () => ipcRenderer.removeListener('browser:download-finished', listener)
}
}
} satisfies Partial<PreloadApi['browser']>
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const browserPageInteractionAndSessionsApi = {
onContextMenuRequested: (
@@ -81,23 +82,17 @@ export const browserPageInteractionAndSessionsApi = {
},
cancelDownload: (args: { downloadId: string }): Promise<boolean> =>
ipcRenderer.invoke('browser:cancelDownload', args),
setGrabMode: (args: {
browserPageId: string
enabled: boolean
}): Promise<{ ok: true } | { ok: false; reason: string }> =>
setGrabMode: (args: { browserPageId: string; enabled: boolean }) =>
ipcRenderer.invoke('browser:setGrabMode', args),
awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise<unknown> =>
awaitGrabSelection: (args: { browserPageId: string; opId: string }) =>
ipcRenderer.invoke('browser:awaitGrabSelection', args),
cancelGrab: (args: { browserPageId: string }): Promise<boolean> =>
ipcRenderer.invoke('browser:cancelGrab', args),
captureSelectionScreenshot: (args: {
browserPageId: string
rect: { x: number; y: number; width: number; height: number }
}): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> =>
ipcRenderer.invoke('browser:captureSelectionScreenshot', args),
extractHoverPayload: (args: {
browserPageId: string
}): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> =>
}) => ipcRenderer.invoke('browser:captureSelectionScreenshot', args),
extractHoverPayload: (args: { browserPageId: string }) =>
ipcRenderer.invoke('browser:extractHoverPayload', args),
onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) =>
@@ -115,7 +110,7 @@ export const browserPageInteractionAndSessionsApi = {
ipcRenderer.on('browser:grabActionShortcut', listener)
return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener)
},
sessionListProfiles: (): Promise<unknown[]> => ipcRenderer.invoke('browser:session:listProfiles'),
sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'),
prepareSshWorkspacePartition: (args: {
targetId: string
browserProfileId?: string
@@ -126,40 +121,28 @@ export const browserPageInteractionAndSessionsApi = {
scope: 'default' | 'isolated' | 'imported'
label: string
userAgentMode?: 'clean' | 'native'
}): Promise<unknown> => ipcRenderer.invoke('browser:session:createProfile', args),
}) => ipcRenderer.invoke('browser:session:createProfile', args),
sessionDeleteProfile: (args: { profileId: string }): Promise<boolean> =>
ipcRenderer.invoke('browser:session:deleteProfile', args),
sessionImportCookies: (args: {
profileId: string
}): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> =>
sessionImportCookies: (args: { profileId: string }) =>
ipcRenderer.invoke('browser:session:importCookies', args),
sessionResolvePartition: (args: { profileId: string | null }): Promise<string | null> =>
ipcRenderer.invoke('browser:session:resolvePartition', args),
sessionDetectBrowsers: (): Promise<unknown[]> =>
ipcRenderer.invoke('browser:session:detectBrowsers'),
sessionDetectBrowsersForClientHost: (args: {
environmentId: string
}): Promise<unknown[] | null> =>
sessionDetectBrowsers: () => ipcRenderer.invoke('browser:session:detectBrowsers'),
sessionDetectBrowsersForClientHost: (args: { environmentId: string }) =>
ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args),
sessionImportFromBrowser: (args: {
profileId: string
browserFamily: string
}): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> =>
sessionImportFromBrowser: (args: { profileId: string; browserFamily: string }) =>
ipcRenderer.invoke('browser:session:importFromBrowser', args),
sessionImportFromBrowserForClientHost: (args: {
environmentId: string
profileId: string
browserFamily: string
browserProfile?: string
}): Promise<
{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } | null
> => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args),
sessionClientRouteImportSources: (args: {
environmentId: string
}): Promise<Record<string, unknown>> =>
}) => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args),
sessionClientRouteImportSources: (args: { environmentId: string }) =>
ipcRenderer.invoke('browser:session:clientRouteImportSources', args),
sessionClearDefaultCookies: (): Promise<boolean> =>
ipcRenderer.invoke('browser:session:clearDefaultCookies'),
notifyActiveTabChanged: (args: { browserPageId: string }): Promise<boolean> =>
ipcRenderer.invoke('browser:activeTabChanged', args)
}
} satisfies Partial<PreloadApi['browser']>
+2 -1
View File
@@ -1,7 +1,8 @@
import { browserGuestRegistrationAndDownloadsApi } from './browser-bridge-guest-registration-and-downloads'
import { browserPageInteractionAndSessionsApi } from './browser-bridge-page-interaction-and-sessions'
import type { PreloadApi } from '../api-types'
export const browserApi = {
...browserGuestRegistrationAndDownloadsApi,
...browserPageInteractionAndSessionsApi
}
} satisfies PreloadApi['browser']
+7 -7
View File
@@ -1,18 +1,18 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const claudeAccountsApi = {
list: (): Promise<unknown> => ipcRenderer.invoke('claudeAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise<unknown> =>
list: () => ipcRenderer.invoke('claudeAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) =>
ipcRenderer.invoke('claudeAccounts:add', args),
cancelPendingLogin: (): Promise<boolean> =>
ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'),
reauthenticate: (args: { accountId: string }): Promise<unknown> =>
reauthenticate: (args: { accountId: string }) =>
ipcRenderer.invoke('claudeAccounts:reauthenticate', args),
remove: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('claudeAccounts:remove', args),
remove: (args: { accountId: string }) => ipcRenderer.invoke('claudeAccounts:remove', args),
select: (args: {
accountId: string | null
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}): Promise<unknown> => ipcRenderer.invoke('claudeAccounts:select', args)
}
}) => ipcRenderer.invoke('claudeAccounts:select', args)
} satisfies PreloadApi['claudeAccounts']
+5 -1
View File
@@ -1,4 +1,8 @@
import { ipcRenderer } from 'electron'
import { createUsageProviderApi } from '../usage-provider-api'
import type { PreloadApi } from '../api-types'
export const claudeUsageApi = createUsageProviderApi(ipcRenderer, 'claudeUsage')
export const claudeUsageApi = createUsageProviderApi(
ipcRenderer,
'claudeUsage'
) satisfies PreloadApi['claudeUsage']
+2 -1
View File
@@ -1,5 +1,6 @@
import { ipcRenderer } from 'electron'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import type { PreloadApi } from '../api-types'
export const cliApi = {
getInstallStatus: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getInstallStatus'),
@@ -11,4 +12,4 @@ export const cliApi = {
ipcRenderer.invoke('cli:installWsl', args),
removeWsl: (args?: { distro?: string | null }): Promise<CliInstallStatus> =>
ipcRenderer.invoke('cli:removeWsl', args)
}
} satisfies PreloadApi['cli']
+8 -10
View File
@@ -1,20 +1,18 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const codexAccountsApi = {
list: (): Promise<unknown> => ipcRenderer.invoke('codexAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise<unknown> =>
list: () => ipcRenderer.invoke('codexAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) =>
ipcRenderer.invoke('codexAccounts:add', args),
reauthenticate: (args: {
accountId: string
activateIfSelectionWasEmpty?: boolean
}): Promise<unknown> => ipcRenderer.invoke('codexAccounts:reauthenticate', args),
remove: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('codexAccounts:remove', args),
reauthenticate: (args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) =>
ipcRenderer.invoke('codexAccounts:reauthenticate', args),
remove: (args: { accountId: string }) => ipcRenderer.invoke('codexAccounts:remove', args),
select: (args: {
accountId: string | null
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}): Promise<unknown> => ipcRenderer.invoke('codexAccounts:select', args),
}) => ipcRenderer.invoke('codexAccounts:select', args),
listStalePanes: (args: {
ptyIds: string[]
}): Promise<
@@ -29,4 +27,4 @@ export const codexAccountsApi = {
ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args),
forgetStalePanes: (args: { ptyIds: string[] }): Promise<void> =>
ipcRenderer.invoke('codexAccounts:forgetStalePanes', args)
}
} satisfies PreloadApi['codexAccounts']
+2 -1
View File
@@ -1,6 +1,7 @@
import { ipcRenderer } from 'electron'
import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types'
import type { PreloadApi } from '../api-types'
export const codexConfigSyncApi = {
status: (): Promise<CodexConfigSyncStatus> => ipcRenderer.invoke('codexConfigSync:status')
}
} satisfies PreloadApi['codexConfigSync']
+5 -1
View File
@@ -1,4 +1,8 @@
import { ipcRenderer } from 'electron'
import { createUsageProviderApi } from '../usage-provider-api'
import type { PreloadApi } from '../api-types'
export const codexUsageApi = createUsageProviderApi(ipcRenderer, 'codexUsage')
export const codexUsageApi = createUsageProviderApi(
ipcRenderer,
'codexUsage'
) satisfies PreloadApi['codexUsage']
@@ -1,8 +1,9 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const computerUsePermissionsApi = {
getStatus: (): Promise<unknown> => ipcRenderer.invoke('computerUsePermissions:getStatus'),
openSetup: (args?: { id?: string }): Promise<unknown> =>
getStatus: () => ipcRenderer.invoke('computerUsePermissions:getStatus'),
openSetup: (args?: { id?: string }) =>
ipcRenderer.invoke('computerUsePermissions:openSetup', args),
reset: (): Promise<unknown> => ipcRenderer.invoke('computerUsePermissions:reset')
}
reset: () => ipcRenderer.invoke('computerUsePermissions:reset')
} satisfies PreloadApi['computerUsePermissions']
+2 -1
View File
@@ -11,6 +11,7 @@ import type { RendererHeapStatistics } from '../../shared/renderer-heap-statisti
import type { RendererProcessMemory } from '../../shared/renderer-process-memory'
import { readRendererHeapStatistics } from '../renderer-heap-statistics-reader'
import { readRendererProcessMemory } from '../renderer-process-memory-reader'
import type { PreloadApi } from '../api-types'
export const crashReportsApi = {
getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'),
@@ -28,4 +29,4 @@ export const crashReportsApi = {
ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args),
readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(),
readProcessMemory: (): Promise<RendererProcessMemory | null> => readRendererProcessMemory()
}
} satisfies PreloadApi['crashReports']
+2 -1
View File
@@ -5,6 +5,7 @@ import type {
DashboardSnapshot,
DashboardSpawnAgentArgs
} from '../../shared/dashboard-snapshot'
import type { PreloadApi } from '../api-types'
export const dashboardApi = {
// Open the pop-out dashboard window, or focus it if already open.
@@ -71,4 +72,4 @@ export const dashboardApi = {
ipcRenderer.invoke('dashboardPopout:spawnAgent', args),
sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise<void> =>
ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args)
}
} satisfies PreloadApi['dashboard']
@@ -1,11 +1,11 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const developerPermissionsApi = {
getStatus: (): Promise<unknown> => ipcRenderer.invoke('developerPermissions:getStatus'),
request: (args: { id: string }): Promise<unknown> =>
ipcRenderer.invoke('developerPermissions:request', args),
getStatus: () => ipcRenderer.invoke('developerPermissions:getStatus'),
request: (args: { id: string }) => ipcRenderer.invoke('developerPermissions:request', args),
openSettings: (args: { id: string }): Promise<void> =>
ipcRenderer.invoke('developerPermissions:openSettings', args),
testLocalNetworkConnection: (args: { host: string; port: number }): Promise<unknown> =>
testLocalNetworkConnection: (args: { host: string; port: number }) =>
ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args)
}
} satisfies PreloadApi['developerPermissions']
+5 -4
View File
@@ -1,15 +1,16 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const diagnosticsApi = {
getStatus: (): Promise<unknown> => ipcRenderer.invoke('diagnostics:getStatus'),
collectBundle: (lookbackMinutes?: number): Promise<unknown> =>
getStatus: () => ipcRenderer.invoke('diagnostics:getStatus'),
collectBundle: (lookbackMinutes?: number) =>
ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes),
openBundlePreview: (bundleSubmissionId: string): Promise<void> =>
ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId),
discardBundlePreview: (bundleSubmissionId: string): Promise<void> =>
ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId),
uploadBundle: (bundleSubmissionId: string): Promise<unknown> =>
uploadBundle: (bundleSubmissionId: string) =>
ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId),
deleteBundle: (ticketId: string): Promise<void> =>
ipcRenderer.invoke('diagnostics:deleteBundle', ticketId)
}
} satisfies PreloadApi['diagnostics']
+2 -1
View File
@@ -8,6 +8,7 @@ import {
type DocPreviewFailure
} from '../../shared/doc-preview-scheme'
import type { DocPreviewGrantRequest } from '../api/doc-preview-api'
import type { PreloadApi } from '../api-types'
export const docPreviewApi = {
mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> =>
@@ -28,4 +29,4 @@ export const docPreviewApi = {
ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener)
return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener)
}
}
} satisfies PreloadApi['docPreview']
+2 -1
View File
@@ -1,5 +1,6 @@
import { preloadE2EConfig } from '../e2e-config'
import type { PreloadApi } from '../api-types'
export const e2eApi = {
getConfig: () => preloadE2EConfig
}
} satisfies PreloadApi['e2e']
+2 -1
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const emulatorApi = {
startFrameStream: (args: {
@@ -95,4 +96,4 @@ export const emulatorApi = {
ipcRenderer.on('ui:emulatorAutoAttach', listener)
return () => ipcRenderer.removeListener('ui:emulatorAutoAttach', listener)
}
}
} satisfies PreloadApi['emulator']
+2 -1
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const exportApi = {
htmlToPdf: (args: {
@@ -7,4 +8,4 @@ export const exportApi = {
}): Promise<
{ success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string }
> => ipcRenderer.invoke('export:html-to-pdf', args)
}
} satisfies PreloadApi['export']
+2 -1
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const feedbackApi = {
submit: (args: {
@@ -10,4 +11,4 @@ export const feedbackApi = {
}): Promise<
{ ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string }
> => ipcRenderer.invoke('feedback:submit', args)
}
} satisfies PreloadApi['feedback']
+2 -1
View File
@@ -8,6 +8,7 @@ import type {
LocalLogTailReadResult,
LocalLogTailWatchArgs
} from '../../shared/local-log-tail-types'
import type { PreloadApi } from '../api-types'
export const fsApi = {
readDir: (args: {
@@ -216,4 +217,4 @@ export const fsApi = {
ipcRenderer.on('fs:changed', listener)
return () => ipcRenderer.removeListener('fs:changed', listener)
}
}
} satisfies PreloadApi['fs']
@@ -35,11 +35,12 @@ import type {
UpdateProjectItemFieldArgs
} from '../../shared/github/project-request-types'
import type { AppStarSource } from '../../shared/gh-star-source'
import type { PreloadApi } from '../api-types'
export const ghMutationsAndProjectsApi = {
setPRAutoMerge: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
enabled: boolean
@@ -49,7 +50,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:setPRAutoMerge', args),
updatePRState: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
updates: { state: 'open' | 'closed' }
@@ -58,7 +59,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:updatePRState', args),
markPRReadyForReview: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
@@ -66,7 +67,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:markPRReadyForReview', args),
requestPRReviewers: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
reviewers: string[]
@@ -75,7 +76,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:requestPRReviewers', args),
removePRReviewers: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
reviewers: string[]
@@ -84,7 +85,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:removePRReviewers', args),
updateIssue: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
number: number
updates: unknown
@@ -92,7 +93,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:updateIssue', args),
addIssueComment: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
number: number
body: string
@@ -101,7 +102,7 @@ export const ghMutationsAndProjectsApi = {
}): Promise<GitHubCommentResult> => ipcRenderer.invoke('gh:addIssueComment', args),
addPRReviewCommentReply: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
commentId: number
@@ -113,7 +114,7 @@ export const ghMutationsAndProjectsApi = {
}): Promise<GitHubCommentResult> => ipcRenderer.invoke('gh:addPRReviewCommentReply', args),
addPRReviewComment: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
@@ -125,12 +126,12 @@ export const ghMutationsAndProjectsApi = {
}): Promise<GitHubCommentResult> => ipcRenderer.invoke('gh:addPRReviewComment', args),
listLabels: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}): Promise<string[]> => ipcRenderer.invoke('gh:listLabels', args),
listAssignableUsers: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}): Promise<GitHubAssignableUser[]> => ipcRenderer.invoke('gh:listAssignableUsers', args),
onWorkItemMutated: (
@@ -199,4 +200,4 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:listIssueTypesBySlug', args),
updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs): Promise<GitHubProjectMutationResult> =>
ipcRenderer.invoke('gh:updateIssueTypeBySlug', args)
}
} satisfies Partial<PreloadApi['gh']>
@@ -9,33 +9,34 @@ import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types'
import type { GitHubCreateIssueResult } from '../../shared/issue-mutation-types'
import type { TaskSourceContext } from '../../shared/task-source-context'
import type { PreloadApi } from '../api-types'
export const ghPullRequestsAndWorkItemsApi = {
viewer: (): Promise<unknown> => ipcRenderer.invoke('gh:viewer'),
repoSlug: (args: { repoPath: string; repoId?: string }): Promise<unknown> =>
viewer: () => ipcRenderer.invoke('gh:viewer'),
repoSlug: (args: { repoPath: string; repoId?: string }) =>
ipcRenderer.invoke('gh:repoSlug', args),
repoUpstream: (args: { repoPath: string; repoId?: string }): Promise<unknown> =>
repoUpstream: (args: { repoPath: string; repoId?: string }) =>
ipcRenderer.invoke('gh:repoUpstream', args),
prForBranch: (args: {
repoPath: string
repoId?: string
repoId?: string | null
branch: string
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
acceptMergedFallbackPR?: boolean
currentHeadOid?: string | null
}): Promise<unknown> => ipcRenderer.invoke('gh:prForBranch', args),
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise<unknown> =>
}) => ipcRenderer.invoke('gh:prForBranch', args),
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) =>
ipcRenderer.invoke('gh:refreshPRNow', args),
enqueuePRRefresh: (args: {
candidate: GitHubPRRefreshCandidate
reason: GitHubPRRefreshReason
priority?: number
}): Promise<unknown> => ipcRenderer.invoke('gh:enqueuePRRefresh', args),
}) => ipcRenderer.invoke('gh:enqueuePRRefresh', args),
reportVisiblePRRefreshCandidates: (args: {
candidates: GitHubPRRefreshCandidate[]
generation: number
}): Promise<unknown> => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args),
}) => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args),
onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void =>
callback(event)
@@ -44,42 +45,42 @@ export const ghPullRequestsAndWorkItemsApi = {
},
issue: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
number: number
}): Promise<unknown> => ipcRenderer.invoke('gh:issue', args),
}) => ipcRenderer.invoke('gh:issue', args),
workItem: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
number: number
type?: 'issue' | 'pr'
}): Promise<unknown> => ipcRenderer.invoke('gh:workItem', args),
}) => ipcRenderer.invoke('gh:workItem', args),
workItemByOwnerRepo: (args: {
repoPath: string
repoId?: string
repoId?: string | null
owner: string
repo: string
host?: string
number: number
type: 'issue' | 'pr'
}): Promise<unknown> => ipcRenderer.invoke('gh:workItemByOwnerRepo', args),
}) => ipcRenderer.invoke('gh:workItemByOwnerRepo', args),
workItemDetails: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
number: number
type?: 'issue' | 'pr'
}): Promise<unknown> => ipcRenderer.invoke('gh:workItemDetails', args),
}) => ipcRenderer.invoke('gh:workItemDetails', args),
notifyWorkItemMutated: (args: {
repoPath: string
repoId?: string
repoId?: string | null
type: 'issue' | 'pr'
number: number
}): Promise<boolean> => ipcRenderer.invoke('gh:notifyWorkItemMutated', args),
prFileContents: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
@@ -88,12 +89,12 @@ export const ghPullRequestsAndWorkItemsApi = {
status: string
headSha: string
baseSha: string
}): Promise<unknown> => ipcRenderer.invoke('gh:prFileContents', args),
listIssues: (args: { repoPath: string; repoId?: string; limit?: number }): Promise<unknown[]> =>
}) => ipcRenderer.invoke('gh:prFileContents', args),
listIssues: (args: { repoPath: string; repoId?: string; limit?: number }) =>
ipcRenderer.invoke('gh:listIssues', args),
createIssue: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
title: string
body: string
@@ -104,7 +105,7 @@ export const ghPullRequestsAndWorkItemsApi = {
ipcRenderer.invoke('gh:countWorkItems', args),
listWorkItems: (args: {
repoPath: string
repoId?: string
repoId?: string | null
limit?: number
query?: string
page?: number
@@ -113,26 +114,26 @@ export const ghPullRequestsAndWorkItemsApi = {
ipcRenderer.invoke('gh:listWorkItems', args),
prChecks: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
headSha?: string
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}): Promise<unknown[]> => ipcRenderer.invoke('gh:prChecks', args),
}) => ipcRenderer.invoke('gh:prChecks', args),
prCheckDetails: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: GitHubOwnerRepo | null
}): Promise<unknown> => ipcRenderer.invoke('gh:prCheckDetails', args),
}) => ipcRenderer.invoke('gh:prCheckDetails', args),
rerunPRChecks: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
headSha?: string
@@ -142,15 +143,15 @@ export const ghPullRequestsAndWorkItemsApi = {
ipcRenderer.invoke('gh:rerunPRChecks', args),
prComments: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}): Promise<unknown[]> => ipcRenderer.invoke('gh:prComments', args),
}) => ipcRenderer.invoke('gh:prComments', args),
setPRCommentReaction: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
reactionSubjectId: string
content: GitHubReactionContent
@@ -159,7 +160,7 @@ export const ghPullRequestsAndWorkItemsApi = {
}): Promise<boolean> => ipcRenderer.invoke('gh:setPRCommentReaction', args),
resolveReviewThread: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
threadId: string
resolve: boolean
@@ -167,7 +168,7 @@ export const ghPullRequestsAndWorkItemsApi = {
}): Promise<boolean> => ipcRenderer.invoke('gh:resolveReviewThread', args),
setPRFileViewed: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
@@ -177,17 +178,17 @@ export const ghPullRequestsAndWorkItemsApi = {
}): Promise<boolean> => ipcRenderer.invoke('gh:setPRFileViewed', args),
updatePRTitle: (args: {
repoPath: string
repoId?: string
repoId?: string | null
prNumber: number
title: string
prRepo?: GitHubOwnerRepo | null
}): Promise<boolean> => ipcRenderer.invoke('gh:updatePRTitle', args),
mergePR: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
method?: 'merge' | 'squash' | 'rebase'
prRepo?: GitHubOwnerRepo | null
}): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args)
}
} satisfies Partial<PreloadApi['gh']>

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