mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge remote-tracking branch 'origin/main' into mobile-rearch
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
; Clean up the relocated terminal daemon on a REAL uninstall.
|
||||
;
|
||||
; Why: the daemon host is deliberately copied to a distinct image name
|
||||
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
|
||||
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
|
||||
; updates. The same design means a normal uninstall's process sweep and file
|
||||
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
|
||||
;
|
||||
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
|
||||
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
|
||||
; defeat the whole feature. Only clean up on a genuine uninstall.
|
||||
;
|
||||
; The image name and the LOCALAPPDATA folder name must stay in sync with
|
||||
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
|
||||
; src/main/daemon/daemon-host-relocation.ts.
|
||||
!macro customUnInstall
|
||||
${ifNot} ${isUpdated}
|
||||
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
|
||||
; Give the OS a moment to release the image lock before removing the tree.
|
||||
Sleep 500
|
||||
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
|
||||
${endIf}
|
||||
!macroend
|
||||
@@ -0,0 +1,79 @@
|
||||
; electron-builder NSIS hooks for the Orca Windows installer.
|
||||
;
|
||||
; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall /
|
||||
; customUnInstall hook Orca needs lives here.
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Markdown "Open with Orca" (issue #10138)
|
||||
;
|
||||
; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows:
|
||||
; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is
|
||||
; WriteRegStr SHELL_CONTEXT "Software\Classes\.md" "" "<ProgID>"
|
||||
; That overwrites whichever editor currently owns .md, with no backup, for every
|
||||
; existing user on their next UPDATE - and APP_UNASSOCIATE never restores it, so
|
||||
; uninstalling Orca would leave .md pointing at a deleted ProgID.
|
||||
;
|
||||
; These writes are additive only. Registering a ProgID plus an OpenWithProgids
|
||||
; hint and an Applications\<exe>\SupportedTypes entry puts Orca in Explorer's
|
||||
; "Open with" list and in "Choose another app", while the default handler stays
|
||||
; exactly where the user left it. Never add a `Software\Classes\.<ext>` default
|
||||
; value here.
|
||||
;
|
||||
; MARKDOWN_PROGID must stay in sync with the extension list handled by
|
||||
; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts.
|
||||
; ---------------------------------------------------------------------------
|
||||
!define MARKDOWN_PROGID "Orca.Markdown"
|
||||
|
||||
!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT
|
||||
WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" ""
|
||||
!macroend
|
||||
|
||||
!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT
|
||||
DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
|
||||
DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}"
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"'
|
||||
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md"
|
||||
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown"
|
||||
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx"
|
||||
; Why: Explorer caches the association list until told otherwise.
|
||||
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
|
||||
!macroend
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Clean up the relocated terminal daemon on a REAL uninstall.
|
||||
;
|
||||
; Why: the daemon host is deliberately copied to a distinct image name
|
||||
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
|
||||
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
|
||||
; updates. The same design means a normal uninstall's process sweep and file
|
||||
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
|
||||
;
|
||||
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
|
||||
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
|
||||
; defeat the whole feature. Only clean up on a genuine uninstall.
|
||||
;
|
||||
; The image name and the LOCALAPPDATA folder name must stay in sync with
|
||||
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
|
||||
; src/main/daemon/daemon-host-relocation.ts.
|
||||
!macro customUnInstall
|
||||
${ifNot} ${isUpdated}
|
||||
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
|
||||
; Give the OS a moment to release the image lock before removing the tree.
|
||||
Sleep 500
|
||||
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
|
||||
${endIf}
|
||||
; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so
|
||||
; dropping them during uninstallOldVersion is correct and keeps the pair symmetric.
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}"
|
||||
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md"
|
||||
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown"
|
||||
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx"
|
||||
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
|
||||
!macroend
|
||||
@@ -0,0 +1,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\}/)
|
||||
})
|
||||
})
|
||||
@@ -110,32 +110,60 @@ export function makeTreeReadOnly(targetPath, chmod = chmodSync) {
|
||||
chmod(targetPath, 0o755)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore owner write permission across a private copy.
|
||||
*
|
||||
* Counterpart to `makeTreeReadOnly`: clonefile, reflink and `cpSync` all carry the source's mode
|
||||
* across, so a tree copied from the write-protected shared cache lands read-only and every patch
|
||||
* the caller then makes -- `plutil -replace`, `codesign` -- fails with EACCES. Only the owner bit
|
||||
* comes back; group and other stay as the source left them.
|
||||
*/
|
||||
export function makeTreeWritable(targetPath, chmod = chmodSync) {
|
||||
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
|
||||
const entryPath = join(targetPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
makeTreeWritable(entryPath, chmod)
|
||||
} else if (!entry.isSymbolicLink()) {
|
||||
const mode = statSync(entryPath, { throwIfNoEntry: false })?.mode
|
||||
chmod(entryPath, mode === undefined ? 0o644 : mode | 0o200)
|
||||
}
|
||||
}
|
||||
chmod(targetPath, 0o755)
|
||||
}
|
||||
|
||||
/**
|
||||
* Share storage when possible, otherwise copy the bytes.
|
||||
*
|
||||
* Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write
|
||||
* through into the source.
|
||||
* through into the source. The copy is unprotected on the way out for the same reason -- a private
|
||||
* tree the caller cannot write to is useless to it.
|
||||
*/
|
||||
export function copyPrivateTree(sourcePath, destinationPath, options = {}) {
|
||||
const platform = options.platform ?? process.platform
|
||||
const copy = options.copy ?? copyTreeVerbatim
|
||||
const unprotect = options.unprotect ?? makeTreeWritable
|
||||
const privateMechanisms = new Set(['clone', 'reflink'])
|
||||
let result = { mechanism: null, copyError: null }
|
||||
if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) {
|
||||
try {
|
||||
const mechanism = shareTree(sourcePath, destinationPath, {
|
||||
...options,
|
||||
hardlink: () => {
|
||||
throw new Error('hardlinks would not be private')
|
||||
}
|
||||
})
|
||||
return { mechanism, copyError: null }
|
||||
result = {
|
||||
mechanism: shareTree(sourcePath, destinationPath, {
|
||||
...options,
|
||||
hardlink: () => {
|
||||
throw new Error('hardlinks would not be private')
|
||||
}
|
||||
}),
|
||||
copyError: null
|
||||
}
|
||||
} catch (copyError) {
|
||||
copy(sourcePath, destinationPath)
|
||||
return { mechanism: null, copyError }
|
||||
result = { mechanism: null, copyError }
|
||||
}
|
||||
} else {
|
||||
copy(sourcePath, destinationPath)
|
||||
}
|
||||
copy(sourcePath, destinationPath)
|
||||
return { mechanism: null, copyError: null }
|
||||
unprotect(destinationPath)
|
||||
return result
|
||||
}
|
||||
|
||||
function copyTreeVerbatim(sourcePath, destinationPath) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
copyPrivateTree,
|
||||
hardlinkTree,
|
||||
makeTreeReadOnly,
|
||||
makeTreeWritable,
|
||||
shareTree
|
||||
} from './space-sharing-copy.mjs'
|
||||
|
||||
@@ -170,7 +171,41 @@ describe('makeTreeReadOnly', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('makeTreeWritable', () => {
|
||||
it.runIf(process.platform !== 'win32')('undoes makeTreeReadOnly for the owner', () => {
|
||||
const { source } = makeTree()
|
||||
makeTreeReadOnly(source)
|
||||
makeTreeWritable(source)
|
||||
const file = path.join(source, 'nested', 'file')
|
||||
expect(statSync(file).mode & 0o200).toBe(0o200)
|
||||
expect(() => writeFileSync(file, 'mutated')).not.toThrow()
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')('adds no write permission beyond the owner', () => {
|
||||
const { source } = makeTree()
|
||||
const executable = path.join(source, 'electron')
|
||||
writeFileSync(executable, 'binary')
|
||||
chmodSync(executable, 0o555)
|
||||
makeTreeWritable(source)
|
||||
expect(statSync(executable).mode & 0o777).toBe(0o755)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyPrivateTree', () => {
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'hands back a tree the caller can patch, even from a write-protected source',
|
||||
() => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'private')
|
||||
makeTreeReadOnly(source)
|
||||
copyPrivateTree(source, destination)
|
||||
// The regression this guards: the shared Electron dist is read-only, clonefile/reflink/cpSync
|
||||
// all carry that across, and `pn dev` then died patching the copied bundle's Info.plist.
|
||||
expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'patched')).not.toThrow()
|
||||
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents')
|
||||
}
|
||||
)
|
||||
|
||||
it('never hardlinks, because the caller patches what it gets back', () => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'private')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -134,3 +134,123 @@ describe('getBranchConflictKindViaExec', () => {
|
||||
expect(exec).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBranchConflictKindViaExec batched remote probe', () => {
|
||||
function remoteNames(count: number): string {
|
||||
return `${Array.from({ length: count }, (_, index) => `remote${index}`).join('\n')}\n`
|
||||
}
|
||||
|
||||
function baseExec(calls: string[][]): (argv: string[]) => Promise<{ stdout: string }> {
|
||||
return async (argv) => {
|
||||
calls.push(argv)
|
||||
if (argv[0] === 'rev-parse') {
|
||||
throw new Error('local branch is absent')
|
||||
}
|
||||
if (argv[0] === 'remote') {
|
||||
return { stdout: remoteNames(3) }
|
||||
}
|
||||
throw new Error(`unexpected git command: ${argv.join(' ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
it('asks one batched child instead of one probe per remote', async () => {
|
||||
const calls: string[][] = []
|
||||
const stdinPayloads: (string | undefined)[] = []
|
||||
const exec = baseExec(calls)
|
||||
const batched = async (
|
||||
argv: string[],
|
||||
options: { stdin: string }
|
||||
): Promise<{ stdout: string }> => {
|
||||
calls.push(argv)
|
||||
stdinPayloads.push(options.stdin)
|
||||
return {
|
||||
stdout: [
|
||||
'refs/remotes/remote0/feature missing',
|
||||
'refs/remotes/remote1/feature missing',
|
||||
'refs/remotes/remote2/feature missing'
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
await expect(
|
||||
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
|
||||
).resolves.toBeNull()
|
||||
expect(calls).toEqual([
|
||||
['rev-parse', '--verify', 'refs/heads/feature'],
|
||||
['remote'],
|
||||
['cat-file', '--batch-check']
|
||||
])
|
||||
expect(stdinPayloads).toEqual([
|
||||
'refs/remotes/remote0/feature\nrefs/remotes/remote1/feature\nrefs/remotes/remote2/feature\n'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a remote conflict from the batched answer', async () => {
|
||||
const calls: string[][] = []
|
||||
const exec = baseExec(calls)
|
||||
const batched = async (): Promise<{ stdout: string }> => ({
|
||||
stdout: [
|
||||
'refs/remotes/remote0/feature missing',
|
||||
`${'a'.repeat(40)} commit 214`,
|
||||
'refs/remotes/remote2/feature missing'
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
await expect(
|
||||
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
|
||||
).resolves.toBe('remote')
|
||||
})
|
||||
|
||||
it('falls back to per-ref probes when the batch cannot answer', async () => {
|
||||
const calls: string[][] = []
|
||||
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
|
||||
calls.push(argv)
|
||||
if (argv[0] === 'rev-parse') {
|
||||
throw new Error('local branch is absent')
|
||||
}
|
||||
if (argv[0] === 'remote') {
|
||||
return { stdout: remoteNames(3) }
|
||||
}
|
||||
if (argv[0] === 'show-ref') {
|
||||
if (argv[4] === 'refs/remotes/remote1/feature') {
|
||||
return { stdout: 'abc refs/remotes/remote1/feature\n' }
|
||||
}
|
||||
throw Object.assign(new Error('missing'), { code: 1, stderr: '' })
|
||||
}
|
||||
throw new Error(`unexpected git command: ${argv.join(' ')}`)
|
||||
}
|
||||
const batched = async (): Promise<{ stdout: string }> => {
|
||||
throw new Error('cat-file is unavailable')
|
||||
}
|
||||
|
||||
await expect(
|
||||
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
|
||||
).resolves.toBe('remote')
|
||||
expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('treats a short batch read as undecided rather than as absence', async () => {
|
||||
const calls: string[][] = []
|
||||
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
|
||||
calls.push(argv)
|
||||
if (argv[0] === 'rev-parse') {
|
||||
throw new Error('local branch is absent')
|
||||
}
|
||||
if (argv[0] === 'remote') {
|
||||
return { stdout: remoteNames(3) }
|
||||
}
|
||||
if (argv[0] === 'show-ref') {
|
||||
throw Object.assign(new Error('missing'), { code: 1, stderr: '' })
|
||||
}
|
||||
throw new Error(`unexpected git command: ${argv.join(' ')}`)
|
||||
}
|
||||
const batched = async (): Promise<{ stdout: string }> => ({
|
||||
stdout: 'refs/remotes/remote0/feature missing'
|
||||
})
|
||||
|
||||
await expect(
|
||||
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
|
||||
).resolves.toBeNull()
|
||||
expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,8 +4,10 @@ import { gitExecFileAsync } from './runner'
|
||||
import { isSafeGitRefName } from '../../shared/git-status-upstream-ref'
|
||||
import {
|
||||
probeAnyExactRef,
|
||||
probeAnyExactRefBatched,
|
||||
type ExactRefProbeExec,
|
||||
type ExactRefProbeExecOptions
|
||||
type ExactRefProbeExecOptions,
|
||||
type ExactRefProbeStdinExec
|
||||
} from './exact-ref-probe'
|
||||
|
||||
export type BranchConflictKind = 'local' | 'remote'
|
||||
@@ -79,12 +81,31 @@ function buildRemoteBranchConflictRefs(
|
||||
return [...refs]
|
||||
}
|
||||
|
||||
/** One batched child answers for every remote; the per-ref probes only run when the host cannot
|
||||
* feed stdin, or when the batch came back undecided. */
|
||||
async function probeAnyRemoteConflictRef(
|
||||
exec: ExactRefProbeExec,
|
||||
batchedExec: ExactRefProbeStdinExec | undefined,
|
||||
candidateRefs: readonly string[],
|
||||
probeOptions: ExactRefProbeExecOptions
|
||||
): Promise<{ found: boolean }> {
|
||||
if (batchedExec) {
|
||||
// A present ref is always decisive, so `found` never survives with `unknown` set.
|
||||
const batched = await probeAnyExactRefBatched(batchedExec, candidateRefs, probeOptions)
|
||||
if (!batched.unknown) {
|
||||
return { found: batched.found }
|
||||
}
|
||||
}
|
||||
return probeAnyExactRef(exec, candidateRefs, probeOptions)
|
||||
}
|
||||
|
||||
/** Run branch-conflict policy through the host that owns Git execution. */
|
||||
export async function getBranchConflictKindViaExec(
|
||||
exec: ExactRefProbeExec,
|
||||
branchName: string,
|
||||
allowedBaseRef?: string,
|
||||
options: ExactRefProbeExecOptions = {}
|
||||
options: ExactRefProbeExecOptions = {},
|
||||
batchedExec?: ExactRefProbeStdinExec
|
||||
): Promise<BranchConflictKind | null> {
|
||||
if (!canQueryRemoteBranchName(branchName)) {
|
||||
return null
|
||||
@@ -104,7 +125,12 @@ export async function getBranchConflictKindViaExec(
|
||||
return null
|
||||
}
|
||||
|
||||
const { found: hasRemoteConflict } = await probeAnyExactRef(exec, candidateRefs, probeOptions)
|
||||
const { found: hasRemoteConflict } = await probeAnyRemoteConflictRef(
|
||||
exec,
|
||||
batchedExec,
|
||||
candidateRefs,
|
||||
probeOptions
|
||||
)
|
||||
|
||||
return hasRemoteConflict ? 'remote' : null
|
||||
} catch {
|
||||
@@ -119,15 +145,22 @@ export function getBranchConflictKind(
|
||||
options: LocalGitExecOptions = {}
|
||||
): Promise<BranchConflictKind | null> {
|
||||
const execOptions = gitExecOptions(path, options)
|
||||
const runLocalGit = (
|
||||
argv: string[],
|
||||
commandOptions?: ExactRefProbeExecOptions & { stdin?: string }
|
||||
): Promise<{ stdout: string }> =>
|
||||
gitExecFileAsync(argv, {
|
||||
...execOptions,
|
||||
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
|
||||
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }),
|
||||
...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin })
|
||||
})
|
||||
return getBranchConflictKindViaExec(
|
||||
(argv, commandOptions) =>
|
||||
gitExecFileAsync(argv, {
|
||||
...execOptions,
|
||||
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
|
||||
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs })
|
||||
}),
|
||||
runLocalGit,
|
||||
branchName,
|
||||
allowedBaseRef
|
||||
allowedBaseRef,
|
||||
{},
|
||||
(argv, commandOptions) => runLocalGit(argv, commandOptions)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']>
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { preloadE2EConfig } from '../e2e-config'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
|
||||
export const e2eApi = {
|
||||
getConfig: () => preloadE2EConfig
|
||||
}
|
||||
} satisfies PreloadApi['e2e']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user