mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge origin/main into mobile-rearch
This commit is contained in:
@@ -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
+52
-47
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);
|
||||
+20
-15
@@ -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';
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+63
-63
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user