Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Load the installed CLI in its current Node process instead of starting a
second Node process, preserving entry selection, arguments, standard streams
and command-owned shutdown ([#984](https://github.com/git-stunts/git-warp/issues/984)).

- Carry the narrow disposable-library dependency in emitted Runtime declarations
so strict ES2022 consumers can import the installed package and use close or
async disposal without a broad ESNext library override
Expand Down
15 changes: 6 additions & 9 deletions bin/git-warp
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
#!/usr/bin/env node

import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';

const dir = dirname(fileURLToPath(import.meta.url));
const cliCandidates = [
resolve(dir, '../dist/bin/git-warp.js'),
resolve(dir, 'git-warp.ts'),
];
const cliPath = cliCandidates.find((candidate) => existsSync(candidate));
const args = process.argv.slice(2);

if (cliPath === undefined) {
process.stderr.write('git-warp CLI entry point is unavailable\n');
process.exit(1);
}

const result = spawnSync(process.execPath, [cliPath, ...args], { stdio: 'inherit' });
if (result.error) {
process.stderr.write(result.error.message + '\n');
process.exit(result.error.code || 1);
try {
await import(pathToFileURL(cliPath).href);
} catch (error) {
process.stderr.write((error instanceof Error ? error.message : String(error)) + '\n');
process.exit(1);
}

process.exit(result.status ?? 1);
130 changes: 130 additions & 0 deletions scripts/check-cli-launcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Bounded native launcher behavior tests; run only in the COPY Docker worker."""
from pathlib import Path
import os
import shutil
import signal
import subprocess
import tempfile

if not Path('/.dockerenv').exists():
raise SystemExit('CLI launcher tests require Docker')
ROOT = Path.cwd()
NODE = shutil.which('node')
if NODE is None:
raise SystemExit('Node is required in the validation worker')
WORK = Path(tempfile.mkdtemp(prefix='cli-launcher-', dir=os.environ['TMPDIR']))
TIMEOUT = 20
COVERAGE = WORK / 'coverage'
COVERAGE.mkdir()
os.environ['NODE_V8_COVERAGE'] = str(COVERAGE)

def run(command, cwd, **options):
return subprocess.run(command, cwd=cwd, timeout=TIMEOUT, capture_output=True, text=True, **options)

def layout(name):
at = WORK / name
(at / 'bin').mkdir(parents=True)
(at / 'package.json').write_text('{"type":"module"}\n')
shutil.copyfile(ROOT / 'bin/git-warp', at / 'bin/git-warp')
return at

def entry(at, source, development=False):
path = at / ('bin/git-warp.ts' if development else 'dist/bin/git-warp.js')
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(source)
return path

def require(condition, message):
if not condition:
raise AssertionError(message)

try:
at = layout('directory with spaces')
entry(at, "process.stdout.write('ARGS '+process.argv.slice(2).join('|'));process.stderr.write('ERR');process.exitCode=7;")
entry(at, "throw new Error('wrong source entry');", development=True)
result = run([NODE, str(at / 'bin/git-warp'), 'a b', '--json', 'λ'], at)
require((result.returncode, result.stdout, result.stderr) == (7, 'ARGS a b|--json|λ', 'ERR'), 'argv/stdout/stderr/exit or dist priority changed')
(at / 'symlink').symlink_to(at / 'bin/git-warp')
result = run([NODE, str(at / 'symlink'), 'symlink'], at)
require(result.returncode == 7 and result.stdout == 'ARGS symlink', 'symlink/path selection failed')
print('LAUNCHER_BOUNDARY packed-selection-spaces-argv-streams-status-symlink PASS', flush=True)

at = layout('development')
entry(at, "const value: string = 'SOURCE';process.stdout.write(value);", development=True)
result = run([NODE, str(at / 'bin/git-warp')], at)
require(result.returncode == 0 and result.stdout == 'SOURCE', 'documented source TypeScript fallback failed')
print('LAUNCHER_BOUNDARY source-selection PASS', flush=True)

at = layout('missing')
result = run([NODE, str(at / 'bin/git-warp')], at)
require(result.returncode == 1 and result.stderr == 'git-warp CLI entry point is unavailable\n' and result.stdout == '', 'missing-entry refusal changed')
at = layout('module-load-failure')
entry(at, "throw new Error('selected module failed');")
result = run([NODE, str(at / 'bin/git-warp')], at)
require(result.returncode == 1 and result.stderr == 'selected module failed\n' and result.stdout == '', 'load failure was swallowed or misclassified')
at = layout('non-error-load-failure')
entry(at, "throw 'non-error module failed';")
result = run([NODE, str(at / 'bin/git-warp')], at)
require(result.returncode == 1 and result.stderr == 'non-error module failed\n' and result.stdout == '', 'non-Error load failure was lost')
print('LAUNCHER_BOUNDARY missing-and-load-failure PASS', flush=True)

at = layout('stdin')
entry(at, "process.stdin.setEncoding('utf8');process.stdin.on('data',v=>process.stdout.write(v));")
result = run([NODE, str(at / 'bin/git-warp')], at, input='input with spaces\nλ\n')
require(result.returncode == 0 and result.stdout == 'input with spaces\nλ\n', 'stdin was not inherited')
print('LAUNCHER_BOUNDARY stdin PASS', flush=True)

for stop in [signal.SIGINT, signal.SIGTERM]:
at = layout('signal-' + str(stop.value))
marker = at / 'cleanup'
entry(at, "import {writeFileSync} from 'node:fs';const timer=setInterval(()=>{},1000);for(const name of ['SIGINT','SIGTERM'])process.once(name,()=>{writeFileSync(process.argv[2],name);clearInterval(timer);process.exitCode=0;});process.stdout.write('READY\\n');")
process = subprocess.Popen([NODE, str(at / 'bin/git-warp'), str(marker)], cwd=at, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True)
try:
import selectors
ready = selectors.DefaultSelector()
ready.register(process.stdout, selectors.EVENT_READ)
require(bool(ready.select(TIMEOUT)), 'signal fixture did not become ready')
require(process.stdout.readline() == 'READY\n', 'signal readiness corrupted')
ready.close()
os.kill(process.pid, stop)
output, error = process.communicate(timeout=TIMEOUT)
require(process.returncode == 0 and marker.read_text() == stop.name and error == '', 'signal was not delivered to selected entry or cleanup failed')
finally:
# A wrapper may die before its child. Reap the whole owned group
# even when the parent already has an exit status.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=5)
print('LAUNCHER_BOUNDARY SIGINT-SIGTERM-cleanup PASS', flush=True)
import json
reports = []
boundaries = set()
for report in COVERAGE.glob('*.json'):
for script in json.loads(report.read_text())['result']:
if not script['url'].endswith('/bin/git-warp'):
continue
ranges = [interval for function in script['functions'] for interval in function['ranges']]
reports.append(ranges)
boundaries.update(offset for interval in ranges for offset in [interval['startOffset'], interval['endOffset']])
segments = list(zip(sorted(boundaries), sorted(boundaries)[1:]))
uncovered = []
for start, end in segments:
visits = []
for ranges in reports:
containing = [interval for interval in ranges if interval['startOffset'] <= start and interval['endOffset'] >= end]
if containing:
# V8 omits redundant child ranges; a segment inherits its
# innermost enclosing count in each individual execution.
smallest = min(containing, key=lambda interval: interval['endOffset'] - interval['startOffset'])
visits.append(smallest['count'])
if visits and max(visits) == 0:
uncovered.append((start, end))
print('LAUNCHER_V8_UNCOVERED_SEGMENTS', uncovered, flush=True)
require(bool(reports) and not uncovered, 'A launcher V8 segment is unexecuted')
print('LAUNCHER_V8_SEGMENTS_ALL_EXECUTED', len(segments), 'executions', len(reports), flush=True)

finally:
shutil.rmtree(WORK)
49 changes: 49 additions & 0 deletions scripts/check-cli-process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Count native Node starts from the real installed CLI; COPY Docker only."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile

if not Path('/.dockerenv').exists():
raise SystemExit('CLI process tests require Docker')
ROOT = Path.cwd()
WORK = Path(tempfile.mkdtemp(prefix='cli-process-', dir=os.environ['TMPDIR']))
TIMEOUT = 30

try:
pack = WORK / 'pack'
consumer = WORK / 'consumer'
pack.mkdir()
consumer.mkdir()
subprocess.run(['node', 'scripts/package-payload/CheckPackagePayload.ts', '--pack-destination', str(pack)], check=True, timeout=TIMEOUT)
subprocess.run(['npm', 'init', '-y'], cwd=consumer, stdout=subprocess.DEVNULL, check=True, timeout=TIMEOUT)
subprocess.run(['npm', 'install', '--ignore-scripts', '--no-audit', '--no-fund', '--fetch-retries=0', '--fetch-timeout=15000', str(next(pack.glob('*.tgz')))], cwd=consumer, stdout=subprocess.DEVNULL, check=True, timeout=TIMEOUT)
preload = WORK / 'trace.cjs'
preload.write_text("const fs=require('node:fs');const p=require('node:process');fs.appendFileSync(p.env.WARP_CLI_START_TRACE,[p.pid,p.ppid,p.argv[1]??''].join(String.fromCharCode(9))+String.fromCharCode(10));")
wrapper = consumer / 'node_modules/@git-stunts/git-warp/bin/git-warp'
cases = [
('help', ['--help'], 0),
('doctor', ['doctor', '--repo', str(consumer)], 3),
('json-failure', ['doctor', '--repo', str(WORK / 'missing'), '--json'], 3),
('unknown', ['no-such-command'], 1),
]
for name, arguments, expected in cases:
trace = WORK / (name + '.tsv')
environment = os.environ.copy()
environment['NODE_OPTIONS'] = '--require=' + str(preload)
environment['WARP_CLI_START_TRACE'] = str(trace)
result = subprocess.run(['node', str(wrapper), *arguments], cwd=consumer, env=environment, capture_output=True, text=True, timeout=TIMEOUT)
rows = trace.read_text().splitlines()
print('INSTALLED_CLI_PROCESS', name, 'node_starts', len(rows), 'exit', result.returncode, flush=True)
if len(rows) != 1 or result.returncode != expected:
raise AssertionError('Installed CLI process count or exit status changed: ' + name)
if name == 'help' and (not result.stdout or result.stderr):
raise AssertionError('Help streams changed')
if name == 'json-failure' and (not result.stdout.startswith('{') or result.stderr):
raise AssertionError('JSON failure streams changed')
if name == 'unknown' and (result.stdout or 'Unknown command' not in result.stderr):
raise AssertionError('Usage failure streams changed')
finally:
shutil.rmtree(WORK)
86 changes: 86 additions & 0 deletions scripts/check-cli-startup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Fresh Node-process startup pilot under one owned, bounded Docker fixture."""
from pathlib import Path
import hashlib
import os
import shutil
import statistics
import subprocess
import tempfile
import time

if not Path('/.dockerenv').exists():
raise SystemExit('CLI startup comparisons require Docker')
ROOT = Path.cwd()
WORK = Path(tempfile.mkdtemp(prefix='cli-startup-', dir=os.environ['TMPDIR']))
SAMPLES = 20
try:
pack = WORK / 'pack'
consumer = WORK / 'consumer'
pack.mkdir()
consumer.mkdir()
subprocess.run(['node', 'scripts/package-payload/CheckPackagePayload.ts', '--pack-destination', str(pack)], check=True)
subprocess.run(['npm', 'init', '-y'], cwd=consumer, stdout=subprocess.DEVNULL, check=True)
subprocess.run(['npm', 'install', '--ignore-scripts', '--no-audit', '--no-fund', '--fetch-retries=0', '--fetch-timeout=15000', str(next(pack.glob('*.tgz')))], cwd=consumer, stdout=subprocess.DEVNULL, check=True)
package = consumer / 'node_modules/@git-stunts/git-warp'
baseline = package / 'bin/baseline-launcher'
baseline.write_bytes((ROOT / 'test/evidence/984-cli-launcher/baseline-wrapper.txt').read_bytes())
candidate = package / 'bin/git-warp'
# Exercise the actual long-running CLI, not only a controlled signal entry.
import json
import selectors
import signal
for stopping in [signal.SIGINT, signal.SIGTERM]:
repository = WORK / ('mcp-' + stopping.name)
repository.mkdir()
subprocess.run(['git', 'init', '-q', str(repository)], check=True)
subprocess.run(['git', '-C', str(repository), 'config', 'user.name', 'CLI signal fixture'], check=True)
subprocess.run(['git', '-C', str(repository), 'config', 'user.email', 'fixture@example.invalid'], check=True)
process = subprocess.Popen(['node', str(candidate), 'mcp', '--repo', str(repository), '--writer', 'cli-signal'], cwd=consumer, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True)
try:
def exchange(identifier, method, params):
process.stdin.write(json.dumps({'jsonrpc': '2.0', 'id': identifier, 'method': method, 'params': params}) + '\n')
process.stdin.flush()
ready = selectors.DefaultSelector()
ready.register(process.stdout, selectors.EVENT_READ)
if not ready.select(20):
raise AssertionError('Actual MCP did not answer')
response = json.loads(process.stdout.readline())
ready.close()
if response.get('id') != identifier or 'error' in response:
raise AssertionError('Actual MCP returned a failed readiness request')
return response
exchange(1, 'initialize', {'protocolVersion': '2024-11-05', 'capabilities': {}, 'clientInfo': {'name': 'launcher-fixture', 'version': '1'}})
response = exchange(2, 'tools/call', {'name': 'warp_lane_describe', 'arguments': {'lane': 'L'}})
if response.get('result', {}).get('isError') is True:
raise AssertionError('Actual MCP Runtime did not open')
os.kill(process.pid, stopping)
output, error = process.communicate(timeout=20)
if process.returncode != 0 or error:
raise AssertionError('Actual MCP did not close gracefully under ' + stopping.name + ': ' + error)
print('ACTUAL_MCP_SIGNAL_CLEAN_EXIT', stopping.name, flush=True)
finally:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=5)

measured = {'baseline': [], 'candidate': []}
for index in range(SAMPLES):
order = ['baseline', 'candidate'] if index % 2 == 0 else ['candidate', 'baseline']
for name in order:
path = baseline if name == 'baseline' else candidate
started = time.perf_counter_ns()
result = subprocess.run(['node', str(path), '--help'], cwd=consumer, capture_output=True, timeout=20)
elapsed = (time.perf_counter_ns() - started) / 1_000_000
if result.returncode != 0 or result.stderr:
raise AssertionError('Help comparison failed: ' + name)
measured[name].append(elapsed)
for name, samples in measured.items():
ordered = sorted(samples)
print('FRESH_PROCESS_STARTUP', name, 'samples', len(samples), 'p50_ms', statistics.median(samples), 'p95_ms', ordered[18], 'p99_ms', ordered[19], 'raw_ms', samples, flush=True)
print('STARTUP_BASELINE_WRAPPER_SHA256', hashlib.sha256(baseline.read_bytes()).hexdigest(), flush=True)
print('STARTUP_COMPARISON_SCOPE fresh-process cold Node startup; filesystem/dependency cache shared, not a cold-disk or universal latency claim', flush=True)
finally:
shutil.rmtree(WORK)
30 changes: 30 additions & 0 deletions test/bats/cli-launcher.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bats

load helpers/docker.bash

setup_file() {
require_docker_tests
}

setup() {
export TMPDIR="${BATS_TEST_TMPDIR}"
}

assert_driver_success() {
if [ "$status" -ne 0 ]; then
printf '%s\n' "$output" >&2
fi
[ "$status" -eq 0 ]
}

@test "installed CLI uses one Node process with stable command statuses" {
cd "${BATS_TEST_DIRNAME}/../.."
run python3 scripts/check-cli-process.py
assert_driver_success
}

@test "launcher preserves entry boundaries, streams, signals and full native coverage" {
cd "${BATS_TEST_DIRNAME}/../.."
run python3 scripts/check-cli-launcher.py
assert_driver_success
}
Loading
Loading