Merge origin/main into mobile-rearch

This commit is contained in:
Jinwoo-H
2026-08-30 19:34:41 -04:00
281 changed files with 11765 additions and 2420 deletions
+52 -3
View File
@@ -31,6 +31,7 @@ jobs:
static_analysis: ${{ steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.filter.outputs.typecheck }}
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
codex_index_heal_contract: ${{ steps.filter.outputs.codex_index_heal_contract }}
xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
test: ${{ steps.filter.outputs.test }}
@@ -296,6 +297,47 @@ jobs:
done
exit "$status"
# Why this job: Orca's session index-heal depends on a Codex behavior — a
# `thread/read` of an unindexed rollout performs a read-repair that inserts the
# `threads` row. Every unit test drives a stub app-server and asserts only that the
# call did not error, so if Codex dropped the repair they would all stay green while
# the subsystem went inert. This runs the pinned real binary and fails when the
# repair stops happening. Pinned because the binary is the thing expected to drift.
codex_index_heal_contract:
name: Codex index-heal contract
needs: [code_paths]
if: needs.code_paths.outputs.codex_index_heal_contract == 'true'
runs-on: ubuntu-latest
env:
CODEX_CLI_VERSION: '0.150.1'
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Install pinned Codex CLI
run: |
set -euo pipefail
npm install --no-audit --no-fund --prefix "$RUNNER_TEMP/codex-cli" \
"@openai/codex@$CODEX_CLI_VERSION"
- name: Verify Codex index-heal contract
env:
# Why REQUIRED: without a binary the suite skips, and a job that skips
# reports success. This turns a failed or missing install into a red test
# instead of a green no-op.
ORCA_CODEX_CONTRACT_REQUIRED: '1'
ORCA_CODEX_CONTRACT_VERSION: ${{ env.CODEX_CLI_VERSION }}
run: |
set -euo pipefail
ORCA_CODEX_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli/node_modules/.bin/codex" \
pnpm exec vitest run --config config/vitest.config.ts \
src/main/codex/codex-index-heal-binary-contract.test.ts
xterm_patch_sync:
name: xterm patch sync
needs: [code_paths]
@@ -310,9 +352,12 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
# Why: the check rebuilds xterm.js from a pinned upstream commit. Caching the
# npm metadata and the shallow clone turns a ~4 min cold run into well under a
# minute; the key is the manifest, so a commit or toolchain bump invalidates it.
# Why: the check rebuilds every package in the manifest from a pinned upstream
# commit — @xterm/xterm and the two addons, each built twice (once unmodified to
# prove the toolchain still reproduces the published bundles, once patched). Caching
# the npm metadata and the shallow clone keeps the repeated cost to the builds
# themselves; the key is the manifest, so a commit, package or toolchain bump
# invalidates it.
- name: Restore upstream xterm build inputs
uses: actions/cache@v5
with:
@@ -845,6 +890,7 @@ jobs:
- root_directory_guard
- typecheck
- git_compatibility
- codex_index_heal_contract
- xterm_patch_sync
- shell_contracts
- test
@@ -875,6 +921,8 @@ jobs:
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
CODEX_INDEX_HEAL_CONTRACT: ${{ needs.codex_index_heal_contract.result }}
CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN: ${{ needs.code_paths.outputs.codex_index_heal_contract }}
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
@@ -920,6 +968,7 @@ jobs:
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
check_job codex_index_heal_contract "$CODEX_INDEX_HEAL_CONTRACT" "$CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN"
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
check_job test "$TEST" "$TEST_SHOULD_RUN"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,212 @@
diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts
index e1728feb219c362dfa2ecb602ff99f830d520757..957da98c8b30835cc2d114b4b66b228b01fdff0a 100644
--- a/src/SerializeAddon.ts
+++ b/src/SerializeAddon.ts
@@ -12,6 +12,36 @@ import { IAttributeData } from 'common/buffer/Types';
import { DEFAULT_ANSI_COLORS } from 'browser/Types';
import { UnderlineStyle } from 'common/buffer/Constants';
+type OscLinkData = { id?: string; uri: string };
+type OscLinkedCell = { extended?: { urlId?: number } };
+type TerminalWithOscLinks = Terminal & {
+ _core?: {
+ _inputHandler?: { _curAttrData?: IAttributeData & OscLinkedCell };
+ _oscLinkService?: { getLinkData: (linkId: number) => OscLinkData | undefined };
+ };
+};
+
+function getOscLinkId(cell: IBufferCell | IAttributeData): number {
+ return (cell as typeof cell & OscLinkedCell).extended?.urlId ?? 0;
+}
+
+function getOscLinkOpenSequence(terminal: Terminal, linkId: number): string {
+ if (!linkId) {
+ return '';
+ }
+ const data = (terminal as TerminalWithOscLinks)._core?._oscLinkService?.getLinkData(linkId);
+ if (!data) {
+ return '';
+ }
+ const params = data.id === undefined ? '' : `id=${data.id}`;
+ return `\u001b]8;${params};${data.uri}\u001b\\`;
+}
+
+function getActiveOscLinkSequence(terminal: Terminal): string {
+ const attrs = (terminal as TerminalWithOscLinks)._core?._inputHandler?._curAttrData;
+ return attrs ? getOscLinkOpenSequence(terminal, getOscLinkId(attrs)) : '';
+}
+
function constrain(value: number, low: number, high: number): number {
return Math.max(low, Math.min(value, high));
}
@@ -148,12 +178,14 @@ class StringSerializeHandler extends BaseSerializeHandler {
// this is a null cell for reference for checking whether background is empty or not
private _backgroundCell: IBufferCell = this._buffer.getNullCell();
+ private _defaultCell: IBufferCell = this._buffer.getNullCell();
private _firstRow: number = 0;
private _lastCursorRow: number = 0;
private _lastCursorCol: number = 0;
private _lastContentCursorRow: number = 0;
private _lastContentCursorCol: number = 0;
+ private _activeOscLinkId: number = 0;
constructor(
buffer: IBuffer,
@@ -214,7 +246,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (
// you must output character to cause overflow, control sequence can't do this
nextRowFirstChar.getChars() &&
- isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0
+ (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0)
) {
if (
// the last character can't be null,
@@ -251,9 +283,14 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (this._nullCellCount > 0) {
// do these because we filled the last several null slot, which we shouldn't
rowSeparator += '\u001b[A';
- rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`;
+ const contentCellCount = currentLine.length - this._nullCellCount;
+ if (contentCellCount > 0) {
+ rowSeparator += `\u001b[${contentCellCount}C`;
+ }
rowSeparator += `\u001b[${this._nullCellCount}X`;
- rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D`;
+ if (contentCellCount > 0) {
+ rowSeparator += `\u001b[${contentCellCount}D`;
+ }
rowSeparator += '\u001b[B';
}
@@ -310,7 +347,20 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
if (flagsChanged) {
if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }
- if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }
+ // PATCH(orca): bold (1) and dim (2) share the single reset param 22, so
+ // they must be diffed as one intensity group with the clearing 22 emitted
+ // BEFORE any re-set. Upstream's independent per-flag diff could emit
+ // "1;22" (bold set, then wiped by dim's clear — \x1b[2mA\x1b[22m\x1b[1mB
+ // loses B's bold on round-trip) or a bare "22" that drops a still-set
+ // bold/dim, garbling Orca's hidden-terminal snapshot restores.
+ const boldChanged = cell.isBold() !== oldCell.isBold();
+ const dimChanged = cell.isDim() !== oldCell.isDim();
+ if (boldChanged || dimChanged) {
+ const clearsIntensity = (boldChanged && !cell.isBold()) || (dimChanged && !cell.isDim());
+ if (clearsIntensity) { sgrSeq.push(22); }
+ if (cell.isBold() && (boldChanged || clearsIntensity)) { sgrSeq.push(1); }
+ if (cell.isDim() && (dimChanged || clearsIntensity)) { sgrSeq.push(2); }
+ }
if (!equalUnderline(cell, oldCell)) {
const style = cell.getUnderlineStyle();
if (style === UnderlineStyle.NONE) {
@@ -337,7 +387,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); }
if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); }
if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); }
- if (cell.isDim() !== oldCell.isDim()) { sgrSeq.push(cell.isDim() ? 2 : 22); }
+ // PATCH(orca): dim handled in the intensity group above.
if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); }
}
}
@@ -346,6 +396,20 @@ class StringSerializeHandler extends BaseSerializeHandler {
return sgrSeq;
}
+ private _setOscLink(linkId: number): string {
+ if (linkId === this._activeOscLinkId) {
+ return '';
+ }
+ let sequence = this._activeOscLinkId ? '\u001b]8;;\u001b\\' : '';
+ this._activeOscLinkId = 0;
+ const openSequence = getOscLinkOpenSequence(this._terminal, linkId);
+ if (openSequence) {
+ this._activeOscLinkId = linkId;
+ sequence += openSequence;
+ }
+ return sequence;
+ }
+
protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {
// a width 0 cell don't need to be count because it is just a placeholder after a CJK character;
const isPlaceHolderCell = cell.getWidth() === 0;
@@ -356,12 +420,21 @@ class StringSerializeHandler extends BaseSerializeHandler {
// this cell don't have content
const isEmptyCell = cell.getChars() === '';
+ const nextLine = isEmptyCell && cell.isInverse() ? this._buffer.getLine(row + 1) : undefined;
+ const nextRowFirstCell = nextLine?.getCell(0, this._nextRowFirstChar);
+ // A pending wide glyph recreates its own final-column padding during replay.
+ const isWideWrapPadding = col === this._terminal.cols - 1 &&
+ nextLine?.isWrapped &&
+ (nextRowFirstCell?.getWidth() ?? 0) > 1 &&
+ !!nextRowFirstCell && attributesEquals(cell, nextRowFirstCell);
+ // Cursor movement cannot reproduce an inverse cell's visible background.
+ const materializeEmptyCell = isEmptyCell && !!cell.isInverse() && !isWideWrapPadding;
const sgrSeq = this._diffStyle(cell, this._cursorStyle);
- // the empty cell style is only assumed to be changed when background changed, because
- // foreground is always 0.
- const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0;
+ const styleChanged = isEmptyCell
+ ? materializeEmptyCell ? sgrSeq.length > 0 : !equalBg(this._cursorStyle, cell)
+ : sgrSeq.length > 0;
/**
* handles style change
@@ -395,7 +468,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
/**
* handles actual content
*/
- if (isEmptyCell) {
+ if (isEmptyCell && !materializeEmptyCell) {
this._nullCellCount += cell.getWidth();
} else {
if (this._nullCellCount > 0) {
@@ -411,7 +484,22 @@ class StringSerializeHandler extends BaseSerializeHandler {
this._nullCellCount = 0;
}
- this._currentRow += cell.getChars();
+ // PATCH(orca): styling alone leaves restored OSC 8 links looking live but unclickable.
+ this._currentRow += this._setOscLink(getOscLinkId(cell));
+
+ if (materializeEmptyCell) {
+ const hasDecoration = !!cell.isUnderline() || !!cell.isStrikethrough() || !!cell.isOverline();
+ if (hasDecoration) {
+ this._currentRow += '\u001b[24;29;55m';
+ }
+ this._currentRow += ' '.repeat(cell.getWidth());
+ if (hasDecoration) {
+ const restoreSgrSeq = this._diffStyle(cell, this._defaultCell);
+ this._currentRow += `\u001b[0m\u001b[${restoreSgrSeq.join(';')}m`;
+ }
+ } else {
+ this._currentRow += cell.getChars();
+ }
// update cursor
this._lastContentCursorRow = this._lastCursorRow = row;
@@ -439,6 +527,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
}
+ // Each buffer is self-contained so links cannot leak into a following buffer.
+ content += this._setOscLink(0);
+
// restore the cursor
if (!excludeFinalCursorPosition) {
const realCursorRow = this._buffer.baseY + this._buffer.cursorY;
@@ -616,6 +707,9 @@ export class SerializeAddon implements ITerminalAddon, ISerializeApi {
content += this._serializeScrollRegion(this._terminal);
}
+ // Restore the source terminal's live OSC pen only after all buffers are complete.
+ content += getActiveOscLinkSequence(this._terminal);
+
return content;
}
@@ -0,0 +1,131 @@
diff --git a/src/GlyphRenderer.ts b/src/GlyphRenderer.ts
index 742f0879ff4f04509e4a07c8efdf0d5743fe8ee5..885a206a394fff783737a412c0b75bf935dd0eca 100644
--- a/src/GlyphRenderer.ts
+++ b/src/GlyphRenderer.ts
@@ -61,6 +61,8 @@ function createFragmentShaderSource(maxFragmentShaderTextureUnits: number): stri
for (let i = 1; i < maxFragmentShaderTextureUnits; i++) {
textureConditionals += ` else if (v_texpage == ${i}) { outColor = texture(u_texture[${i}], v_texcoord); }`;
}
+ // A v_texpage beyond the sampler budget matches no branch above. Leaving outColor unwritten
+ // is undefined behaviour in GLSL ES and paints garbage, so fall through to transparent.
return (`#version 300 es
precision lowp float;
@@ -74,7 +76,7 @@ out vec4 outColor;
void main() {
if (v_texpage == 0) {
outColor = texture(u_texture[0], v_texcoord);
- } ${textureConditionals}
+ } ${textureConditionals} else { outColor = vec4(0.0, 0.0, 0.0, 0.0); }
}`);
}
diff --git a/src/TextureAtlas.ts b/src/TextureAtlas.ts
index 4977ad741065e26bbfd35bc50e7558a033b13340..f55805ddc3f266415f7f8e81e5b8c318e7db194c 100644
--- a/src/TextureAtlas.ts
+++ b/src/TextureAtlas.ts
@@ -3,6 +3,7 @@
* @license MIT
*/
+import { FontWeight } from '@xterm/xterm';
import { IColorContrastCache } from 'browser/Types';
import { DIM_OPACITY, TEXT_BASELINE } from './Constants';
import { tryDrawCustomGlyph } from './customGlyphs/CustomGlyphRasterizer';
@@ -135,21 +136,23 @@ export class TextureAtlas implements ITextureAtlas {
private _pageLayoutVersion = 0;
public get pageLayoutVersion(): number { return this._pageLayoutVersion; }
+ // Orca diagnostics: read by terminal-render-desync-weight-probe.ts to tell a real
+ // bold-collapse from a repaint problem. Canvas silently keeps its previous font when an
+ // assignment fails to parse, which rasterizes glyphs at a stale weight.
+ public fontProbeMismatchCount = 0;
+ public fontProbeLastMismatch: { desired: string, actual: string } | undefined;
+
public clearTexture(): void {
- if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
+ // Guard on every page rather than pages[0]: a merged page is never written through
+ // currentRow, so once one lands at index 0 the old check made every later clear a no-op.
+ if (this._pages.every(page => page.glyphs.length === 0 && page.currentRow.x === 0 && page.currentRow.y === 0)) {
return;
}
- for (const page of this._pages) {
- page.clear();
- }
- this._cacheMap.clear();
- this._cacheMapCombined.clear();
- this._didWarmUp = false;
-
- // Invalidate renderer models so all texture pages are refreshed. The atlas may be shared, in
- // which case the clearing renderer has cleared only its own model and every other owner still
- // holds texture coords into the rows just wiped.
- this._pageLayoutVersion++;
+ // Return the atlas to its constructor state instead of clearing in place: page.clear() leaves
+ // page.glyphs populated, which would keep the guard above from ever firing again. Eviction
+ // also bumps _pageLayoutVersion, so every renderer sharing this atlas rebuilds its model.
+ this._evictAllPages();
+ this._createNewPage();
}
private _createNewPage(): AtlasPage {
@@ -465,6 +468,36 @@ export class TextureAtlas implements ITextureAtlas {
return this._config.colors.contrastCache;
}
+ /**
+ * Orca diagnostic. Canvas ignores a font assignment it cannot parse and silently keeps the
+ * previous value, so a bad family or weight rasterizes every glyph at a stale weight. Record
+ * the mismatch rather than correcting it: the goal is to tell that failure apart from a
+ * repaint bug when a terminal renders bold-collapsed.
+ */
+ private _probeRasterizationFontWeight(fontWeight: FontWeight): void {
+ const desired = String(fontWeight);
+ // Only numeric weights are comparable; keywords round-trip through Canvas unchanged.
+ if (!/^(?:[1-8]\d{2}|900)$/.test(desired)) {
+ return;
+ }
+ // Canvas normalizes font serialization: Chromium omits 400 and emits the keyword bold for 700.
+ const token = this._tmpCtx.font.match(
+ /(?:^|\s)(normal|bold|[1-9]\d{0,3})(?=\s+\d+(?:\.\d+)?px(?:\s|$))/
+ )?.[1] ?? '400';
+ const actual = token === 'normal' ? '400' : token === 'bold' ? '700' : token;
+ if (actual === desired) {
+ return;
+ }
+ this.fontProbeMismatchCount++;
+ this.fontProbeLastMismatch = { desired, actual: this._tmpCtx.font };
+ try {
+ (globalThis as { __orcaAtlasFontProbe?: (mismatch: { desired: string, actual: string }) => void })
+ .__orcaAtlasFontProbe?.(this.fontProbeLastMismatch);
+ } catch {
+ // Diagnostics only; a throwing listener must never break rasterization.
+ }
+ }
+
private _drawToCache(codeOrChars: number | string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph {
const chars = typeof codeOrChars === 'number' ? String.fromCharCode(codeOrChars) : codeOrChars;
@@ -536,6 +569,7 @@ export class TextureAtlas implements ITextureAtlas {
const fontStyle = italic ? 'italic' : '';
this._tmpCtx.font =
`${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`;
+ this._probeRasterizationFontWeight(fontWeight);
this._tmpCtx.textBaseline = TEXT_BASELINE;
const powerlineGlyph = chars.length === 1 && isPowerlineGlyph(chars.charCodeAt(0));
diff --git a/src/WebglRenderer.ts b/src/WebglRenderer.ts
index a951efba5c75e82735cd39b6b22c4b5d5fad5928..e7f80c3a8c14dd65a16a97f1d17e3da1d65ed8bf 100644
--- a/src/WebglRenderer.ts
+++ b/src/WebglRenderer.ts
@@ -386,7 +386,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
// page's version, so re-run the update and force a full texture rebind.
let merged = false;
let mergeRetries = 0;
- while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) {
+ // Test the retry budget before beginFrame: beginFrame latches the page layout version it
+ // observed, so tripping the limit after consuming it would strand a stale model with no
+ // later frame able to notice it needs rebuilding.
+ while (this._charAtlas && mergeRetries++ < Constants.MERGE_RETRY_LIMIT && this._glyphRenderer.value.beginFrame()) {
merged = true;
this._clearModel(true);
this._updateModel(0, this._terminal.rows - 1);
@@ -1,5 +1,5 @@
diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
index 4557e1652c34737fdf853436bd9328d9918eee2b..aff6ba624523849c2a39878a2181cbd1e931791d 100644
index 67893b7966eb095db4459b8837de9766921f36c5..0abf7ecac1aa108f7caf711d0a5c610a688d34fd 100644
--- a/src/browser/CoreBrowserTerminal.ts
+++ b/src/browser/CoreBrowserTerminal.ts
@@ -325,6 +325,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
@@ -32,7 +32,7 @@ index 4557e1652c34737fdf853436bd9328d9918eee2b..aff6ba624523849c2a39878a2181cbd1
this._register(addDisposableListener(this.textarea!, 'input', (ev: InputEvent) => this._inputEvent(ev), true));
this._register(this.onRender(() => this._compositionHelper!.updateCompositionElements()));
}
@@ -551,6 +565,11 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
@@ -552,6 +566,11 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._compositionView = this._document.createElement('div');
this._compositionView.classList.add('composition-view');
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
@@ -44,7 +44,7 @@ index 4557e1652c34737fdf853436bd9328d9918eee2b..aff6ba624523849c2a39878a2181cbd1
this._helperContainer.appendChild(this._compositionView);
this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService);
@@ -1008,7 +1027,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
@@ -1009,7 +1028,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._onKey.fire({ key, domEvent: ev });
this._showCursor();
@@ -55,7 +55,7 @@ index 4557e1652c34737fdf853436bd9328d9918eee2b..aff6ba624523849c2a39878a2181cbd1
this._keyPressHandled = true;
@@ -1026,6 +1047,15 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
@@ -1027,6 +1048,15 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
* @param ev The input event to be handled.
*/
protected _inputEvent(ev: InputEvent): boolean {
@@ -88,7 +88,7 @@ index 497afcf535f3eaca00889525a77e15eb633ccd96..96d499b34605f860608382114c3fbdc0
export interface IBrowser {
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f01bd98554 100644
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..d4d5106d8dd723eceed87310c193ccfe0c516b06 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -3,8 +3,9 @@
@@ -575,7 +575,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
// Since composition* events happen before the changes take place in the textarea on most
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
@@ -172,37 +479,310 @@ export class CompositionHelper {
@@ -172,37 +479,315 @@ export class CompositionHelper {
// - The last compositionupdate event's data property does not always accurately describe
// the character, a counter example being Korean where an ending consonsant can move to
// the following character if the following input is a vowel.
@@ -609,11 +609,11 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
+ pending.finalizerTimer = undefined;
+ if (this._compositionTransactionId === pending.transactionId) {
+ this._isAwaitingCompositionEnd = false;
+ }
+ if (this._pendingComposition === pending) {
+ this._sendPendingComposition(pending, true);
}
- }, 0);
+ if (this._pendingComposition === pending) {
+ this._sendPendingComposition(pending, true);
+ }
+ });
}
}
@@ -830,12 +830,17 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
+ if (
+ this._compositionEndTimer !== timer ||
+ !this._isComposing ||
+ this._compositionTransactionId !== transactionId ||
+ !this._compositionEndBelongsToCurrentTransaction(endData)
+ this._compositionTransactionId !== transactionId
+ ) {
+ return;
+ }
+ this._compositionEndTimer = undefined;
+ if (!this._compositionEndBelongsToCurrentTransaction(endData)) {
+ if (endData.length === 0 && !this._hasCompositionProgress()) {
+ this._cancelComposition();
+ }
+ return;
+ }
+ this._finalizeComposition(true, endData);
+ this._dispatchCompositionSessionEvent(new CustomEvent(
+ XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
@@ -913,7 +918,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
/**
* Apply any changes made to the textarea after the current event chain is allowed to complete.
* This should be called when not currently composing but a keydown event with the "composition
@@ -222,6 +802,9 @@ export class CompositionHelper {
@@ -222,6 +807,9 @@ export class CompositionHelper {
const diff = newValue.replace(oldValue, '');
@@ -923,7 +928,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
this._dataAlreadySent = diff;
if (newValue.length > oldValue.length) {
@@ -236,6 +819,101 @@ export class CompositionHelper {
@@ -236,6 +824,101 @@ export class CompositionHelper {
}, 0);
}
@@ -1025,7 +1030,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
/**
* Positions the composition view on top of the cursor and the textarea just below it (so the
* IME helper dialog is positioned correctly).
@@ -243,10 +921,23 @@ export class CompositionHelper {
@@ -243,10 +926,23 @@ export class CompositionHelper {
* necessary as the IME events across browsers are not consistently triggered.
*/
public updateCompositionElements(dontRecurse?: boolean): void {
@@ -1050,7 +1055,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..cf3e685c7e1b29a6e9d0d4747e1b30f0
if (this._bufferService.buffer.isCursorInViewport) {
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
@@ -265,20 +956,38 @@ export class CompositionHelper {
@@ -265,20 +961,38 @@ export class CompositionHelper {
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
this._compositionView.style.maxWidth = maxWidth + 'px';
this._compositionView.style.overflow = 'hidden';
+64 -6
View File
@@ -2,7 +2,7 @@
"$schemaNote": "Consumed by config/scripts/regenerate-xterm-patches.mjs. See docs/reference/xterm-patch-regeneration.md.",
"upstream": {
"repository": "https://github.com/xtermjs/xterm.js.git",
"commit": "53a98a720ae4a973e384fa2440880d09537132f3",
"commit": "d3e32b344dfe7dd6015cff6a9aeaaeaeccdc2789",
"commitSource": "bin/publish.js stamps package.json.commit before npm publish, so the published tarball names its own commit. The generator asserts the two agree."
},
"sourcemaps": {
@@ -19,17 +19,75 @@
"packages": [
{
"name": "@xterm/xterm",
"version": "6.1.0-beta.287",
"version": "6.1.0-beta.303",
"packageDir": ".",
"versionStampFile": "src/common/Version.ts",
"sourcePatch": "config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch",
"patch": "config/patches/@xterm__xterm@6.1.0-beta.287.patch",
"sourcePatch": "config/patches/xterm-src/@xterm__xterm@6.1.0-beta.303.src.patch",
"patch": "config/patches/@xterm__xterm@6.1.0-beta.303.patch",
"generatedPaths": ["lib/"],
"build": [{ "cwd": ".", "command": "npm", "args": ["run", "package"] }]
"build": [
{
"cwd": ".",
"command": "npm",
"args": ["run", "package"]
}
]
},
{
"name": "@xterm/addon-webgl",
"version": "0.20.0-beta.299",
"packageDir": "addons/addon-webgl",
"$note": "No versionStampFile: publish.js stamps the addon's package.json, which overlayBuildOutput never patches. The root `build` is required because the addon's own tsgo -p . has empty files/include and only project references, so it emits nothing on its own; `package` is the addon's webpack (CJS half) and the root `esbuild-package` emits the ESM half.",
"sourcePatch": "config/patches/xterm-src/@xterm__addon-webgl@0.20.0-beta.299.src.patch",
"patch": "config/patches/@xterm__addon-webgl@0.20.0-beta.299.patch",
"generatedPaths": ["lib/"],
"build": [
{
"cwd": "../..",
"command": "npm",
"args": ["run", "build"]
},
{
"cwd": ".",
"command": "npm",
"args": ["run", "package"]
},
{
"cwd": "../..",
"command": "npm",
"args": ["run", "esbuild-package"]
}
]
},
{
"name": "@xterm/addon-serialize",
"version": "0.15.0-beta.300",
"packageDir": "addons/addon-serialize",
"$note": "No versionStampFile: publish.js stamps the addon's package.json, which overlayBuildOutput never patches. The root `build` is required because the addon's own tsgo -p . has empty files/include and only project references, so it emits nothing on its own; `package` is the addon's webpack (CJS half) and the root `esbuild-package` emits the ESM half.",
"sourcePatch": "config/patches/xterm-src/@xterm__addon-serialize@0.15.0-beta.300.src.patch",
"patch": "config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch",
"generatedPaths": ["lib/"],
"build": [
{
"cwd": "../..",
"command": "npm",
"args": ["run", "build"]
},
{
"cwd": ".",
"command": "npm",
"args": ["run", "package"]
},
{
"cwd": "../..",
"command": "npm",
"args": ["run", "esbuild-package"]
}
]
}
],
"forbiddenBuildScripts": {
"why": "`npm run setup` runs a development esbuild (minify:false), so calling it after the packaging build overwrites lib/*.mjs with an unminified bundle and a mismatched map. Publish order is: stamp Version.ts, then `npm run package` only.",
"why": "`npm run setup` runs a development esbuild (minify:false), so calling it after the packaging build overwrites lib/*.mjs with an unminified bundle and a mismatched map. The packaging scripts are `package` and `esbuild-package`; nothing here may run a development pass after them.",
"scripts": ["setup", "presetup", "postsetup", "esbuild", "esbuild-watch", "dev"]
}
}
@@ -0,0 +1,37 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
describe('Codex index-heal contract PR gate', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const job = workflow.jobs.codex_index_heal_contract
it('installs and verifies against one pinned Codex version', () => {
const install = job.steps.find((step) => step.name === 'Install pinned Codex CLI')
const verify = job.steps.find((step) => step.name === 'Verify Codex index-heal contract')
// Why one source: the install and the runtime version assertion drifting apart is
// the failure that would leave this job verifying a Codex nobody declared.
expect(job.env.CODEX_CLI_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
expect(install.run).toContain('"@openai/codex@$CODEX_CLI_VERSION"')
expect(verify.env.ORCA_CODEX_CONTRACT_VERSION).toBe('${{ env.CODEX_CLI_VERSION }}')
// The install prefix and the binary the test is pointed at must be the same tree.
expect(install.run).toContain('--prefix "$RUNNER_TEMP/codex-cli"')
expect(verify.run).toContain(
'ORCA_CODEX_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli/node_modules/.bin/codex"'
)
expect(verify.run).toContain('src/main/codex/codex-index-heal-binary-contract.test.ts')
})
it('fails rather than skipping when the Codex binary is missing', () => {
const verify = job.steps.find((step) => step.name === 'Verify Codex index-heal contract')
// Why asserted: the contract skips itself without a binary, so a failed install
// would otherwise turn this job into a green no-op that verifies nothing.
expect(verify.env.ORCA_CODEX_CONTRACT_REQUIRED).toBe('1')
expect(job.steps.find((step) => step.name === 'Install pinned Codex CLI').run).toContain(
'set -euo pipefail'
)
})
})
@@ -13,16 +13,6 @@ const packageJson = JSON.parse(readProject('package.json'))
const pnpmWorkspace = parse(readProject('pnpm-workspace.yaml'))
describe('Electron runtime package contract', () => {
it('keeps shared WebGL atlas invalidation reproducible from vendored source', () => {
const patch = readProject('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
expect(patch).toContain('readonly clearModelGeneration: number')
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
expect(patch).toContain('this.clearModelGeneration++')
expect(patch).toContain('this._atlas._clearModelGeneration||0')
expect(patch.match(/\^\(\?:\[1-8\]\\d\{2\}\|900\)\$/g)).toHaveLength(3)
})
it('keeps root postinstall as the single Electron binary install owner', () => {
expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs')
expect(pnpmWorkspace.allowBuilds).not.toHaveProperty('electron')
+22
View File
@@ -19,6 +19,7 @@ export const PR_CHECK_JOBS = [
'static_analysis',
'typecheck',
'git_compatibility',
'codex_index_heal_contract',
'xterm_patch_sync',
'shell_contracts',
'test',
@@ -47,6 +48,24 @@ const GIT_COMPAT_PREFIXES = [
'config/scripts/git-binary-compatibility'
]
// Why narrow: the contract pins Codex's read-repair, so it runs when the heal that
// depends on it, its app-server transport, or the contract itself changes.
const CODEX_INDEX_HEAL_CONTRACT_PREFIXES = [
'src/main/codex/codex-index-heal-binary-contract',
'src/main/codex/codex-session-index-heal',
'src/main/codex/codex-app-server-session',
'src/main/codex/codex-state-db',
'src/main/sqlite/sync-database',
'src/main/codex/codex-app-server-capability-signal',
'src/main/codex/codex-process-exit-deadline',
'src/main/codex/codex-session-backfill',
'src/main/codex/codex-session-index-heal-state',
'src/main/codex-cli/command',
'src/main/win32-utils',
'src/shared/node-cli-command-resolution',
'src/shared/windows-batch-spawn'
]
const XTERM_PREFIXES = [
'config/patches/xterm-upstream.json',
'config/patches/@xterm',
@@ -267,6 +286,9 @@ function jobDetector(job) {
switch (job) {
case 'git_compatibility':
return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
case 'codex_index_heal_contract':
return (files) =>
files.some((file) => matchesPrefix(file, CODEX_INDEX_HEAL_CONTRACT_PREFIXES))
case 'xterm_patch_sync':
return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
case 'shell_contracts':
@@ -19,6 +19,7 @@ const expensiveJobs = [
'static_analysis',
'typecheck',
'git_compatibility',
'codex_index_heal_contract',
'xterm_patch_sync',
'shell_contracts',
'test',
@@ -132,6 +133,49 @@ describe('per-job path classification', () => {
})
})
it('runs the Codex index-heal contract only when the heal or its transport changes', () => {
expectClassification(['src/main/codex/codex-session-index-heal.ts'], {
codex_index_heal_contract: true,
package: true,
package_windows: true
})
expectClassification(['src/main/sqlite/sync-database.ts'], {
codex_index_heal_contract: true,
package: true,
package_windows: true
})
expectClassification(['src/main/codex/codex-app-server-session.ts'], {
codex_index_heal_contract: true,
package: true,
package_windows: true
})
expectClassification(['src/main/codex/codex-index-heal-binary-contract.test.ts'], {
codex_index_heal_contract: true
})
// Keep the real-binary gate live when a transport or launch dependency changes.
for (const file of [
'src/main/codex/codex-app-server-capability-signal.ts',
'src/main/codex/codex-process-exit-deadline.ts',
'src/main/codex/codex-session-backfill.ts',
'src/main/codex/codex-session-index-heal-state.ts',
'src/main/codex-cli/command.ts',
'src/main/win32-utils.ts',
'src/shared/node-cli-command-resolution.ts',
'src/shared/windows-batch-spawn.ts'
]) {
expectClassification([file], {
codex_index_heal_contract: true,
package: true,
package_windows: true
})
}
// A neighbouring Codex module must not drag the real-binary job in.
expectClassification(['src/main/codex/codex-home-paths.ts'], {
package: true,
package_windows: true
})
})
it('runs xterm patch sync only when xterm inputs change', () => {
expectClassification(['config/patches/xterm-upstream.json'], {
xterm_patch_sync: true
+3 -1
View File
@@ -373,7 +373,9 @@ describe('PR E2E gate contract', () => {
const unreachableSpecs = new Set([
'tests/e2e/ssh-docker-relay-perf.spec.ts',
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts',
// Hosted mobile WebView SSH requires a macOS iOS simulator; the Docker lane is Linux.
'tests/e2e/hosted-mobile-webview-ssh.spec.ts'
])
// Why comments are stripped: this file's own runner lists the two exempt specs by name in a
// prose comment. A substring scan over raw text would count any spec merely *discussed* in a
@@ -412,6 +412,7 @@ describe('PR workflow parallelism', () => {
'root_directory_guard',
'typecheck',
'git_compatibility',
'codex_index_heal_contract',
'xterm_patch_sync',
'shell_contracts',
'test',
+60 -161
View File
@@ -17,129 +17,19 @@ import {
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import {
CHECKOUT_DIFF_FLAGS,
PNPM_DIFF_FLAGS,
assertSourceDerivationsAgree,
escapeRegExp,
formatCheckFailure,
normalizePnpmDiff,
pnpmDiffEnvironment
} from './xterm-patch-text.mjs'
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const MANIFEST_RELATIVE_PATH = path.join('config', 'patches', 'xterm-upstream.json')
/**
* Flags pnpm@12 passes to `git diff` in its own `diff_folders()`. A patch built
* with anything else is a patch pnpm may re-diff differently on the next
* `pnpm patch-commit`, so the byte-comparison gate would never settle.
*/
export const PNPM_DIFF_FLAGS = [
'-c',
'core.safecrlf=false',
'-c',
'core.quotePath=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color',
'--'
]
/**
* The same formatting as PNPM_DIFF_FLAGS minus `--no-index`, so a diff taken
* inside the upstream checkout is byte-comparable with the emitted patch.
*/
export const CHECKOUT_DIFF_FLAGS = PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index')
/** Applies pnpm's git config isolation so local machine settings cannot change the patch. */
export function pnpmDiffEnvironment(baseEnvironment = process.env) {
return {
...baseEnvironment,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: '/dev/null'
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function trimSurroundingSlashes(value) {
return value[0] === '/' || value.endsWith('/') ? value.replace(/^\/|\/$/g, '') : value
}
/**
* Reproduces pnpm's post-processing of the raw `git diff` output: strip the two
* scratch folder prefixes, drop a trailing no-newline marker, and remove
* .DS_Store entries a macOS run would otherwise smuggle in.
*/
export function normalizePnpmDiff(stdout, folderA, folderB) {
const a = folderA.replace(/\\/g, '/')
const b = folderB.replace(/\\/g, '/')
return stdout
.replace(new RegExp(`(a|b)(${escapeRegExp(`/${trimSurroundingSlashes(a)}/`)})`, 'g'), '$1/')
.replace(new RegExp(`(a|b)${escapeRegExp(`/${trimSurroundingSlashes(b)}/`)}`, 'g'), '$1/')
.replace(new RegExp(escapeRegExp(`${a}/`), 'g'), '')
.replace(new RegExp(escapeRegExp(`${b}/`), 'g'), '')
.replace(/\n\\ No newline at end of file\n$/, '\n')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]+?(?=^diff --git)/gm, '')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]*$/gm, '')
}
/** Splits a patch into one entry per `diff --git` stanza, keeping the raw text. */
export function splitPatchEntries(patchText) {
return patchText
.split(/^(?=diff --git )/m)
.filter((entry) => entry.startsWith('diff --git '))
.map((text) => {
const header = text.slice(0, text.indexOf('\n'))
const match = /^diff --git a\/(.+) b\/\1$/.exec(header)
if (!match) {
throw new Error(`Unsupported diff header (renames are not supported): ${header}`)
}
return { path: match[1], text }
})
}
export function selectPatchEntries(patchText, matches) {
return splitPatchEntries(patchText)
.filter((entry) => matches(entry.path))
.map((entry) => entry.text)
.join('')
}
/** The hand-editable half of a patch: everything under `src/`. */
export function sourceHunks(patchText) {
return selectPatchEntries(patchText, (file) => file.startsWith('src/'))
}
/**
* The source patch and the emitted patch are the same edits diffed two ways, so
* they must produce the same bytes. A source hunk the emitted patch cannot carry
* would be deleted by the next `--write`, so this fails instead of shipping one.
*/
export function assertSourceDerivationsAgree(checkoutSource, patchText) {
const checkout = sourceHunks(checkoutSource)
const emitted = sourceHunks(patchText)
if (checkout === emitted) {
return
}
throw new Error(
[
'The checkout diff and the emitted patch disagree on a source file.',
` from checkout: [${splitPatchEntries(checkout)
.map((e) => e.path)
.join(', ')}]`,
` from patch: [${splitPatchEntries(emitted)
.map((e) => e.path)
.join(', ')}]`,
` first difference at character ${firstDifferenceIndex(checkout, emitted)}`,
'',
'A hunk the emitted patch cannot name is never installed, so it cannot ship',
'here; upstream .npmignore strips `src/**/*.test.ts`.'
].join('\n')
)
}
export function stampVersionSource(source, version) {
const stamped = source.replace(
/export const XTERM_VERSION = '[^']+';/,
@@ -249,6 +139,11 @@ export function readLockfileResolutionHashes(lockfileText, packageKey) {
)
}
/** False for a key the lockfile has never seen, which is the normal state mid version bump. */
export function lockfileHasPatchEntry(lockfileText, packageKey) {
return lockfilePatchHashPattern(packageKey).test(lockfileText)
}
export function lockfilePatchHashIsStale(lockfileText, packageKey, hash) {
return (
readLockfilePatchHash(lockfileText, packageKey) !== hash ||
@@ -265,35 +160,6 @@ export function updateLockfilePatchHash(lockfileText, packageKey, hash) {
)
}
export function firstDifferenceIndex(left, right) {
const limit = Math.min(left.length, right.length)
for (let index = 0; index < limit; index += 1) {
if (left[index] !== right[index]) {
return index
}
}
return left.length === right.length ? -1 : limit
}
export function formatCheckFailure({ name, patchPath, committed, regenerated }) {
const index = firstDifferenceIndex(committed, regenerated)
const committedFiles = splitPatchEntries(committed).map((entry) => entry.path)
const regeneratedFiles = splitPatchEntries(regenerated).map((entry) => entry.path)
return [
`${name}: ${patchPath} is not what the pinned upstream build produces.`,
` committed: ${Buffer.byteLength(committed)} bytes, files [${committedFiles.join(', ')}]`,
` regenerated: ${Buffer.byteLength(regenerated)} bytes, files [${regeneratedFiles.join(', ')}]`,
` first difference at character ${index}`,
'',
'The bundle hunks are generated. Do not edit them. Change the source patch',
'instead and regenerate both files:',
'',
' node config/scripts/regenerate-xterm-patches.mjs --write',
'',
'See docs/reference/xterm-patch-regeneration.md.'
].join('\n')
}
// npm ships as `npm.cmd` on Windows, and execFile applies no PATHEXT and refuses
// a `.cmd` target without a shell (CVE-2024-27980). git and tar are real .exe.
const WINDOWS_SHIM_COMMANDS = new Set(['npm', 'npx', 'pnpm', 'yarn'])
@@ -431,11 +297,13 @@ function buildPackage(upstreamRoot, packageEntry, manifest) {
for (const directory of ['lib', 'out', 'out-esbuild']) {
rmSync(path.join(packageRoot, directory), { recursive: true, force: true })
}
const stampPath = path.join(packageRoot, packageEntry.versionStampFile)
writeFileSync(
stampPath,
stampVersionSource(readFileSync(stampPath, 'utf8'), packageEntry.version)
)
if (packageEntry.versionStampFile) {
const stampPath = path.join(packageRoot, packageEntry.versionStampFile)
writeFileSync(
stampPath,
stampVersionSource(readFileSync(stampPath, 'utf8'), packageEntry.version)
)
}
assertBuildStepsAllowed(manifest)
for (const step of packageEntry.build) {
run(step.command, step.args, { cwd: path.join(packageRoot, step.cwd), stdio: 'inherit' })
@@ -529,9 +397,17 @@ function regeneratePackage(packageEntry, manifest, context) {
assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry)
run('git', ['reset', '--quiet', '--hard', manifest.upstream.commit], { cwd: upstreamRoot })
run('git', ['apply', '--whitespace=nowarn', path.join(repoRoot, packageEntry.sourcePatch)], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
const packageDir = toPosix(packageEntry.packageDir)
run(
'git',
[
'apply',
'--whitespace=nowarn',
...(packageDir === '.' ? [] : [`--directory=${packageDir}`]),
path.join(repoRoot, packageEntry.sourcePatch)
],
{ cwd: upstreamRoot }
)
buildPackage(upstreamRoot, packageEntry, manifest)
const patchedDir = path.join(workDir, 'patched', packageEntry.name.replace(/[@/]/g, '_'))
@@ -540,11 +416,20 @@ function regeneratePackage(packageEntry, manifest, context) {
// Leave the checkout diffable: the pinned commit plus the source patch, with
// no publish-time version stamp mixed in, so `git diff` there is the source
// patch and nothing else.
run('git', ['checkout', '--', packageEntry.versionStampFile], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
if (packageEntry.versionStampFile) {
run('git', ['checkout', '--', packageEntry.versionStampFile], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
}
const source = diffCheckoutSource(path.join(upstreamRoot, packageEntry.packageDir))
if (source.trim().length === 0) {
throw new Error(
`${packageEntry.name}: applying ${packageEntry.sourcePatch} left the checkout unchanged. ` +
'git apply reports success when it skips every hunk, so this is a path-rooting bug, ' +
'not an empty patch.'
)
}
const patch = diffFolders(pristineDir, patchedDir)
assertSourceDerivationsAgree(source, patch)
return { patch, source }
@@ -566,6 +451,7 @@ export function regenerateXtermPatches({
let lockfileChanged = false
const failures = []
const pendingInstall = []
for (const packageEntry of manifest.packages) {
const shortCommit = manifest.upstream.commit.slice(0, 12)
log(`${packageEntry.name}@${packageEntry.version}: regenerating from ${shortCommit}`)
@@ -584,7 +470,10 @@ export function regenerateXtermPatches({
writeFileSync(sourcePatchPath, canonicalSource)
log(` wrote ${packageEntry.patch} (${Buffer.byteLength(regenerated)} bytes)`)
log(` wrote ${packageEntry.sourcePatch} (${Buffer.byteLength(canonicalSource)} bytes)`)
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
if (!lockfileHasPatchEntry(lockfile, packageKey)) {
pendingInstall.push(packageKey)
log(` pnpm-lock.yaml has no entry for ${packageKey} yet; run pnpm install`)
} else if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
lockfile = updateLockfilePatchHash(lockfile, packageKey, hash)
lockfileChanged = true
log(` updated pnpm-lock.yaml patch hash to ${hash}`)
@@ -592,7 +481,11 @@ export function regenerateXtermPatches({
continue
}
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
if (!lockfileHasPatchEntry(lockfile, packageKey)) {
failures.push(
`${packageKey}: pnpm-lock.yaml has no patchedDependencies entry. Run pnpm install.`
)
} else if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
const stale = Array.from(
new Set(readLockfileResolutionHashes(lockfile, packageKey).filter((v) => v !== hash))
)
@@ -639,6 +532,12 @@ export function regenerateXtermPatches({
if (lockfileChanged) {
writeFileSync(lockfilePath, lockfile)
}
if (pendingInstall.length > 0) {
log(
`\nRun pnpm install to key the lockfile to the new patches: ${pendingInstall.join(', ')}\n` +
'Then rerun with --check, which is the authority on the lockfile.'
)
}
if (failures.length > 0) {
throw new Error(failures.join('\n\n'))
}
@@ -5,25 +5,29 @@ import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
CHECKOUT_DIFF_FLAGS,
PNPM_DIFF_FLAGS,
assertBuildStepsAllowed,
assertPublishedCommit,
assertSourceDerivationsAgree,
assertSourcemapPolicy,
firstDifferenceIndex,
formatCheckFailure,
lockfileHasPatchEntry,
lockfilePatchHashIsStale,
normalizePnpmDiff,
patchHash,
pnpmDiffEnvironment,
readLockfilePatchHash,
readLockfileResolutionHashes,
sourceHunks,
splitPatchEntries,
stampVersionSource,
updateLockfilePatchHash
} from './regenerate-xterm-patches.mjs'
import {
CHECKOUT_DIFF_FLAGS,
PNPM_DIFF_FLAGS,
assertSourceDerivationsAgree,
firstDifferenceIndex,
formatCheckFailure,
normalizePnpmDiff,
pnpmDiffEnvironment,
selectPatchEntries,
sourceHunks,
splitPatchEntries
} from './xterm-patch-text.mjs'
// Only the tests need to slice the generated half out of a patch; the generator
// reads `generatedPaths` directly where it compares against the pristine build.
@@ -278,10 +282,20 @@ describe('source derivation agreement', () => {
})
it('diffs the checkout with pnpm formatting so the two halves stay comparable', () => {
expect(CHECKOUT_DIFF_FLAGS).toEqual(PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index'))
expect(CHECKOUT_DIFF_FLAGS.filter((flag) => flag !== '--relative')).toEqual(
PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index')
)
expect(CHECKOUT_DIFF_FLAGS).toContain('--full-index')
expect(CHECKOUT_DIFF_FLAGS).not.toContain('--no-index')
})
it('passes --relative as a flag, not as a pathspec', () => {
// After `--` git reads it as a path, silently leaving addon diffs rooted at the
// repo instead of the package, which drops every source hunk from the patch.
expect(CHECKOUT_DIFF_FLAGS.indexOf('--relative')).toBeLessThan(
CHECKOUT_DIFF_FLAGS.indexOf('--')
)
})
})
describe('manifest guards', () => {
@@ -409,6 +423,13 @@ describe('lockfile coupling', () => {
/no patchedDependencies entry/
)
})
it('reports a missing key without throwing, which is the state mid version bump', () => {
// A bump renames the key, so --write has nothing to rewrite until pnpm install
// creates it. Aborting there would strand the run before the later packages.
expect(lockfileHasPatchEntry(lockfile, '@xterm/xterm@6.1.0-beta.287')).toBe(true)
expect(lockfileHasPatchEntry(lockfile, '@xterm/xterm@6.1.0-beta.303')).toBe(false)
})
})
describe('check-mode reporting', () => {
@@ -435,18 +456,20 @@ describe('check-mode reporting', () => {
// These run without network or a build, so ordinary `pnpm test` catches the two
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
describe('committed xterm patch artifacts', () => {
it('normalizes Chromium font-weight serialization in every WebGL runtime copy', async () => {
const patch = await readFile(
path.join(REPO_ROOT, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
'utf8'
)
expect(patch).not.toMatch(/desiredWeight !== '400'|orcaProbeWeight!=="400"/)
expect(
patch.match(/(?:actualWeightToken === 'normal'|orcaActualWeightToken==="normal")/g)
).toHaveLength(3)
expect(
patch.match(/(?:actualWeight !== desiredWeight|orcaActualWeight!==orcaProbeWeight)/g)
).toHaveLength(3)
it('carries the font-weight probe into every WebGL runtime copy', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
const webgl = manifest.packages.find((entry) => entry.name === '@xterm/addon-webgl')
const patch = await readFile(path.join(REPO_ROOT, webgl.patch), 'utf8')
// The ESM and CJS bundles mangle locals differently, so anchor on the global the
// renderer diagnostics read; a copy missing it reports a font mismatch as a repaint bug.
for (const file of ['src/TextureAtlas.ts', 'lib/addon-webgl.js', 'lib/addon-webgl.mjs']) {
const stanza = selectPatchEntries(patch, (candidate) => candidate === file)
expect(stanza, file).not.toBe('')
expect(stanza, file).toContain('__orcaAtlasFontProbe')
}
// The probe must compare the rasterized weight against the requested one; comparing
// against a literal '400' would call every non-default weight a mismatch.
expect(sourceHunks(patch)).toContain('actual === desired')
})
it('records the lockfile hash pnpm derives from the patch file', async () => {
+3
View File
@@ -42,6 +42,8 @@ if (runtime.status !== 0) {
// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
// semantic decisions (which ptyId to capture, which split direction) that change
// what the repro measures. Tracked in stablyai/orca#16764.
// hosted-mobile-webview-ssh.spec.ts — requires a macOS iOS simulator and cannot run on the
// Linux Docker runner. Run test:e2e:hosted-mobile-webview:ssh on macOS before release.
//
// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
// grep-inverts away.
@@ -80,6 +82,7 @@ const result = spawnSync(
'tests/e2e/ssh-restart-tab-accumulation.spec.ts',
'tests/e2e/ssh-skill-installation.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
'tests/e2e/ssh-docker-native-chat-transcript.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
+161
View File
@@ -0,0 +1,161 @@
/**
* How an xterm patch is formatted, split and compared.
*
* Split out of regenerate-xterm-patches.mjs, which orchestrates the clone and the
* builds. Everything here is pure text: it needs no upstream checkout, so the unit
* tests exercise it with no network and no build.
*/
/**
* Flags pnpm@12 passes to `git diff` in its own `diff_folders()`. A patch built
* with anything else is a patch pnpm may re-diff differently on the next
* `pnpm patch-commit`, so the byte-comparison gate would never settle.
*/
export const PNPM_DIFF_FLAGS = [
'-c',
'core.safecrlf=false',
'-c',
'core.quotePath=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color',
'--'
]
/**
* The same formatting as PNPM_DIFF_FLAGS minus `--no-index`, so a diff taken
* inside the upstream checkout is byte-comparable with the emitted patch.
* `--relative` scopes paths to the package directory, which is what the published
* tarball is rooted at; it is a no-op for a package whose packageDir is the repo root.
*/
export const CHECKOUT_DIFF_FLAGS = PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index').flatMap(
// Ahead of the `--` separator, or git reads it as a pathspec and silently keeps
// repo-root-relative paths.
(flag) => (flag === '--' ? ['--relative', '--'] : [flag])
)
/** Applies pnpm's git config isolation so local machine settings cannot change the patch. */
export function pnpmDiffEnvironment(baseEnvironment = process.env) {
return {
...baseEnvironment,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: '/dev/null'
}
}
export function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function trimSurroundingSlashes(value) {
return value[0] === '/' || value.endsWith('/') ? value.replace(/^\/|\/$/g, '') : value
}
/**
* Reproduces pnpm's post-processing of the raw `git diff` output: strip the two
* scratch folder prefixes, drop a trailing no-newline marker, and remove
* .DS_Store entries a macOS run would otherwise smuggle in.
*/
export function normalizePnpmDiff(stdout, folderA, folderB) {
const a = folderA.replace(/\\/g, '/')
const b = folderB.replace(/\\/g, '/')
return stdout
.replace(new RegExp(`(a|b)(${escapeRegExp(`/${trimSurroundingSlashes(a)}/`)})`, 'g'), '$1/')
.replace(new RegExp(`(a|b)${escapeRegExp(`/${trimSurroundingSlashes(b)}/`)}`, 'g'), '$1/')
.replace(new RegExp(escapeRegExp(`${a}/`), 'g'), '')
.replace(new RegExp(escapeRegExp(`${b}/`), 'g'), '')
.replace(/\n\\ No newline at end of file\n$/, '\n')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]+?(?=^diff --git)/gm, '')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]*$/gm, '')
}
/** Splits a patch into one entry per `diff --git` stanza, keeping the raw text. */
export function splitPatchEntries(patchText) {
return patchText
.split(/^(?=diff --git )/m)
.filter((entry) => entry.startsWith('diff --git '))
.map((text) => {
const header = text.slice(0, text.indexOf('\n'))
const match = /^diff --git a\/(.+) b\/\1$/.exec(header)
if (!match) {
throw new Error(`Unsupported diff header (renames are not supported): ${header}`)
}
return { path: match[1], text }
})
}
export function selectPatchEntries(patchText, matches) {
return splitPatchEntries(patchText)
.filter((entry) => matches(entry.path))
.map((entry) => entry.text)
.join('')
}
/** The hand-editable half of a patch: everything under `src/`. */
export function sourceHunks(patchText) {
return selectPatchEntries(patchText, (file) => file.startsWith('src/'))
}
/**
* The source patch and the emitted patch are the same edits diffed two ways, so
* they must produce the same bytes. A source hunk the emitted patch cannot carry
* would be deleted by the next `--write`, so this fails instead of shipping one.
*/
export function assertSourceDerivationsAgree(checkoutSource, patchText) {
const checkout = sourceHunks(checkoutSource)
const emitted = sourceHunks(patchText)
if (checkout === emitted) {
return
}
throw new Error(
[
'The checkout diff and the emitted patch disagree on a source file.',
` from checkout: [${splitPatchEntries(checkout)
.map((e) => e.path)
.join(', ')}]`,
` from patch: [${splitPatchEntries(emitted)
.map((e) => e.path)
.join(', ')}]`,
` first difference at character ${firstDifferenceIndex(checkout, emitted)}`,
'',
'A hunk the emitted patch cannot name is never installed, so it cannot ship',
'here; upstream .npmignore strips `src/**/*.test.ts`.'
].join('\n')
)
}
export function firstDifferenceIndex(left, right) {
const limit = Math.min(left.length, right.length)
for (let index = 0; index < limit; index += 1) {
if (left[index] !== right[index]) {
return index
}
}
return left.length === right.length ? -1 : limit
}
export function formatCheckFailure({ name, patchPath, committed, regenerated }) {
const index = firstDifferenceIndex(committed, regenerated)
const committedFiles = splitPatchEntries(committed).map((entry) => entry.path)
const regeneratedFiles = splitPatchEntries(regenerated).map((entry) => entry.path)
return [
`${name}: ${patchPath} is not what the pinned upstream build produces.`,
` committed: ${Buffer.byteLength(committed)} bytes, files [${committedFiles.join(', ')}]`,
` regenerated: ${Buffer.byteLength(regenerated)} bytes, files [${regeneratedFiles.join(', ')}]`,
` first difference at character ${index}`,
'',
'The bundle hunks are generated. Do not edit them. Change the source patch',
'instead and regenerate both files:',
'',
' node config/scripts/regenerate-xterm-patches.mjs --write',
'',
'See docs/reference/xterm-patch-regeneration.md.'
].join('\n')
}
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
const require = createRequire(import.meta.url)
const readProject = (file) => readFileSync(join(projectDir, file), 'utf8')
const xtermManifest = JSON.parse(readProject('config/patches/xterm-upstream.json'))
const readInstalled = (name, file) =>
readFileSync(join(resolve(require.resolve(`${name}/package.json`), '..'), file), 'utf8')
describe('vendored xterm WebGL runtime contract', () => {
it('keeps shared WebGL atlas invalidation per renderer', () => {
// Orca panes with the same font share one atlas, so a page merge or a clear in one
// pane invalidates cached texture coords in all of them. Recovery has to be observed
// per renderer: a consume-once flag lets whichever pane draws first eat the
// notification and leaves its siblings drawing from a stale model.
//
// Upstream owns this since addon-webgl 0.20.0-beta.299, as a monotonic
// pageLayoutVersion each renderer latches independently, so it is no longer something
// Orca patches in. Assert it on the resolved dependency rather than on the patch.
const atlas = readInstalled('@xterm/addon-webgl', 'src/TextureAtlas.ts')
expect(atlas).toContain('public get pageLayoutVersion(): number')
expect(atlas).toContain('this._pageLayoutVersion++')
const glyphRenderer = readInstalled('@xterm/addon-webgl', 'src/GlyphRenderer.ts')
expect(glyphRenderer).toContain(
'this._atlas.pageLayoutVersion !== this._lastSeenPageLayoutVersion'
)
// Both shipped bundles have to carry it, not just the source beside them.
for (const bundle of ['lib/addon-webgl.js', 'lib/addon-webgl.mjs']) {
const contents = readInstalled('@xterm/addon-webgl', bundle)
expect(contents, bundle).toContain('pageLayoutVersion')
expect(contents, bundle).toContain('_lastSeenPageLayoutVersion')
}
})
it('keeps the Orca-only WebGL hunks in the generated patch', () => {
const webgl = xtermManifest.packages.find((entry) => entry.name === '@xterm/addon-webgl')
const patch = readProject(webgl.patch)
// A v_texpage past the sampler budget must resolve to a defined colour.
expect(patch).toContain('else { outColor = vec4(0.0, 0.0, 0.0, 0.0); }')
// clearTexture must not no-op once a merged page occupies index 0.
expect(patch).toContain('this._pages.every(page => page.glyphs.length === 0')
// The merge retry budget is spent before beginFrame latches the version it saw.
expect(patch).toContain(
'mergeRetries++ < Constants.MERGE_RETRY_LIMIT && this._glyphRenderer.value.beginFrame()'
)
})
})
+76 -28
View File
@@ -24,13 +24,15 @@ truth. Everything else is derived from it by
`config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact
upstream commit the published tarball was built from.
This policy covers `@xterm/xterm` only. The addon patches
(`@xterm/addon-webgl`, `@xterm/addon-serialize`) are still hand-edited bundles
and are tracked separately; see [Known Gaps](#known-gaps).
`@xterm/addon-webgl` and `@xterm/addon-serialize` are generated the same way,
from their own source patches under `config/patches/xterm-src/`. Their entries
differ only in `packageDir` and build steps; everything below applies to all
three. `@xterm/addon-ligatures` is the one patch still written by hand — see
[Known Gaps](#known-gaps).
## Rules
1. Never edit `config/patches/@xterm__xterm@<version>.patch`. Edit the source
1. Never edit `config/patches/@xterm__*@<version>.patch`. Edit the source
patch and regenerate.
2. Never edit `lib/` inside a patched `node_modules` tree and re-run
`pnpm patch-commit`. That is how bundle hunks stop matching their sources.
@@ -51,14 +53,16 @@ and are tracked separately; see [Known Gaps](#known-gaps).
patch hash in two places — `patchedDependencies` and every resolution key that
depends on the patched package — and on a warm store it will leave the
resolution keys at their previous value while reporting success. That installs
locally and drifts on CI's cold store. Always finish on step 4, and if it
reports a stale hash after an install, rerun `--write`.
locally and drifts on CI's cold store. For a version bump, follow the **Version
Bumps** workflow through step 5 (the final `--check`); if it reports a stale hash
after an install, rerun `--write`. For a source-only edit, the four-step workflow
above ends at `--check`.
## Workflow
```sh
# 1. Edit the source hunks.
$EDITOR config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
$EDITOR config/patches/xterm-src/@xterm__xterm@6.1.0-beta.303.src.patch
# 2. Rebuild the bundle hunks, the full patch, and the lockfile hash.
node config/scripts/regenerate-xterm-patches.mjs --write
@@ -77,10 +81,20 @@ any run it is left at the pinned commit with the source patch applied:
```sh
node config/scripts/regenerate-xterm-patches.mjs --check --work-dir=/tmp/xterm
$EDITOR /tmp/xterm/upstream/src/browser/input/CompositionHelper.ts
git -C /tmp/xterm/upstream diff -- src/ > config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
git -C /tmp/xterm/upstream diff -- src/ > config/patches/xterm-src/@xterm__xterm@6.1.0-beta.303.src.patch
node config/scripts/regenerate-xterm-patches.mjs --write --work-dir=/tmp/xterm
```
For an addon, edit under `addons/<name>/` and take the diff from that directory
with `--relative`, so the patch is rooted at the package the way the published
tarball is:
```sh
$EDITOR /tmp/xterm/upstream/addons/addon-webgl/src/TextureAtlas.ts
git -C /tmp/xterm/upstream/addons/addon-webgl diff --relative -- src/ \
> config/patches/xterm-src/@xterm__addon-webgl@0.20.0-beta.299.src.patch
```
`--write` rewrites the source patch into the canonical form it would emit on a
re-diff, so a hand-produced `git diff` gets normalized on the first run rather
than fighting `--check` forever.
@@ -107,6 +121,18 @@ Upstream's publish path is `npm ci` → stamp `Version.ts` → `npm run package`
`npm run package` runs webpack for `lib/xterm.js` and then, via `postpackage`,
`bin/esbuild_all.mjs --prod` for `lib/xterm.mjs`.
An addon needs three steps, in this order, and the first is easy to miss:
1. **root `npm run build`.** The addon's own `npm run build` is
`tsgo -p .` against a tsconfig whose `files` and `include` are both empty and
which only lists project references. In `-p` mode tsgo does not build
references, so it succeeds while emitting nothing, and the addon's webpack
then fails on a missing `./out/`. The root build is what populates it.
2. **addon `npm run package`** — the addon's own webpack, which emits the CJS
`lib/addon-*.js`. The root `package` script never builds this.
3. **root `npm run esbuild-package`** — `bin/esbuild_all.mjs --prod`, which emits
the ESM `lib/addon-*.mjs` for every addon at once.
**Do not run `npm run setup` after the packaging build.** `setup` is the
development esbuild pass with `minify: false`. Running it afterwards overwrites
`lib/xterm.mjs` with an unminified bundle and a map that no longer matches, and
@@ -171,8 +197,30 @@ error naming it. Recovery is to move the pin to the next upstream commit whose
published bundles, and regenerate. The committed patch keeps working the whole
time — only regeneration is blocked, so this is never an outage.
## Patch Path Rooting
A published tarball is rooted at the package, so an addon's patch names
`src/TextureAtlas.ts`, not `addons/addon-webgl/src/TextureAtlas.ts`. Two places
have to agree with that, and both fail silently if they do not:
- The checkout diff passes `--relative`, which must sit **before** the `--`
separator in `CHECKOUT_DIFF_FLAGS`. After it, git reads it as a pathspec and
keeps repo-root-relative paths, and every source hunk then falls out of the
emitted patch.
- `git apply` runs from the repo root with `--directory=<packageDir>`. Run from
a subdirectory instead, git still resolves patch paths from the repo root,
skips every hunk, and **exits 0**. The generator guards this by failing when
applying a source patch leaves the checkout unchanged.
## Version Bumps
Upstream publishes each package only when its own output changes, so the four
packages carry different beta numbers while sharing one commit — at the time of
writing `@xterm/xterm@6.1.0-beta.303` and `@xterm/headless@6.1.0-beta.302` are
both built from `d3e32b3`. Match on `package.json.commit`, never on the version
string; `xterm-user-scrolling-contract.test.ts` asserts that pairing for
headless and core.
Bumping `@xterm/xterm` is:
1. Update the version in `package.json` and run `pnpm install`.
@@ -182,6 +230,9 @@ Bumping `@xterm/xterm` is:
`package.json`, and `toolchain` to whatever the new `package-lock.json`
resolves.
4. `node config/scripts/regenerate-xterm-patches.mjs --write`.
5. `pnpm install`, then `--check`. On a bump the lockfile has no entry under the
new key yet, so `--write` reports the gap and leaves the hash to `pnpm
install`; `--check` is what proves the two agree afterwards.
Step 4 is where a real upstream conflict shows up: `git apply` of the source
patch fails against the new tree. Resolve it in the checkout, re-diff, and
@@ -220,25 +271,22 @@ no build, so they run in the ordinary test shards.
## Known Gaps
`@xterm/addon-webgl` and `@xterm/addon-serialize` are still hand-edited minified
bundles. Their patches carry a literal `/* PATCH(orca): ... */` comment inside
minified code and parser round-trip artifacts, and neither patch touches its
`.map` file, so both addons currently ship sourcemaps whose offsets do not match
the shipped bundle — the defect `sourcemaps.policy` rules out for `@xterm/xterm`
and which folding them into this manifest would also fix.
`@xterm/addon-ligatures` is still patched by hand, and can stay that way: the
patch is a fifteen-line `package.json` edit that repoints `module` and adds an
`exports` block, touching no bundle and no sourcemap. Nothing about it is
generated, so there is nothing for this harness to verify.
Both addons do build from the pinned commit: the registry stamps
`53a98a720ae4a973e384fa2440880d09537132f3` on `addon-webgl@0.20.0-beta.286` and
`addon-serialize@0.15.0-beta.287` alike, despite the mismatched version numbers.
On 2026-08-17 their published `lib/*.mjs` and `lib/*.mjs.map` reproduced byte for
byte from that commit. That was a one-off measurement, not an invariant: no check
in this repo re-runs it, so treat it as a starting point to re-measure rather than
as something the harness holds true.
The addons were folded into this manifest on 2026-08-29. Before that they were
hand-edited minified bundles carrying a literal `/* PATCH(orca): ... */` comment
inside minified code, parser round-trip artifacts (`!0` printed back as `true`,
locals renamed `i` → `i5`), and no `.map` hunks at all — so both shipped
sourcemaps whose offsets did not match the bundle beside them. All four
`@xterm/addon-webgl` artifacts and all four `@xterm/addon-serialize` artifacts
now reproduce byte for byte from the pinned commit, which is what closed it.
What blocks folding them in is the other half of their published output. Both
also ship a CJS `lib/addon-*.js`, and the root `package` script does not build
it — upstream's webpack entry is core's `Terminal.js` only, and `postpackage`
emits ESM. So a manifest entry for either addon needs a build step this harness
does not have, and the CJS halves are **unverified**: nothing here has yet
reproduced them from source. Until that exists, folding them in would emit a
patch whose CJS stanza came from the current hand-edited bundle.
The one thing still unproven is that this holds across upstream revisions rather
than at this commit. `addon-serialize.js.map` did not reproduce on the first
attempt here; the cause was a stale `out/` from a wrong build order, not
upstream nondeterminism, and it reproduced exactly once the root build ran
first. Treat a future non-reproducing artifact as a build-order bug until proven
otherwise.
+1 -1
View File
@@ -105,7 +105,7 @@
"@expo/dom-webview@55.0.5": "patches/@expo__dom-webview@55.0.5.patch",
"@expo/log-box@55.0.12": "patches/@expo__log-box@55.0.12.patch",
"react-native-webview@13.16.1": "patches/react-native-webview@13.16.1.patch",
"react-native@0.83.9": "patches/react-native@0.83.9.patch"
"react-native@0.83.10": "patches/react-native@0.83.10.patch"
}
}
}
@@ -10,9 +10,9 @@ import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import type {
HostWorkspaceCreationOperations,
NewWorkspaceRepository
NewWorkspaceRepository,
NewWorkspaceRuntimeSettings
} from '../worktree/host-workspace-creation-operations'
import type { NewWorkspaceRuntimeSettings } from '../worktree/host-workspace-creation-operations'
import type { RetiredNameRegistry } from '../../../src/shared/worktree/retired-name-registry'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useHostProtocolGates } from '../components/host-protocol-gates-context'
import { useHostClient } from '../transport/client-context'
@@ -13,7 +13,6 @@ import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-iden
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
import { resolveHostRouteActionState } from '../host-route-action-state'
import { visibleHostRouteNotice } from '../host-route-notice'
import { useState } from 'react'
import { useActiveWorktreeScroll } from '../hooks/use-active-worktree-scroll'
import { useNow } from '../hooks/use-now'
import type { HostWorkspaceOperations } from '../worktree/host-workspace-operations'
+9 -9
View File
@@ -160,8 +160,8 @@
"@linear/sdk": "^82.1.0",
"@noble/hashes": "1.8.0",
"@parcel/watcher": "^2.5.6",
"@xterm/addon-serialize": "0.15.0-beta.287",
"@xterm/headless": "6.1.0-beta.287",
"@xterm/addon-serialize": "0.15.0-beta.300",
"@xterm/headless": "6.1.0-beta.302",
"agent-browser": "~0.27.0",
"electron-updater": "^6.8.9",
"i18next": "^26.3.1",
@@ -221,13 +221,13 @@
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.2.0",
"@xterm/addon-fit": "0.12.0-beta.287",
"@xterm/addon-ligatures": "0.11.0-beta.287",
"@xterm/addon-search": "0.17.0-beta.287",
"@xterm/addon-unicode11": "0.10.0-beta.287",
"@xterm/addon-web-links": "0.13.0-beta.287",
"@xterm/addon-webgl": "0.20.0-beta.286",
"@xterm/xterm": "6.1.0-beta.287",
"@xterm/addon-fit": "0.12.0-beta.300",
"@xterm/addon-ligatures": "0.11.0-beta.300",
"@xterm/addon-search": "0.17.0-beta.300",
"@xterm/addon-unicode11": "0.10.0-beta.300",
"@xterm/addon-web-links": "0.13.0-beta.300",
"@xterm/addon-webgl": "0.20.0-beta.299",
"@xterm/xterm": "6.1.0-beta.303",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+63 -63
View File
@@ -110,10 +110,10 @@ overrides:
patchedDependencies:
'@vscode/windows-process-tree@0.8.0': 9217ef36c01ed74127fef5512b0c92089cdbf820fd6c109dd671137eebdc7585
'@xterm/addon-ligatures@0.11.0-beta.287': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
'@xterm/addon-serialize@0.15.0-beta.287': af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c
'@xterm/addon-webgl@0.20.0-beta.286': 2c301a06ad9caa635d746147dcb2b1c69aa026904f65e1183564f08a0e601b91
'@xterm/xterm@6.1.0-beta.287': 0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356
'@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
'@xterm/addon-serialize@0.15.0-beta.300': 851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294
'@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e
'@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d
lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
node-pty@1.1.0: 9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17
@@ -140,11 +140,11 @@ importers:
specifier: ^2.5.6
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))
specifier: 0.15.0-beta.300
version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
specifier: 6.1.0-beta.302
version: 6.1.0-beta.302
agent-browser:
specifier: ~0.27.0
version: 0.27.0
@@ -318,26 +318,26 @@ importers:
specifier: ^5.2.0
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))
specifier: 0.12.0-beta.300
version: 0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))
specifier: 0.11.0-beta.300
version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))
specifier: 0.17.0-beta.300
version: 0.17.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))
specifier: 0.10.0-beta.300
version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))
specifier: 0.13.0-beta.300
version: 0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=2c301a06ad9caa635d746147dcb2b1c69aa026904f65e1183564f08a0e601b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))
specifier: 0.20.0-beta.299
version: 0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356)
specifier: 6.1.0-beta.303
version: 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -3502,47 +3502,47 @@ packages:
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
'@xterm/addon-fit@0.12.0-beta.287':
resolution: {integrity: sha512-2MDj+J4x67bjOS/SuBPxSYEWH38NbX6ENV18RbKVOhfRYCX3yERnuHBOrgH9hYdY8rCtsLQmuVgF6cmvCJiV2w==}
'@xterm/addon-fit@0.12.0-beta.300':
resolution: {integrity: sha512-geBeBBKqHe0Dmf+rT79wU/rglFJFja1QmKfcgmnQQhJTvAlrP+yHT2s+yy5PBeP8CtGPDvh5UcoWmunR9fBCuQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.287
'@xterm/xterm': ^6.1.0-beta.303
'@xterm/addon-ligatures@0.11.0-beta.287':
resolution: {integrity: sha512-NiCDOnrzgQe7Dkpab4QtQ8VpkcLC7p8WjyLbn1EcZxhbuNO4NmiXgiMbXYnzhyPTkdz9xOarwDRKB8ulMMTLTQ==}
'@xterm/addon-ligatures@0.11.0-beta.300':
resolution: {integrity: sha512-f+Uol/BSxHSLnqGDjixstqYHwI/cPgUOfCqP5Lc591xe/MlAgaFIBd8xMEAlUnJnz6/jIVoQMSNNhpk8a4asrQ==}
engines: {node: '>8.0.0'}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.287
'@xterm/xterm': ^6.1.0-beta.303
'@xterm/addon-search@0.17.0-beta.287':
resolution: {integrity: sha512-nFyUwT/i7i4i1BgqYhQsHIuUGhE18g0KiuoxU6e78n63zGN6RVw5jExvZ8PPa3zVuu/vf8pJ9G9gQs2OmekX4g==}
'@xterm/addon-search@0.17.0-beta.300':
resolution: {integrity: sha512-D3kPBm0qB+AWP+qzgq7JUSCMZOjYxAX9g+bbmLiCBKhgt6dWk40GVG2YgFQRuS55Guj0ooVu93Y/nKEZ7jKxQQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.287
'@xterm/xterm': ^6.1.0-beta.303
'@xterm/addon-serialize@0.15.0-beta.287':
resolution: {integrity: sha512-UPN1vUaDabYmRr3LU4uQdGzsIJ8/+zJH8OQ+kTOPWtKG3ETkwu3z2tDtSZslDSSg/LtAUfUVtrQdbsxAl5KlLQ==}
'@xterm/addon-serialize@0.15.0-beta.300':
resolution: {integrity: sha512-OCcnMMOCP+p5pIuXIro+u+wfNkSD8J2CZUqmyfTULxgs7jc+vyDL6UiaKmbs2q6MUcq4F9P5Oju3J8d4SEKOmQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.287
'@xterm/xterm': ^6.1.0-beta.303
'@xterm/addon-unicode11@0.10.0-beta.287':
resolution: {integrity: sha512-79JP5qtNh/+emyqW/1TPcFtPrmMIUgypCcx2g6Eo/vQrZF6YNaQrKkJTovMg2RoxbbRy72Oe1SZcpU2b37hzrw==}
'@xterm/addon-unicode11@0.10.0-beta.300':
resolution: {integrity: sha512-Q678teiannkxiDQtMrr/MtuYAZXc+pou7BWVlL68DTvWdYZqYVrLNjwb1E4PCzO1arEZzO9zaTOhsXfDoiz+gw==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.287
'@xterm/xterm': ^6.1.0-beta.303
'@xterm/addon-web-links@0.13.0-beta.287':
resolution: {integrity: sha512-1+5+zWtIGBOygy4yB9is0V9PwEZSz2NsRzQyNqGb+7hnTW2PXqvUCjq6GLvyUflHQw8VQ7jXtUuFE+ETVzcx+A==}
'@xterm/addon-web-links@0.13.0-beta.300':
resolution: {integrity: sha512-9RsQJadgK6cRBIC+k3P9MSTbzRB+/mF229K/shxI9vAV6DYmxRSgmxSGtoOY4MHw7nHuPUC0FxH82eK+GKQPEA==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.287
'@xterm/xterm': ^6.1.0-beta.303
'@xterm/addon-webgl@0.20.0-beta.286':
resolution: {integrity: sha512-Nvk9Jic7OCXtL1lS1/Ryurkpd2VElO7PCBOf+DEB3ODs9yU+F31sJwQD+LIdZ6GAJ+Atp7LuWHjSNhRv/xSY0w==}
'@xterm/addon-webgl@0.20.0-beta.299':
resolution: {integrity: sha512-Buf/Qyff1rzbYAhH3Wb96wkhH73XzQ0b0j/JZUqss0TlXmPGen1anA35jDR0FYpNCCXE41KKM39ZrQSBG2P3OQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.287
'@xterm/xterm': ^6.1.0-beta.303
'@xterm/headless@6.1.0-beta.287':
resolution: {integrity: sha512-3Jjh1uM9r+euj8Qosm/9SuZIVIZKMMus/Ekk9Spav842WVoFH3F1APTeHl3VynHNi1uHdEbb6QrnHzKA/iJlBQ==}
'@xterm/headless@6.1.0-beta.302':
resolution: {integrity: sha512-UZTvl9zme7wu4q8H5dvudJ7jDYk2BkXbDpiuhbDj0eDg0smoDHk5vcHDfy8zokwDCIHCZXV5Yzy45Wi7DC7dXQ==}
'@xterm/xterm@6.1.0-beta.287':
resolution: {integrity: sha512-3aOHVlxlRVb3tOHI4b/z2O/OsRnqpW5u0qq8OqpqbwLVTbUhThmQ7mBKaehGdbsmcCCBTP1cBwhG0qcvuOjowA==}
'@xterm/xterm@6.1.0-beta.303':
resolution: {integrity: sha512-tqzB2bLPxbRCxQfAdP9K7v0RoS4NgqGWKHAtS4WxqOH9dFgApDojwx5WSD173OomClx40l0cpKDfERMsLPYShA==}
abbrev@4.0.0:
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
@@ -9757,39 +9757,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))':
'@xterm/addon-fit@0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))':
'@xterm/addon-ligatures@0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))':
'@xterm/addon-search@0.17.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))':
'@xterm/addon-serialize@0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))':
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))':
'@xterm/addon-web-links@0.13.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=2c301a06ad9caa635d746147dcb2b1c69aa026904f65e1183564f08a0e601b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356))':
'@xterm/addon-webgl@0.20.0-beta.299(patch_hash=94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356)
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/headless@6.1.0-beta.302': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=0139c8795bd015a024045e24fa5dfb6fdadb17912c495f9aa9594e50061a0356)': {}
'@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)': {}
abbrev@4.0.0: {}
+4 -4
View File
@@ -40,9 +40,9 @@ overrides:
patchedDependencies:
node-pty@1.1.0: config/patches/node-pty@1.1.0.patch
'@xterm/addon-ligatures@0.11.0-beta.287': config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
'@xterm/addon-webgl@0.20.0-beta.286': config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/addon-serialize@0.15.0-beta.287': config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
'@xterm/xterm@6.1.0-beta.287': config/patches/@xterm__xterm@6.1.0-beta.287.patch
'@xterm/addon-ligatures@0.11.0-beta.300': config/patches/@xterm__addon-ligatures@0.11.0-beta.300.patch
'@xterm/addon-webgl@0.20.0-beta.299': config/patches/@xterm__addon-webgl@0.20.0-beta.299.patch
'@xterm/addon-serialize@0.15.0-beta.300': config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch
'@xterm/xterm@6.1.0-beta.303': config/patches/@xterm__xterm@6.1.0-beta.303.patch
lint-staged@16.4.0: config/patches/lint-staged@16.4.0.patch
'@vscode/windows-process-tree@0.8.0': config/patches/@vscode__windows-process-tree@0.8.0.patch
+56 -5
View File
@@ -4,7 +4,8 @@ import type {
RuntimeWorktreeListResult,
RuntimeWorktreeRecord
} from '../shared/runtime-types'
import { isPathInsideOrEqual } from '../shared/cross-platform-path'
import { isPathInsideOrEqual, isWslUncPathForCallerLinuxPath } from '../shared/cross-platform-path'
import { parseWslUncPath } from '../shared/wsl-paths'
import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime/types'
import { getOptionalStringFlag, getRequiredStringFlag } from './flags'
@@ -31,6 +32,56 @@ export function normalizeWorktreeSelector(selector: string, cwd: string): string
return selector
}
/**
* Rewrites the Linux path a WSL shell prints into the UNC path the runtime stored (#16628).
*
* Why here and not in the runtime: only the CLI sits in the distro, so only it can
* prove which distro the typed path belongs to. Translating once at this chokepoint
* covers `worktree show`, `terminal list --worktree` and `worktree rm --worktree`.
*/
export async function resolveCallerDistroPathSelector(
selector: string,
cwd: string,
client: RuntimeClient
): Promise<string> {
// Why not WSL_DISTRO_NAME: it is also set for a Linux-native CLI whose runtime stores
// POSIX paths, so it would name a distro for a caller that has none. ORCA_CLI_CWD, which
// the WSL launcher always sets and which arrives here as the invocation cwd, proves it.
const callerDistro = parseWslUncPath(cwd)?.distro
const linuxPath = selector.startsWith('path:') ? selector.slice(5) : ''
if (
!callerDistro ||
client.isRemote ||
!linuxPath.startsWith('/') ||
linuxPath.startsWith('//') ||
// Backslash is a legal Linux filename character but a separator once a path reads as UNC.
linuxPath.includes('\\')
) {
return selector
}
const worktrees = await client.call<RuntimeWorktreeListResult>('worktree.list', {
limit: 10_000
})
const match = worktrees.result.worktrees.find((worktree) =>
isWslUncPathForCallerLinuxPath(worktree.path, linuxPath, callerDistro)
)
// Why the stored spelling rather than a synthesized UNC path: an unmatched selector must
// reach the runtime verbatim and fail as the caller typed it, never as a guessed distro.
return match ? `path:${match.path}` : selector
}
export async function normalizeWorktreeSelectorForCaller(
selector: string,
cwd: string,
client: RuntimeClient
): Promise<string> {
return await resolveCallerDistroPathSelector(
normalizeWorktreeSelector(selector, cwd),
cwd,
client
)
}
function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient): void {
if (!client.isRemote) {
return
@@ -95,7 +146,7 @@ export async function getOptionalWorktreeSelector(
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
return await normalizeWorktreeSelectorForCaller(value, cwd, client)
}
export async function getRequiredWorktreeSelector(
@@ -109,7 +160,7 @@ export async function getRequiredWorktreeSelector(
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
return await normalizeWorktreeSelectorForCaller(value, cwd, client)
}
// Why: local browser commands default to the current worktree by auto-resolving
@@ -128,7 +179,7 @@ export async function getBrowserWorktreeSelector(
assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
return await normalizeWorktreeSelectorForCaller(value, cwd, client)
}
if (client.isRemote) {
return undefined
@@ -184,7 +235,7 @@ export async function getBrowserCommandTarget(
}
return {
page,
worktree: normalizeWorktreeSelector(explicitWorktree, cwd)
worktree: await normalizeWorktreeSelectorForCaller(explicitWorktree, cwd, client)
}
}
@@ -0,0 +1,120 @@
/**
* The CLI half of #16628: inside WSL the user types `/home/neil/qa-repo`, the runtime stored the
* UNC path Windows sees. Only this process sits in the distro, so only it may translate — and the
* same selector reaches `worktree rm`, so an unprovable distro must leave the path alone.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeWorktreeRecord } from '../shared/runtime-types'
import type { RuntimeClient } from './runtime-client'
import { normalizeWorktreeSelectorForCaller } from './selectors'
const UBUNTU = 'Ubuntu-24.04'
const DEBIAN = 'Debian'
const LINUX_PATH = '/home/neil/qa-repo'
function uncPath(distro: string, linuxPath: string): string {
return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
}
function worktreeRecord(path: string): RuntimeWorktreeRecord {
return { id: `repo::${path}`, path } as RuntimeWorktreeRecord
}
function makeClient(paths: readonly string[], isRemote = false) {
const call = vi.fn(async (method: string) => {
if (method !== 'worktree.list') {
throw new Error(`unexpected method ${method}`)
}
return {
result: { worktrees: paths.map(worktreeRecord), totalCount: paths.length, truncated: false }
}
})
return { client: { isRemote, call } as unknown as RuntimeClient, call }
}
afterEach(() => {
vi.unstubAllEnvs()
})
describe('normalizeWorktreeSelectorForCaller in a WSL shell (#16628)', () => {
it.each([
['a backslash UNC registration', uncPath(UBUNTU, LINUX_PATH)],
['a forward-slash UNC registration', `//wsl.localhost/${UBUNTU}${LINUX_PATH}`],
['the wsl$ alias', `\\\\wsl$\\${UBUNTU}${LINUX_PATH.replace(/\//g, '\\')}`]
])('resolves the typed Linux path to %s', async (_label, storedPath) => {
const { client } = makeClient([storedPath])
await expect(
normalizeWorktreeSelectorForCaller(
`path:${LINUX_PATH}`,
uncPath(UBUNTU, '/home/neil'),
client
)
).resolves.toBe(`path:${storedPath}`)
})
it('leaves the path alone when only another distro spells it', async () => {
const { client } = makeClient([uncPath(UBUNTU, LINUX_PATH)])
// Interop forwards WSL_DISTRO_NAME, so it must not outvote the cwd that proves the distro.
vi.stubEnv('WSL_DISTRO_NAME', UBUNTU)
await expect(
normalizeWorktreeSelectorForCaller(
`path:${LINUX_PATH}`,
uncPath(DEBIAN, '/home/neil'),
client
)
).resolves.toBe(`path:${LINUX_PATH}`)
})
it('picks the caller-distro worktree when two distros spell the same Linux path', async () => {
const { client } = makeClient([uncPath(UBUNTU, LINUX_PATH), uncPath(DEBIAN, LINUX_PATH)])
await expect(
normalizeWorktreeSelectorForCaller(
`path:${LINUX_PATH}`,
uncPath(DEBIAN, '/home/neil'),
client
)
).resolves.toBe(`path:${uncPath(DEBIAN, LINUX_PATH)}`)
})
it.each([
['a Linux-native cwd', '/home/neil', `path:${LINUX_PATH}`],
['a Windows drive cwd', 'C:\\Users\\neil', `path:${LINUX_PATH}`]
])('never lists worktrees for %s', async (_label, cwd, selector) => {
const { client, call } = makeClient([uncPath(UBUNTU, LINUX_PATH)])
vi.stubEnv('WSL_DISTRO_NAME', UBUNTU)
await expect(normalizeWorktreeSelectorForCaller(selector, cwd, client)).resolves.toBe(selector)
expect(call).not.toHaveBeenCalled()
})
it.each([
['a branch selector', 'branch:qa'],
['a relative path', 'path:qa-repo'],
// A UNC path is already the runtime's spelling; a backslash inside a Linux path has no UNC form.
['a UNC path selector', `path:${uncPath(UBUNTU, LINUX_PATH)}`],
['a Linux path containing a backslash', 'path:/home/neil/qa\\repo']
])('leaves %s untranslated', async (_label, selector) => {
const { client, call } = makeClient([uncPath(UBUNTU, LINUX_PATH)])
await expect(
normalizeWorktreeSelectorForCaller(selector, uncPath(UBUNTU, '/home/neil'), client)
).resolves.toBe(selector)
expect(call).not.toHaveBeenCalled()
})
it('leaves a remote runtime alone, whose worktrees the caller cwd cannot name', async () => {
const { client, call } = makeClient([uncPath(UBUNTU, LINUX_PATH)], true)
await expect(
normalizeWorktreeSelectorForCaller(
`path:${LINUX_PATH}`,
uncPath(UBUNTU, '/home/neil'),
client
)
).resolves.toBe(`path:${LINUX_PATH}`)
expect(call).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,213 @@
import { execFile } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import SyncDatabase from '../sqlite/sync-database'
import { runCodexAppServerSession, type CodexAppServerRpc } from './codex-app-server-session'
import { findNewestCodexStateDbPath } from './codex-state-db'
// Why this file exists: every other index-heal test drives a stub app-server and
// asserts "healed" as "the `thread/read` call did not error". That pins Orca's half
// of the contract and nothing about Codex's. The behavior Orca actually depends on
// lives in the Codex binary — a read of an unindexed rollout performs a read-repair
// that inserts the `threads` row. If Codex ever dropped that repair, the stub-driven
// tests would all stay green while the subsystem went silently inert. This is the
// real-binary backstop, built to the same shape as the Git binary compatibility
// contract in src/shared/git-binary-compatibility.test.ts.
//
// Keep it narrow. It pins the four arms that ablation established Orca relies on,
// and deliberately asserts nothing else about the app-server, so an unrelated Codex
// release does not redden it into being disabled.
const execFileAsync = promisify(execFile)
const binary = process.env.ORCA_CODEX_CONTRACT_BINARY
const expectedVersion = process.env.ORCA_CODEX_CONTRACT_VERSION
const describeCodexContract = binary ? describe : describe.skip
// Why this guard: skipping is the right local-dev default, but a CI job whose whole
// purpose is the real binary must not pass by reporting zero assertions. The job sets
// ORCA_CODEX_CONTRACT_REQUIRED=1, which turns a missing binary into a red test.
describe.runIf(process.env.ORCA_CODEX_CONTRACT_REQUIRED === '1' && !binary)(
'codex binary index-heal contract prerequisites',
() => {
it('was given a Codex binary to run against', () => {
expect.fail(
'ORCA_CODEX_CONTRACT_REQUIRED=1 but ORCA_CODEX_CONTRACT_BINARY is unset, so the contract would have silently skipped'
)
})
}
)
// Why fixed: `thread/read` never reaches the network, and a whole session is
// spawn + initialize + one RPC. A generous ceiling still fails fast on a wedged child.
const SESSION_TIMEOUT_MS = 60_000
// Why: each contract case can use three bounded app-server sessions; keep Vitest's
// watchdog longer than both child deadlines so cleanup cannot race a test timeout.
const CONTRACT_TEST_TIMEOUT_MS = SESSION_TIMEOUT_MS * 3 + 10_000
type ThreadRow = { id: string; archived: number }
describeCodexContract(
'codex binary index-heal contract',
{ timeout: CONTRACT_TEST_TIMEOUT_MS },
() => {
const disposableHomes: string[] = []
beforeAll(async () => {
// Why assert the version: the whole point of a real-binary check is that the
// binary drifts. A job that quietly ran some other Codex would report a
// contract this repo never verified.
const { stdout } = await execFileAsync(binary!, ['--version'], {
timeout: SESSION_TIMEOUT_MS
})
expect(stdout.trim()).toBe(`codex-cli ${expectedVersion}`)
})
afterEach(() => {
while (disposableHomes.length > 0) {
rmSync(disposableHomes.pop() as string, { recursive: true, force: true })
}
})
/**
* Builds a disposable CODEX_HOME in the state Orca actually heals from: Codex's
* own one-shot sqlite backfill has already run and stamped itself `complete`, so
* rollouts that appear afterwards are exactly the ones it will never index on its
* own. Never points at the user's real ~/.codex.
*/
async function createBackfilledCodexHome(): Promise<string> {
const home = mkdtempSync(join(tmpdir(), 'orca-codex-heal-contract-'))
disposableHomes.push(home)
mkdirSync(join(home, 'sessions'), { recursive: true })
// An app-server session over an empty sessions tree is what stamps the backfill complete.
await runAppServerSession(home, async () => undefined)
expect(readThreadRows(home)).toEqual([])
return home
}
function writeRollout(home: string, threadId: string, stamp: string): void {
const dayDir = join(home, 'sessions', '2026', '08', '29')
mkdirSync(dayDir, { recursive: true })
const meta = {
timestamp: '2026-08-29T21:17:33.760Z',
ordinal: 0,
type: 'session_meta',
payload: {
session_id: threadId,
id: threadId,
timestamp: '2026-08-29T21:17:26.840Z',
cwd: tmpdir(),
originator: 'codex-tui',
cli_version: expectedVersion,
source: 'cli',
thread_source: 'user',
model_provider: 'openai'
}
}
const userMessage = {
timestamp: '2026-08-29T21:17:40.000Z',
ordinal: 1,
type: 'event_msg',
payload: { type: 'user_message', message: 'index-heal contract fixture' }
}
writeFileSync(
join(dayDir, `rollout-${stamp}-${threadId}.jsonl`),
`${JSON.stringify(meta)}\n${JSON.stringify(userMessage)}\n`
)
}
// Why reuse runCodexAppServerSession rather than a local JSON-RPC client: it is the
// transport the heal itself uses, so a framing or handshake regression that would
// break the heal breaks this check too.
async function runAppServerSession<T>(
home: string,
body: (rpc: CodexAppServerRpc) => Promise<T>
): Promise<T> {
return runCodexAppServerSession(
{
command: binary!,
args: ['app-server'],
cliPath: binary!,
env: { CODEX_HOME: home },
timeoutMs: SESSION_TIMEOUT_MS
},
body
)
}
function readThreadRows(home: string): ThreadRow[] {
const stateDbPath = findNewestCodexStateDbPath(home)
if (!stateDbPath) {
return []
}
const db = new SyncDatabase(stateDbPath, { readonly: true, fileMustExist: true })
try {
return db
.prepare('SELECT id, archived FROM threads ORDER BY id')
.all() as unknown as ThreadRow[]
} finally {
db.close()
}
}
// Arms 1 and 2 are one test on purpose: the "no read" pass is the negative control
// that makes the insert causal rather than incidental. Split across two tests they
// would run against two different homes and prove nothing about each other.
it('inserts the state row because of the read, not because the server ran', async () => {
const home = await createBackfilledCodexHome()
const threadId = '01a04f62-715e-7830-9371-50db585caa71'
writeRollout(home, threadId, '2026-08-29T14-17-26')
// Control: a complete app-server session that issues no `thread/read`.
await runAppServerSession(home, async () => undefined)
expect(readThreadRows(home)).toEqual([])
await runAppServerSession(home, async (rpc) => {
await rpc.request('thread/read', { threadId })
})
expect(readThreadRows(home)).toEqual([{ id: threadId, archived: 0 }])
})
it('leaves an already-indexed thread as a single row', async () => {
const home = await createBackfilledCodexHome()
const threadId = '01a04f62-715e-7830-9371-50db585caa72'
writeRollout(home, threadId, '2026-08-29T15-00-00')
await runAppServerSession(home, async (rpc) => {
await rpc.request('thread/read', { threadId })
})
expect(readThreadRows(home)).toEqual([{ id: threadId, archived: 0 }])
await runAppServerSession(home, async (rpc) => {
await rpc.request('thread/read', { threadId })
})
expect(readThreadRows(home)).toEqual([{ id: threadId, archived: 0 }])
})
// Why this arm: the heal reads tens of thousands of rollouts. If a read cleared
// `archived`, the pass would silently resurrect every thread the user had archived.
it('stamps an archived thread archived rather than resurrecting it', async () => {
const home = await createBackfilledCodexHome()
const threadId = '01a04f62-715e-7830-9371-50db585caa73'
writeRollout(home, threadId, '2026-08-29T16-00-00')
// The archived state is created here, never assumed: a real Codex home may
// never have had a thread archived, and this arm would then pass vacuously.
await runAppServerSession(home, async (rpc) => {
await rpc.request('thread/read', { threadId })
await rpc.request('thread/archive', { threadId })
})
expect(readThreadRows(home)).toEqual([{ id: threadId, archived: 1 }])
await runAppServerSession(home, async (rpc) => {
await rpc.request('thread/read', { threadId })
})
expect(readThreadRows(home)).toEqual([{ id: threadId, archived: 1 }])
})
}
)
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { hostedReviewOptionArgs } from '../github/pr-refresh-candidate-policy'
import { getHostedReviewLocalGitOptions } from '../source-control/hosted-review-git-options'
import { gitOptionsForWorktree, gitReadOptionsForWorktree } from './git-runtime-options'
describe('git admission tier plumbing', () => {
it('preserves tiers through both runtime option constructors', () => {
expect(
gitOptionsForWorktree('/repo', { wslDistro: 'Ubuntu', admissionTier: 'interactive' })
).toEqual({ cwd: '/repo', wslDistro: 'Ubuntu', admissionTier: 'interactive' })
expect(
gitReadOptionsForWorktree('/repo', { wslDistro: 'Ubuntu', admissionTier: 'background' })
).toEqual({
cwd: '/repo',
wslDistro: 'Ubuntu',
admissionTier: 'background',
preferWslDirectGit: true
})
})
it('preserves the hosted-review execution tier beside WSL routing', () => {
expect(
getHostedReviewLocalGitOptions({
localGitExecOptions: { wslDistro: 'Ubuntu', admissionTier: 'interactive' }
})
).toEqual({ wslDistro: 'Ubuntu', admissionTier: 'interactive' })
})
it.each([
['manual', 'interactive'],
['visible', 'background'],
['active', 'background'],
['post-push', 'background'],
['swr', 'background']
] as const)('maps PR refresh reason %s to %s admission', (reason, admissionTier) => {
const [options] = hostedReviewOptionArgs(
{
localGitOptions: { wslDistro: 'Ubuntu' },
linkedPRNumber: null,
fallbackPRNumber: null,
fallbackPRSource: null,
currentHeadOid: null
},
reason
)
expect(options?.localGitExecOptions).toEqual({ wslDistro: 'Ubuntu', admissionTier })
})
})
@@ -6,11 +6,15 @@ import type { WslProcessGroupTermination } from '../wsl-process-group-terminatio
import { createAbortError } from './abort-error'
import { killSpawnedCommandTree } from './spawned-command-tree-kill'
import { DEFAULT_GIT_MAX_BUFFER } from './git-exec-options'
import type { GitAdmissionTier } from './git-exec-options'
type ExecFileCaptureOptions = Omit<ExecFileOptions, 'timeout'> & {
timeout?: number
stdin?: string
terminationBarrier?: boolean
onChildTerminated?: () => void
admissionTier?: GitAdmissionTier
createTimeoutError?: () => Error
}
const GIT_TERMINATION_BARRIER_FALLBACK_TIMEOUT_MS = 2_147_000_000
@@ -30,6 +34,7 @@ export async function execFileCaptureToTermination(
maxOutputBytes: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
signal: options.signal,
terminationBarrier: termination ?? true,
onChildTerminated: options.onChildTerminated,
...(options.stdin === undefined ? {} : { input: options.stdin })
})
const stdout = options.encoding === 'buffer' ? Buffer.from(result.stdout) : result.stdout
@@ -38,13 +43,13 @@ export async function execFileCaptureToTermination(
if (result.code === 0 && !result.timedOut && !options.signal?.aborted) {
return { stdout, stderr }
}
const error = new Error(
result.timedOut
? `${command} timed out.`
: options.signal?.aborted
? 'The operation was aborted.'
: cleanStderr.trim() || `${command} exited with ${result.code}.`
)
const error = result.timedOut
? (options.createTimeoutError?.() ?? new Error(`${command} timed out.`))
: new Error(
options.signal?.aborted
? 'The operation was aborted.'
: cleanStderr.trim() || `${command} exited with ${result.code}.`
)
if (options.signal?.aborted) {
error.name = 'AbortError'
}
@@ -80,6 +85,7 @@ export function execFileCapture(
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
options.onChildTerminated?.()
reject(createAbortError())
return
}
@@ -88,6 +94,14 @@ export function execFileCapture(
let terminating = false
let child: ChildProcess | null = null
let timer: NodeJS.Timeout | null = null
let terminationReported = false
const reportChildTerminated = (): void => {
if (terminationReported) {
return
}
terminationReported = true
options.onChildTerminated?.()
}
const cleanup = (): void => {
if (timer) {
clearTimeout(timer)
@@ -160,15 +174,20 @@ export function execFileCapture(
)
recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt)
} catch (error) {
reportChildTerminated()
finish(error instanceof Error ? error : new Error(String(error)))
return
}
child.once('error', (error) => {
if (!child?.pid) {
reportChildTerminated()
}
if (!terminating) {
finish(error)
}
})
child.once('close', reportChildTerminated)
if (options.stdin !== undefined) {
endSubprocessStdin(child.stdin, options.stdin)
@@ -181,7 +200,7 @@ export function execFileCapture(
return
}
terminating = true
const timeoutError = new Error(`${command} timed out.`)
const timeoutError = options.createTimeoutError?.() ?? new Error(`${command} timed out.`)
if (!child) {
terminating = false
finish(timeoutError)
@@ -0,0 +1,85 @@
import type { AdmissionWaiter } from './git-admission-state'
export type WaiterLane = {
items: AdmissionWaiter[]
head: number
count: number
baseEligible: boolean
headroomEligible: boolean
version: number
}
export type Candidate = { lane: WaiterLane; waiter: AdmissionWaiter; version: number }
const CANDIDATE_HEAP_COMPACTION_SLACK = 64
export class CandidateHeap {
private items: Candidate[] = []
get size(): number {
return this.items.length
}
push(candidate: Candidate): void {
this.items.push(candidate)
let index = this.items.length - 1
while (index > 0) {
const parent = Math.floor((index - 1) / 2)
if (this.items[parent].waiter.id <= candidate.waiter.id) {
break
}
this.items[index] = this.items[parent]
index = parent
}
this.items[index] = candidate
}
peek(valid: (candidate: Candidate) => boolean): Candidate | null {
// Eligibility changes invalidate by version so route updates stay O(log N).
while (this.items.length > 0 && !valid(this.items[0])) {
this.pop()
}
return this.items[0] ?? null
}
compactIfOversized(maxLiveCandidates: number, valid: (candidate: Candidate) => boolean): void {
if (this.items.length <= maxLiveCandidates * 2 + CANDIDATE_HEAP_COMPACTION_SLACK) {
return
}
this.items = this.items.filter(valid)
for (let index = Math.floor(this.items.length / 2) - 1; index >= 0; index -= 1) {
this.siftDown(index)
}
}
private pop(): void {
const last = this.items.pop()
if (!last || this.items.length === 0) {
return
}
this.items[0] = last
this.siftDown(0)
}
private siftDown(index: number): void {
const candidate = this.items[index]
let cursor = index
while (true) {
const left = cursor * 2 + 1
if (left >= this.items.length) {
break
}
const right = left + 1
const child =
right < this.items.length && this.items[right].waiter.id < this.items[left].waiter.id
? right
: left
if (this.items[child].waiter.id >= candidate.waiter.id) {
break
}
this.items[cursor] = this.items[child]
cursor = child
}
this.items[cursor] = candidate
}
}
@@ -0,0 +1,60 @@
// Runs on every platform: parity against real git is the Windows-relevant half of
// the admission evidence, so it must not share the storm harness's POSIX gate.
import { execFileSync } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { gitExecFileAsync, gitExecFileAsyncBuffer } from './git-exec-file'
import { _resetGitAdmissionForTests } from './git-subprocess-admission'
const tempRoots: string[] = []
const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED
afterEach(async () => {
if (originalAdmissionDisabled === undefined) {
delete process.env.ORCA_GIT_ADMISSION_DISABLED
} else {
process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled
}
_resetGitAdmissionForTests()
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
function setAdmissionDisabled(disabled: boolean): void {
if (disabled) {
process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
} else {
delete process.env.ORCA_GIT_ADMISSION_DISABLED
}
}
it('keeps real git output byte-identical with admission on and bypassed', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'orca-git-output-parity-'))
tempRoots.push(root)
execFileSync('git', ['init', '-q'], { cwd: root })
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root })
execFileSync('git', ['config', 'user.name', 'Orca Test'], { cwd: root })
await writeFile(path.join(root, 'tracked.txt'), 'line one\nline two\n')
await writeFile(path.join(root, 'blob.bin'), Buffer.from([0, 1, 2, 3, 255]))
execFileSync('git', ['add', '.'], { cwd: root })
execFileSync('git', ['commit', '-q', '-m', 'fixture'], { cwd: root })
await writeFile(path.join(root, 'tracked.txt'), 'line one\nchanged\n')
const runBattery = async (disabled: boolean): Promise<(string | Buffer)[]> => {
setAdmissionDisabled(disabled)
return [
(await gitExecFileAsync(['status', '--porcelain=v2'], { cwd: root })).stdout,
(await gitExecFileAsync(['diff', '--numstat'], { cwd: root })).stdout,
(await gitExecFileAsync(['branch', '-a'], { cwd: root })).stdout,
(await gitExecFileAsync(['rev-parse', 'HEAD'], { cwd: root })).stdout,
(await gitExecFileAsyncBuffer(['show', 'HEAD:blob.bin'], { cwd: root })).stdout
]
}
const enabled = await runBattery(false)
const bypassed = await runBattery(true)
expect(bypassed).toEqual(enabled)
expect(await readFile(path.join(root, 'blob.bin'))).toEqual(enabled.at(-1))
console.info('GIT_ADMISSION_OUTPUT_PARITY=byte-identical')
})
@@ -0,0 +1,168 @@
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, spawnMock, span, withGitSpanMock, startGitSpanMock } = vi.hoisted(() => {
const span = {
setAttribute: vi.fn(),
end: vi.fn(),
fail: vi.fn()
}
return {
execFileMock: vi.fn(),
spawnMock: vi.fn(),
span,
withGitSpanMock: vi.fn(async (_attributes: unknown, run: (value: typeof span) => unknown) => {
try {
const result = await run(span)
span.end()
return result
} catch (error) {
span.fail(error)
throw error
}
}),
startGitSpanMock: vi.fn(() => span)
}
})
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal()),
execFile: execFileMock,
spawn: spawnMock
}))
vi.mock('../../observability/instrumentation', () => ({
withGitSpan: withGitSpanMock,
startGitSpan: startGitSpanMock
}))
import { gitExecFileAsync, gitExecFileAsyncBuffer } from './git-exec-file'
import { withGitAdmission } from './git-spawn'
import { gitStreamStdout } from './git-stream-stdout'
import {
GitAdmissionScheduler,
_gitAdmissionSnapshotForTests,
_resetGitAdmissionForTests
} from './git-subprocess-admission'
type ExecCallback = (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void
function mockChild(): ChildProcess {
const child = new EventEmitter() as EventEmitter & Record<string, unknown>
child.pid = 1234
child.kill = vi.fn(() => true)
child.stdin = Object.assign(new EventEmitter(), { end: vi.fn() })
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
return child as unknown as ChildProcess
}
async function queueBehindBlocker(): Promise<{
release: () => void
advance: () => void
}> {
let now = 0
const scheduler = new GitAdmissionScheduler({
generalCap: 1,
generalHeadroom: 0,
now: () => now
})
_resetGitAdmissionForTests(scheduler)
const blocker = await scheduler.acquire({ args: ['status'], cwd: '/blocker' })
return {
release: blocker.release,
advance: () => {
now = 37
}
}
}
async function releaseQueued(blocker: { release: () => void; advance: () => void }): Promise<void> {
await vi.waitFor(() => expect(_gitAdmissionSnapshotForTests().queued).toBe(1))
blocker.advance()
blocker.release()
}
describe('git admission span coverage', () => {
beforeEach(() => {
execFileMock.mockReset()
spawnMock.mockReset()
span.setAttribute.mockReset()
span.end.mockReset()
span.fail.mockReset()
withGitSpanMock.mockClear()
startGitSpanMock.mockClear()
})
afterEach(() => _resetGitAdmissionForTests())
it('records queue wait for string exec inside its span', async () => {
const blocker = await queueBehindBlocker()
const child = mockChild()
let callback: ExecCallback | undefined
execFileMock.mockImplementation(
(_command: string, _args: string[], _options: unknown, received: ExecCallback) => {
callback = received
return child
}
)
const pending = gitExecFileAsync(['status'], { cwd: '/repo' })
await releaseQueued(blocker)
await vi.waitFor(() => expect(callback).toBeTypeOf('function'))
child.emit('close', 0, null)
callback?.(null, 'ok', '')
await expect(pending).resolves.toEqual({ stdout: 'ok', stderr: '' })
expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37)
expect(span.end).toHaveBeenCalledOnce()
})
it('records queue wait for buffer exec inside its span', async () => {
const blocker = await queueBehindBlocker()
const child = mockChild()
let callback: ExecCallback | undefined
execFileMock.mockImplementation(
(_command: string, _args: string[], _options: unknown, received: ExecCallback) => {
callback = received
return child
}
)
const pending = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { cwd: '/repo' })
await releaseQueued(blocker)
await vi.waitFor(() => expect(callback).toBeTypeOf('function'))
child.emit('close', 0, null)
callback?.(null, Buffer.from('blob'), Buffer.alloc(0))
await expect(pending).resolves.toEqual({ stdout: Buffer.from('blob') })
expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37)
expect(span.end).toHaveBeenCalledOnce()
})
it('records queue wait for stream exec inside its span', async () => {
const blocker = await queueBehindBlocker()
const child = mockChild()
spawnMock.mockReturnValue(child)
const pending = gitStreamStdout(['status'], { cwd: '/repo', onStdout: () => {} })
await releaseQueued(blocker)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
child.emit('close', 0, null)
await expect(pending).resolves.toEqual({ stoppedEarly: false })
expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37)
expect(span.end).toHaveBeenCalledOnce()
})
it('keeps the manual spawn span open through queue wait and child close', async () => {
const blocker = await queueBehindBlocker()
const child = mockChild()
const pending = withGitAdmission(['status'], { cwd: '/repo' }, () => child)
await releaseQueued(blocker)
await expect(pending).resolves.toBe(child)
expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37)
expect(span.end).not.toHaveBeenCalled()
child.emit('close', 0, null)
expect(span.end).toHaveBeenCalledOnce()
expect(span.fail).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,141 @@
import { availableParallelism } from 'node:os'
import type { GitAdmissionTier } from './git-exec-options'
export const GENERAL_CAP = Math.max(2, Math.min(4, availableParallelism() - 4))
export const NETWORK_CAP = 3
export const GENERAL_HEADROOM = 2
export const NETWORK_HEADROOM = 1
export const ROUTE_CAP = 2
export const ROUTE_HEADROOM = 1
export const GIT_ADMISSION_AGING_MS = 15_000
// General 4+2 and network 3+1 are disjoint, so at most ten git children run globally.
export const MAX_GIT_CHILDREN = 10
export type AdmissionClass = 'general' | 'network'
export type AdmissionSlotKind = 'base' | 'headroom'
export type GitAdmissionRequest = {
args: readonly string[]
cwd: string
wslDistro?: string
tier?: GitAdmissionTier
signal?: AbortSignal
}
export type GitAdmissionGrant = {
queueWaitMs: number
release: () => void
}
export type AdmissionBudgetSnapshot = {
key: string
baseCapacity: number
headroomCapacity: number
baseUsed: number
headroomUsed: number
}
export type GitAdmissionEvent = {
sequence: number
phase: 'grant' | 'release'
waiterId: number
args: readonly string[]
tier: GitAdmissionTier
admissionClass: AdmissionClass
route: string | null
slotKind: AdmissionSlotKind
queueWaitMs: number
queued: number
budgets: AdmissionBudgetSnapshot[]
}
export type AdmissionSchedulerConfig = {
generalCap: number
networkCap: number
generalHeadroom: number
networkHeadroom: number
routeCap: number
routeHeadroom: number
agingMs: number
now: () => number
onAdmissionEvent?: (event: GitAdmissionEvent) => void
}
export const DEFAULT_ADMISSION_SCHEDULER_CONFIG: AdmissionSchedulerConfig = {
generalCap: GENERAL_CAP,
networkCap: NETWORK_CAP,
generalHeadroom: GENERAL_HEADROOM,
networkHeadroom: NETWORK_HEADROOM,
routeCap: ROUTE_CAP,
routeHeadroom: ROUTE_HEADROOM,
agingMs: GIT_ADMISSION_AGING_MS,
now: () => performance.now()
}
export type AdmissionBudget = {
baseCapacity: number
headroomCapacity: number
baseUsed: number
headroomUsed: number
}
export type AdmissionWaiter = {
id: number
args: readonly string[]
tier: GitAdmissionTier
admissionClass: AdmissionClass
route: string | null
enqueuedAt: number
budgetKeys: readonly string[]
signal?: AbortSignal
state: 'queued' | 'granted' | 'settled'
slotKind?: AdmissionSlotKind
resolve: (grant: GitAdmissionGrant) => void
reject: (error: Error) => void
onAbort: () => void
}
type AdmissionEventDetails = {
waiter: AdmissionWaiter
slotKind: AdmissionSlotKind
phase: GitAdmissionEvent['phase']
queueWaitMs: number
queued: number
budgets: ReadonlyMap<string, AdmissionBudget>
}
export class AdmissionEventPublisher {
private nextSequence = 0
constructor(private readonly listener?: (event: GitAdmissionEvent) => void) {}
publish(details: AdmissionEventDetails): void {
if (!this.listener) {
return
}
const event: GitAdmissionEvent = {
sequence: this.nextSequence++,
phase: details.phase,
waiterId: details.waiter.id,
args: details.waiter.args,
tier: details.waiter.tier,
admissionClass: details.waiter.admissionClass,
route: details.waiter.route,
slotKind: details.slotKind,
queueWaitMs: details.queueWaitMs,
queued: details.queued,
budgets: [...details.budgets].map(([key, budget]) => ({ key, ...budget }))
}
try {
this.listener(event)
} catch {
// Measurement must never affect admission.
}
}
}
export const ADMISSION_TIER_VALUE: Record<GitAdmissionTier, number> = {
interactive: 0,
status: 1,
background: 2
}
@@ -0,0 +1,250 @@
/**
* POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path.
* Timing distributions are reported for field comparison; CI assertions stay structural.
*/
import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { gitExecFileAsync } from './git-exec-file'
import {
GIT_ADMISSION_AGING_MS,
GitAdmissionScheduler,
MAX_GIT_CHILDREN,
_gitAdmissionSnapshotForTests,
_resetGitAdmissionForTests,
type GitAdmissionEvent
} from './git-subprocess-admission'
type StormMeasurement = {
mode: 'disabled' | 'enabled'
maxConcurrentChildren: number
eventLoopMaxDriftMs: number
eventLoopP99DriftMs: number
interactiveP50Ms: number
interactiveP95Ms: number
totalWallMs: number
admissionEvents: GitAdmissionEvent[]
interactiveQueueSnapshots: InteractiveQueueSnapshot[]
}
type InteractiveQueueSnapshot = {
commandLabel: string
backgroundWaiterIds: number[]
}
const tempRoots: string[] = []
const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED
afterEach(async () => {
if (originalAdmissionDisabled === undefined) {
delete process.env.ORCA_GIT_ADMISSION_DISABLED
} else {
process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled
}
_resetGitAdmissionForTests()
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
function percentile(values: readonly number[], percentileValue: number): number {
if (values.length === 0) {
return 0
}
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)]
}
async function liveChildCount(stateDir: string): Promise<number> {
return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length
}
async function createStubGit(root: string): Promise<string> {
const binDir = path.join(root, 'bin')
await mkdir(binDir)
const stubPath = path.join(binDir, 'git')
await writeFile(
stubPath,
`#!/bin/sh
set -eu
live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live"
: > "$live"
trap 'rm -f "$live"' EXIT HUP INT TERM
sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")"
printf 'stub:%s\\n' "$*"
`
)
await chmod(stubPath, 0o755)
return binDir
}
function setAdmissionMode(mode: StormMeasurement['mode']): void {
if (mode === 'disabled') {
process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
} else {
delete process.env.ORCA_GIT_ADMISSION_DISABLED
}
}
async function measureStorm(mode: StormMeasurement['mode']): Promise<StormMeasurement> {
setAdmissionMode(mode)
const admissionEvents: GitAdmissionEvent[] = []
const admissionClockStartedAt = performance.now()
_resetGitAdmissionForTests(
new GitAdmissionScheduler({
now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1),
onAdmissionEvent: (event) => admissionEvents.push(event)
})
)
const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${mode}-`))
tempRoots.push(root)
const stateDir = path.join(root, 'state')
await mkdir(stateDir)
const binDir = await createStubGit(root)
const repoDirs = await Promise.all(
Array.from({ length: 6 }, async (_, index) => {
const repoDir = path.join(root, `repo-${index}`)
await mkdir(repoDir)
return repoDir
})
)
const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
const startedAt = performance.now()
const drifts: number[] = []
let nextJankSample = performance.now() + 50
const jankTimer = setInterval(() => {
const now = performance.now()
drifts.push(Math.max(0, now - nextJankSample))
nextJankSample = now + 50
}, 50)
let maxConcurrentChildren = 0
const censusTimer = setInterval(() => {
void liveChildCount(stateDir).then((count) => {
maxConcurrentChildren = Math.max(maxConcurrentChildren, count)
})
}, 5)
const background = Array.from({ length: 60 }, (_, index) =>
gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], {
cwd: repoDirs[index % repoDirs.length],
env: {
...baseEnv,
ORCA_STUB_ID: `background-${index}`,
ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200',
ORCA_STUB_STATE_DIR: stateDir
},
admissionTier: 'background'
})
)
const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = []
const interactiveLatencies = await Promise.all(
Array.from(
{ length: 10 },
(_, index) =>
new Promise<number>((resolve, reject) => {
setTimeout(
() => {
void (async () => {
const concurrentAtInjection = await liveChildCount(stateDir)
const commandStartedAt = performance.now()
const commandLabel = `interactive-${index}`
interactiveQueueSnapshots.push({
commandLabel,
backgroundWaiterIds: _gitAdmissionSnapshotForTests()
.queuedWaiters.filter((waiter) => waiter.tier === 'background')
.map((waiter) => waiter.id)
})
await gitExecFileAsync(['rev-parse', commandLabel], {
cwd: repoDirs[index % repoDirs.length],
env: {
...baseEnv,
ORCA_STUB_ID: `interactive-${index}`,
ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)),
ORCA_STUB_STATE_DIR: stateDir
},
admissionTier: 'interactive'
})
resolve(performance.now() - commandStartedAt)
})().catch(reject)
},
50 * (index + 1)
)
})
)
)
await Promise.all(background)
clearInterval(censusTimer)
clearInterval(jankTimer)
maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir))
const totalWallMs = performance.now() - startedAt
return {
mode,
maxConcurrentChildren,
eventLoopMaxDriftMs: Math.max(0, ...drifts),
eventLoopP99DriftMs: percentile(drifts, 0.99),
interactiveP50Ms: percentile(interactiveLatencies, 0.5),
interactiveP95Ms: percentile(interactiveLatencies, 0.95),
totalWallMs,
admissionEvents,
interactiveQueueSnapshots
}
}
function formatMeasurementTable(rows: readonly StormMeasurement[]): string {
const rounded = rows.map((row) => ({
mode: row.mode,
maxConcurrentChildren: row.maxConcurrentChildren,
eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1),
eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1),
interactiveP50Ms: row.interactiveP50Ms.toFixed(1),
interactiveP95Ms: row.interactiveP95Ms.toFixed(1),
totalWallMs: row.totalWallMs.toFixed(1)
}))
return JSON.stringify(rounded)
}
function assertAdmissionLedger(measurement: StormMeasurement): void {
const activeWaiters = new Set<number>()
const grantSequenceByWaiter = new Map<number, number>()
const grantByLabel = new Map<string, GitAdmissionEvent>()
measurement.admissionEvents.forEach((event, index) => {
expect(event.sequence).toBe(index)
if (event.phase === 'grant') {
activeWaiters.add(event.waiterId)
grantSequenceByWaiter.set(event.waiterId, event.sequence)
const label = event.args.find((arg) => arg.startsWith('interactive-'))
if (label) {
grantByLabel.set(label, event)
}
} else {
expect(activeWaiters.delete(event.waiterId)).toBe(true)
}
expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN)
for (const budget of event.budgets) {
expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity)
expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity)
}
})
expect(activeWaiters.size).toBe(0)
for (const snapshot of measurement.interactiveQueueSnapshots) {
const interactiveGrant = grantByLabel.get(snapshot.commandLabel)
expect(interactiveGrant).toBeDefined()
const preceded = snapshot.backgroundWaiterIds.filter(
(waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity)
).length
expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9))
}
}
describe.skipIf(process.platform === 'win32')('git admission storm measurement', () => {
it('reports bounded-concurrency before and after measurements', async () => {
const disabled = await measureStorm('disabled')
const enabled = await measureStorm('enabled')
console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`)
assertAdmissionLedger(enabled)
})
// Output parity lives in git-admission-output-parity.test.ts: it needs real git,
// not the PATH stub, so it runs on win32 too and cannot share this file's gate.
})
@@ -0,0 +1,249 @@
import type { AdmissionClass, AdmissionSlotKind, AdmissionWaiter } from './git-admission-state'
import { CandidateHeap, type Candidate, type WaiterLane } from './git-admission-candidate-heap'
import type { GitAdmissionTier } from './git-exec-options'
type SelectedWaiter = {
waiter: AdmissionWaiter
slotKind: AdmissionSlotKind
}
const TIERS = ['interactive', 'status', 'background'] as const
const createLane = (): WaiterLane => ({
items: [],
head: 0,
count: 0,
baseEligible: false,
headroomEligible: false,
version: 0
})
type TierLanes = Record<GitAdmissionTier, Map<string | null, WaiterLane>>
const createTierLanes = (): TierLanes => ({
interactive: new Map(),
status: new Map(),
background: new Map()
})
export class GitAdmissionWaiterQueue {
private readonly lanes: Record<AdmissionClass, TierLanes> = {
general: createTierLanes(),
network: createTierLanes()
}
private readonly countsByBudget = new Map<string, number>()
private readonly baseCandidates: Record<AdmissionClass, Record<GitAdmissionTier, CandidateHeap>> =
{
general: {
interactive: new CandidateHeap(),
status: new CandidateHeap(),
background: new CandidateHeap()
},
network: {
interactive: new CandidateHeap(),
status: new CandidateHeap(),
background: new CandidateHeap()
}
}
private readonly headroomCandidates: Record<AdmissionClass, CandidateHeap> = {
general: new CandidateHeap(),
network: new CandidateHeap()
}
private totalCount = 0
get count(): number {
return this.totalCount
}
/** @internal - exposed for bounded-storage regression tests only. */
get candidateCountForTests(): number {
let count = this.headroomCandidates.general.size + this.headroomCandidates.network.size
for (const admissionClass of ['general', 'network'] as const) {
for (const tier of TIERS) {
count += this.baseCandidates[admissionClass][tier].size
}
}
return count
}
enqueue(waiter: AdmissionWaiter): void {
const lanes = this.lanes[waiter.admissionClass][waiter.tier]
let lane = lanes.get(waiter.route)
if (!lane) {
lane = createLane()
lanes.set(waiter.route, lane)
}
lane.items.push(waiter)
lane.count += 1
this.totalCount += 1
for (const key of waiter.budgetKeys) {
this.countsByBudget.set(key, (this.countsByBudget.get(key) ?? 0) + 1)
}
if (lane.count === 1) {
this.publishLaneHead(waiter.admissionClass, waiter.tier, lane)
}
}
dequeue(waiter: AdmissionWaiter): void {
const lanes = this.lanes[waiter.admissionClass][waiter.tier]
const lane = lanes.get(waiter.route)
if (!lane) {
return
}
lane.count -= 1
this.totalCount -= 1
for (const key of waiter.budgetKeys) {
const current = this.countsByBudget.get(key)
if (current === 1) {
this.countsByBudget.delete(key)
} else if (current !== undefined) {
this.countsByBudget.set(key, current - 1)
}
}
const previousHead = lane.items[lane.head]
this.compact(lane)
if (lane.count === 0) {
lane.version += 1
lanes.delete(waiter.route)
this.compactCandidateHeaps(waiter.admissionClass, waiter.tier)
} else if (lane.items[lane.head] !== previousHead) {
lane.version += 1
this.publishLaneHead(waiter.admissionClass, waiter.tier, lane)
this.compactCandidateHeaps(waiter.admissionClass, waiter.tier)
}
}
updateRouteEligibility(
admissionClass: AdmissionClass,
route: string | null,
baseEligible: boolean,
headroomEligible: boolean
): void {
for (const tier of TIERS) {
const lane = this.lanes[admissionClass][tier].get(route)
if (
!lane ||
(lane.baseEligible === baseEligible && lane.headroomEligible === headroomEligible)
) {
continue
}
lane.baseEligible = baseEligible
lane.headroomEligible = headroomEligible
lane.version += 1
this.publishLaneHead(admissionClass, tier, lane)
this.compactCandidateHeaps(admissionClass, tier)
}
}
hasBudget(key: string): boolean {
return this.countsByBudget.has(key)
}
snapshot(): AdmissionWaiter[] {
return (['general', 'network'] as const)
.flatMap((admissionClass) =>
TIERS.flatMap((tier) => {
return [...this.lanes[admissionClass][tier].values()].flatMap((lane) =>
lane.items.slice(lane.head).filter((waiter) => waiter.state === 'queued')
)
})
)
.sort((left, right) => left.id - right.id)
}
nextFitting(
admissionClass: AdmissionClass,
effectiveTier: (waiter: AdmissionWaiter) => number,
allowBase: boolean,
allowHeadroom: boolean,
abort: (waiter: AdmissionWaiter) => void
): SelectedWaiter | null {
while (true) {
const candidates: SelectedWaiter[] = []
if (allowBase) {
// Aging preserves order within a raw tier, so only its oldest eligible head can win.
for (const tier of TIERS) {
const candidate = this.peekValid(this.baseCandidates[admissionClass][tier], 'base')
if (candidate) {
candidates.push({ waiter: candidate.waiter, slotKind: 'base' })
}
}
}
if (allowHeadroom) {
const candidate = this.peekValid(this.headroomCandidates[admissionClass], 'headroom')
if (candidate) {
candidates.push({ waiter: candidate.waiter, slotKind: 'headroom' })
}
}
const selected = candidates.sort(
(left, right) =>
effectiveTier(left.waiter) - effectiveTier(right.waiter) ||
left.waiter.id - right.waiter.id ||
(left.slotKind === 'base' ? -1 : 1)
)[0]
if (!selected || !selected.waiter.signal?.aborted) {
return selected ?? null
}
abort(selected.waiter)
}
}
private publishLaneHead(
admissionClass: AdmissionClass,
tier: GitAdmissionTier,
lane: WaiterLane
): void {
const waiter = lane.items[lane.head]
if (!waiter || waiter.state !== 'queued') {
return
}
const candidate = { lane, waiter, version: lane.version }
if (lane.baseEligible) {
this.baseCandidates[admissionClass][tier].push(candidate)
}
if (tier === 'interactive' && lane.headroomEligible) {
this.headroomCandidates[admissionClass].push(candidate)
}
}
private peekValid(heap: CandidateHeap, slotKind: AdmissionSlotKind): Candidate | null {
return heap.peek((candidate) => this.candidateIsValid(candidate, slotKind))
}
private candidateIsValid(candidate: Candidate, slotKind: AdmissionSlotKind): boolean {
const { lane, waiter, version } = candidate
return (
version === lane.version &&
waiter === lane.items[lane.head] &&
waiter.state === 'queued' &&
(slotKind === 'base' ? lane.baseEligible : lane.headroomEligible)
)
}
private compactCandidateHeaps(admissionClass: AdmissionClass, tier: GitAdmissionTier): void {
const liveLaneCount = this.lanes[admissionClass][tier].size
this.baseCandidates[admissionClass][tier].compactIfOversized(liveLaneCount, (candidate) =>
this.candidateIsValid(candidate, 'base')
)
if (tier === 'interactive') {
this.headroomCandidates[admissionClass].compactIfOversized(liveLaneCount, (candidate) =>
this.candidateIsValid(candidate, 'headroom')
)
}
}
private compact(lane: WaiterLane): void {
let head = lane.head
while (head < lane.items.length && lane.items[head].state !== 'queued') {
head += 1
}
lane.head = head
if (lane.count === 0) {
lane.items.length = 0
lane.head = 0
return
}
const tombstones = lane.items.length - head - lane.count
if (head < 256 && (lane.items.length < 256 || tombstones <= lane.count)) {
return
}
lane.items = lane.items.slice(head).filter((candidate) => candidate.state === 'queued')
lane.head = 0
}
}
@@ -0,0 +1,144 @@
import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { gitExecFileAsync, gitExecFileAsyncBuffer } from './git-exec-file'
import { GitCommandTimeoutError } from './git-command-timeout'
import { gitStreamStdout } from './git-stream-stdout'
const tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function createTimedGitFixture(): Promise<{
cwd: string
env: NodeJS.ProcessEnv
}> {
const root = await mkdtemp(path.join(tmpdir(), 'orca-git-timeout-'))
tempRoots.push(root)
const binDir = path.join(root, 'bin')
const cwd = path.join(root, 'repo')
await mkdir(binDir)
await mkdir(cwd)
const script = path.join(binDir, 'git')
await writeFile(
script,
`#!/usr/bin/env node
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function run() {
if (process.env.ORCA_STUB_PROGRESSIVE === '1') {
const remaining = Math.max(0, Number(process.env.ORCA_STUB_EXIT_AT_MS) - Date.now())
const step = remaining / 3
await sleep(step)
process.stdout.write('one')
await sleep(step)
process.stdout.write('two')
await sleep(step)
process.stdout.write('three')
return
}
await sleep(Number(process.env.ORCA_STUB_SLEEP_MS))
process.stdout.write('done')
}
void run()
`
)
await chmod(script, 0o755)
return {
cwd,
env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
}
}
describe.skipIf(process.platform === 'win32')('git read timeout behavior', () => {
it('allows a progressively streaming read that exits at 0.9 times the deadline', async () => {
const fixture = await createTimedGitFixture()
let output = ''
await expect(
gitStreamStdout(['status', '--porcelain=v2'], {
...fixture,
env: {
...fixture.env,
ORCA_STUB_PROGRESSIVE: '1',
ORCA_STUB_EXIT_AT_MS: String(Date.now() + 900)
},
timeoutMsForTest: 1000,
onStdout: (chunk) => {
output += chunk
}
})
).resolves.toEqual({ stoppedEarly: false })
expect(output).toBe('onetwothree')
})
it('rejects a silent read at 1.1 times the deadline with a typed error', async () => {
const fixture = await createTimedGitFixture()
await expect(
gitExecFileAsync(['status', '--porcelain=v2'], {
...fixture,
env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '110' },
timeoutMsForTest: 100
})
).rejects.toBeInstanceOf(GitCommandTimeoutError)
})
it('times out a silent streaming read with the same typed error', async () => {
const fixture = await createTimedGitFixture()
await expect(
gitStreamStdout(['status'], {
...fixture,
env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '110' },
timeoutMsForTest: 100,
onStdout: () => {}
})
).rejects.toBeInstanceOf(GitCommandTimeoutError)
})
it('applies the read default to binary blob reads', async () => {
const fixture = await createTimedGitFixture()
await expect(
gitExecFileAsyncBuffer(['show', 'HEAD:file.bin'], {
...fixture,
env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '110' },
timeoutMsForTest: 100
})
).rejects.toBeInstanceOf(GitCommandTimeoutError)
})
it.each([['fetch'], ['checkout'], ['unrecognized-command']])(
'does not default-timeout the fail-safe %s class',
async (subcommand) => {
const fixture = await createTimedGitFixture()
await expect(
gitExecFileAsync([subcommand], {
...fixture,
env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '110' },
timeoutMsForTest: 100
})
).resolves.toMatchObject({ stdout: 'done' })
}
)
})
describe.skipIf(process.platform === 'win32' || process.env.ORCA_RUN_SLOW_GIT_SMOKE !== '1')(
'slow git read timeout smoke',
() => {
it('allows a 90 second read and terminates a 130 second wedge', async () => {
const fixture = await createTimedGitFixture()
await expect(
gitExecFileAsync(['status'], {
...fixture,
env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '90000' }
})
).resolves.toMatchObject({ stdout: 'done' })
await expect(
gitExecFileAsync(['status'], {
...fixture,
env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '130000' }
})
).rejects.toBeInstanceOf(GitCommandTimeoutError)
}, 230_000)
}
)
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
GIT_READ_TIMEOUT_MS,
GitCommandTimeoutError,
gitCommandTimeoutMs
} from './git-command-timeout'
describe('gitCommandTimeoutMs', () => {
it('pins the production read deadline', () => {
expect(GIT_READ_TIMEOUT_MS).toBe(120_000)
})
it.each([
[['status', '--porcelain=v2'], 120_000],
[['show', 'HEAD:file'], 120_000],
[['fetch', 'origin'], undefined],
[['checkout', 'main'], undefined],
[['unknown'], undefined]
] as const)('selects the fail-safe default for %j', (args, expected) => {
expect(gitCommandTimeoutMs(args, undefined)).toBe(expected)
})
it('preserves every explicit timeout', () => {
expect(gitCommandTimeoutMs(['status'], 7)).toBe(7)
expect(gitCommandTimeoutMs(['fetch'], 7)).toBe(7)
})
it('provides a deterministic read-timeout seam', () => {
expect(gitCommandTimeoutMs(['status'], undefined, 25)).toBe(25)
})
})
describe('GitCommandTimeoutError', () => {
it('is typed and retains its deadline', () => {
const error = new GitCommandTimeoutError(25)
expect(error).toBeInstanceOf(Error)
expect(error).toMatchObject({ name: 'GitCommandTimeoutError', timeoutMs: 25 })
})
})
@@ -0,0 +1,23 @@
import { classifyGitCommand } from '../wsl-direct-git-read-commands'
export const GIT_READ_TIMEOUT_MS = 120_000
export class GitCommandTimeoutError extends Error {
readonly timeoutMs: number
constructor(timeoutMs: number) {
super('git timed out.')
this.name = 'GitCommandTimeoutError'
this.timeoutMs = timeoutMs
}
}
export function gitCommandTimeoutMs(
args: readonly string[],
explicitTimeoutMs: number | undefined,
defaultReadTimeoutMs = GIT_READ_TIMEOUT_MS
): number | undefined {
return (
explicitTimeoutMs ?? (classifyGitCommand(args) === 'read' ? defaultReadTimeoutMs : undefined)
)
}
@@ -0,0 +1,237 @@
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
execFileMock,
spawnMock,
killSpawnedCommandTreeMock,
signalProcessTreeMock,
forceTerminateProcessTreeMock
} = vi.hoisted(() => ({
execFileMock: vi.fn(),
spawnMock: vi.fn(),
killSpawnedCommandTreeMock: vi.fn().mockResolvedValue(undefined),
signalProcessTreeMock: vi.fn().mockResolvedValue(false),
forceTerminateProcessTreeMock: vi.fn().mockResolvedValue(false)
}))
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal()),
execFile: execFileMock,
spawn: spawnMock
}))
vi.mock('./spawned-command-tree-kill', () => ({
killSpawnedCommandTree: killSpawnedCommandTreeMock
}))
vi.mock('../../../shared/child-process/process-tree-termination', () => ({
signalProcessTree: signalProcessTreeMock,
forceTerminateProcessTree: forceTerminateProcessTreeMock
}))
import { gitExecFileAsync, gitExecFileAsyncBuffer } from './git-exec-file'
import { execFileCapture } from './exec-file-capture'
import {
GitAdmissionScheduler,
_gitAdmissionSnapshotForTests,
_resetGitAdmissionForTests
} from './git-subprocess-admission'
type ExecCallback = (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void
function mockChild(pid: number | undefined = 1234): ChildProcess {
const child = new EventEmitter() as EventEmitter & Record<string, unknown>
child.pid = pid
child.kill = vi.fn(() => true)
child.stdin = Object.assign(new EventEmitter(), { end: vi.fn() })
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
return child as unknown as ChildProcess
}
describe('git exec admission lifetime', () => {
beforeEach(() => {
vi.useFakeTimers()
execFileMock.mockReset()
spawnMock.mockReset()
killSpawnedCommandTreeMock.mockClear()
_resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 1 }))
})
afterEach(() => {
vi.useRealTimers()
_resetGitAdmissionForTests()
})
it('retains the string-exec permit after timeout settlement until close', async () => {
const child = mockChild()
execFileMock.mockReturnValue(child)
const pending = gitExecFileAsync(['status'], { cwd: '/repo', timeout: 10 })
const rejection = expect(pending).rejects.toThrow('timed out')
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(10)
await rejection
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1)
child.emit('close', null, 'SIGKILL')
await Promise.resolve()
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0)
})
it('retains the buffer-exec permit after maxBuffer settlement until close', async () => {
const child = mockChild()
let callback: ExecCallback | undefined
execFileMock.mockImplementation(
(_command: string, _args: string[], _options: unknown, received: ExecCallback) => {
callback = received
return child
}
)
const pending = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { cwd: '/repo' })
await vi.waitFor(() => expect(callback).toBeTypeOf('function'))
callback?.(new Error('maxBuffer exceeded'), Buffer.alloc(0), Buffer.alloc(0))
await expect(pending).rejects.toThrow('maxBuffer exceeded')
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1)
child.emit('close', null, 'SIGTERM')
await Promise.resolve()
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0)
})
it('does not admit a same-repo fetch while it waits for the FETCH_HEAD lock', async () => {
_resetGitAdmissionForTests(new GitAdmissionScheduler({ networkCap: 2, networkHeadroom: 0 }))
const children = [mockChild(1001), mockChild(1002)]
const callbacks: ExecCallback[] = []
execFileMock.mockImplementation(
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
callbacks.push(callback)
return children[callbacks.length - 1]
}
)
const first = gitExecFileAsync(['fetch', 'origin'], { cwd: '/same-repo' })
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce())
const second = gitExecFileAsync(['fetch', 'origin'], { cwd: '/same-repo' })
await Promise.resolve()
expect(execFileMock).toHaveBeenCalledOnce()
expect(_gitAdmissionSnapshotForTests()).toMatchObject({
queued: 0,
budgets: { network: { baseUsed: 1, headroomUsed: 0 } }
})
callbacks[0]?.(null, '', '')
children[0].emit('close', 0, null)
await expect(first).resolves.toEqual({ stdout: '', stderr: '' })
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(2))
callbacks[1]?.(null, '', '')
children[1].emit('close', 0, null)
await expect(second).resolves.toEqual({ stdout: '', stderr: '' })
expect(_gitAdmissionSnapshotForTests().budgets.network?.baseUsed).toBe(0)
})
it('reports pre-aborted capture as a no-child termination', async () => {
const controller = new AbortController()
const onChildTerminated = vi.fn()
controller.abort()
await expect(
execFileCapture('git', ['status'], {
signal: controller.signal,
onChildTerminated
})
).rejects.toMatchObject({ name: 'AbortError' })
expect(execFileMock).not.toHaveBeenCalled()
expect(onChildTerminated).toHaveBeenCalledOnce()
})
it('releases termination-barrier admission on confirmed close', async () => {
const child = mockChild()
spawnMock.mockReturnValue(child)
const pending = gitExecFileAsync(['status'], {
cwd: '/repo',
terminationBarrier: true
})
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1)
child.emit('close', 0, null)
await expect(pending).resolves.toEqual({ stdout: '', stderr: '' })
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0)
})
it('retains barrier admission past bounded settlement until termination is observed', async () => {
const child = mockChild()
spawnMock.mockReturnValue(child)
const pending = gitExecFileAsync(['status'], {
cwd: '/repo',
terminationBarrier: true,
timeout: 10
})
const rejection = expect(pending).rejects.toThrow('timed out')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(2010)
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1)
await vi.advanceTimersByTimeAsync(10_000)
await rejection
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1)
child.emit('close', null, 'SIGKILL')
await Promise.resolve()
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0)
})
it('serializes FETCH_HEAD callers before they enter admission', async () => {
_resetGitAdmissionForTests(new GitAdmissionScheduler({ networkCap: 1, networkHeadroom: 1 }))
const children = new Map<string, ChildProcess>()
const callbacks = new Map<string, ExecCallback>()
execFileMock.mockImplementation(
(_command: string, args: string[], _options: unknown, callback: ExecCallback) => {
const label = args[1] ?? ''
const child = mockChild()
children.set(label, child)
callbacks.set(label, callback)
return child
}
)
const first = gitExecFileAsync(['fetch', 'first'], {
cwd: '/repo',
admissionTier: 'background'
})
await vi.waitFor(() => expect(callbacks.has('first')).toBe(true))
const background = gitExecFileAsync(['fetch', 'background'], {
cwd: '/repo',
admissionTier: 'background'
})
const interactive = gitExecFileAsync(['fetch', 'interactive'], {
cwd: '/repo',
admissionTier: 'interactive'
})
await Promise.resolve()
expect(_gitAdmissionSnapshotForTests().queued).toBe(0)
expect([...callbacks.keys()]).toEqual(['first'])
callbacks.get('first')?.(null, '', '')
await expect(first).resolves.toEqual({ stdout: '', stderr: '' })
await vi.waitFor(() => expect(_gitAdmissionSnapshotForTests().queued).toBe(1))
expect([...callbacks.keys()]).toEqual(['first'])
children.get('first')?.emit('close', 0, null)
await vi.waitFor(() => expect(callbacks.has('background')).toBe(true))
expect([...callbacks.keys()]).toEqual(['first', 'background'])
callbacks.get('background')?.(null, '', '')
await expect(background).resolves.toEqual({ stdout: '', stderr: '' })
await vi.waitFor(() => expect(callbacks.has('interactive')).toBe(true))
children.get('background')?.emit('close', 0, null)
callbacks.get('interactive')?.(null, '', '')
await expect(interactive).resolves.toEqual({ stdout: '', stderr: '' })
children.get('interactive')?.emit('close', 0, null)
})
})
+134 -55
View File
@@ -10,7 +10,7 @@ import {
prepareWslLinkedWorktreeGitRouting
} from '../wsl-linked-worktree-git-routing'
import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution'
import type { GitExecOptions } from './git-exec-options'
import type { GitAdmissionTier, GitExecOptions } from './git-exec-options'
import { execFileCapture, execFileCaptureToTermination } from './exec-file-capture'
import {
pendingWslDirectGitReadEnvironment,
@@ -22,6 +22,8 @@ import {
import { prepareWindowsHostGitEnvironment } from './windows-host-git-environment'
import { buildNetworkSshPolicyEnv } from './git-ssh-policy-env'
import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-process-env'
import { acquireGitAdmission } from './git-subprocess-admission'
import { GitCommandTimeoutError, gitCommandTimeoutMs } from './git-command-timeout'
/**
* Async git command execution. Drop-in replacement for
@@ -34,7 +36,7 @@ async function gitExecFileAsyncUnlocked(
// Why: span the user-visible `git <subcommand>` form, not the resolved binary, so dashboards group by intent.
return withGitSpan(
{ args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) },
async () => {
async (span) => {
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, {
signal: options.signal
@@ -61,18 +63,37 @@ async function gitExecFileAsyncUnlocked(
const policy = effectiveOptions.useConfiguredSshCommandForNetwork
? await buildNetworkSshPolicyEnv(effectiveOptions)
: { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const }
const grant = await acquireGitAdmission({
args,
cwd: options.cwd,
wslDistro: options.wslDistro,
tier: options.admissionTier,
signal: options.signal
})
span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs)
const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest)
const terminationState: { current: Promise<void> | null } = { current: null }
const capture = (
command: ResolvedCommand
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => {
let reportTerminated: () => void = () => {}
terminationState.current = new Promise<void>((resolve) => {
reportTerminated = resolve
})
const captureOptions = {
cwd: command.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
timeout: timeoutMs,
stdin: options.stdin,
env: policy.env,
signal: options.signal,
terminationBarrier: options.terminationBarrier
terminationBarrier: options.terminationBarrier,
admissionTier: options.admissionTier,
onChildTerminated: reportTerminated,
...(timeoutMs === undefined
? {}
: { createTimeoutError: () => new GitCommandTimeoutError(timeoutMs) })
}
return options.terminationBarrier
? execFileCaptureToTermination(
@@ -83,34 +104,50 @@ async function gitExecFileAsyncUnlocked(
)
: execFileCapture(command.binary, command.args, captureOptions)
}
let result: { stdout: string | Buffer; stderr: string | Buffer }
try {
result = await capture(resolved)
} catch (error) {
if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) {
const wasMissing = invalidateMissingDirectWslGit(error, resolved)
const fallback = resolveGitCommand(
args,
effectiveOptions,
true,
effectiveOptions.captureWslLoginShellOutput
)
result = await capture(fallback)
// Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient.
disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved)
const { stdout, stderr } = result
return {
stdout: readCapturedGitString(stdout as string, fallback),
stderr: stderr as string
const runCapturedCommand = async (): Promise<{ stdout: string; stderr: string }> => {
let result: { stdout: string | Buffer; stderr: string | Buffer }
try {
result = await capture(resolved)
} catch (error) {
if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) {
await terminationState.current
const wasMissing = invalidateMissingDirectWslGit(error, resolved)
const fallback = resolveGitCommand(
args,
effectiveOptions,
true,
effectiveOptions.captureWslLoginShellOutput
)
result = await capture(fallback)
// Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient.
disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved)
const { stdout, stderr } = result
return {
stdout: readCapturedGitString(stdout as string, fallback),
stderr: stderr as string
}
}
if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') {
Object.assign(error, { gitSshPolicyMode: policy.mode })
}
throw error
}
if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') {
Object.assign(error, { gitSshPolicyMode: policy.mode })
const { stdout, stderr } = result
return {
stdout: readCapturedGitString(stdout as string, resolved),
stderr: stderr as string
}
}
try {
return await runCapturedCommand()
} finally {
const termination = terminationState.current
if (termination) {
void termination.then(grant.release)
} else {
grant.release()
}
throw error
}
const { stdout, stderr } = result
return { stdout: readCapturedGitString(stdout as string, resolved), stderr: stderr as string }
}
)
}
@@ -119,11 +156,15 @@ export function gitExecFileAsync(
args: string[],
options: GitExecOptions
): Promise<{ stdout: string; stderr: string }> {
const run = () => gitExecFileAsyncUnlocked(args, options)
const command = resolveGitFetchHeadCommand(args, options.cwd)
return command.needsLock
? runWithGitFetchHeadLock(command.cwd, options.signal, run, command.gitDir)
: run()
? runWithGitFetchHeadLock(
command.cwd,
options.signal,
() => gitExecFileAsyncUnlocked(args, options),
command.gitDir
)
: gitExecFileAsyncUnlocked(args, options)
}
/**
@@ -132,31 +173,69 @@ export function gitExecFileAsync(
*/
export async function gitExecFileAsyncBuffer(
args: string[],
options: { cwd: string; maxBuffer?: number; wslDistro?: string; preferWslDirectGit?: boolean }
options: {
cwd: string
maxBuffer?: number
timeout?: number
timeoutMsForTest?: number
env?: NodeJS.ProcessEnv
wslDistro?: string
preferWslDirectGit?: boolean
admissionTier?: GitAdmissionTier
}
): Promise<{ stdout: Buffer }> {
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro)
}
const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options)
if (readEnvironmentReady) {
await readEnvironmentReady
}
// `git show` is a read, so this normally runs with no shell at all. The fence
// still matters for the login-shell fallback: these are raw blob bytes going
// straight to the diff/blob viewer, where a banner becomes file content.
let resolved = resolveGitCommand(args, options, false, true)
const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined)
if (environmentReady) {
await environmentReady
}
resolved = resolveGitCommand(args, options, false, true)
const { stdout } = (await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'buffer',
maxBuffer: options.maxBuffer,
env: untranslatedGitOutputEnv()
})) as { stdout: Buffer }
return { stdout: readCapturedGitBuffer(stdout, resolved) }
return withGitSpan({ args, cwd: options.cwd }, async (span) => {
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro)
}
const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options)
if (readEnvironmentReady) {
await readEnvironmentReady
}
// `git show` is a read, so this normally runs with no shell at all. The fence
// still matters for the login-shell fallback: these are raw blob bytes going
// straight to the diff/blob viewer, where a banner becomes file content.
let resolved = resolveGitCommand(args, options, false, true)
const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined)
if (environmentReady) {
await environmentReady
}
resolved = resolveGitCommand(args, options, false, true)
const grant = await acquireGitAdmission({
args,
cwd: options.cwd,
wslDistro: options.wslDistro,
tier: options.admissionTier
})
span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs)
const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest)
let termination: Promise<void> | null = null
try {
let reportTerminated: () => void = () => {}
termination = new Promise<void>((resolve) => {
reportTerminated = resolve
})
const { stdout } = (await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'buffer',
maxBuffer: options.maxBuffer,
timeout: timeoutMs,
env: untranslatedGitOutputEnv(options.env),
admissionTier: options.admissionTier,
onChildTerminated: reportTerminated,
...(timeoutMs === undefined
? {}
: { createTimeoutError: () => new GitCommandTimeoutError(timeoutMs) })
})) as { stdout: Buffer }
return { stdout: readCapturedGitBuffer(stdout, resolved) }
} finally {
if (termination) {
void termination.then(grant.release)
} else {
grant.release()
}
}
})
}
/**
@@ -1,11 +1,15 @@
// Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay MAX_GIT_BUFFER.
export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024
export type GitAdmissionTier = 'interactive' | 'status' | 'background'
export type GitExecOptions = {
cwd: string
encoding?: BufferEncoding | 'buffer'
maxBuffer?: number
timeout?: number
/** Overrides only the default read deadline in tests; explicit timeout still wins. */
timeoutMsForTest?: number
stdin?: string
env?: NodeJS.ProcessEnv
signal?: AbortSignal
@@ -14,4 +18,6 @@ export type GitExecOptions = {
useConfiguredSshCommandForNetwork?: boolean
terminationBarrier?: boolean
captureWslLoginShellOutput?: boolean
/** Scheduler priority for this child; status is the safe default. */
admissionTier?: GitAdmissionTier
}
@@ -0,0 +1,79 @@
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { withGitAdmission } from './git-spawn'
import {
GitAdmissionScheduler,
_gitAdmissionSnapshotForTests,
_resetGitAdmissionForTests
} from './git-subprocess-admission'
function mockChild(pid: number | undefined = 1234): ChildProcess {
const child = new EventEmitter() as EventEmitter & Record<string, unknown>
child.pid = pid
child.kill = vi.fn(() => true)
return child as unknown as ChildProcess
}
describe('git spawn admission lifetime', () => {
beforeEach(() => {
_resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 1 }))
})
afterEach(() => _resetGitAdmissionForTests())
it('releases a normally closed child exactly once', async () => {
const child = mockChild()
await withGitAdmission(['status'], { cwd: '/repo' }, () => child)
child.emit('close', 0, null)
child.emit('close', 0, null)
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0)
})
it('retains an early-killed child until eventual close', async () => {
const child = mockChild()
await withGitAdmission(['status'], { cwd: '/repo' }, () => child)
child.kill()
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1)
child.emit('close', null, 'SIGTERM')
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0)
})
it('retains a live child error until eventual close and releases once', async () => {
const child = mockChild()
await withGitAdmission(['status'], { cwd: '/repo' }, () => child)
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1)
child.emit('error', new Error('kill delivery failed'))
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1)
child.emit('close', null, 'SIGKILL')
child.emit('close', null, 'SIGKILL')
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0)
})
it('releases a no-PID spawn error without waiting for close', async () => {
const child = mockChild(0)
await withGitAdmission(['status'], { cwd: '/repo' }, () => child)
child.emit('error', new Error('ENOENT'))
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0)
})
it('aborts while queued without releasing the running child', async () => {
const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 })
_resetGitAdmissionForTests(scheduler)
const running = await scheduler.acquire({ args: ['status'], cwd: '/repo' })
const controller = new AbortController()
const pending = withGitAdmission(['status'], { cwd: '/repo', signal: controller.signal }, () =>
mockChild()
)
controller.abort()
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1)
running.release()
})
})
+73 -3
View File
@@ -1,15 +1,22 @@
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
import { startGitSpan } from '../../observability/instrumentation'
import { createAbortError } from './abort-error'
import { resolveGitCommand } from './git-command-resolution'
import { untranslatedGitOutputEnv } from './git-process-env'
import { prepareWindowsHostGitEnvironment } from './windows-host-git-environment'
import type { GitAdmissionTier } from './git-exec-options'
import { acquireGitAdmission } from './git-subprocess-admission'
/**
* Spawn a git child process. Drop-in replacement for
* `spawn('git', args, { cwd, stdio, ... })`.
*/
export type GitSpawnOptions = SpawnOptions & { cwd: string; wslDistro?: string }
export type GitSpawnOptions = SpawnOptions & {
cwd: string
wslDistro?: string
admissionTier?: GitAdmissionTier
}
export async function gitSpawnAfterWindowsEnvironmentReady(
args: string[],
@@ -28,11 +35,74 @@ export async function gitSpawnAfterWindowsEnvironmentReady(
if (options.signal?.aborted) {
throw createAbortError()
}
return gitSpawn(args, env === options.env ? options : { ...options, env })
return withGitAdmission(args, env === options.env ? options : { ...options, env })
}
export async function withGitAdmission(
args: string[],
options: GitSpawnOptions,
spawnChild: () => ChildProcess = () => gitSpawn(args, options)
): Promise<ChildProcess> {
const span = startGitSpan({ args, cwd: options.cwd })
let grant: Awaited<ReturnType<typeof acquireGitAdmission>> | null = null
try {
grant = await acquireGitAdmission({
args,
cwd: options.cwd,
wslDistro: options.wslDistro,
tier: options.admissionTier,
signal: options.signal
})
span.setAttribute('git.queue_wait_ms', grant.queueWaitMs)
if (options.signal?.aborted) {
grant.release()
span.fail(createAbortError())
throw createAbortError()
}
const child = spawnChild()
let finalized = false
let liveError: Error | null = null
const finalize = (error?: Error): void => {
if (finalized) {
return
}
finalized = true
child.off('error', handleError)
child.off('close', handleClose)
grant?.release()
if (error) {
span.fail(error)
} else {
span.end()
}
}
const handleError = (error: Error): void => {
if (!child.pid) {
finalize(error)
} else {
liveError = error
}
}
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => {
const exitError =
liveError ??
(code === 0 && signal === null
? undefined
: new Error(`git exited with ${code ?? signal ?? 'unknown'}.`))
finalize(exitError)
}
child.on('error', handleError)
child.once('close', handleClose)
return child
} catch (error) {
grant?.release()
span.fail(error instanceof Error ? error : new Error(String(error)))
throw error
}
}
export function gitSpawn(args: string[], options: GitSpawnOptions): ChildProcess {
const { wslDistro, ...spawnOptions } = options
const { wslDistro, admissionTier: _admissionTier, ...spawnOptions } = options
const resolved = resolveGitCommand(args, {
cwd: options.cwd,
...(wslDistro ? { wslDistro } : {}),
@@ -4,6 +4,7 @@ import { execFileCapture } from './exec-file-capture'
import { resolveGitCommand } from './git-command-resolution'
import { DEFAULT_GIT_MAX_BUFFER, type GitExecOptions } from './git-exec-options'
import { promptGuardGitEnv } from './git-process-env'
import { acquireGitAdmission } from './git-subprocess-admission'
export type GitSshPolicyMode =
| 'default'
@@ -131,6 +132,18 @@ export async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise
// Why fenced: a login-shell banner here reads as a user-configured sshCommand,
// which skips the BatchMode fallback below and disarms the no-prompt guard.
const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true, true)
const probeArgs = ['config', '--get', 'core.sshCommand']
const grant = await acquireGitAdmission({
args: probeArgs,
cwd: options.cwd,
wslDistro: options.wslDistro,
tier: options.admissionTier,
signal: options.signal
})
let reportTerminated: () => void = () => {}
const terminated = new Promise<void>((resolve) => {
reportTerminated = resolve
})
let configuredCommand = ''
try {
const { stdout } = await execFileCapture(resolved.binary, resolved.args, {
@@ -139,12 +152,15 @@ export async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise
maxBuffer: DEFAULT_GIT_MAX_BUFFER,
timeout: CORE_SSH_COMMAND_PROBE_TIMEOUT_MS,
env: promptEnv,
signal: options.signal
signal: options.signal,
onChildTerminated: reportTerminated
})
const payload = resolved.captured?.readStdout(String(stdout)) ?? String(stdout)
configuredCommand = payload.trim()
} catch {
configuredCommand = ''
} finally {
void terminated.then(grant.release)
}
if (!configuredCommand) {
@@ -0,0 +1,79 @@
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitSpawnMock, killSpawnedCommandTreeMock } = vi.hoisted(() => ({
gitSpawnMock: vi.fn(),
killSpawnedCommandTreeMock: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./git-spawn', () => ({ gitSpawn: gitSpawnMock }))
vi.mock('./spawned-command-tree-kill', () => ({
killSpawnedCommandTree: killSpawnedCommandTreeMock
}))
import { gitStreamStdout } from './git-stream-stdout'
import {
GitAdmissionScheduler,
_gitAdmissionSnapshotForTests,
_resetGitAdmissionForTests
} from './git-subprocess-admission'
function mockChild(): ChildProcess {
const child = new EventEmitter() as EventEmitter & Record<string, unknown>
child.pid = 1234
child.kill = vi.fn(() => true)
child.stdin = null
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
return child as unknown as ChildProcess
}
describe('git stream admission lifetime', () => {
beforeEach(() => {
gitSpawnMock.mockReset()
killSpawnedCommandTreeMock.mockClear()
_resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 1 }))
})
afterEach(() => _resetGitAdmissionForTests())
it('retains the permit after maxBuffer settlement until close', async () => {
const child = mockChild()
gitSpawnMock.mockReturnValue(child)
const pending = gitStreamStdout(['status'], {
cwd: '/repo',
maxBuffer: 1,
onStdout: () => {}
})
await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledOnce())
child.stdout?.emit('data', Buffer.from('xx'))
await expect(pending).rejects.toThrow('maxBuffer')
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1)
child.emit('close', null, 'SIGKILL')
await Promise.resolve()
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0)
})
it('retains the permit after abort settlement until close', async () => {
const child = mockChild()
const controller = new AbortController()
gitSpawnMock.mockReturnValue(child)
const pending = gitStreamStdout(['status'], {
cwd: '/repo',
signal: controller.signal,
onStdout: () => {}
})
await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledOnce())
controller.abort()
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1)
child.emit('close', null, 'SIGKILL')
await Promise.resolve()
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0)
})
})
@@ -9,7 +9,11 @@ import {
import { createAbortError } from './abort-error'
import { killSpawnedCommandTree } from './spawned-command-tree-kill'
import type { ResolvedCommand } from './wsl-command-resolution'
import { DEFAULT_GIT_MAX_BUFFER, type GitExecOptions } from './git-exec-options'
import {
DEFAULT_GIT_MAX_BUFFER,
type GitAdmissionTier,
type GitExecOptions
} from './git-exec-options'
import {
pendingWslDirectGitReadEnvironment,
directWslGitExitCode,
@@ -21,6 +25,8 @@ import {
import { prepareWindowsHostGitEnvironment } from './windows-host-git-environment'
import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-process-env'
import { gitSpawn } from './git-spawn'
import { acquireGitAdmission } from './git-subprocess-admission'
import { GitCommandTimeoutError, gitCommandTimeoutMs } from './git-command-timeout'
/** Result of a streamed git command; `stoppedEarly` is true when onStdout asked to stop before the child exited. */
export type GitStreamResult = { stoppedEarly: boolean }
@@ -33,6 +39,11 @@ export type GitStreamOptions = {
signal?: AbortSignal
/** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */
maxBuffer?: number
/** Explicit wall-clock deadline; read commands default to the production backstop. */
timeoutMs?: number
/** Overrides only the default read deadline in tests. */
timeoutMsForTest?: number
admissionTier?: GitAdmissionTier
/**
* Called for each decoded stdout chunk. Return true to stop: the child is
* killed and the promise resolves with stoppedEarly=true.
@@ -52,7 +63,8 @@ export async function gitStreamStdout(
options: GitStreamOptions
): Promise<GitStreamResult> {
const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER
return withGitSpan({ args, cwd: options.cwd }, async () => {
const timeoutMs = gitCommandTimeoutMs(args, options.timeoutMs, options.timeoutMsForTest)
return withGitSpan({ args, cwd: options.cwd }, async (span) => {
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, {
signal: options.signal
@@ -63,7 +75,8 @@ export async function gitStreamStdout(
...(options.env ? { env: options.env } : {}),
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.preferWslDirectGit ? { preferWslDirectGit: true } : {}),
...(options.signal ? { signal: options.signal } : {})
...(options.signal ? { signal: options.signal } : {}),
...(options.admissionTier ? { admissionTier: options.admissionTier } : {})
}
const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, gitOptions)
if (readEnvironmentReady) {
@@ -79,6 +92,15 @@ export async function gitStreamStdout(
gitOptions.env = await environmentReady
}
resolved = resolveGitCommand(args, gitOptions)
const grant = await acquireGitAdmission({
args,
cwd: options.cwd,
wslDistro: options.wslDistro,
tier: options.admissionTier,
signal: options.signal
})
span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs)
const terminationState: { current: Promise<void> | null } = { current: null }
const stream = (command: ResolvedCommand): Promise<GitStreamResult> =>
new Promise<GitStreamResult>((resolve, reject) => {
if (options.signal?.aborted) {
@@ -106,8 +128,25 @@ export async function gitStreamStdout(
} else {
child = gitSpawn(args, spawnOptions)
}
let terminationReported = false
terminationState.current = new Promise<void>((resolveTermination) => {
const reportTermination = (): void => {
if (terminationReported) {
return
}
terminationReported = true
resolveTermination()
}
child.once('close', reportTermination)
child.once('error', () => {
if (!child.pid) {
reportTermination()
}
})
})
let settled = false
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
let stoppedEarly = false
let stdoutBytes = 0
let stderr = ''
@@ -117,6 +156,10 @@ export async function gitStreamStdout(
const stderrDecoder = new StringDecoder('utf8')
const cleanup = (): void => {
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
}
child.stdout?.off('data', onStdoutData)
child.stderr?.off('data', onStderrData)
child.off('error', onError)
@@ -194,32 +237,52 @@ export async function gitStreamStdout(
finish(createAbortError())
}
function onTimeout(): void {
void killSpawnedCommandTree(child)
finish(new GitCommandTimeoutError(timeoutMs as number))
}
child.stdout?.on('data', onStdoutData)
child.stderr?.on('data', onStderrData)
child.on('error', onError)
child.on('close', onClose)
options.signal?.addEventListener('abort', onAbort, { once: true })
if (timeoutMs !== undefined && timeoutMs > 0) {
timeoutTimer = setTimeout(onTimeout, timeoutMs)
}
if (options.signal?.aborted) {
onAbort()
}
})
try {
return await stream(resolved)
} catch (error) {
const stdoutBytes =
error && typeof error === 'object' ? (error as { stdoutBytes?: unknown }).stdoutBytes : null
if (
stdoutBytes === 0 &&
directWslGitExitCode(error, resolved) !== null &&
!options.signal?.aborted
) {
const wasMissing = invalidateMissingDirectWslGit(error, resolved)
resolved = resolveGitCommandWithoutProbe(args, gitOptions)
const result = await stream(resolved)
disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved)
return result
try {
return await stream(resolved)
} catch (error) {
const stdoutBytes =
error && typeof error === 'object'
? (error as { stdoutBytes?: unknown }).stdoutBytes
: null
if (
stdoutBytes === 0 &&
directWslGitExitCode(error, resolved) !== null &&
!options.signal?.aborted
) {
await terminationState.current
const wasMissing = invalidateMissingDirectWslGit(error, resolved)
resolved = resolveGitCommandWithoutProbe(args, gitOptions)
const result = await stream(resolved)
disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved)
return result
}
throw error
}
} finally {
const termination = terminationState.current
if (termination) {
void termination.then(grant.release)
} else {
grant.release()
}
throw error
}
})
}
@@ -0,0 +1,558 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
GENERAL_CAP,
GitAdmissionScheduler,
MAX_GIT_CHILDREN,
NETWORK_CAP,
_gitAdmissionSnapshotForTests,
_resetGitAdmissionForTests,
acquireGitAdmission
} from './git-subprocess-admission'
import type { GitAdmissionEvent } from './git-admission-state'
const local = (tier: 'interactive' | 'status' | 'background' = 'status') => ({
args: ['status'],
cwd: '/repo',
tier
})
const schedulerWithOneSlot = (now: () => number = () => 0): GitAdmissionScheduler =>
new GitAdmissionScheduler({
generalCap: 1,
networkCap: 1,
generalHeadroom: 1,
networkHeadroom: 1,
routeCap: 1,
routeHeadroom: 1,
now
})
afterEach(() => {
delete process.env.ORCA_GIT_ADMISSION_DISABLED
_resetGitAdmissionForTests()
})
describe('GitAdmissionScheduler', () => {
it('pins the global base budgets and absolute maximum', () => {
expect(GENERAL_CAP).toBeGreaterThanOrEqual(2)
expect(GENERAL_CAP).toBeLessThanOrEqual(4)
expect(NETWORK_CAP).toBe(3)
expect(MAX_GIT_CHILDREN).toBe(10)
})
it('keeps base and headroom counters separate and grants interactive all-headroom', async () => {
const scheduler = schedulerWithOneSlot()
const base = await scheduler.acquire(local('background'))
const interactive = await scheduler.acquire({
...local('interactive'),
cwd: 'C:\\repo',
wslDistro: 'Ubuntu'
})
expect(scheduler.snapshot().budgets).toMatchObject({
general: { baseUsed: 1, headroomUsed: 1 },
'route:general:wsl:ubuntu': { baseUsed: 0, headroomUsed: 1 }
})
base.release()
expect(scheduler.snapshot().budgets.general).toEqual({ baseUsed: 0, headroomUsed: 1 })
interactive.release()
expect(scheduler.snapshot().budgets.general).toEqual({ baseUsed: 0, headroomUsed: 0 })
})
it('partitions network and general budgets globally and per route', async () => {
const scheduler = schedulerWithOneSlot()
const fetch = await scheduler.acquire({
args: ['fetch'],
cwd: 'C:\\repo',
wslDistro: 'Ubuntu',
tier: 'background'
})
const status = await scheduler.acquire({
args: ['status'],
cwd: 'C:\\repo',
wslDistro: 'Ubuntu',
tier: 'status'
})
expect(scheduler.snapshot().budgets).toMatchObject({
network: { baseUsed: 1, headroomUsed: 0 },
general: { baseUsed: 1, headroomUsed: 0 },
'route:network:wsl:ubuntu': { baseUsed: 1, headroomUsed: 0 },
'route:general:wsl:ubuntu': { baseUsed: 1, headroomUsed: 0 }
})
fetch.release()
status.release()
})
it('uses network and route headroom for an interactive push', async () => {
const scheduler = schedulerWithOneSlot()
const fetch = await scheduler.acquire({
args: ['fetch'],
cwd: '\\\\server\\repo',
tier: 'background'
})
const push = await scheduler.acquire({
args: ['push'],
cwd: '\\\\server\\repo',
tier: 'interactive'
})
expect(scheduler.snapshot().budgets).toMatchObject({
network: { baseUsed: 1, headroomUsed: 1 },
'route:network:unc:server': { baseUsed: 1, headroomUsed: 1 }
})
fetch.release()
push.release()
})
it('admits same-route interactive work when general and route bases are full', async () => {
const scheduler = new GitAdmissionScheduler({
generalCap: 2,
generalHeadroom: 2,
routeCap: 2,
routeHeadroom: 1
})
const request = { cwd: 'C:\\repo', wslDistro: 'Ubuntu' }
const background = await Promise.all([
scheduler.acquire({ ...request, args: ['status'], tier: 'background' }),
scheduler.acquire({ ...request, args: ['status'], tier: 'background' })
])
const interactive = await scheduler.acquire({
...request,
args: ['status'],
tier: 'interactive'
})
expect(scheduler.snapshot().budgets).toMatchObject({
general: { baseUsed: 2, headroomUsed: 1 },
'route:general:wsl:ubuntu': { baseUsed: 2, headroomUsed: 1 }
})
background.forEach((grant) => grant.release())
interactive.release()
})
it('keeps status isolated from wedged route fetches and reserves push headroom', async () => {
const scheduler = new GitAdmissionScheduler()
const request = { cwd: 'C:\\repo', wslDistro: 'Ubuntu' }
const fetches = await Promise.all([
scheduler.acquire({ ...request, args: ['fetch'], tier: 'background' }),
scheduler.acquire({ ...request, args: ['fetch'], tier: 'background' })
])
const thirdFetch = await scheduler.acquire({
args: ['fetch'],
cwd: '/other-repo',
tier: 'background'
})
const status = await scheduler.acquire({ ...request, args: ['status'], tier: 'status' })
const push = await scheduler.acquire({ ...request, args: ['push'], tier: 'interactive' })
expect(scheduler.snapshot().budgets).toMatchObject({
network: { baseUsed: 3, headroomUsed: 1 },
general: { baseUsed: 1, headroomUsed: 0 },
'route:network:wsl:ubuntu': { baseUsed: 2, headroomUsed: 1 },
'route:general:wsl:ubuntu': { baseUsed: 1, headroomUsed: 0 }
})
fetches.forEach((grant) => grant.release())
thirdFetch.release()
status.release()
push.release()
})
it('ages background work ahead of fresh base waiters without granting it headroom', async () => {
let now = 0
const scheduler = schedulerWithOneSlot(() => now)
const running = await scheduler.acquire(local('status'))
const order: string[] = []
const backgroundPromise = scheduler.acquire(local('background')).then((grant) => {
order.push('background')
return grant
})
now = 30_000
const headroom = await scheduler.acquire(local('interactive'))
const freshPromise = scheduler.acquire(local('interactive')).then((grant) => {
order.push('fresh')
return grant
})
running.release()
const background = await backgroundPromise
expect(order).toEqual(['background'])
expect(scheduler.snapshot().queued).toBe(1)
background.release()
const fresh = await freshPromise
expect(order).toEqual(['background', 'fresh'])
headroom.release()
fresh.release()
})
it('keeps a route base slot free when fresh interactive work needs global headroom', async () => {
let now = 0
const scheduler = schedulerWithOneSlot(() => now)
const running = await scheduler.acquire(local('status'))
const agedPromise = scheduler.acquire({
...local('background'),
cwd: 'C:\\repo',
wslDistro: 'Ubuntu'
})
now = 30_000
const interactive = await scheduler.acquire({
...local('interactive'),
cwd: 'C:\\repo',
wslDistro: 'Ubuntu'
})
expect(scheduler.snapshot().budgets['route:general:wsl:ubuntu']).toEqual({
baseUsed: 0,
headroomUsed: 1
})
running.release()
const aged = await agedPromise
expect(scheduler.snapshot().budgets['route:general:wsl:ubuntu']).toEqual({
baseUsed: 1,
headroomUsed: 1
})
aged.release()
interactive.release()
})
it('does not let a saturated route delay local-disk work below the general cap', async () => {
const scheduler = new GitAdmissionScheduler({
generalCap: 3,
generalHeadroom: 1,
routeCap: 1,
routeHeadroom: 1
})
const routed = await scheduler.acquire({
...local('background'),
cwd: 'C:\\repo',
wslDistro: 'Ubuntu'
})
const queuedRoute = scheduler.acquire({
...local('background'),
cwd: 'C:\\other',
wslDistro: 'Ubuntu'
})
const localDisk = await scheduler.acquire({ ...local('status'), cwd: 'C:\\local' })
expect(scheduler.snapshot().queued).toBe(1)
localDisk.release()
routed.release()
const secondRoute = await queuedRoute
secondRoute.release()
})
it('admits local-disk work beside two saturated-route children below the cap', async () => {
const scheduler = new GitAdmissionScheduler({ generalCap: 4, routeCap: 2 })
const routed = await Promise.all([
scheduler.acquire({
...local('background'),
cwd: 'C:\\repo',
wslDistro: 'Ubuntu'
}),
scheduler.acquire({
...local('background'),
cwd: 'C:\\other',
wslDistro: 'Ubuntu'
})
])
const localDisk = await scheduler.acquire({ ...local('status'), cwd: 'C:\\local' })
expect(scheduler.snapshot()).toMatchObject({
queued: 0,
budgets: {
general: { baseUsed: 3, headroomUsed: 0 },
'route:general:wsl:ubuntu': { baseUsed: 2, headroomUsed: 0 }
}
})
routed.forEach((grant) => grant.release())
localDisk.release()
})
it('removes an aborted queued waiter without changing occupancy', async () => {
const scheduler = schedulerWithOneSlot()
const running = await scheduler.acquire(local())
const controller = new AbortController()
const queued = scheduler.acquire({ ...local(), signal: controller.signal })
controller.abort()
await expect(queued).rejects.toMatchObject({ name: 'AbortError' })
expect(scheduler.snapshot()).toMatchObject({
queued: 0,
budgets: { general: { baseUsed: 1, headroomUsed: 0 } }
})
running.release()
})
it('bounds canceled candidate storage while the only permit stays saturated', async () => {
const scheduler = schedulerWithOneSlot()
const running = await scheduler.acquire(local())
const canceled: Promise<void>[] = []
for (let index = 0; index < 4_000; index += 1) {
const controller = new AbortController()
const request = scheduler.acquire({ ...local(), signal: controller.signal }).then(
() => undefined,
(error) => expect(error).toMatchObject({ name: 'AbortError' })
)
controller.abort()
canceled.push(request)
}
await Promise.all(canceled)
expect(scheduler.snapshot().queued).toBe(0)
expect(scheduler.snapshot().candidateCount).toBeLessThanOrEqual(64)
running.release()
expect(scheduler.snapshot().candidateCount).toBe(0)
})
it('does not decrement for an already-aborted signal with a free slot', async () => {
const scheduler = schedulerWithOneSlot()
const controller = new AbortController()
controller.abort()
await expect(
scheduler.acquire({ ...local(), signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' })
expect(scheduler.snapshot()).toEqual({
queued: 0,
queuedWaiters: [],
candidateCount: 0,
budgets: {}
})
})
it('returns a grant selected in the same tick when abort wins delivery', async () => {
const scheduler = schedulerWithOneSlot()
const running = await scheduler.acquire(local())
const controller = new AbortController()
const queued = scheduler.acquire({ ...local(), signal: controller.signal })
running.release()
controller.abort()
await expect(queued).rejects.toMatchObject({ name: 'AbortError' })
expect(scheduler.snapshot()).toMatchObject({
queued: 0,
budgets: { general: { baseUsed: 0, headroomUsed: 0 } }
})
})
it('preserves FIFO after removing an aborted interactive waiter', async () => {
const scheduler = schedulerWithOneSlot()
const running = await scheduler.acquire(local())
const firstController = new AbortController()
const first = scheduler.acquire({ ...local('interactive'), signal: firstController.signal })
const second = scheduler.acquire(local('interactive'))
const third = scheduler.acquire(local('interactive'))
firstController.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
running.release()
const secondGrant = await second
secondGrant.release()
const thirdGrant = await third
thirdGrant.release()
})
it('completes a background burst while interactive work uses reserved headroom', async () => {
const scheduler = new GitAdmissionScheduler({ generalCap: 2, generalHeadroom: 1 })
const order: number[] = []
const background = Array.from({ length: 20 }, (_, index) =>
scheduler.acquire(local('background')).then((grant) => {
order.push(index)
grant.release()
})
)
let interactiveRan = false
const interactive = scheduler.acquire(local('interactive')).then((grant) => {
interactiveRan = true
grant.release()
})
await Promise.all([...background, interactive])
expect(interactiveRan).toBe(true)
expect(order).toEqual(Array.from({ length: 20 }, (_, index) => index))
})
it('drains a large saturated FIFO burst without retaining settled waiters', async () => {
const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 })
const running = await scheduler.acquire(local('status'))
const count = 4_000
const order: number[] = []
const queued = Array.from({ length: count }, (_, index) =>
scheduler.acquire(local('background')).then((grant) => {
order.push(index)
grant.release()
})
)
expect(scheduler.snapshot().queued).toBe(count)
running.release()
await Promise.all(queued)
expect(order).toEqual(Array.from({ length: count }, (_, index) => index))
expect(scheduler.snapshot()).toMatchObject({ queued: 0, queuedWaiters: [] })
})
it('skips a 4k saturated-route lane without rescanning its blocked prefix', async () => {
const scheduler = new GitAdmissionScheduler({
generalCap: 2,
generalHeadroom: 0,
routeCap: 1,
routeHeadroom: 0
})
const routedRunning = await scheduler.acquire({
...local('background'),
wslDistro: 'Ubuntu'
})
const localRunning = await scheduler.acquire(local('background'))
let abortedReads = 0
const signal = {
get aborted() {
abortedReads += 1
return false
},
addEventListener: () => {},
removeEventListener: () => {}
} as unknown as AbortSignal
const count = 4_000
const routed = Array.from({ length: count }, () =>
scheduler
.acquire({ ...local('background'), wslDistro: 'Ubuntu', signal })
.then((grant) => grant.release())
)
const localWork = Array.from({ length: count }, () =>
scheduler.acquire({ ...local('background'), signal }).then((grant) => grant.release())
)
abortedReads = 0
localRunning.release()
await Promise.all(localWork)
expect(scheduler.snapshot().queued).toBe(count)
routedRunning.release()
await Promise.all(routed)
expect(abortedReads).toBeLessThan(30_000)
expect(scheduler.snapshot()).toMatchObject({ queued: 0, queuedWaiters: [] })
})
it('selects one eligible route without scanning thousands of saturated routes', async () => {
const routeCount = 2_000
const scheduler = new GitAdmissionScheduler({
generalCap: routeCount,
generalHeadroom: 0,
routeCap: 1,
routeHeadroom: 0
})
const running = await Promise.all(
Array.from({ length: routeCount }, (_, index) =>
scheduler.acquire({ ...local('background'), wslDistro: `distro-${index}` })
)
)
let aborted = false
let abortedReads = 0
const abortListeners = new Set<() => void>()
const signal = {
get aborted() {
abortedReads += 1
return aborted
},
addEventListener: (_event: string, listener: () => void) => abortListeners.add(listener),
removeEventListener: (_event: string, listener: () => void) => abortListeners.delete(listener)
} as unknown as AbortSignal
const queued = Array.from({ length: routeCount }, (_, index) =>
scheduler
.acquire({ ...local('background'), wslDistro: `distro-${index}`, signal })
.then((grant) => grant.release())
)
abortedReads = 0
running.at(-1)?.release()
await queued.at(-1)
expect(abortedReads).toBeLessThanOrEqual(2)
expect(scheduler.snapshot().queued).toBe(routeCount - 1)
aborted = true
for (const listener of abortListeners) {
listener()
}
await Promise.allSettled(queued.slice(0, -1))
running.slice(0, -1).forEach((grant) => grant.release())
expect(scheduler.snapshot()).toMatchObject({ queued: 0, queuedWaiters: [] })
})
it('captures killswitch state in each release closure', async () => {
const scheduler = schedulerWithOneSlot()
_resetGitAdmissionForTests(scheduler)
process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
const bypass = await acquireGitAdmission(local())
delete process.env.ORCA_GIT_ADMISSION_DISABLED
bypass.release()
expect(_gitAdmissionSnapshotForTests().budgets.general).toBeUndefined()
const admitted = await acquireGitAdmission(local())
process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
admitted.release()
expect(_gitAdmissionSnapshotForTests().budgets.general).toEqual({
baseUsed: 0,
headroomUsed: 0
})
})
it('publishes monotonic grant and release events with cap metadata', async () => {
const events: GitAdmissionEvent[] = []
const scheduler = new GitAdmissionScheduler({
generalCap: 1,
generalHeadroom: 1,
routeCap: 1,
routeHeadroom: 1,
onAdmissionEvent: (event) => events.push(event)
})
const background = await scheduler.acquire({
...local('background'),
cwd: 'C:\\repo',
wslDistro: 'Ubuntu'
})
const interactive = await scheduler.acquire({
...local('interactive'),
cwd: 'C:\\repo',
wslDistro: 'Ubuntu'
})
background.release()
interactive.release()
expect(events.map(({ sequence, phase, slotKind }) => [sequence, phase, slotKind])).toEqual([
[0, 'grant', 'base'],
[1, 'grant', 'headroom'],
[2, 'release', 'base'],
[3, 'release', 'headroom']
])
expect(events[1]).toMatchObject({
tier: 'interactive',
admissionClass: 'general',
route: 'wsl:ubuntu',
queued: 0
})
expect(events[1]?.budgets).toEqual(
expect.arrayContaining([
{
key: 'general',
baseCapacity: 1,
headroomCapacity: 1,
baseUsed: 1,
headroomUsed: 1
},
{
key: 'route:general:wsl:ubuntu',
baseCapacity: 1,
headroomCapacity: 1,
baseUsed: 1,
headroomUsed: 1
}
])
)
})
})
@@ -0,0 +1,325 @@
import { uncRouteKey } from '../../providers/working-directory-validation'
import { classifyGitCommand } from '../wsl-direct-git-read-commands'
import { createAbortError } from './abort-error'
import { GitAdmissionWaiterQueue } from './git-admission-waiter-queue'
import {
ADMISSION_TIER_VALUE,
AdmissionEventPublisher,
DEFAULT_ADMISSION_SCHEDULER_CONFIG,
type AdmissionBudget,
type AdmissionClass,
type AdmissionSchedulerConfig,
type AdmissionSlotKind,
type AdmissionWaiter,
type GitAdmissionGrant,
type GitAdmissionRequest
} from './git-admission-state'
export type {
GitAdmissionEvent,
GitAdmissionGrant,
GitAdmissionRequest
} from './git-admission-state'
export {
GENERAL_CAP,
GENERAL_HEADROOM,
GIT_ADMISSION_AGING_MS,
MAX_GIT_CHILDREN,
NETWORK_CAP,
NETWORK_HEADROOM,
ROUTE_CAP,
ROUTE_HEADROOM
} from './git-admission-state'
function commandClass(args: readonly string[]): AdmissionClass {
return classifyGitCommand(args) === 'network' ? 'network' : 'general'
}
function routeKey(request: GitAdmissionRequest): string | null {
const distro = request.wslDistro?.trim().toLowerCase()
return distro ? `wsl:${distro}` : uncRouteKey(request.cwd)
}
export class GitAdmissionScheduler {
private readonly config: AdmissionSchedulerConfig
private readonly budgets = new Map<string, AdmissionBudget>()
private readonly waiters = new GitAdmissionWaiterQueue()
private nextWaiterId = 0
private readonly eventPublisher: AdmissionEventPublisher
constructor(config: Partial<AdmissionSchedulerConfig> = {}) {
this.config = { ...DEFAULT_ADMISSION_SCHEDULER_CONFIG, ...config }
this.eventPublisher = new AdmissionEventPublisher(this.config.onAdmissionEvent)
}
acquire(request: GitAdmissionRequest): Promise<GitAdmissionGrant> {
if (request.signal?.aborted) {
return Promise.reject(createAbortError())
}
const enqueuedAt = this.config.now()
const { admissionClass, route, budgetKeys } = this.resolveBudgets(request)
return new Promise<GitAdmissionGrant>((resolve, reject) => {
const waiter: AdmissionWaiter = {
id: this.nextWaiterId++,
args: request.args,
tier: request.tier ?? 'status',
admissionClass,
route,
enqueuedAt,
budgetKeys,
signal: request.signal,
state: 'queued',
resolve,
reject,
onAbort: () => this.abort(waiter)
}
this.waiters.enqueue(waiter)
this.refreshRouteEligibility(admissionClass, route)
request.signal?.addEventListener('abort', waiter.onAbort, { once: true })
if (request.signal?.aborted) {
this.abort(waiter)
return
}
// Adding a blocked waiter cannot make an older waiter runnable. Avoid a
// queue scan for every arrival while the fixed-size budget is saturated.
if (this.slotKindFor(waiter)) {
this.drain(admissionClass)
}
})
}
snapshot(): {
queued: number
queuedWaiters: { id: number; args: readonly string[]; tier: AdmissionWaiter['tier'] }[]
budgets: Record<string, { baseUsed: number; headroomUsed: number }>
candidateCount: number
} {
const queuedWaiters = this.waiters.snapshot()
return {
queued: this.waiters.count,
queuedWaiters: queuedWaiters.map(({ id, args, tier }) => ({ id, args, tier })),
candidateCount: this.waiters.candidateCountForTests,
budgets: Object.fromEntries(
[...this.budgets].map(([key, budget]) => [
key,
{ baseUsed: budget.baseUsed, headroomUsed: budget.headroomUsed }
])
)
}
}
private resolveBudgets(request: GitAdmissionRequest): {
admissionClass: AdmissionClass
route: string | null
budgetKeys: readonly string[]
} {
const admissionClass = commandClass(request.args)
const route = routeKey(request)
const keys: string[] = [admissionClass]
if (route) {
keys.push(`route:${admissionClass}:${route}`)
}
for (const key of keys) {
this.ensureBudget(key)
}
return { admissionClass, route, budgetKeys: keys }
}
private ensureBudget(key: string): AdmissionBudget {
let budget = this.budgets.get(key)
if (budget) {
return budget
}
const isRoute = key.startsWith('route:')
const isNetwork = key === 'network'
budget = {
baseCapacity: isRoute
? this.config.routeCap
: isNetwork
? this.config.networkCap
: this.config.generalCap,
headroomCapacity: isRoute
? this.config.routeHeadroom
: isNetwork
? this.config.networkHeadroom
: this.config.generalHeadroom,
baseUsed: 0,
headroomUsed: 0
}
this.budgets.set(key, budget)
return budget
}
private effectiveTier(waiter: AdmissionWaiter, now: number): number {
const promotions = Math.floor((now - waiter.enqueuedAt) / this.config.agingMs)
return Math.max(0, ADMISSION_TIER_VALUE[waiter.tier] - promotions)
}
private fits(waiter: AdmissionWaiter, slotKind: AdmissionSlotKind): boolean {
return waiter.budgetKeys.every((key) => {
const budget = this.ensureBudget(key)
return slotKind === 'base'
? budget.baseUsed < budget.baseCapacity
: budget.headroomUsed < budget.headroomCapacity
})
}
private slotKindFor(waiter: AdmissionWaiter): AdmissionSlotKind | null {
return this.fits(waiter, 'base')
? 'base'
: waiter.tier === 'interactive' && this.fits(waiter, 'headroom')
? 'headroom'
: null
}
private drain(admissionClass: AdmissionClass): void {
while (true) {
const now = this.config.now()
const globalBudget = this.ensureBudget(admissionClass)
const selected = this.waiters.nextFitting(
admissionClass,
(waiter) => this.effectiveTier(waiter, now),
globalBudget.baseUsed < globalBudget.baseCapacity,
globalBudget.headroomUsed < globalBudget.headroomCapacity,
(waiter) => this.abort(waiter)
)
if (!selected) {
return
}
this.grant(selected.waiter, selected.slotKind, now)
}
}
private grant(waiter: AdmissionWaiter, slotKind: AdmissionSlotKind, now: number): void {
waiter.state = 'granted'
waiter.slotKind = slotKind
for (const key of waiter.budgetKeys) {
const budget = this.ensureBudget(key)
if (slotKind === 'base') {
budget.baseUsed += 1
} else {
budget.headroomUsed += 1
}
}
this.refreshRouteEligibility(waiter.admissionClass, waiter.route)
this.waiters.dequeue(waiter)
const queueWaitMs = Math.max(0, now - waiter.enqueuedAt)
this.publishEvent(waiter, slotKind, 'grant', queueWaitMs)
queueMicrotask(() => {
if (waiter.state !== 'granted') {
return
}
waiter.state = 'settled'
waiter.signal?.removeEventListener('abort', waiter.onAbort)
waiter.resolve({
queueWaitMs,
release: this.releaseOnce(waiter, slotKind, queueWaitMs)
})
})
}
private releaseOnce(
waiter: AdmissionWaiter,
slotKind: AdmissionSlotKind,
queueWaitMs: number
): () => void {
let released = false
return () => {
if (released) {
return
}
released = true
for (const key of waiter.budgetKeys) {
const budget = this.ensureBudget(key)
if (slotKind === 'base') {
budget.baseUsed -= 1
} else {
budget.headroomUsed -= 1
}
}
this.refreshRouteEligibility(waiter.admissionClass, waiter.route)
this.publishEvent(waiter, slotKind, 'release', queueWaitMs)
this.pruneRouteBudgets(waiter.budgetKeys)
this.drain(waiter.admissionClass)
}
}
private abort(waiter: AdmissionWaiter): void {
if (waiter.state === 'settled') {
return
}
if (waiter.state === 'granted' && waiter.slotKind) {
this.releaseOnce(
waiter,
waiter.slotKind,
Math.max(0, this.config.now() - waiter.enqueuedAt)
)()
}
const wasQueued = waiter.state === 'queued'
waiter.state = 'settled'
waiter.signal?.removeEventListener('abort', waiter.onAbort)
if (wasQueued) {
this.waiters.dequeue(waiter)
this.pruneRouteBudgets(waiter.budgetKeys)
}
waiter.reject(createAbortError())
}
private publishEvent(
waiter: AdmissionWaiter,
slotKind: AdmissionSlotKind,
phase: 'grant' | 'release',
queueWaitMs: number
): void {
this.eventPublisher.publish({
phase,
waiter,
slotKind,
queueWaitMs,
queued: this.waiters.count,
budgets: this.budgets
})
}
private pruneRouteBudgets(keys: readonly string[]): void {
for (const key of keys) {
const budget = this.budgets.get(key)
if (
budget &&
key.startsWith('route:') &&
budget.baseUsed === 0 &&
budget.headroomUsed === 0 &&
!this.waiters.hasBudget(key)
) {
this.budgets.delete(key)
}
}
}
private refreshRouteEligibility(admissionClass: AdmissionClass, route: string | null): void {
const budget = route ? this.ensureBudget(`route:${admissionClass}:${route}`) : null
this.waiters.updateRouteEligibility(
admissionClass,
route,
!budget || budget.baseUsed < budget.baseCapacity,
!budget || budget.headroomUsed < budget.headroomCapacity
)
}
}
let scheduler = new GitAdmissionScheduler()
export function acquireGitAdmission(request: GitAdmissionRequest): Promise<GitAdmissionGrant> {
if (process.env.ORCA_GIT_ADMISSION_DISABLED === '1') {
return Promise.resolve({ queueWaitMs: 0, release: () => {} })
}
return scheduler.acquire(request)
}
export function _resetGitAdmissionForTests(replacement = new GitAdmissionScheduler()): void {
scheduler = replacement
}
export function _gitAdmissionSnapshotForTests(): ReturnType<GitAdmissionScheduler['snapshot']> {
return scheduler.snapshot()
}
+7 -2
View File
@@ -1,16 +1,20 @@
import type { GitAdmissionTier } from './command-runner/git-exec-options'
export type GitRuntimeOptions = {
wslDistro?: string
signal?: AbortSignal
admissionTier?: GitAdmissionTier
}
export function gitOptionsForWorktree(
cwd: string,
options: GitRuntimeOptions = {}
): { cwd: string; wslDistro?: string; signal?: AbortSignal } {
): { cwd: string; wslDistro?: string; signal?: AbortSignal; admissionTier?: GitAdmissionTier } {
return {
cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.signal ? { signal: options.signal } : {})
...(options.signal ? { signal: options.signal } : {}),
...(options.admissionTier ? { admissionTier: options.admissionTier } : {})
}
}
@@ -27,6 +31,7 @@ export function gitReadOptionsForWorktree(
cwd: string
wslDistro?: string
signal?: AbortSignal
admissionTier?: GitAdmissionTier
preferWslDirectGit: true
} {
return { ...gitOptionsForWorktree(cwd, options), preferWslDirectGit: true }
+2 -3
View File
@@ -1,10 +1,9 @@
import type { GitPushTarget } from '../../shared/worktree/types'
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
import { gitExecFileAsync } from './runner'
import type { GitExecOptions as GitCommandExecOptions } from './command-runner/git-exec-options'
type GitExecOptions = {
wslDistro?: string
}
type GitExecOptions = Pick<GitCommandExecOptions, 'wslDistro' | 'admissionTier'>
export async function validateGitPushTarget(
repoPath: string,
+4 -1
View File
@@ -2,6 +2,7 @@ import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe'
import { isTransientGitProbeError, readRemoteUrl } from './remote-url-probe'
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
import type { GitAdmissionTier } from './command-runner/git-exec-options'
/**
* The "is this repo mine?" probe every forge integration runs: read the remote's
@@ -25,6 +26,7 @@ type CachedRepoRef<Ref> = { value: Ref | null; expiresAt: number }
export type RemoteRefLocalGitOptions = {
wslDistro?: string
admissionTier?: GitAdmissionTier
}
export type RemoteRefProbeCache<Ref> = {
@@ -79,7 +81,8 @@ export function createRemoteRefProbeCache<Ref>(
{
repoPath,
connectionId,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
},
remoteName
)
+4 -1
View File
@@ -4,6 +4,7 @@ import {
} from '../providers/ssh-git-dispatch'
import { gitExecFileAsync } from './runner'
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
import type { GitAdmissionTier } from './command-runner/git-exec-options'
/**
* The `git remote get-url` probe every forge integration runs to decide whether
@@ -23,6 +24,7 @@ export type RemoteUrlProbeContext = {
repoPath: string
connectionId?: string | null
wslDistro?: string
admissionTier?: GitAdmissionTier
}
/** Reads a remote URL, or null when the repo's SSH runtime is not connected. */
@@ -43,7 +45,8 @@ export async function readRemoteUrl(
const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: context.repoPath,
timeout: REMOTE_URL_PROBE_TIMEOUT_MS,
...(context.wslDistro ? { wslDistro: context.wslDistro } : {})
...(context.wslDistro ? { wslDistro: context.wslDistro } : {}),
...(context.admissionTier ? { admissionTier: context.admissionTier } : {})
})
return stdout
}
+47
View File
@@ -22,6 +22,12 @@ import {
translateWslOutputPaths,
wslAwareSpawn
} from './runner'
import {
GitAdmissionScheduler,
_resetGitAdmissionForTests
} from './command-runner/git-subprocess-admission'
afterEach(() => _resetGitAdmissionForTests())
type MockChildProcess = EventEmitter & {
stdout: EventEmitter
@@ -215,6 +221,7 @@ describe('runner execFile timeout handling', () => {
timeout: 1000
})
const rejection = expect(promise).rejects.toThrow('git timed out.')
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(1000)
await rejection
@@ -251,6 +258,7 @@ describe('runner execFile timeout handling', () => {
}
)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
controller.abort()
child.emit('close', 0, null)
await vi.advanceTimersByTimeAsync(1_999)
@@ -432,6 +440,36 @@ describe('runner execFile timeout handling', () => {
)
})
it('admits the core.sshCommand probe before spawning it', async () => {
const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 })
_resetGitAdmissionForTests(scheduler)
const blocker = await scheduler.acquire({ args: ['status'], cwd: '/repo', tier: 'status' })
const calls: string[][] = []
execFileMock.mockImplementation((_cmd, args, _opts, cb) => {
const child = createMockChildProcess(1234 + calls.length)
calls.push(args)
cb(null, '', '')
queueMicrotask(() => child.emit('close', 0, null))
return child
})
const pending = gitExecFileAsync(['fetch', '--no-write-fetch-head', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
await Promise.resolve()
expect(execFileMock).not.toHaveBeenCalled()
blocker.release()
await pending
expect(calls).toEqual([
['config', '--get', 'core.sshCommand'],
['fetch', '--no-write-fetch-head', 'origin']
])
})
it('replaces configured BatchMode for opted-in mergeable OpenSSH commands', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
@@ -737,6 +775,7 @@ describe('gitStreamStdout', () => {
chunks.push(chunk)
}
})
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
child.stdout.emit('data', Buffer.from('? a.txt\n'))
child.stdout.emit('data', Buffer.from('? b.txt\n'))
child.emit('close', 0)
@@ -759,11 +798,13 @@ describe('gitStreamStdout', () => {
return true
}
})
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
child.stdout.emit('data', Buffer.from('? a.txt\n'))
await expect(promise).resolves.toEqual({ stoppedEarly: true })
expect(child.kill).toHaveBeenCalled()
expect(calls).toBe(1)
child.emit('close', null, 'SIGTERM')
})
it('rejects when stdout exceeds the maxBuffer backstop', async () => {
@@ -776,10 +817,12 @@ describe('gitStreamStdout', () => {
onStdout: () => {}
})
const rejection = expect(promise).rejects.toThrow('git stdout exceeded maxBuffer.')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
child.stdout.emit('data', Buffer.from('way too much'))
await rejection
expect(child.kill).toHaveBeenCalled()
child.emit('close', null, 'SIGTERM')
})
it('rejects on a non-zero exit with stderr context', async () => {
@@ -788,6 +831,7 @@ describe('gitStreamStdout', () => {
const promise = gitStreamStdout(['status'], { cwd: '/repo', onStdout: () => {} })
const rejection = expect(promise).rejects.toThrow('git exited with 128')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
child.stderr.emit('data', Buffer.from('fatal: not a git repository'))
child.emit('close', 128)
@@ -805,10 +849,12 @@ describe('gitStreamStdout', () => {
}
})
const rejection = expect(promise).rejects.toThrow('parser blew up')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
child.stdout.emit('data', Buffer.from('? a.txt\n'))
await rejection
expect(child.kill).toHaveBeenCalled()
child.emit('close', null, 'SIGTERM')
})
it('handles a late spawn error after cancellation', async () => {
@@ -821,6 +867,7 @@ describe('gitStreamStdout', () => {
signal: controller.signal,
onStdout: () => {}
})
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
controller.abort()
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
@@ -20,6 +20,7 @@ import {
gitSpawnAfterWindowsEnvironmentReady,
gitStreamStdout
} from './runner'
import { _resetGitAdmissionForTests } from './command-runner/git-subprocess-admission'
type MockChildProcess = EventEmitter & {
stdout: EventEmitter
@@ -64,6 +65,7 @@ describe('Windows host Git environment readiness', () => {
})
afterEach(() => {
_resetGitAdmissionForTests()
configureWindowsHostGitEnvironmentReadiness(null)
if (originalPath === undefined) {
delete process.env.Path
+52 -19
View File
@@ -19,6 +19,11 @@ vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn
import { pendingWslDirectGitReadEnvironment } from './command-runner/git-command-resolution'
import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner'
import {
GitAdmissionScheduler,
_resetGitAdmissionForTests,
type GitAdmissionEvent
} from './command-runner/git-subprocess-admission'
import {
disableWslGitReadEnvironment,
getWslGitReadEnvironment,
@@ -33,6 +38,8 @@ import {
type WslLinkedWorktreeRoutingFileSystem
} from './wsl-linked-worktree-git-routing'
afterEach(() => _resetGitAdmissionForTests())
const DISTRO = 'Ubuntu'
const LOGIN_ENVIRONMENT = {
gitPath: '/home/user/bin/git',
@@ -493,36 +500,53 @@ describe('WSL direct Git reads', () => {
})
})
it('invalidates a missing direct executable and retries through the login shell', async () => {
it('waits for direct Git to close before retrying through the login shell', async () => {
await withPlatform('win32', async () => {
const admissionEvents: GitAdmissionEvent[] = []
_resetGitAdmissionForTests(
new GitAdmissionScheduler({ onAdmissionEvent: (event) => admissionEvents.push(event) })
)
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
let directChild!: ReturnType<typeof createMockChild>
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() =>
directChild = child
queueMicrotask(() => {
callback?.(
Object.assign(new Error('exit 127'), { code: 127 }),
'',
'/usr/bin/env: No such file or directory'
)
)
})
return child
})
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() => callback?.(null, 'ok', ''))
queueMicrotask(() => {
child.emit('close', 0, null)
callback?.(null, 'ok', '')
})
return child
})
await expect(
gitExecFileAsync(['status', '--short'], {
cwd: String.raw`C:\repo`,
preferWslDirectGit: true,
wslDistro: DISTRO
})
).resolves.toEqual({ stdout: 'ok', stderr: '' })
const result = gitExecFileAsync(['status', '--short'], {
cwd: String.raw`C:\repo`,
preferWslDirectGit: true,
wslDistro: DISTRO
})
await vi.waitFor(() => expect(directChild).toBeDefined())
await Promise.resolve()
expect(execFileMock).toHaveBeenCalledTimes(1)
directChild.emit('close', 127, null)
await expect(result).resolves.toEqual({ stdout: 'ok', stderr: '' })
expect(execFileMock.mock.calls[0]?.[1]).toContain('--exec')
expect(execFileMock.mock.calls[1]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc'])
expect(admissionEvents.map(({ phase, waiterId }) => [phase, waiterId])).toEqual([
['grant', 0],
['release', 0]
])
})
})
@@ -531,14 +555,18 @@ describe('WSL direct Git reads', () => {
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() =>
queueMicrotask(() => {
callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'helper failed')
)
child.emit('close', 128, null)
})
return child
})
execFileMock.mockImplementation((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() => callback?.(null, 'ok', ''))
queueMicrotask(() => {
callback?.(null, 'ok', '')
child.emit('close', 0, null)
})
return child
})
const options = {
@@ -562,21 +590,26 @@ describe('WSL direct Git reads', () => {
execFileMock
.mockImplementationOnce((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() =>
queueMicrotask(() => {
callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'missing ref')
)
child.emit('close', 128, null)
})
return child
})
.mockImplementationOnce((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() =>
queueMicrotask(() => {
callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'missing ref')
)
child.emit('close', 128, null)
})
return child
})
.mockImplementationOnce((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() => callback?.(null, 'ok', ''))
queueMicrotask(() => {
callback?.(null, 'ok', '')
child.emit('close', 0, null)
})
return child
})
const options = {
@@ -21,6 +21,9 @@ vi.mock('node:fs/promises', async (importOriginal) => ({
}))
import { gitExecFileAsync } from './runner'
import { _resetGitAdmissionForTests } from './command-runner/git-subprocess-admission'
afterEach(() => _resetGitAdmissionForTests())
import {
resetWslLinkedWorktreeGitRoutingForTests,
WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS
@@ -18,6 +18,9 @@ vi.mock('../observability/instrumentation', () => ({
vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() }))
import { gitExecFileAsync, gitExecFileAsyncBuffer } from './runner'
import { _resetGitAdmissionForTests } from './command-runner/git-subprocess-admission'
afterEach(() => _resetGitAdmissionForTests())
import { resetWslGitReadEnvironmentForTests } from './wsl-git-read-environment'
const DISTRO = 'Ubuntu'
@@ -18,6 +18,9 @@ vi.mock('../observability/instrumentation', () => ({
vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() }))
import { gitExecFileAsync } from './runner'
import { _resetGitAdmissionForTests } from './command-runner/git-subprocess-admission'
afterEach(() => _resetGitAdmissionForTests())
import {
resetWslGitReadEnvironmentForTests,
seedWslGitReadEnvironmentForTests
+6 -1
View File
@@ -17,6 +17,7 @@ export {
configureWindowsHostGitEnvironmentReadiness
} from './command-runner/windows-host-git-environment'
export { DEFAULT_GIT_MAX_BUFFER } from './command-runner/git-exec-options'
export { GitCommandTimeoutError } from './command-runner/git-command-timeout'
export {
appendGitConfigEnv,
gitOptionalLocksDisabledEnv,
@@ -32,7 +33,11 @@ export {
} from './command-runner/git-exec-file'
export { commandExecFileAsync } from './command-runner/command-exec-file'
export { gitStreamStdout, type GitStreamResult } from './command-runner/git-stream-stdout'
export { gitSpawn, gitSpawnAfterWindowsEnvironmentReady } from './command-runner/git-spawn'
export {
gitSpawn,
gitSpawnAfterWindowsEnvironmentReady,
withGitAdmission
} from './command-runner/git-spawn'
export { isTransientGhError } from './command-runner/gh-retry-policy'
export { applyGhHostToArgs } from './command-runner/gh-host-args'
export { ghExecFileAsync } from './command-runner/gh-exec-file'
@@ -2,6 +2,7 @@ import type { GitRuntimeOptions } from '../git-runtime-options'
export type GetStatusOptions = GitRuntimeOptions & {
includeIgnored?: boolean
includeLineStats?: boolean
reuseLineStats?: boolean
/** Merge-base OID the caller wants the branch line total measured against;
* omitted means the chip is hidden, so no ranged diff runs at all. */
+7 -3
View File
@@ -53,7 +53,9 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri
return stableInFlightKey([
worktreePath,
options.wslDistro ?? '',
options.admissionTier ?? 'status',
options.includeIgnored === true,
options.includeLineStats !== false,
options.reuseLineStats === true,
// Why: the result carries a total only for callers who asked, and only for
// this fork point, so a shared lease must never serve one to the other.
@@ -102,7 +104,8 @@ async function runGetStatus(
options: GetStatusOptions = {}
): Promise<GitStatusResult> {
const lineStatsCacheKey = getStatusLineStatsCacheKey(worktreePath, options)
const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey)
const lineStatsWriteToken =
options.includeLineStats === false ? null : beginGitStatusLineStatsCacheWrite(lineStatsCacheKey)
let effectiveUpstreamStatus: GitUpstreamStatus | undefined
let statusSucceeded = false
// Why: a bad limit (negative/fractional/NaN) breaks early-stop; require a valid non-negative int (0 disables the cap).
@@ -132,6 +135,7 @@ async function runGetStatus(
const result = await gitStreamStdout(statusArgs, {
cwd: worktreePath,
wslDistro: options.wslDistro,
admissionTier: options.admissionTier,
preferWslDirectGit: true,
// Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock.
env: gitOptionalLocksDisabledEnv(),
@@ -209,7 +213,7 @@ async function runGetStatus(
// Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set.
let branchLineTotal: GitBranchLineTotal | undefined
if (!didHitLimit) {
if (!didHitLimit && lineStatsWriteToken !== null) {
const branchLineTotalInput = createBranchLineTotalInput(
worktreePath,
entries,
@@ -227,7 +231,7 @@ async function runGetStatus(
...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {})
})
branchLineTotal = lineStats.branchLineTotal
} else {
} else if (lineStatsWriteToken !== null) {
clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken)
}
@@ -35,6 +35,9 @@ vi.mock('../../../shared/git-discard-path-safety', () => ({
import { bulkDiscardChanges } from './discard-changes'
import { resetWslGitReadEnvironmentForTests } from '../wsl-git-read-environment'
import { _resetGitAdmissionForTests } from '../command-runner/git-subprocess-admission'
afterEach(() => _resetGitAdmissionForTests())
const DISTRO = 'Ubuntu-24.04'
const WSL_WORKTREE = `\\\\wsl$\\${DISTRO}\\home\\emilio\\projects\\orca`
+12 -6
View File
@@ -71,15 +71,19 @@ describe('getStatus', () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
})
it('opts status reads into direct WSL Git without changing mutation options', async () => {
it('preserves status admission through the direct WSL stream without changing mutation options', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
await getStatus('/repo', { wslDistro: 'Ubuntu' })
await getStatus('/repo', { wslDistro: 'Ubuntu', admissionTier: 'interactive' })
await stageFile('/repo', 'src/file.ts', { wslDistro: 'Ubuntu' })
expect(gitStreamOptionsMock).toHaveBeenCalledWith(
expect.objectContaining({ preferWslDirectGit: true, wslDistro: 'Ubuntu' })
expect.objectContaining({
admissionTier: 'interactive',
preferWslDirectGit: true,
wslDistro: 'Ubuntu'
})
)
const addOptions = gitExecFileAsyncMock.mock.calls.find(([args]) =>
(args as string[]).includes('add')
@@ -290,16 +294,18 @@ describe('getStatus', () => {
getStatus('/other-repo'),
getStatus('/repo', { wslDistro: 'Ubuntu' }),
getStatus('/repo', { includeIgnored: true }),
getStatus('/repo', { includeLineStats: false }),
getStatus('/repo', { reuseLineStats: true }),
getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }),
getStatus('/repo', { limit: 1 }),
getStatus('/repo', { sharedLinkPaths: ['node_modules'] })
getStatus('/repo', { sharedLinkPaths: ['node_modules'] }),
getStatus('/repo', { admissionTier: 'interactive' })
]
await vi.waitFor(() => expect(statusCommandCalls).toBe(8))
await vi.waitFor(() => expect(statusCommandCalls).toBe(10))
releases.splice(0).forEach((release) => release())
await Promise.all(reads)
expect(statusCommandCalls).toBe(8)
expect(statusCommandCalls).toBe(10)
})
it('clears in-flight status reads when a mutation runs', async () => {
+29
View File
@@ -368,6 +368,35 @@ describe('getStatus', () => {
])
})
it('omits line stats without overwriting the reusable line-stats cache', async () => {
readFileMock.mockResolvedValue('gitdir: /stats-repo/.git/worktrees/feature\n')
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('status')) {
return Promise.resolve({
stdout:
'# branch.oid head-stats\n' +
'1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n'
})
}
if (args.includes('--numstat')) {
return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' })
}
return Promise.resolve({ stdout: '' })
})
const withStats = await getStatus('/stats-repo')
const withoutStats = await getStatus('/stats-repo', { includeLineStats: false })
const reused = await getStatus('/stats-repo', { reuseLineStats: true })
expect(withoutStats.entries).toEqual(
withStats.entries.map(({ added: _added, removed: _removed, ...entry }) => entry)
)
expect(reused.entries).toEqual(withStats.entries)
expect(
gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat'))
).toHaveLength(1)
})
it('reuses unchanged line stats only when the safety hint is present', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
+8 -2
View File
@@ -7,9 +7,11 @@ import { getPublishTargetStatus } from '../../shared/git-publish-target-status'
import { gitExecFileAsync } from './runner'
import { validateGitPushTarget } from './push-target-validation'
import { nativeAndWslGitUpstreamStatusReadOwner } from './git-upstream-status-read-owner'
import type { GitAdmissionTier } from './command-runner/git-exec-options'
type GitExecOptions = {
wslDistro?: string
admissionTier?: GitAdmissionTier
}
export function invalidateGitUpstreamStatusReads(): void {
@@ -19,8 +21,12 @@ export function invalidateGitUpstreamStatusReads(): void {
function gitExecOptions(
cwd: string,
options: GitExecOptions = {}
): { cwd: string; wslDistro?: string } {
return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd }
): { cwd: string; wslDistro?: string; admissionTier?: GitAdmissionTier } {
return {
cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.admissionTier ? { admissionTier: options.admissionTier } : {})
}
}
async function getBehindCommitsArePatchEquivalent(
@@ -0,0 +1,26 @@
// The common-dir check decides whether a create is confirmed, so a false accept adopts a foreign repo.
import { describe, expect, it } from 'vitest'
import { isSameCommonDirPath } from './worktree-listing'
describe('isSameCommonDirPath', () => {
it('keeps WSL paths case-sensitive on a Windows host', () => {
expect(isSameCommonDirPath('/home/u/RepoA/.git', '/home/u/repoa/.git', 'win32')).toBe(false)
expect(isSameCommonDirPath('/home/u/repoa/.git', '/home/u/repoa/.git', 'win32')).toBe(true)
})
it('refuses to compare a Linux path against a Windows one', () => {
expect(isSameCommonDirPath('/home/u/repo/.git', 'C:\\repo\\.git', 'win32')).toBe(false)
expect(
isSameCommonDirPath('/home/u/repo/.git', '\\\\wsl.localhost\\Ubuntu\\home\\u\\repo\\.git')
).toBe(false)
})
it('still folds drive-letter case and slash style for Windows paths', () => {
expect(isSameCommonDirPath('C:\\Repo\\.git', 'c:/repo/.git', 'win32')).toBe(true)
})
it('normalizes without folding case on a POSIX host', () => {
expect(isSameCommonDirPath('/repo/./x/../.git', '/repo/.git', 'linux')).toBe(true)
expect(isSameCommonDirPath('/repo/.GIT', '/repo/.git', 'linux')).toBe(false)
})
})
+12 -8
View File
@@ -191,15 +191,19 @@ export async function finalizePreparedWorktree(
refreshLocalBaseRef,
finalizeGitOptions
)
const { stdout: targetHeadOutput } = await gitExecFileAsync(
['rev-parse', '--verify', `${baseContext.effectiveBase}^{commit}`],
gitExecOptions(repoPath, finalizeGitOptions)
)
const [targetHeadResult, preparedHeadResult] = await Promise.all([
gitExecFileAsync(
['rev-parse', '--verify', `${baseContext.effectiveBase}^{commit}`],
gitExecOptions(repoPath, finalizeGitOptions)
),
gitExecFileAsync(
['rev-parse', '--verify', 'HEAD'],
gitExecOptions(preparedPath, finalizeGitOptions)
)
])
const { stdout: targetHeadOutput } = targetHeadResult
const targetHead = targetHeadOutput.trim()
const { stdout: preparedHeadOutput } = await gitExecFileAsync(
['rev-parse', '--verify', 'HEAD'],
gitExecOptions(preparedPath, finalizeGitOptions)
)
const { stdout: preparedHeadOutput } = preparedHeadResult
if (preparedHeadOutput.trim() !== targetHead) {
await gitExecFileAsync(
[...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead],
@@ -0,0 +1,174 @@
// Real-binary coverage for the create-verification fallback (#16520): the mocked-runner suite cannot
// prove that the row rebuilt from `rev-parse`/`symbolic-ref` is the row `git worktree list` reports.
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 { clearGitCapabilityStateForTests, getLocalGitCapabilityCache } from './git-capability-state'
import { describeCreatedWorktree, listWorktreesStrict } from './worktree'
const execFileAsync = promisify(execFile)
let scratchDir = ''
let repoPath = ''
let worktreePath = ''
async function git(args: string[], cwd: string): Promise<string> {
const { stdout } = await execFileAsync('git', args, { cwd })
return stdout
}
async function seedRepo(path: string, email: string): Promise<void> {
await mkdir(path, { recursive: true })
await git(['init', '-q'], path)
await git(['config', 'user.email', email], path)
await git(['config', 'user.name', 'Created Description'], path)
await writeFile(join(path, 'seed.txt'), 'seed\n')
await git(['add', '-A'], path)
await git(['commit', '-qm', 'seed'], path)
}
beforeEach(async () => {
// realpath: macOS hands out /var/... temp paths while Git reports /private/var/...
scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-created-worktree-')))
repoPath = join(scratchDir, 'repo')
worktreePath = join(scratchDir, 'workspaces', 'feature')
await seedRepo(repoPath, 'created@example.invalid')
await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature'], repoPath)
})
afterEach(async () => {
clearGitCapabilityStateForTests()
await rm(scratchDir, { recursive: true, force: true })
})
describe('describeCreatedWorktree against the real Git binary', () => {
it('rebuilds exactly the row the listing reports for the same worktree', async () => {
const listed = (await listWorktreesStrict(repoPath)).find(
(worktree) => worktree.branch === 'refs/heads/feature'
)
expect(listed).toBeDefined()
await expect(describeCreatedWorktree(repoPath, worktreePath, 'feature')).resolves.toEqual(
listed
)
})
it('accepts a fully qualified branch ref', async () => {
await expect(
describeCreatedWorktree(repoPath, worktreePath, 'refs/heads/feature')
).resolves.toMatchObject({ branch: 'refs/heads/feature', isMainWorktree: false })
})
it('rejects a checkout owned by a different repo', async () => {
const otherRepo = join(scratchDir, 'other')
await seedRepo(otherRepo, 'other@example.invalid')
await expect(
describeCreatedWorktree(otherRepo, worktreePath, 'feature')
).resolves.toBeUndefined()
})
it('rejects a detached HEAD', async () => {
await git(['checkout', '-q', '--detach'], worktreePath)
await expect(
describeCreatedWorktree(repoPath, worktreePath, 'feature')
).resolves.toBeUndefined()
})
it('rejects a worktree whose HEAD Git cannot resolve', async () => {
// Deleting the branch ref leaves HEAD symbolic but unborn: the ref check still passes.
await rm(join(repoPath, '.git', 'refs', 'heads', 'feature'))
await expect(
describeCreatedWorktree(repoPath, worktreePath, 'feature')
).resolves.toBeUndefined()
})
it('rejects a path that is not a worktree', async () => {
const plainDir = join(scratchDir, 'not-a-worktree')
await mkdir(plainDir, { recursive: true })
await expect(describeCreatedWorktree(repoPath, plainDir, 'feature')).resolves.toBeUndefined()
})
// `ln` is absent from the Windows test PATH.
it.skipIf(process.platform === 'win32')(
'still recovers when the repo root reaches Git through a symlink',
async () => {
const linkedRepo = join(scratchDir, 'repo-link')
await execFileAsync('ln', ['-s', repoPath, linkedRepo])
// Why this case: comparing a single common-dir reading rejected every symlinked root, leaving
// the recovery inert exactly where the listing is most likely to have been the only witness.
await expect(
describeCreatedWorktree(linkedRepo, worktreePath, 'feature')
).resolves.toMatchObject({ branch: 'refs/heads/feature' })
}
)
// `mkfifo` stands in for a `.git` on a hung mount: the read never rejects on its own.
it.skipIf(process.platform === 'win32')(
"still settles when the repo's .git blocks forever",
async () => {
const stalledRepo = join(scratchDir, 'stalled')
await mkdir(stalledRepo, { recursive: true })
const stalledDotGit = join(stalledRepo, '.git')
await execFileAsync('mkfifo', [stalledDotGit])
try {
await expect(
describeCreatedWorktree(stalledRepo, worktreePath, 'feature', { timeout: 250 })
).resolves.toBeUndefined()
} finally {
// Release the pending read so the fifo does not pin a threadpool thread for the whole run.
await writeFile(stalledDotGit, '')
}
}
)
it('recovers a worktree created from a bare repo', async () => {
const bareRepo = join(scratchDir, 'bare.git')
await execFileAsync('git', ['clone', '-q', '--bare', repoPath, bareRepo])
const bareWorktree = join(scratchDir, 'workspaces', 'from-bare')
await git(['worktree', 'add', '-q', bareWorktree, '-b', 'from-bare'], bareRepo)
// Why this case: a bare repo has no `.git` file to walk, so the filesystem reading is absent
// and only Git's own `--git-common-dir` can confirm the worktree belongs to this repo.
await expect(
describeCreatedWorktree(bareRepo, bareWorktree, 'from-bare')
).resolves.toMatchObject({ branch: 'refs/heads/from-bare', isMainWorktree: false })
})
it('recovers on the Git 2.25 baseline, which ignores --path-format=absolute', async () => {
// Forces the no-`--path-format` fallback against the real binary: it answers with paths Git has
// not made absolute, so the readings only agree once they are canonicalized.
getLocalGitCapabilityCache({ cwd: repoPath }).rememberUnsupported('rev-parse-path-format')
const listed = (await listWorktreesStrict(repoPath)).find(
(worktree) => worktree.branch === 'refs/heads/feature'
)
await expect(describeCreatedWorktree(repoPath, worktreePath, 'feature')).resolves.toEqual(
listed
)
})
// `ln` is absent from the Windows test PATH.
it.skipIf(process.platform === 'win32')(
'recovers a symlinked root on the Git 2.25 baseline',
async () => {
const linkedRepo = join(scratchDir, 'repo-link')
await execFileAsync('ln', ['-s', repoPath, linkedRepo])
getLocalGitCapabilityCache({ cwd: linkedRepo }).rememberUnsupported('rev-parse-path-format')
// Why this pair: without `--path-format=absolute` Git answers `.git`, which resolves against the
// symlink spelling the caller passed, while the worktree answers with the real root. Only
// canonicalizing both makes them the same object store.
await expect(
describeCreatedWorktree(linkedRepo, worktreePath, 'feature')
).resolves.toMatchObject({ branch: 'refs/heads/feature' })
}
)
})
+85 -4
View File
@@ -1,6 +1,6 @@
import { stat } from 'node:fs/promises'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { toWslExecutionSpace } from '../../shared/wsl-paths'
import {
hasUnsupportedRevParsePathFormatEcho,
isUnsupportedRevParsePathFormatError,
@@ -42,7 +42,7 @@ function parseRepoLocation(repoPath: string, output: string): RepoLocation | und
}
}
async function readRepoLocation(
export async function readRepoLocation(
repoPath: string,
resolveBasePath: string,
options: GitWorktreeExecOptions = {}
@@ -79,6 +79,88 @@ async function readRepoLocation(
}
}
/**
* The repo's common dir as Git reports it.
*
* Why `--git-common-dir` alone: adding `--show-toplevel` makes rev-parse fail outright on a bare repo.
* Why the Git-space base: Git < 2.31 (the 2.25 baseline) ignores `--path-format=absolute` and prints a
* relative `.git` at a main worktree root; resolving that against a UNC repoPath would compare a
* Windows path against the worktree-side Linux answer, leaving the create's recovery inert (#16520).
*/
export async function readRepoCommonDirFromGit(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<string | undefined> {
const resolveBasePath = toWslExecutionSpace(repoPath)
const readCommonDir = (stdout: string): string | undefined => {
const commonDir = stdout
.split('\n')
.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
.findLast((line) => line.length > 0 && !line.startsWith('-'))
return commonDir ? resolveRevParsePath(resolveBasePath, commonDir) : undefined
}
try {
return await withLocalGitCapabilityCacheForExecution(
{ cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal },
(capabilities) =>
capabilities.runWithFallback(
'rev-parse-path-format',
async () => {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--path-format=absolute', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
if (hasUnsupportedRevParsePathFormatEcho(stdout)) {
capabilities.rememberUnsupported('rev-parse-path-format')
}
return readCommonDir(stdout)
},
async () => {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
return readCommonDir(stdout)
},
isUnsupportedRevParsePathFormatError
)
)
} catch {
return undefined
}
}
/** The branch ref checked out in `worktreePath`, or undefined when HEAD is detached. */
export async function readCheckedOutBranchRef(
worktreePath: string,
options: GitWorktreeExecOptions = {}
): Promise<string | undefined> {
try {
const { stdout } = await gitExecFileAsync(
['symbolic-ref', '--quiet', 'HEAD'],
gitExecOptions(worktreePath, options)
)
return stdout.trim() || undefined
} catch {
return undefined
}
}
export async function readWorktreeHeadOid(
worktreePath: string,
options: GitWorktreeExecOptions = {}
): Promise<string> {
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', 'HEAD'],
gitExecOptions(worktreePath, options)
)
return stdout.trim()
} catch {
return ''
}
}
async function normalizeMainWorktreePath(
repoPath: string,
worktrees: GitWorktreeInfo[],
@@ -88,8 +170,7 @@ async function normalizeMainWorktreePath(
const mainWorktree = worktrees[mainIndex]
// Why: under WSL, porcelain/rev-parse paths are Linux but repoPath is UNC; compare in Git-output
// space so the early-return matches and we skip a needless rev-parse per poll (runner still gets repoPath).
const wslRepo = parseWslUncPath(repoPath)
const comparablePath = wslRepo ? wslRepo.linuxPath : repoPath
const comparablePath = toWslExecutionSpace(repoPath)
if (!mainWorktree || areWorktreePathsEqual(mainWorktree.path, comparablePath)) {
return worktrees
}
+163 -5
View File
@@ -1,11 +1,26 @@
import { stat } from 'node:fs/promises'
import { realpath, stat } from 'node:fs/promises'
import { join, posix } from 'node:path'
import { isWorktreeCreatePreparation } from '../../shared/worktree/create-preparation'
import { toWslExecutionSpace } from '../../shared/wsl-paths'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { readTranslatedWorktreeGraph, readWorktreeList } from './worktree-list-reader'
import {
readCheckedOutBranchRef,
readRepoCommonDirFromGit,
readRepoLocation,
readTranslatedWorktreeGraph,
readWorktreeHeadOid,
readWorktreeList
} from './worktree-list-reader'
import type { GitWorktreeExecOptions } from './worktree-operation-options'
import { getErrorCode, isNotGitRepositoryError } from './worktree-operation-options'
import { translateWorktreePath } from './worktree-path-comparison'
import { detectSparseCheckout } from './worktree-sparse-state'
import {
WORKTREE_LIST_TIMEOUT_MS,
getErrorCode,
isNotGitRepositoryError,
normalizeLocalBranchRef
} from './worktree-operation-options'
import { areWorktreePathsEqual, translateWorktreePath } from './worktree-path-comparison'
import { detectSparseCheckout, resolveGitCommonDir } from './worktree-sparse-state'
import { resolveGitDir } from './source-control/resolve-git-dir'
const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
@@ -107,3 +122,146 @@ async function annotateSparseCheckoutStatus(
await Promise.all(Array.from({ length: workerCount }, () => detectNext()))
return annotated
}
/**
* The repo's common dir from the filesystem, as a second opinion on Git's own reading.
*
* Deadlined because a `.git` on a hung mount (dead NFS/SSHFS, stalled WSL 9p) never rejects, and an
* unbounded read here would leave the whole create IPC pending instead of failing like it used to.
*/
async function readRepoCommonDirFromDisk(
repoPath: string,
timeoutMs: number
): Promise<string | undefined> {
try {
const dotGit = join(repoPath, '.git')
// A bare repo has no `.git`, and resolveGitDir would fabricate one; offer no candidate instead.
await withDeadline(stat(dotGit), timeoutMs)
const commonDir = await withDeadline(
resolveGitDir(repoPath).then(resolveGitCommonDir),
timeoutMs
)
// Node answers in the caller's space, Git in the distro's. Without this the WSL candidate is a UNC
// path that can never equal Git's `/home/...`, leaving this witness inert on exactly the fallback
// path that needs it (realpath cannot bridge the two: a Linux path has no local inode).
return toWslExecutionSpace(commonDir)
} catch {
return undefined
}
}
async function withDeadline<T>(work: Promise<T>, timeoutMs: number): Promise<T> {
let timer: NodeJS.Timeout | undefined
try {
return await Promise.race([
work,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('deadline exceeded')), timeoutMs)
})
])
} finally {
clearTimeout(timer)
}
}
async function canonicalizeLocalPath(pathValue: string): Promise<string> {
try {
return await realpath(pathValue)
} catch {
// A path Git reported from another execution space (WSL, SSH) has no local inode.
return pathValue
}
}
/**
* Whether the created worktree's common dir names the same object store as the repo.
*
* Why accept any candidate: the readings come from different spaces — Git resolves symlinks and,
* under WSL, answers in Linux paths, while the filesystem walk keeps the caller's (possibly UNC)
* spelling. Comparing only one rejected every symlinked-root, WSL, and bare-repo create (#16520).
*/
async function isSameRepoCommonDir(
createdCommonDir: string,
candidates: readonly (string | undefined)[]
): Promise<boolean> {
const present = candidates.filter((candidate): candidate is string => Boolean(candidate))
if (present.some((candidate) => isSameCommonDirPath(createdCommonDir, candidate))) {
return true
}
const [canonicalCreated, ...canonicalCandidates] = await Promise.all(
[createdCommonDir, ...present].map(canonicalizeLocalPath)
)
return canonicalCandidates.some((candidate) => isSameCommonDirPath(canonicalCreated, candidate))
}
/**
* Why not areWorktreePathsEqual alone: it folds case for every path once the host is Windows, so on
* a Windows desktop two WSL repos differing only in case would be accepted as the same object store.
*/
export function isSameCommonDirPath(
left: string,
right: string,
platform = process.platform
): boolean {
const leftIsPosix = isPosixAbsolutePath(left)
if (leftIsPosix || isPosixAbsolutePath(right)) {
return leftIsPosix && isPosixAbsolutePath(right) && posix.resolve(left) === posix.resolve(right)
}
return areWorktreePathsEqual(left, right, platform)
}
function isPosixAbsolutePath(pathValue: string): boolean {
return pathValue.startsWith('/') && !pathValue.startsWith('//')
}
/**
* Reconstruct the listing row for a worktree `git worktree add` just created, by asking Git about
* the worktree itself. Used when the listing fails or omits it, so a create does not abandon a
* worktree Git already wrote to disk (#16520). Returns undefined unless Git resolves the path into
* this repo's object store with the expected branch checked out.
*/
export async function describeCreatedWorktree(
repoPath: string,
worktreePath: string,
branch: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo | undefined> {
const expectedRef = `refs/heads/${normalizeLocalBranchRef(branch)}`
// Bound Git recovery after the bounded listing failed; filesystem canonicalization stays best effort.
const deadlined: GitWorktreeExecOptions = {
...options,
timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS
}
const [created, repoGitCommonDir, checkedOutRef, head] = await Promise.all([
readRepoLocation(worktreePath, toWslExecutionSpace(worktreePath), deadlined),
readRepoCommonDirFromGit(repoPath, deadlined),
readCheckedOutBranchRef(worktreePath, deadlined),
readWorktreeHeadOid(worktreePath, deadlined)
])
// An unreadable HEAD means Git could not confirm the worktree, so report nothing rather than a blank OID.
if (!created || checkedOutRef !== expectedRef || !head) {
return undefined
}
if (!(await isSameRepoCommonDir(created.commonDir, [repoGitCommonDir]))) {
// Only now read the second opinion from disk: a `.git` on a hung mount pins a threadpool thread
// that no deadline can reclaim, so never pay that on the path where Git already agreed.
const repoDiskCommonDir = await readRepoCommonDirFromDisk(
repoPath,
deadlined.timeout ?? WORKTREE_LIST_TIMEOUT_MS
)
if (!(await isSameRepoCommonDir(created.commonDir, [repoDiskCommonDir]))) {
return undefined
}
}
const [described] = await annotateSparseCheckoutStatus([
{
path: translateWorktreePath(created.topLevel, repoPath, options),
head,
branch: expectedRef,
isBare: false,
// `git worktree add` only ever produces a linked worktree.
isMainWorktree: false
}
])
return described
}
+4 -3
View File
@@ -50,9 +50,10 @@ export function _resetWorktreeScanCacheForTests(): void {
}
/**
* Share one in-flight scan per (repo, distro, deadline, generation). `run` decides whether failures
* soften or propagate; both variants must coalesce so a create overlapping a sidebar refresh does
* not spawn a second `git worktree list` (expensive on Windows).
* Share one in-flight scan per (repo, distro, deadline, generation, runner). Coalescing keeps a
* sidebar refresh from spawning a second `git worktree list` (expensive on Windows), but only
* within one failure discipline: a strict joiner must never inherit a lenient scan's softened `[]`,
* so the strict and lenient runners scan separately by design.
*/
function shareWorktreeScan(
repoPath: string,
+1 -1
View File
@@ -29,7 +29,7 @@ export async function detectSparseCheckout(worktreePath: string): Promise<boolea
// Resolve the shared common gitdir for a (possibly linked) worktree gitdir. A linked worktree's
// gitdir holds a `commondir` file pointing at the repo's main `.git`; the main worktree's gitdir
// is itself the common dir.
async function resolveGitCommonDir(gitDir: string): Promise<string> {
export async function resolveGitCommonDir(gitDir: string): Promise<string> {
try {
const raw = (await readFile(join(gitDir, 'commondir'), 'utf-8')).trim()
if (raw.length > 0) {
+1 -1
View File
@@ -6,7 +6,7 @@ export {
} from './worktree-add'
export { forceDeleteLocalBranch } from './worktree-branch-removal'
export { parseWorktreeList } from './worktree-list-parser'
export { listWorktreeGraph, listWorktreesStrict } from './worktree-listing'
export { describeCreatedWorktree, listWorktreeGraph, listWorktreesStrict } from './worktree-listing'
export { moveWorktree } from './worktree-move'
export {
WORKTREE_ADD_TIMEOUT_MAX_MS,
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { isWslDirectGitReadCommand } from './wsl-direct-git-read-commands'
import { classifyGitCommand, isWslDirectGitReadCommand } from './wsl-direct-git-read-commands'
describe('isWslDirectGitReadCommand', () => {
it.each([
@@ -49,6 +49,8 @@ describe('isWslDirectGitReadCommand', () => {
[['symbolic-ref', 'HEAD', 'refs/heads/main']],
[['worktree', 'add', '/tmp/wt']],
[['branch', '-D', 'feature']],
[['branch', '-r', '-d', 'origin/feature']],
[['branch', '-rd', 'origin/feature']],
[['branch', 'newbranch']],
[[]]
])('keeps %j on the login shell', (args) => {
@@ -89,3 +91,72 @@ describe('isWslDirectGitReadCommand', () => {
expect(isWslDirectGitReadCommand(args)).toBe(false)
})
})
describe('classifyGitCommand', () => {
it.each([
[['fetch', '--all'], 'network'],
[['pull', '--rebase'], 'network'],
[['push', 'origin', 'main'], 'network'],
[['clone', 'https://example.com/repo.git'], 'network'],
[['ls-remote', '--heads', 'origin'], 'network'],
[['submodule', 'update', '--init'], 'network'],
[['remote', 'update', '--prune'], 'network'],
[['-c', 'maintenance.auto=false', 'fetch', '--all'], 'network'],
[['-C', '/repo', 'status', '--porcelain=v2'], 'read'],
[['status', '--porcelain=v2'], 'read'],
[['branch', '--show-current'], 'read'],
[['branch', '-r', '-d', 'origin/feature'], 'other'],
[['branch', '-rd', 'origin/feature'], 'other'],
[['remote', 'get-url', 'origin'], 'read'],
[['config', '--get-regexp', String.raw`^remote\.`], 'read'],
[['show', '--end-of-options', 'HEAD:file'], 'read'],
[['blame', '--', 'file'], 'read'],
[['submodule', 'status'], 'read'],
[['submodule', 'sync'], 'other'],
[['submodule', 'foreach', 'status'], 'other'],
[['remote', 'show', 'origin'], 'other'],
[['remote', 'add', 'origin', 'url'], 'other'],
[['checkout', 'main'], 'other'],
[['made-up-command'], 'other'],
[[], 'other'],
// Unparsed space-separated global values remain fail-safe.
[['--git-dir', '/repo/.git', 'log'], 'other'],
[['--work-tree', '/repo', 'status'], 'other'],
[['-c', 'key=value'], 'other']
] as const)('classifies %j as %s', (args, expected) => {
expect(classifyGitCommand(args)).toBe(expected)
})
it.each([
[['--version'], 'other'],
[['branch', '-a'], 'read'],
[['branch', '--show-current'], 'read'],
[['check-ref-format', '--branch', 'topic'], 'read'],
[['checkout', 'topic'], 'other'],
[['commit', '-m', 'message'], 'other'],
[['config', '--get', 'remote.origin.url'], 'read'],
[['config', '--local', 'key', 'value'], 'other'],
[['diff', '--numstat', 'HEAD'], 'read'],
[['fetch', '--prune'], 'network'],
[['init'], 'other'],
[['ls-files', '-z'], 'read'],
[['ls-tree', 'HEAD'], 'read'],
[['merge', '--abort'], 'other'],
[['merge-base', 'HEAD', 'origin/main'], 'read'],
[['pull', '--rebase'], 'network'],
[['rebase', '--abort'], 'other'],
[['remote'], 'read'],
[['remote', '-v'], 'read'],
[['remote', 'get-url', 'origin'], 'read'],
[['restore', '--staged', '--', 'file'], 'other'],
[['rev-list', '--count', 'HEAD'], 'read'],
[['rev-parse', 'HEAD'], 'read'],
[['show', ':file'], 'read'],
[['status', '--porcelain=v2'], 'read'],
[['update-ref', '-d', 'refs/orca/tmp'], 'other'],
[['worktree', 'list', '--porcelain'], 'read'],
[['worktree', 'prune'], 'other']
] as const)('covers the production form %j as %s', (args, expected) => {
expect(classifyGitCommand(args)).toBe(expected)
})
})
+78 -3
View File
@@ -16,6 +16,7 @@
const ALWAYS_READ_SUBCOMMANDS = new Set([
'blame',
'cat-file',
'check-ref-format',
'check-ignore',
'describe',
'diff',
@@ -35,10 +36,52 @@ const ALWAYS_READ_SUBCOMMANDS = new Set([
// Read markers that appear as a flag anywhere after the subcommand.
const READ_FLAG_SUBCOMMANDS: Record<string, ReadonlySet<string>> = {
branch: new Set(['--list', '-l', '--show-current', '--contains', '--points-at']),
branch: new Set([
'--list',
'-l',
'--show-current',
'--contains',
'--points-at',
'--all',
'-a',
'--remotes',
'-r'
]),
config: new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l'])
}
const BRANCH_MUTATION_FLAGS = new Set([
'--copy',
'--create-reflog',
'--delete',
'--edit-description',
'--force',
'--move',
'--no-create-reflog',
'--no-track',
'--recurse-submodules',
'--set-upstream-to',
'--track',
'--unset-upstream'
])
const BRANCH_MUTATION_SHORT_FLAGS = new Set(['c', 'C', 'd', 'D', 'f', 'm', 'M', 't', 'u'])
function hasBranchMutationFlag(args: readonly string[]): boolean {
return args.some((arg) => {
const flag = arg.split('=')[0]
if (BRANCH_MUTATION_FLAGS.has(flag)) {
return true
}
return (
/^-[^-]/.test(flag) &&
flag
.slice(1)
.split('')
.some((part) => BRANCH_MUTATION_SHORT_FLAGS.has(part))
)
})
}
// Read markers that must be the *first non-flag* argument, i.e. the action.
// Position matters here: matching them anywhere would read `worktree remove list`
// as a listing, because a worktree may legitimately be named "list".
@@ -52,7 +95,7 @@ const READ_ACTION_SUBCOMMANDS: Record<string, ReadonlySet<string>> = {
const BARE_FORM_IS_READ = new Set(['remote', 'submodule'])
/** Leading `-c key=value` / `--git-dir=...` style options precede the subcommand. */
function findSubcommandIndex(args: readonly string[]): number {
export function findGitSubcommandIndex(args: readonly string[]): number {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '-c' || arg === '-C') {
@@ -68,7 +111,7 @@ function findSubcommandIndex(args: readonly string[]): number {
}
export function isWslDirectGitReadCommand(args: readonly string[]): boolean {
const subcommandIndex = findSubcommandIndex(args)
const subcommandIndex = findGitSubcommandIndex(args)
if (subcommandIndex === -1) {
return false
}
@@ -86,6 +129,10 @@ export function isWslDirectGitReadCommand(args: readonly string[]): boolean {
return rest.filter((arg) => arg !== '--' && !arg.startsWith('-')).length <= 1
}
if (subcommand === 'branch' && hasBranchMutationFlag(rest)) {
return false
}
const readActions = READ_ACTION_SUBCOMMANDS[subcommand]
if (readActions) {
const action = rest.find((arg) => !arg.startsWith('-'))
@@ -103,3 +150,31 @@ export function isWslDirectGitReadCommand(args: readonly string[]): boolean {
const readFlags = READ_FLAG_SUBCOMMANDS[subcommand]
return Boolean(readFlags && rest.some((arg) => readFlags.has(arg.split('=')[0])))
}
export type GitCommandClass = 'network' | 'read' | 'other'
const NETWORK_SUBCOMMANDS = new Set(['fetch', 'pull', 'push', 'clone', 'ls-remote'])
function positionalAction(args: readonly string[], subcommandIndex: number): string | undefined {
return args.slice(subcommandIndex + 1).find((arg) => !arg.startsWith('-'))
}
/** Classify only commands whose dominant phase is remote transfer as network work. */
export function classifyGitCommand(args: readonly string[]): GitCommandClass {
const subcommandIndex = findGitSubcommandIndex(args)
if (subcommandIndex === -1) {
return 'other'
}
const subcommand = args[subcommandIndex]
if (NETWORK_SUBCOMMANDS.has(subcommand)) {
return 'network'
}
const action = positionalAction(args, subcommandIndex)
if (
(subcommand === 'submodule' && action === 'update') ||
(subcommand === 'remote' && action === 'update')
) {
return 'network'
}
return isWslDirectGitReadCommand(args) ? 'read' : 'other'
}
@@ -5,6 +5,7 @@ import type {
PRReviewDecision
} from '../../../../shared/github/pull-request-types'
import { gitExecFileAsync } from '../../gh-utils'
import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options'
import {
getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
@@ -158,7 +159,7 @@ export function normalizePullRequestLookupData(data: PullRequestLookupData): Pul
export async function getCurrentHeadOid(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
localGitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {}
): Promise<string | null> {
const provider = connectionId ? getSshGitProvider(connectionId) : null
if (connectionId && !provider) {
@@ -171,7 +172,8 @@ export async function getCurrentHeadOid(
try {
const result = await gitExecFileAsync(['rev-parse', 'HEAD'], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
})
return result.stdout.trim() || null
} catch {
@@ -4,6 +4,7 @@ import {
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
} from '../../../providers/ssh-git-dispatch'
import { readLocalGitConfigSignature } from '../../local-git-config-signature'
import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options'
import {
TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS,
trackedUpstreamSnapshotCache,
@@ -24,7 +25,7 @@ export async function getTrackedUpstreamBranch(
repoPath: string,
branchName: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
localGitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {}
): Promise<TrackedUpstreamBranch | null> {
const cacheKey = getTrackedUpstreamBranchCacheKey(repoPath, connectionId, localGitOptions)
const now = Date.now()
@@ -95,7 +96,7 @@ export async function getTrackedUpstreamBranch(
export async function probeTrackedUpstreamSnapshot(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
localGitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {}
): Promise<TrackedUpstreamSnapshotProbeResult> {
const startingGitConfigSignature = await readLocalGitConfigSignature({
repoPath,
@@ -129,7 +130,7 @@ export async function probeTrackedUpstreamSnapshot(
export async function probeTrackedUpstreamBranches(
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
localGitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {}
): Promise<{
probeFailed: boolean
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
@@ -149,7 +150,8 @@ export async function probeTrackedUpstreamBranches(
try {
const result = await gitExecFileAsync(args, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
})
return {
probeFailed: false,
+20
View File
@@ -298,6 +298,26 @@ describe('getPRConflictSummary caching', () => {
).toHaveLength(1)
})
it('preserves background admission across the complete WSL derivation chain', async () => {
mockGitDispatch()
await getPRConflictSummary('/repo-root', 'main', 'github-base-oid', 'head-oid-1', {
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(5)
for (const [, options] of gitExecFileAsyncMock.mock.calls) {
expect(options).toEqual(
expect.objectContaining({
cwd: '/repo-root',
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
)
}
})
it('keeps identities distinct when paths or ref names contain a joiner character', async () => {
mockGitDispatch()
+9 -16
View File
@@ -4,6 +4,7 @@ import {
isUnsupportedMergeTreeWriteTreeError
} from '../../shared/git-merge-tree-capability'
import { gitExecFileAsync } from '../git/runner'
import { gitOptionsForWorktree, type GitRuntimeOptions } from '../git/git-runtime-options'
import {
clearGitCapabilityStateForTests,
withLocalGitCapabilityCacheForExecution
@@ -21,9 +22,7 @@ import {
storeCachedSummary
} from './conflict-summary-cache'
type LocalGitExecOptions = {
wslDistro?: string
}
type LocalGitExecOptions = Pick<GitRuntimeOptions, 'wslDistro' | 'admissionTier'>
export function __resetPRConflictSummaryCachesForTests(): void {
clearGitCapabilityStateForTests()
@@ -159,9 +158,8 @@ async function resolveLatestBaseOid(
// Why: cap the fetch at 10 s so slow or unreachable remotes don't block
// the conflict-summary derivation indefinitely.
await gitExecFileAsync(['fetch', '--quiet', remoteName, baseRefName], {
cwd: repoPath,
timeout: 10_000,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions),
timeout: 10_000
})
} catch {
// Why: fetching the base ref keeps the conflict list aligned with GitHub's
@@ -172,8 +170,7 @@ async function resolveLatestBaseOid(
for (const ref of [`refs/remotes/${remoteName}/${baseRefName}`, `${remoteName}/${baseRefName}`]) {
try {
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions)
})
const oid = stdout.trim()
if (oid) {
@@ -194,8 +191,7 @@ async function resolveMergeBase(
localGitOptions: LocalGitExecOptions
): Promise<string> {
const { stdout } = await gitExecFileAsync(['merge-base', headOid, baseOid], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions)
})
return stdout.trim()
}
@@ -206,8 +202,7 @@ async function countCommits(
localGitOptions: LocalGitExecOptions
): Promise<number> {
const { stdout } = await gitExecFileAsync(['rev-list', '--count', range], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions)
})
return Number.parseInt(stdout.trim(), 10) || 0
}
@@ -251,8 +246,7 @@ async function loadConflictingFiles(
async () => {
try {
const result = await gitExecFileAsync(modernArgs, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions)
})
return parseMergeTreeNameOnlyOutput(result.stdout)
} catch (error) {
@@ -288,8 +282,7 @@ async function loadConflictingFilesWithLegacyMergeTree(
): Promise<string[]> {
try {
const result = await gitExecFileAsync(legacyArgs, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions)
})
return parseMergeTreeNameOnlyOutput(result.stdout)
} catch (fallbackError) {
@@ -14,6 +14,7 @@ import {
} from './github-remote-identity-parsing'
import { classifyGitHubOwnerRepoFromRemoteUrl } from './github-ssh-host-alias-resolution'
import { isStableMissingGitRemoteError } from '../git/stable-missing-git-remote-error'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
export type OwnerRepo = GitHubOwnerRepo
@@ -24,10 +25,12 @@ export type GitHubRepoContext = {
repoPath: string
connectionId?: string | null
wslDistro?: string
admissionTier?: GitAdmissionTier
}
export type LocalGitExecOptions = {
wslDistro?: string
admissionTier?: GitAdmissionTier
}
export type GitHubRemoteIdentityProbeOptions = {
@@ -42,7 +45,8 @@ export function githubRepoContext(
return {
repoPath,
connectionId: connectionId ?? null,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
}
}
@@ -50,12 +54,14 @@ export function ghRepoExecOptions(context: GitHubRepoContext): {
cwd?: string
encoding?: BufferEncoding
wslDistro?: string
admissionTier?: GitAdmissionTier
} {
return context.connectionId
? {}
: {
cwd: context.repoPath,
...(context.wslDistro ? { wslDistro: context.wslDistro } : {})
...(context.wslDistro ? { wslDistro: context.wslDistro } : {}),
...(context.admissionTier ? { admissionTier: context.admissionTier } : {})
}
}
+13 -3
View File
@@ -25,11 +25,15 @@ function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boole
}
export function hostedReviewOptionArgs(
candidate: PRBranchLookupCandidate
candidate: PRBranchLookupCandidate,
reason: GitHubPRRefreshReason = 'visible'
): [] | [GitHubPRBranchLookupOptions] {
const options: GitHubPRBranchLookupOptions = {}
if (candidate.localGitOptions?.wslDistro) {
options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro }
options.localGitExecOptions = {
...(candidate.localGitOptions?.wslDistro
? { wslDistro: candidate.localGitOptions.wslDistro }
: {}),
admissionTier: admissionTierForRefreshReason(reason)
}
if (shouldAcceptMergedFallbackPR(candidate)) {
options.acceptMergedFallbackPR = true
@@ -40,6 +44,12 @@ export function hostedReviewOptionArgs(
return Object.keys(options).length > 0 ? [options] : []
}
export function admissionTierForRefreshReason(
reason: GitHubPRRefreshReason
): 'interactive' | 'background' {
return reason === 'manual' ? 'interactive' : 'background'
}
export function refreshKey(candidate: GitHubPRRefreshCandidate): string {
const connectionScope = candidate.connectionId ?? 'local'
const runtimeScope = candidate.connectionId
@@ -231,7 +231,8 @@ describe('pr-refresh-coordinator', () => {
'feature/test',
null,
null,
null
null,
{ localGitExecOptions: { admissionTier: 'background' } }
)
expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith(
2,
@@ -239,7 +240,8 @@ describe('pr-refresh-coordinator', () => {
'feature/test',
null,
'ssh-1',
null
null,
{ localGitExecOptions: { admissionTier: 'background' } }
)
})
@@ -277,7 +279,8 @@ describe('pr-refresh-coordinator', () => {
'feature/test',
null,
null,
null
null,
{ localGitExecOptions: { admissionTier: 'background' } }
)
expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith(
2,
@@ -286,7 +289,7 @@ describe('pr-refresh-coordinator', () => {
null,
null,
null,
{ localGitExecOptions: { wslDistro: 'Ubuntu' } }
{ localGitExecOptions: { wslDistro: 'Ubuntu', admissionTier: 'background' } }
)
})
@@ -207,7 +207,29 @@ describe('pr-refresh-coordinator', () => {
null,
null,
12,
{ acceptMergedFallbackPR: true }
{
acceptMergedFallbackPR: true,
localGitExecOptions: { admissionTier: 'interactive' }
}
)
})
it('preserves an automatic fallback reason and keeps its git work background', async () => {
const { refreshPRNow } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: Date.now() })
await refreshPRNow(makeCandidate(), 'swr')
expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith(
'/repo',
'feature/test',
null,
null,
null,
{ localGitExecOptions: { admissionTier: 'background' } }
)
expect(sendMock.mock.calls.map(([, event]) => event.reason)).toEqual(
expect.arrayContaining(['swr'])
)
})
})
+13 -11
View File
@@ -112,7 +112,10 @@ export function _getPRRefreshAliasCountForTests(key: string): number {
return queue.aliasCount(key)
}
export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise<PRRefreshOutcome> {
export async function refreshPRNow(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason = 'manual'
): Promise<PRRefreshOutcome> {
const alias = aliasFromCandidate(candidate)
const key = refreshKey(candidate)
const existing = queue.get(key)
@@ -128,7 +131,7 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise
message: `Cannot refresh PR for this worktree: ${skippedReason}`,
fetchedAt: Date.now()
}
events.broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason })
events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason })
return outcome
}
@@ -139,14 +142,14 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise
key,
candidate,
aliases: aliasMap,
reason: 'manual',
reason,
priority: 40,
dueAt: gateUntil,
queuedAt: queue.nextOrder()
})
events.broadcast({
aliases,
reason: 'manual',
reason,
status: 'paused',
pausedUntil: gateUntil,
skippedReason: 'rate-limit'
@@ -165,17 +168,14 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise
queue.delete(key)
const requestSequence = events.nextSequence()
const requestStartedAt = Date.now()
events.broadcast(
{ aliases, reason: 'manual', status: 'in-flight', requestStartedAt },
requestSequence
)
events.broadcast({ aliases, reason, status: 'in-flight', requestStartedAt }, requestSequence)
const outcome = await getPRForBranchOutcome(
candidate.repoPath,
candidate.branch,
candidate.linkedPRNumber ?? null,
candidate.connectionId ?? null,
candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs(candidate)
...hostedReviewOptionArgs(candidate, reason)
)
let plannedRetryAt: number | undefined
let broadcastOutcome = outcome
@@ -186,12 +186,14 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise
events.observe(candidate, outcome)
retry.noteManualGate(key, broadcastOutcome)
events.broadcast(
{ aliases, reason: 'manual', outcome: broadcastOutcome, requestStartedAt },
{ aliases, reason, outcome: broadcastOutcome, requestStartedAt },
requestSequence
)
drainer.scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, {
plannedRetryAt,
pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS
...(reason === 'manual'
? { pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS }
: {})
})
return broadcastOutcome
}
+1 -1
View File
@@ -193,7 +193,7 @@ export class PRRefreshQueueDrainer {
next.candidate.linkedPRNumber ?? null,
next.candidate.connectionId ?? null,
next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs(next.candidate)
...hostedReviewOptionArgs(next.candidate, next.reason)
)
let plannedRetryAt: number | undefined
let broadcastOutcome = outcome
+4 -1
View File
@@ -3,9 +3,11 @@ import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache'
import { glabExecFileAsync } from '../git/runner'
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
export type LocalGitExecOptions = {
wslDistro?: string
admissionTier?: GitAdmissionTier
}
const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000
@@ -150,7 +152,8 @@ async function probeGlabKnownHosts(
timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS,
...(!connectionId && localGitOptions.wslDistro
? { wslDistro: localGitOptions.wslDistro }
: {})
: {}),
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
})
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
const remembered = knownHostsCacheByExecutionContext.get(key) ?? []
@@ -1,4 +1,5 @@
import { glabExecFileAsync } from '../git/runner'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe'
import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache'
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
@@ -123,7 +124,8 @@ async function resolveProjectRefForRemote(
{
repoPath,
connectionId,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
},
remoteName
)
@@ -246,12 +248,13 @@ export function glabRepoExecOptions(
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): { cwd?: string; wslDistro?: string } {
): { cwd?: string; wslDistro?: string; admissionTier?: GitAdmissionTier } {
return connectionId
? {}
: {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
}
}
+15
View File
@@ -663,6 +663,21 @@ describe('getGlabKnownHosts', () => {
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { timeout: 10_000 })
})
it('preserves WSL and background admission on the cold auth-status probe', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
await getGlabKnownHosts(undefined, {
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {
timeout: 10_000,
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
})
it('falls back to default when glab auth status fails', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated'))
@@ -1,5 +1,20 @@
import { describe, expect, it } from 'vitest'
import { findCreatedWorktree } from './created-worktree-reconciliation'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describeCreatedWorktree, listWorktreesSharedStrict } from '../git/worktree'
import { findCreatedWorktree, resolveCreatedWorktree } from './created-worktree-reconciliation'
vi.mock('../git/worktree', () => ({
listWorktreesSharedStrict: vi.fn(),
describeCreatedWorktree: vi.fn()
}))
const CREATED = {
path: '/workspaces/feature',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
const MAIN = { ...CREATED, path: '/repo', branch: 'refs/heads/main', isMainWorktree: true }
describe('findCreatedWorktree', () => {
it('prefers the direct path match', () => {
@@ -81,3 +96,105 @@ describe('findCreatedWorktree', () => {
}
)
})
describe('resolveCreatedWorktree', () => {
beforeEach(() => {
vi.mocked(listWorktreesSharedStrict).mockReset()
vi.mocked(describeCreatedWorktree).mockReset().mockResolvedValue(undefined)
})
it('reports the whole listing when it contains the created row', async () => {
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN, CREATED])
await expect(
resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
).resolves.toEqual({ created: CREATED, worktrees: [MAIN, CREATED], listingComplete: true })
expect(describeCreatedWorktree).not.toHaveBeenCalled()
})
it('completes the create from the direct read when the listing fails', async () => {
vi.mocked(listWorktreesSharedStrict).mockRejectedValue(new Error('git timed out.'))
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
await expect(
resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
).resolves.toEqual({ created: CREATED, worktrees: [], listingComplete: false })
})
it('completes the create from the direct read when the listing omits the row', async () => {
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN])
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
await expect(
resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
).resolves.toMatchObject({ created: CREATED, listingComplete: false })
})
it("surfaces the listing's own failure rather than an opaque message", async () => {
const failure = new Error('fatal: not a git repository')
vi.mocked(listWorktreesSharedStrict).mockRejectedValue(failure)
await expect(resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')).rejects.toBe(
failure
)
})
it('keeps the listing failure when the direct read itself throws', async () => {
const failure = new Error('fatal: not a git repository')
vi.mocked(listWorktreesSharedStrict).mockRejectedValue(failure)
vi.mocked(describeCreatedWorktree).mockRejectedValue(new Error('rev-parse exploded'))
await expect(resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')).rejects.toBe(
failure
)
})
it('names the path and branch when the listing succeeded without the row', async () => {
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN])
await expect(resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')).rejects.toThrow(
'Worktree created but not found in listing: /workspaces/feature (branch feature)'
)
})
it("adds the direct read's failure when the listing merely omitted the row", async () => {
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN])
vi.mocked(describeCreatedWorktree).mockRejectedValue(new Error('rev-parse exploded'))
await expect(resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')).rejects.toThrow(
'Worktree created but not found in listing: /workspaces/feature (branch feature): rev-parse exploded'
)
})
it('charges the recovery what the listing left of the budget, not a fresh one', async () => {
vi.mocked(listWorktreesSharedStrict).mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 60))
throw new Error('git worktree list timed out.')
})
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
const options = vi.mocked(describeCreatedWorktree).mock.lastCall?.[3]
expect(options?.timeout).toBeGreaterThanOrEqual(5_000)
expect(options?.timeout).toBeLessThan(30_000)
})
it("keeps the caller's own deadline instead of the shared budget", async () => {
vi.mocked(listWorktreesSharedStrict).mockRejectedValue(new Error('git worktree list failed.'))
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature', { timeout: 1_234 })
expect(vi.mocked(describeCreatedWorktree).mock.lastCall?.[3]).toMatchObject({ timeout: 1_234 })
})
it('forwards exec options only when the caller supplied them', async () => {
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([CREATED])
await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
expect(listWorktreesSharedStrict).toHaveBeenLastCalledWith('/repo')
await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature', { wslDistro: 'Ubuntu' })
expect(listWorktreesSharedStrict).toHaveBeenLastCalledWith('/repo', { wslDistro: 'Ubuntu' })
})
})
@@ -1,3 +1,8 @@
import { describeCreatedWorktree, listWorktreesSharedStrict } from '../git/worktree'
// Not via the worktree barrel: suites mock that module wholesale and would blank the constant.
import { WORKTREE_LIST_TIMEOUT_MS } from '../git/worktree-operation-options'
import type { GitWorktreeExecOptions } from '../git/worktree'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { areWorktreePathsEqual } from './worktree-path-comparison'
export function findCreatedWorktree<T extends { path: string; branch?: string }>(
@@ -15,3 +20,81 @@ export function findCreatedWorktree<T extends { path: string; branch?: string }>
return worktrees.find((worktree) => worktree.branch === `refs/heads/${branchName}`)
}
export type CreatedWorktreeResolution = {
created: GitWorktreeInfo
/** Rows `git worktree list` returned; empty when only the direct read found the worktree. */
worktrees: readonly GitWorktreeInfo[]
/** Whether `worktrees` is the repo's whole listing, and so usable as its authorized-root set. */
listingComplete: boolean
}
/** `created but not found in listing` is load-bearing for `classifyWorkspaceCreateError`. */
export function createdWorktreeNotFoundError(worktreePath: string, branchName: string): Error {
return new Error(
`Worktree created but not found in listing: ${worktreePath} (branch ${branchName})`
)
}
/**
* Find the row for a worktree `git worktree add` just created, preferring the repo listing and
* falling back to asking Git about the worktree itself.
*
* Why the fallback: the listing was the only witness the old code had, so any Git-level listing
* failure failed a create whose worktree and branch were already on disk, orphaning both (#16520).
*/
/** A listing that burned the whole budget still leaves the direct read a chance to answer. */
const MIN_CREATED_WORKTREE_RECOVERY_MS = 5_000
export async function resolveCreatedWorktree(
repoPath: string,
worktreePath: string,
branchName: string,
options?: GitWorktreeExecOptions
): Promise<CreatedWorktreeResolution> {
const startedAt = Date.now()
let listingError: unknown
try {
const worktrees = options
? await listWorktreesSharedStrict(repoPath, options)
: await listWorktreesSharedStrict(repoPath)
const created = findCreatedWorktree(worktrees, worktreePath, branchName)
if (created) {
return { created, worktrees, listingComplete: true }
}
} catch (err) {
listingError = err
}
let described: GitWorktreeInfo | undefined
let describeError: unknown
try {
// One budget for verifying the create, not one per attempt: a hung Git already spent the
// listing's deadline, and charging the recovery a fresh one doubles the wait before the error.
const remainingMs = Math.max(
WORKTREE_LIST_TIMEOUT_MS - (Date.now() - startedAt),
MIN_CREATED_WORKTREE_RECOVERY_MS
)
described = await describeCreatedWorktree(repoPath, worktreePath, branchName, {
...options,
timeout: options?.timeout ?? remainingMs
})
} catch (err) {
// Why keep, not rethrow: the recovery must not replace the listing's own, more informative failure.
describeError = err
}
if (described) {
return { created: described, worktrees: [], listingComplete: false }
}
if (listingError) {
throw listingError
}
const notFound = createdWorktreeNotFoundError(worktreePath, branchName)
if (describeError) {
// The listing simply omitted the row, so the direct read holds the only actionable failure.
throw new Error(
`${notFound.message}: ${describeError instanceof Error ? describeError.message : String(describeError)}`
)
}
throw notFound
}
@@ -0,0 +1,123 @@
import type * as NodeFsPromises from 'node:fs/promises'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktrees } from '../repo-worktrees'
import type { Store } from '../persistence'
import type { Repo } from '../../shared/repo-types'
import {
__resetCreatedWorktreeRootsForTests,
invalidateAuthorizedRootsCache,
rebuildAuthorizedRootsCache,
registerCreatedWorktreeRoot,
resolveRegisteredWorktreePath
} from './registered-worktree-roots-cache'
const { statMock } = vi.hoisted(() => ({ statMock: vi.fn() }))
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof NodeFsPromises>('node:fs/promises')
return { ...actual, stat: statMock }
})
vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return { ...actual, listRepoWorktrees: vi.fn() }
})
const repo: Repo = {
id: 'repo-1',
path: '/repos/app',
displayName: 'app',
badgeColor: '#000000',
addedAt: 1,
kind: 'git'
}
const RECOVERED = resolve('/linked/recovered')
function makeStore(): Store {
return {
getRepos: () => [repo],
getProjectGroups: () => [],
getFolderWorkspaces: () => [],
getSettings: () => ({})
} as unknown as Store
}
function statError(code: string): NodeJS.ErrnoException {
const error = new Error(code) as NodeJS.ErrnoException
error.code = code
return error
}
/** A recovered root outlives a rebuild unless the rebuild proves it is listed again or gone (#16520). */
describe('recovered worktree root pruning', () => {
beforeEach(() => {
invalidateAuthorizedRootsCache()
__resetCreatedWorktreeRootsForTests()
vi.mocked(listRepoWorktrees).mockReset()
// The #16520 outage itself: `listWorktrees` softens every Git failure to `[]`, so the rebuild
// reports success with the recovered row missing and the probe is the only remaining evidence.
vi.mocked(listRepoWorktrees).mockResolvedValue([])
statMock.mockReset()
statMock.mockResolvedValue({})
})
afterEach(() => {
vi.useRealTimers()
})
it('retires a recovered root the probe proves is gone', async () => {
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, RECOVERED)
statMock.mockRejectedValue(statError('ENOENT'))
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath(RECOVERED, store)).rejects.toThrow('Access denied')
})
it('keeps a recovered root when the probe fails for any reason but ENOENT', async () => {
// A transient EACCES/EIO is not evidence of removal, and revoking on it would deny a worktree
// the user is working in.
for (const code of ['EACCES', 'EIO', 'EPERM']) {
__resetCreatedWorktreeRootsForTests()
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, RECOVERED)
statMock.mockRejectedValue(statError(code))
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath(RECOVERED, store)).resolves.toBe(RECOVERED)
}
})
it('finishes the rebuild and keeps the root when the probe never settles', async () => {
// A dead mount pins the syscall; the rebuild gates filesystem auth, so it must not wait on it.
vi.useFakeTimers()
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, RECOVERED)
statMock.mockReturnValue(new Promise(() => {}))
const rebuild = rebuildAuthorizedRootsCache(store)
await vi.advanceTimersByTimeAsync(5_000)
await rebuild
vi.useRealTimers()
await expect(resolveRegisteredWorktreePath(RECOVERED, store)).resolves.toBe(RECOVERED)
})
it('refuses a recovered root at capacity instead of evicting an authorized one', async () => {
const store = makeStore()
const first = resolve('/linked/recovered-0')
for (let i = 0; i < 64; i += 1) {
registerCreatedWorktreeRoot(store, repo.id, resolve(`/linked/recovered-${i}`))
}
const overflow = resolve('/linked/recovered-overflow')
registerCreatedWorktreeRoot(store, repo.id, overflow)
await expect(resolveRegisteredWorktreePath(first, store)).resolves.toBe(first)
await expect(resolveRegisteredWorktreePath(overflow, store)).rejects.toThrow('Access denied')
})
})
+76
View File
@@ -18,8 +18,10 @@ import {
} from './filesystem-auth'
import { isDescendantOrEqual, validateGitRelativeFilePath } from './filesystem-path-containment'
import {
__resetCreatedWorktreeRootsForTests,
invalidateAuthorizedRootsCache,
rebuildAuthorizedRootsCache,
registerCreatedWorktreeRoot,
resolveRegisteredWorktreePath
} from './registered-worktree-roots-cache'
@@ -95,6 +97,7 @@ function makeStore(
describe('filesystem auth worktree roots', () => {
beforeEach(() => {
invalidateAuthorizedRootsCache()
__resetCreatedWorktreeRootsForTests()
vi.mocked(listRepoWorktrees).mockReset()
})
@@ -121,6 +124,79 @@ describe('filesystem auth worktree roots', () => {
expect(listRepoWorktrees).toHaveBeenCalledTimes(1)
})
it("keeps a repo's roots when its listing fails mid-rebuild", async () => {
// Why: the rebuild runs while Git is still broken, and dropping the roots would revoke
// a worktree a create just recovered without a listing (#16520).
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, '/linked/recovered')
vi.mocked(listRepoWorktrees).mockRejectedValue(new Error('git worktree list failed.'))
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath('/linked/recovered', store)).resolves.toBe(
resolve('/linked/recovered')
)
})
it('keeps a recovered root when a healthy rebuild still cannot list it', async () => {
// Why: the rebuild recomputes from the very listing that omitted the row, so replacing the cache
// would re-deny the worktree the create just recovered (#16520).
const scratch = await mkdtemp(join(await realpath(tmpdir()), 'orca-recovered-'))
const recovered = join(scratch, 'feature')
await mkdir(recovered)
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, recovered)
vi.mocked(listRepoWorktrees).mockResolvedValue([])
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath(recovered, store)).resolves.toBe(resolve(recovered))
await rm(scratch, { recursive: true, force: true })
})
it('survives a rebuild that was already in flight when the create recovered', async () => {
const scratch = await mkdtemp(join(await realpath(tmpdir()), 'orca-recovered-race-'))
const recovered = join(scratch, 'feature')
await mkdir(recovered)
const store = makeStore()
// Register mid-listing: the rebuild's own result was computed before this worktree existed.
vi.mocked(listRepoWorktrees).mockImplementation(async () => {
registerCreatedWorktreeRoot(store, repo.id, recovered)
return []
})
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath(recovered, store)).resolves.toBe(resolve(recovered))
await rm(scratch, { recursive: true, force: true })
})
it('retires a recovered root once the listing can see it again', async () => {
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, '/linked/feature')
vi.mocked(listRepoWorktrees).mockResolvedValue([
{
path: '/linked/feature',
head: '',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
await rebuildAuthorizedRootsCache(store)
// Still authorized, but now from the listing itself; a later listing that drops it is authoritative.
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).resolves.toBe(
resolve('/linked/feature')
)
vi.mocked(listRepoWorktrees).mockResolvedValue([])
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).rejects.toThrow(
'Access denied'
)
})
it('bounds concurrent repo probes while rebuilding authorized roots', async () => {
const repos = Array.from({ length: 20 }, (_, index) => ({
...repo,
@@ -90,10 +90,13 @@ describe('registerFilesystemHandlers', () => {
await handlers.get('git:branchCompare')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
baseRef: 'origin/main'
baseRef: 'origin/main',
admissionTier: 'background'
})
expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main', {})
expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main', {
admissionTier: 'background'
})
})
it('allows git operations on worktrees outside repo/workspace roots', async () => {
@@ -242,7 +245,7 @@ describe('registerFilesystemHandlers', () => {
filePath: path.join('src', 'file.ts'),
oldPath: path.join('src', 'old-file.ts')
},
{}
{ admissionTier: 'interactive' }
)
})
@@ -96,7 +96,9 @@ describe('registerFilesystemHandlers', () => {
})
).resolves.toEqual({ success: true, message: 'Update README' })
expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {})
expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
admissionTier: 'interactive'
})
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(context, params, {
kind: 'local',
cwd: WORKTREE_FEATURE_PATH
@@ -253,6 +255,7 @@ describe('registerFilesystemHandlers', () => {
})
expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
admissionTier: 'interactive',
wslDistro: 'Ubuntu'
})
expect(prepareForCodexLaunch).toHaveBeenCalledWith({
@@ -80,7 +80,9 @@ describe('registerFilesystemHandlers', () => {
})
).resolves.toEqual({ success: true })
expect(commitChangesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'feat: ship commit', {})
expect(commitChangesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'feat: ship commit', {
admissionTier: 'interactive'
})
})
it('returns local commit hook failure payload from git:commit', async () => {

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