Files
sencho/backend/src/services/git/nativeGitTransport.ts
T
Anso 48f010475b feat(gitops): resolve branch, tag, and SHA refs to immutable commits before fetch (#1864)
* feat(gitops): resolve branch, tag, and SHA refs to immutable commits before fetch

The ref model now resolves a configured branch, tag, or full commit SHA to
an immutable commit before any content is downloaded, and records both the
configured and the resolved identity where revision state persists.

- RefKind (branch | tag | sha) is a resolved property, not caller-asserted.
  A bare string resolves branch-first, then tag; a full 40/64-hex SHA
  self-resolves with no remote round-trip. Branch and tag both fetch via a
  bare --branch name; a SHA uses init + shallow fetch + detached checkout.
- A single ls-remote with narrow heads/tags refspecs pins the configured ref
  to an immutable SHA; rev-parse HEAD must equal the resolved SHA or the
  fetch refuses (tip-changed) instead of materializing unreviewed content.
- Error union grew: REF_NOT_FOUND (ref-neutral, replaces BRANCH_NOT_FOUND),
  UNSUPPORTED_REF (a pinned SHA the host will not serve), and a service-level
  REF_DELETED upgrade that fires when a classified REF_NOT_FOUND occurs for a
  source with prior fetch history (a vanished ref reads as delete/force-push,
  not a fresh typo). Status mapping: REF_NOT_FOUND/REF_DELETED to 404,
  UNSUPPORTED_REF to 400.
- Configured-vs-resolved identity is recorded via a nullable resolved_ref_kind
  column on gitops_generations (added to CREATE TABLE and re-added for legacy
  installs through maybeAddCol). The kind is deliberately NOT in the plan
  fingerprint: two sources naming the same commit differently are the same plan.

Docs updated (git-sources feature page, connect-a-git-source tutorial, and the
native-git-transport internal deep-dive) to the ref-neutral naming.

* fix(gitops): harden ref resolution after pre-merge audit

Request peeled annotated-tag refs from ls-remote, detect force-pushes and
ref-kind changes against prior fetch identity, persist resolved kind on
application rows, and add real-git tag/SHA integration coverage plus
ref-neutral UI and operator docs.

* test(gitops): mock verifyFastForward in direct producer suite

The producer tests stub the transport seam but were missing resolved kind
on resolveRef and a verifyFastForward stub, so second pulls tripped the new
ref-continuity checks as REF_DELETED.

* test(git): remove unused buildBareFixtureRepo helper

Fixes backend lint failure after the integration fixture was refactored
to buildRichFixtureRepo without dropping the old wrapper.

* fix(gitops): correct fast-forward ancestry verification under size bounds

Replace the dual shallow-fetch ancestry probe with a single-tip deepen
strategy, keep verifier Git work inside the transport watchdog, and add
real-Git regression coverage for linear advances and rewritten history.

* fix(gitops): bound fast-forward verification with exponential deepen

Replace per-commit deepen loops with exponential steps, cap remote fetch
rounds, and share one deadline across verifier Git calls. Budget exhaustion
now surfaces as a classified timeout instead of REF_DELETED.

* fix(gitops): classify fast-forward probe failures accurately

Normalize verifier probe timeouts and unexpected exit codes into transport
failures, interpret merge-base status 1 as proven non-ancestry only, and
treat shallow stagnation as timeout instead of REF_DELETED.

* fix(gitops): satisfy tsc on probeFailure never returns

* fix(gitops): address Phase E QA findings on ref verification

Remove the fast-forward scratch repo after verification so pull size
caps are not inflated, classify GitHub not-our-ref as UNSUPPORTED_REF,
persist fetched_resolved_ref_kind on create-from-git, and broaden
REF_DELETED copy for retagged tags.
2026-08-28 16:15:37 -04:00

1044 lines
45 KiB
TypeScript

import { spawn, type ChildProcess } from 'child_process';
import { promises as fs, existsSync } from 'fs';
import os from 'os';
import path from 'path';
import { ensureGitBinary, getGitExecPath } from './gitBinary';
import {
CREDENTIAL_HELPER_CONFIG_VALUE,
GIT_HELPER_PATH_ENV_VAR,
GIT_TOKEN_ENV_VAR,
writeCredentialHelper,
} from './credentialHelper';
import { isTransportFailure, type TransportFailure } from './errors';
import type { FetchRequest, FetchResult, GitTransport, ResolveRequest, ResolveResult } from './types';
/**
* Native git transport: every Git operation is an `execFile`-style spawn of
* the real git CLI with an argv array (never a shell), a hardened
* environment, and per-invocation config flags.
*
* Hardening applied to every invocation:
* - `GIT_CONFIG_NOSYSTEM=1` plus an isolated empty HOME/USERPROFILE so the
* operator's ~/.gitconfig (credential helpers, insteadOf rewrites, hooks)
* cannot influence fetches.
* - `protocol.allow=never` with only https re-enabled: no file://, git://,
* ext::, or ssh:// this early in the program.
* - `core.hooksPath` pointed at an empty directory we own, so repository
* scripts can never run. (A literal /dev/null works on Linux but not
* Windows; an empty dir is portable.)
* - `GIT_TERMINAL_PROMPT=0` and a neutralized GIT_ASKPASS so a missing or
* wrong credential fails fast instead of hanging on a prompt.
* - Every remaining git-config channel is pinned empty (GIT_CONFIG_GLOBAL /
* GIT_CONFIG_SYSTEM to the null device, XDG_CONFIG_HOME cleared,
* GIT_CONFIG_COUNT zeroed) and inherited GIT_TRACE is cleared so packet
* dumps cannot carry URL material.
* - The token reaches git ONLY through the credential helper reading
* SENCHO_GIT_TOKEN from the child env; it never appears in argv or URLs.
*
* Dev/E2E certificate bridge: when NODE_EXTRA_CA_CERTS is set (the existing
* dev/CI wiring for the e2e TLS fixture server), its CAs are combined with
* platform defaults into <workspace>/.meta/combined-ca.pem and passed as
* http.sslCAInfo, mirroring Node's add-not-replace semantics. Without the
* variable, POSIX passes nothing (OpenSSL uses system trust) and Windows
* pins Git's own bundled bundle (see detectWindowsCABundle).
*/
const DEFAULT_TIMEOUT_MS = 30_000;
const LS_REMOTE_MAX_MS = 10_000;
const STDERR_CAP = 16_384;
const WATCHDOG_INTERVAL_MS = 1_000;
const SHA_PATTERN = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/i;
interface RunResult {
stdout: string;
stderr: string;
exitCode: number;
}
interface RunOptions {
cwd?: string;
env: NodeJS.ProcessEnv;
timeoutMs: number;
onSpawn?: (child: ChildProcess) => void;
}
function isTimeoutError(e: unknown): boolean {
return typeof e === 'object' && e !== null && (e as { gitTimedOut?: unknown }).gitTimedOut === true;
}
/** Flag an error as a git timeout so `isTimeoutError` recognises it downstream. */
function asTimeoutError<T extends Error>(err: T): T {
return Object.assign(err, { gitTimedOut: true });
}
/**
* Kill the whole child tree. POSIX uses the process group; Windows taskkill.
*
* Resolves when the kill OPERATION is finished, which on Windows means
* taskkill has itself exited. That matters because taskkill walks the tree in
* a separate process: the direct git child can close while taskkill is still
* terminating its descendants, so a caller that settled on the child's close
* alone could start deleting the workspace out from under processes that are
* still running in it. On POSIX the group signal is delivered synchronously,
* so there is nothing further to await.
*
* Never rejects: a kill that cannot be confirmed is reported through the
* fallback warning, and the caller's own confirmation timeout bounds the wait.
*/
function killTree(child: ChildProcess | undefined): Promise<void> {
if (!child?.pid) return Promise.resolve();
if (process.platform === 'win32') {
return new Promise<void>((resolve) => {
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true });
// 'error' and 'close' can both fire for one spawn attempt; only the
// first should trigger the fallback so a single kill never falls back
// (or logs) twice.
let finished = false;
const finish = (why?: string) => {
if (finished) return;
finished = true;
if (why) {
console.warn(`[GitSource:transport] taskkill ${why} for pid ${child.pid}; falling back to child.kill() (tree-kill guarantee no longer holds: descendants of ${child.pid} may still be running)`);
// This runs in an event callback, where a throw would be an
// uncaught exception rather than a rejection of this promise.
try {
child.kill();
} catch (e) {
console.warn(`[GitSource:transport] fallback kill for pid ${child.pid} failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
resolve();
};
killer.on('error', (err) => finish(`failed to spawn (${err.message})`));
killer.on('close', (code) => finish(code === 0 ? undefined : `exited ${code}`));
});
}
try {
// The child is spawned detached, so it leads its own process group.
process.kill(-child.pid, 'SIGKILL');
} catch {
try {
child.kill('SIGKILL');
} catch (e) {
console.warn(`[GitSource:transport] fallback kill for pid ${child.pid} failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
return Promise.resolve();
}
/**
* Await a kill that has already been issued, but never longer than
* KILL_CONFIRM_TIMEOUT_MS. A platform kill helper that wedges must not turn
* into a caller that hangs forever with nothing logged; past the bound we say
* so and carry on, exactly as runGit's own confirmation timer does.
*/
async function awaitKillConfirmed(kill: Promise<void> | undefined, what: string): Promise<void> {
if (!kill) return;
let timer: NodeJS.Timeout | undefined;
const bound = new Promise<void>((resolve) => {
timer = setTimeout(() => {
console.warn(`[GitSource:transport] ${what} not confirmed within ${KILL_CONFIRM_TIMEOUT_MS}ms; continuing cleanup while it may still be running`);
resolve();
}, KILL_CONFIRM_TIMEOUT_MS);
});
try {
await Promise.race([kill, bound]);
} finally {
clearTimeout(timer);
}
}
/** Bound on how long to wait for a confirmed close after a kill is issued, so a kill that never reports back cannot hang the caller forever. */
const KILL_CONFIRM_TIMEOUT_MS = 5_000;
/**
* Spawn git and collect output. On timeout, kills the entire child tree and
* waits for its `close` event before rejecting: settling as soon as the kill
* is merely issued (rather than confirmed) would let the caller start
* cleaning up the workspace while the child tree, or the platform kill
* helper (taskkill), is still running. Resolves with whatever exit code git
* reported when it closes on its own.
*/
function runGit(args: string[], opts: RunOptions): Promise<RunResult> {
return new Promise((resolve, reject) => {
const child = spawn('git', args, {
cwd: opts.cwd,
env: opts.env,
windowsHide: true,
detached: process.platform !== 'win32',
});
let stdout = '';
let stderr = '';
let settled = false;
let timedOut = false;
let killConfirmTimer: NodeJS.Timeout | undefined;
// Resolves once the kill operation itself is done (see killTree); a
// timed-out run must not settle before BOTH this and the child's own
// close event.
let killFinished: Promise<void> | undefined;
const append = (cur: string, chunk: Buffer) => (cur.length < STDERR_CAP ? cur + chunk.toString('utf8') : cur);
const timer = setTimeout(() => {
if (settled) return;
timedOut = true;
killFinished = killTree(child);
killConfirmTimer = setTimeout(() => {
if (settled) return;
settled = true;
console.error(`[GitSource:transport] kill not confirmed within ${KILL_CONFIRM_TIMEOUT_MS}ms for pid ${child.pid}; the process may still be running`);
reject(asTimeoutError(new Error('git timed out (kill unconfirmed)')));
}, KILL_CONFIRM_TIMEOUT_MS);
}, opts.timeoutMs);
child.stdout?.on('data', (c: Buffer) => {
stdout = append(stdout, c);
});
child.stderr?.on('data', (c: Buffer) => {
stderr = append(stderr, c);
});
const finishSettle = (): boolean => {
if (settled) return false;
settled = true;
clearTimeout(timer);
clearTimeout(killConfirmTimer);
return true;
};
/**
* Reject a timed-out run, but only once the kill operation has also
* finished. The direct child can be gone while the platform kill
* helper (taskkill) is still walking its descendants, and settling in
* that window releases the caller to delete a workspace those
* descendants are still using. Both settle paths below go through
* here; the kill-confirmation timer above still bounds the wait.
*/
const rejectAfterKill = (err: Error): void => {
void (killFinished ?? Promise.resolve()).then(() => {
if (finishSettle()) reject(asTimeoutError(err));
});
};
child.on('error', (err) => {
if (settled) return;
// 'error' can also fire after the kill has been issued (e.g. the
// process could not be killed); preserve the timeout flag so
// callers still classify this as a timeout rather than a bare
// exit failure, and wait for the kill exactly as 'close' does.
if (timedOut) {
rejectAfterKill(err);
return;
}
if (finishSettle()) reject(err);
});
child.on('close', (code) => {
if (settled) return;
if (timedOut) {
rejectAfterKill(new Error('git timed out'));
return;
}
if (finishSettle()) resolve({ stdout, stderr, exitCode: code ?? -1 });
});
opts.onSpawn?.(child);
});
}
// ─── Workspace layout ────────────────────────────────────────────────────────
interface WorkspaceLayout {
/** Scratch dir for transport-owned files (credential helper script, combined CA bundle, isolated HOME). */
metaDir: string;
hooksDir: string;
homeDir: string;
}
async function prepareWorkspace(root: string): Promise<WorkspaceLayout> {
const metaDir = path.join(root, '.meta');
const hooksDir = path.join(metaDir, 'hooks');
const homeDir = path.join(metaDir, 'home');
await fs.mkdir(hooksDir, { recursive: true });
await fs.mkdir(homeDir, { recursive: true });
return { metaDir, hooksDir, homeDir };
}
function buildEnv(homeDir: string, token?: string | null, helperPath?: string | null): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
GIT_CONFIG_NOSYSTEM: '1',
// GIT_CONFIG_NOSYSTEM does not block explicit config-file pointers or
// XDG lookups; pin every channel git could read operator config from.
GIT_CONFIG_GLOBAL: os.devNull,
GIT_CONFIG_SYSTEM: os.devNull,
XDG_CONFIG_HOME: '',
GIT_CONFIG_COUNT: '0',
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: '',
// An inherited trace flag would widen the log surface with packet
// dumps that can carry URL material.
GIT_TRACE: '',
HOME: homeDir,
};
if (process.platform === 'win32') {
env.USERPROFILE = homeDir;
}
if (token) {
env[GIT_TOKEN_ENV_VAR] = token;
}
if (helperPath) {
// The helper's location, kept out of the credential.helper config
// value so no workspace path character can change how git's shell
// parses it. See credentialHelper.ts.
env[GIT_HELPER_PATH_ENV_VAR] = helperPath;
}
return env;
}
/**
* Windows-only: locate the CA bundle bundled with Git for Windows. Stripping
* system gitconfig (GIT_CONFIG_NOSYSTEM) also strips the installer's
* http.sslCAInfo pointer to this file, and unlike Linux there is no /etc/ssl
* default for the OpenSSL backend to fall back on.
*/
async function detectWindowsCABundle(): Promise<string | null> {
try {
const execPath = await getGitExecPath();
const installRoot = path.resolve(execPath, '..', '..'); // <install>/mingw64/libexec/git-core -> <install>/mingw64
const candidates = [
path.join(installRoot, 'etc', 'ssl', 'certs', 'ca-bundle.crt'),
path.resolve(execPath, '..', '..', '..', 'usr', 'ssl', 'certs', 'ca-bundle.crt'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate.split(path.sep).join('/');
}
} catch {
// Fall through: without a bundle the fetch fails with a clear TLS
// classification instead of a silent trust downgrade.
}
return null;
}
/** First existing system CA bundle for OpenSSL-backed git on POSIX. */
const POSIX_CA_BUNDLE_CANDIDATES = [
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
];
/**
* Build the CA-anchor configuration for one fetch.
*
* Mirrors Node's own NODE_EXTRA_CA_CERTS semantics (extra anchors ADDED to
* the defaults, never replacing them) by writing a combined PEM bundle into
* the fetch workspace's `.meta` dir:
* - No NODE_EXTRA_CA_CERTS: production posture. POSIX passes nothing and
* lets OpenSSL use system trust; Windows pins Git's own bundled bundle,
* because stripping system gitconfig also strips the installer's pointer
* to it.
* - With NODE_EXTRA_CA_CERTS: defaults PLUS the extra CAs, so the dev/E2E
* fixture server and public hosts validate in the same process state.
*/
async function resolveCaArgs(layout: WorkspaceLayout): Promise<string[]> {
const extraPath = process.env.NODE_EXTRA_CA_CERTS;
const hasExtra = Boolean(extraPath && existsSync(extraPath));
const isWindows = process.platform === 'win32';
if (!hasExtra && !isWindows) {
return [];
}
if (isWindows && !hasExtra) {
// Windows without an override: anchor to Git's bundled bundle directly.
const bundle = await detectWindowsCABundle();
return bundle ? ['-c', `http.sslCAInfo=${bundle}`] : [];
}
let defaultPem = '';
let winBundle: string | null = null;
if (isWindows) {
winBundle = await detectWindowsCABundle();
if (winBundle) {
try {
defaultPem = await fs.readFile(winBundle.replace(/\//g, path.sep), 'utf8');
} catch {
console.warn(`[GitSource:transport] could not read system CA bundle at ${winBundle}; combined anchors will contain only NODE_EXTRA_CA_CERTS entries.`);
}
}
} else {
for (const candidate of POSIX_CA_BUNDLE_CANDIDATES) {
if (!existsSync(candidate)) continue;
try {
defaultPem = await fs.readFile(candidate, 'utf8');
break;
} catch {
// Try the next candidate.
}
}
if (!defaultPem) {
console.warn('[GitSource:transport] no readable system CA bundle found; combined anchors will contain only NODE_EXTRA_CA_CERTS entries.');
}
}
let extraPem = '';
try {
extraPem = await fs.readFile(extraPath as string, 'utf8');
} catch {
console.warn('[GitSource:transport] could not read the file configured via NODE_EXTRA_CA_CERTS; ignoring custom anchors.');
// Windows still has working defaults; fall back to them instead of
// dropping every anchor.
return isWindows && winBundle ? ['-c', `http.sslCAInfo=${winBundle}`] : [];
}
const combinedPath = path.join(layout.metaDir, 'combined-ca.pem');
await fs.writeFile(combinedPath, `${defaultPem}\n${extraPem}`, { mode: 0o600 });
return ['-c', `http.sslCAInfo=${combinedPath.split(path.sep).join('/')}`];
}
/**
* Config shared by every invocation. With no helper, credential.helper is
* explicitly cleared so nothing from the environment can answer prompts.
*/
async function commonArgs(layout: WorkspaceLayout, helperPath: string | null): Promise<string[]> {
const args = [
'-c', 'protocol.allow=never',
'-c', 'protocol.https.allow=always',
'-c', `core.hooksPath=${layout.hooksDir.split(path.sep).join('/')}`,
];
if (process.platform === 'win32') {
// With every config channel neutralized above, git falls back to its
// build-default TLS backend, which on Git for Windows can be
// schannel. Schannel ignores http.sslCAInfo (breaking the dev/E2E CA
// bridge) and trusts per-Windows-cert-store state, so pin the
// OpenSSL backend that ships with Git for Windows. Production Alpine
// git is OpenSSL-backed and unaffected by this flag's absence.
args.push('-c', 'http.sslBackend=openssl');
}
args.push(...await resolveCaArgs(layout));
if (helperPath !== null) {
// A fixed value: the helper's path reaches git through the child env
// instead of being interpolated here, so a workspace path containing
// a space (or a quote, `$`, `;`, ...) cannot change how git's shell
// parses it. See credentialHelper.ts for the parsing rule.
args.push('-c', `credential.helper=${CREDENTIAL_HELPER_CONFIG_VALUE}`);
} else {
args.push('-c', 'credential.helper=');
}
return args;
}
/**
* Everything one invocation needs: the workspace layout, the child env, and
* the shared config argv.
*
* The credential handoff is assembled in exactly this one place because its
* three parts have to agree. If the config named the helper variable but the
* env did not export it, git would find nothing to run, fall back to an
* anonymous fetch, and a private repo's 401 would then classify as
* REPO_NOT_FOUND instead of AUTH_FAILED. That is a silent downgrade, so the
* config arg is keyed off the helper actually having been written rather than
* off the token being present.
*/
async function prepareInvocation(
workspaceRoot: string,
token?: string | null,
): Promise<{ layout: WorkspaceLayout; env: NodeJS.ProcessEnv; baseArgs: string[] }> {
const layout = await prepareWorkspace(workspaceRoot);
const helperPath = token ? await writeCredentialHelper(layout.metaDir) : null;
const env = buildEnv(layout.homeDir, token, helperPath);
// The same helperPath drives the env export and the config arg, so the two
// cannot describe different worlds.
const baseArgs = await commonArgs(layout, helperPath);
return { layout, env, baseArgs };
}
// ─── Input validation ────────────────────────────────────────────────────────
function invalidUrl(host: string, hasToken: boolean): TransportFailure {
return { transportFailure: true as const, reason: 'invalid-url', host, hasToken };
}
function assertValidRepoUrl(repoUrl: string, hasToken: boolean): URL {
let url: URL;
try {
url = new URL(repoUrl);
} catch {
throw invalidUrl('unknown', hasToken);
}
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password) {
throw invalidUrl(url.host || 'unknown', hasToken);
}
return url;
}
/**
* Ceiling on a ref name. Git itself imposes no branch-length limit worth
* matching (`check-ref-format --branch` accepts names into the thousands, up
* to the filesystem's own path limits), so this is Sencho's bound, not git's,
* and the API and the transport have to agree on it: a name the route accepts
* and stores must not be rejected later by the transport that fetches it.
* `routes/gitSources.ts` imports this constant for exactly that reason.
*/
export const REF_MAX_LEN = 256;
// ASCII control characters, space, and the characters git's own
// check-ref-format forbids anywhere in a ref (~^:?*[\). Everything else,
// including non-ASCII scripts, is a legitimate branch-name character.
const REF_DISALLOWED_CHARS = /[\x00-\x20\x7f~^:?*[\\]/;
/**
* Validates a ref name against the rules `git check-ref-format --branch`
* applies to a branch: no control characters, space, or `~^:?*[\`; no `..`
* or `@{`; no path component starting with `.` or ending in `.lock`; no
* leading `-` (git's own `--branch` mode already refuses this, since a
* leading dash makes the name ambiguous with a flag on argv, which is
* exactly the injection risk this validator exists to close). The one rule
* that is ours rather than git's is REF_MAX_LEN, shared with the route so the
* two cannot disagree.
*/
function assertValidRef(ref: string, host: string, hasToken: boolean): void {
const segments = ref.split('/');
const valid = ref.length > 0
&& ref.length <= REF_MAX_LEN
&& !ref.startsWith('-')
&& !REF_DISALLOWED_CHARS.test(ref)
&& !ref.includes('..')
&& !ref.includes('@{')
&& !ref.endsWith('.')
&& segments.every((seg) => seg.length > 0 && !seg.startsWith('.') && !seg.endsWith('.lock'));
if (!valid) {
throw { transportFailure: true as const, reason: 'invalid-ref', host, hasToken } satisfies TransportFailure;
}
}
// ─── Size watchdog ───────────────────────────────────────────────────────────
async function treeSize(root: string): Promise<number> {
let total = 0;
const stack: string[] = [root];
while (stack.length) {
const dir = stack.pop();
if (!dir) break;
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(p);
} else if (entry.isFile()) {
total += (await fs.stat(p)).size;
}
// Symlinks are neither followed nor counted; clones made here do
// not create them and counting targets could inflate the sum.
}
}
return total;
}
interface Watchdog {
stop(): void;
}
/**
* Polls the workspace's on-disk size and fires `onBreach` once it exceeds
* `maxBytes`. Exported for tests.
*/
export function startSizeWatchdog(
root: string,
maxBytes: number,
onBreach: () => void,
): Watchdog {
let stopped = false;
let busy = false;
let readFailures = 0;
const timer = setInterval(() => {
if (stopped || busy) return;
busy = true;
void treeSize(root)
.then((size) => {
readFailures = 0;
if (!stopped && size > maxBytes) {
stopped = true;
clearInterval(timer);
onBreach();
}
})
.catch(() => {
// Benign right after teardown (ENOENT mid-walk), but a
// persistent inability to measure the workspace silently
// disables the documented cap; say so once per streak.
readFailures += 1;
if (!stopped && readFailures === 3) {
console.warn('[GitSource:transport] could not stat clone workspace; GITSOURCE_MAX_CLONE_BYTES enforcement is degraded for this fetch.');
}
})
.finally(() => {
busy = false;
});
}, WATCHDOG_INTERVAL_MS);
return {
stop(): void {
stopped = true;
clearInterval(timer);
},
};
}
// ─── Transport implementation ────────────────────────────────────────────────
async function ensureBinaryReady(hasToken: boolean): Promise<void> {
try {
await ensureGitBinary();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
const stale = /too old/.test(message);
throw { transportFailure: true as const, reason: stale ? 'git-old' : 'git-missing', stderr: message, host: 'unknown', hasToken } satisfies TransportFailure;
}
}
interface ResolvedRemoteRefs {
branchSha: string | null;
tagSha: string | null;
}
/**
* Ask the remote where a bare ref name lives. One `ls-remote` with explicit
* refspecs for both namespaces keeps the response tiny (a name matches at
* most a couple of lines even on huge repos), so the stdout cap can never
* truncate the answer we need. For an annotated tag the peeled `^{}` entry
* carries the commit, so it wins over the raw tag-object line; a lightweight
* tag's raw line already points at the commit.
*/
async function lsRemoteRefs(
url: URL,
ref: string,
env: NodeJS.ProcessEnv,
baseArgs: string[],
timeoutMs: number,
hasToken: boolean,
): Promise<ResolvedRemoteRefs> {
let res: RunResult;
try {
res = await runGit(
[...baseArgs, 'ls-remote', url.href, `refs/heads/${ref}`, `refs/tags/${ref}`, `refs/tags/${ref}^{}`],
{ env, timeoutMs: Math.min(timeoutMs, LS_REMOTE_MAX_MS) },
);
} catch (e) {
// A resolution-phase timeout must classify like any other network
// timeout, not leak the internal flagged error to callers.
if (isTimeoutError(e)) {
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
}
throw e;
}
if (res.exitCode !== 0) {
throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: baseArgs, host: url.host, hasToken } satisfies TransportFailure;
}
const found: ResolvedRemoteRefs = { branchSha: null, tagSha: null };
for (const line of res.stdout.split(/\r?\n/)) {
const tabIndex = line.indexOf('\t');
if (tabIndex === -1) continue;
const sha = line.slice(0, tabIndex).trim();
if (!SHA_PATTERN.test(sha)) continue;
const full = line.slice(tabIndex + 1).trim();
if (full === `refs/heads/${ref}`) {
found.branchSha = sha.toLowerCase();
} else if (full === `refs/tags/${ref}^{}`) {
found.tagSha = sha.toLowerCase();
} else if (full === `refs/tags/${ref}` && found.tagSha === null) {
found.tagSha = sha.toLowerCase();
}
}
return found;
}
/** Remote fetch rounds allowed after the initial shallow tip fetch. */
const MAX_FF_FETCH_ROUNDS = 14;
/** Per-round deepen step cap for exponential backoff. */
const MAX_FF_DEEPEN_STEP = 2048;
/**
* Whether `descendantSha` is a fast-forward from `ancestorSha` on the remote.
* Distinguishes a normal branch advance from a force-push after ls-remote has
* already resolved the ref to a new tip.
*
* Fetches the descendant tip once, then deepens that shallow boundary with
* exponentially increasing steps until the prior commit is reachable, the
* downloaded history is complete, or a safety budget is exhausted. Operational
* failures throw a classified TransportFailure; only a proven non-fast-forward
* returns false.
*/
export async function verifyFastForward(req: {
repoUrl: string;
ancestorSha: string;
descendantSha: string;
token?: string | null;
timeoutMs?: number;
workspaceRoot: string;
maxBytes: number;
}): Promise<boolean> {
const ancestor = req.ancestorSha.toLowerCase();
const descendant = req.descendantSha.toLowerCase();
if (ancestor === descendant) return true;
const hasToken = Boolean(req.token);
await ensureBinaryReady(hasToken);
const url = assertValidRepoUrl(req.repoUrl, hasToken);
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
const remainingMs = (): number => Math.max(1, deadline - Date.now());
const assertTimeBudget = (): void => {
if (Date.now() >= deadline) {
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
}
};
const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token);
const repoDir = path.join(req.workspaceRoot, 'ff-check');
await fs.mkdir(repoDir, { recursive: true });
let sizeExceeded = false;
let activeChild: ChildProcess | undefined;
let breachKill: Promise<void> | undefined;
const watchdog = startSizeWatchdog(req.workspaceRoot, req.maxBytes, () => {
sizeExceeded = true;
breachKill = killTree(activeChild);
});
const throwIfSizeExceeded = (): void => {
if (sizeExceeded) {
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure;
}
};
try {
const materialize = async (args: string[]): Promise<RunResult> => {
assertTimeBudget();
let res: RunResult;
try {
res = await runGit(args, {
cwd: repoDir,
env,
timeoutMs: remainingMs(),
onSpawn: (child) => { activeChild = child; },
});
} catch (e) {
throwIfSizeExceeded();
if (isTimeoutError(e)) {
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
}
throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), argv: args, host: url.host, hasToken } satisfies TransportFailure;
}
throwIfSizeExceeded();
if (res.exitCode !== 0) {
throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: args, host: url.host, hasToken } satisfies TransportFailure;
}
return res;
};
const probeFailure = (res: RunResult, argv: string[]): never => {
throw {
transportFailure: true as const,
reason: 'exit',
stderr: res.stderr,
exitCode: res.exitCode,
argv,
host: url.host,
hasToken,
} satisfies TransportFailure;
};
const runProbe = async (args: string[]): Promise<RunResult> => {
assertTimeBudget();
try {
const res = await runGit(args, {
cwd: repoDir,
env,
timeoutMs: Math.min(remainingMs(), LS_REMOTE_MAX_MS),
});
throwIfSizeExceeded();
return res;
} catch (e) {
throwIfSizeExceeded();
if (isTimeoutError(e)) {
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
}
throw {
transportFailure: true as const,
reason: 'exit',
stderr: e instanceof Error ? e.message : String(e),
argv: args,
host: url.host,
hasToken,
} satisfies TransportFailure;
}
};
const isMissingObjectProbe = (res: RunResult): boolean => {
if (res.exitCode === 0) return false;
if (res.exitCode === 1) return true;
const err = res.stderr.toLowerCase();
return err.includes('not a valid object name')
|| err.includes('bad object')
|| err.includes('could not get');
};
await materialize([...baseArgs, 'init']);
await materialize([...baseArgs, 'fetch', '--depth=1', url.href, descendant]);
const countReachable = async (): Promise<number> => {
const argv = [...baseArgs, 'rev-list', '--count', descendant];
const listed = await runProbe(argv);
if (listed.exitCode !== 0) {
return probeFailure(listed, argv);
}
const parsed = Number.parseInt(listed.stdout.trim(), 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw {
transportFailure: true as const,
reason: 'exit',
stderr: `unexpected rev-list output: ${listed.stdout}`,
exitCode: listed.exitCode,
argv,
host: url.host,
hasToken,
} satisfies TransportFailure;
}
return parsed;
};
const isShallowRepository = async (): Promise<boolean> => {
const argv = [...baseArgs, 'rev-parse', '--is-shallow-repository'];
const shallow = await runProbe(argv);
if (shallow.exitCode !== 0) {
return probeFailure(shallow, argv);
}
const flag = shallow.stdout.trim();
if (flag === 'true') return true;
if (flag === 'false') return false;
throw {
transportFailure: true as const,
reason: 'exit',
stderr: `unexpected shallow-repository output: ${shallow.stdout}`,
exitCode: shallow.exitCode,
argv,
host: url.host,
hasToken,
} satisfies TransportFailure;
};
const isProvenAncestor = async (): Promise<boolean> => {
const argv = [...baseArgs, 'merge-base', '--is-ancestor', ancestor, descendant];
const ancestry = await runProbe(argv);
if (ancestry.exitCode === 0) return true;
if (ancestry.exitCode === 1) return false;
return probeFailure(ancestry, argv);
};
let reachableCount = await countReachable();
let fetchRounds = 1;
let deepenStep = 1;
const assertWithinSizeBudget = async (): Promise<void> => {
const finalSize = await treeSize(req.workspaceRoot).catch((e: unknown) => {
console.warn(`[GitSource:transport] final size measurement failed for ${req.workspaceRoot}, failing closed: ${e instanceof Error ? e.message : String(e)}`);
return -1;
});
if (sizeExceeded || finalSize < 0 || finalSize > req.maxBytes) {
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure;
}
};
while (true) {
assertTimeBudget();
const ancestorArgv = [...baseArgs, 'cat-file', '-e', `${ancestor}^{commit}`];
const hasAncestor = await runProbe(ancestorArgv);
if (hasAncestor.exitCode === 0) {
await assertWithinSizeBudget();
return await isProvenAncestor();
}
if (!isMissingObjectProbe(hasAncestor)) {
return probeFailure(hasAncestor, ancestorArgv);
}
if (!(await isShallowRepository())) {
await assertWithinSizeBudget();
return false;
}
if (fetchRounds >= MAX_FF_FETCH_ROUNDS) {
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
}
const previousCount = reachableCount;
await materialize([...baseArgs, 'fetch', `--deepen=${deepenStep}`, url.href, descendant]);
fetchRounds += 1;
reachableCount = await countReachable();
if (reachableCount <= previousCount) {
if (!(await isShallowRepository())) {
await assertWithinSizeBudget();
return false;
}
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
}
deepenStep = Math.min(deepenStep * 2, MAX_FF_DEEPEN_STEP);
}
} finally {
watchdog.stop();
await awaitKillConfirmed(breachKill, `size-breach kill for ${url.host}`);
await fs.rm(repoDir, { recursive: true, force: true }).catch((e: unknown) => {
console.warn(`[GitSource:transport] failed to remove fast-forward scratch repo ${repoDir}: ${e instanceof Error ? e.message : String(e)}`);
});
}
}
export const nativeGitTransport: GitTransport = {
async resolveRef(req: ResolveRequest): Promise<ResolveResult> {
const hasToken = Boolean(req.token);
await ensureBinaryReady(hasToken);
const url = assertValidRepoUrl(req.repoUrl, hasToken);
if (SHA_PATTERN.test(req.ref)) {
// A full SHA is self-resolving: the immutable identity IS the
// value, so there is nothing to look up. Reachability is verified
// at fetch, where the host either serves the object or refuses it
// (classified as UNSUPPORTED_REF).
return { commitSha: req.ref.toLowerCase(), kind: 'sha' };
}
assertValidRef(req.ref, url.host, hasToken);
const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token);
const found = await lsRemoteRefs(
url, req.ref, env, baseArgs,
req.timeoutMs ?? DEFAULT_TIMEOUT_MS, hasToken,
);
if (found.branchSha) return { commitSha: found.branchSha, kind: 'branch' };
if (found.tagSha) return { commitSha: found.tagSha, kind: 'tag' };
throw { transportFailure: true as const, reason: 'ref-not-found', host: url.host, hasToken } satisfies TransportFailure;
},
async fetchAtCommit(req: FetchRequest): Promise<FetchResult> {
const hasToken = Boolean(req.token);
await ensureBinaryReady(hasToken);
const url = assertValidRepoUrl(req.repoUrl, hasToken);
assertValidRef(req.ref, url.host, hasToken);
const { layout, env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token);
const checkout = path.join(req.workspaceRoot, 'repo');
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
// Shared between the watchdog breach flag and the active child handle:
// whichever fires first tears the clone down; the other becomes a no-op.
let sizeExceeded = false;
let activeChild: ChildProcess | undefined;
// A breach kill is subject to the same ordering hazard as a timeout
// kill: the caller deletes this workspace as soon as we return, so the
// kill has to be finished first, not merely issued.
let breachKill: Promise<void> | undefined;
const watchdog = startSizeWatchdog(req.workspaceRoot, req.maxBytes, () => {
sizeExceeded = true;
breachKill = killTree(activeChild);
});
try {
// Run each materialization step through one failure mapper. A size
// breach wins over the timeout wording: both kills are ours, but
// the operator guidance differs. runGit resolves on any exit code,
// so a non-zero exit is classified here by its real stderr rather
// than leaking a generic GIT_ERROR upstream.
const materialize = async (args: string[]): Promise<RunResult> => {
let res: RunResult;
try {
res = await runGit(args, {
cwd: layout.homeDir, env, timeoutMs,
onSpawn: (child) => { activeChild = child; },
});
} catch (e) {
// A size breach wins over the timeout wording.
if (sizeExceeded) {
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure;
}
if (isTimeoutError(e)) {
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
}
throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), argv: args, host: url.host, hasToken } satisfies TransportFailure;
}
// A watchdog-triggered SIGKILL settles runGit's promise via the
// child's normal 'close' event (code null -> exitCode -1), not
// a rejection, so this is the common path for an in-flight
// breach and must check sizeExceeded before the generic mapping.
if (sizeExceeded) {
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure;
}
if (res.exitCode !== 0) {
throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: args, host: url.host, hasToken } satisfies TransportFailure;
}
return res;
};
if (req.refKind === 'sha') {
// `--branch` cannot take a bare SHA, so a pinned commit uses a
// third strategy: init a repo, fetch exactly that object, and
// check it out detached. The host must allow fetching a direct
// SHA (GitHub does by default); a refusal surfaces as a
// non-zero `git fetch` here and classifies as UNSUPPORTED_REF.
await materialize([...baseArgs, 'init', checkout]);
await materialize([...baseArgs, '-C', checkout, 'fetch', '--depth=1', url.href, req.ref]);
await materialize([...baseArgs, '-C', checkout, 'checkout', '--detach', req.ref]);
} else {
// A bare name works for both branches and tags: `--branch`
// detaches at the named ref's commit either way, and passing a
// fully-qualified `refs/tags/<ref>` is rejected by git
// (`Remote branch ... not found`). The resolved kind is
// already pinned by ls-remote, and the rev-parse HEAD
// verification below confirms the checkout matched it.
const branchArg = req.ref;
await materialize([
...baseArgs, 'clone',
'--depth=1', '--single-branch', '--no-tags', '--no-recurse-submodules',
'--branch', branchArg, url.href, checkout,
]);
}
let actual: string;
try {
const head = await runGit([...baseArgs, 'rev-parse', 'HEAD'], {
cwd: checkout,
env,
timeoutMs: Math.min(timeoutMs, LS_REMOTE_MAX_MS),
});
actual = head.stdout.trim().toLowerCase();
if (!SHA_PATTERN.test(actual)) {
throw { transportFailure: true as const, reason: 'exit', stderr: `unexpected rev-parse output: ${head.stdout}`, exitCode: head.exitCode, host: url.host, hasToken } satisfies TransportFailure;
}
} catch (e) {
if (isTransportFailure(e)) throw e;
if (isTimeoutError(e)) {
throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure;
}
throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), host: url.host, hasToken } satisfies TransportFailure;
}
if (actual !== req.commitSha.toLowerCase()) {
// The branch tip moved between resolution and fetch. Refuse
// rather than materialize content nobody reviewed.
throw { transportFailure: true as const, reason: 'tip-changed', host: url.host, hasToken } satisfies TransportFailure;
}
// Deterministic final measure: a breach landing between the last
// watchdog tick and successful verification must not slip through
// as an over-cap success. A read failure here (permissions,
// workspace removed mid-walk) must fail closed rather than treat
// an unmeasurable workspace as within budget.
const finalSize = await treeSize(req.workspaceRoot).catch((e: unknown) => {
console.warn(`[GitSource:transport] final size measurement failed for ${req.workspaceRoot}, failing closed: ${e instanceof Error ? e.message : String(e)}`);
return -1;
});
if (sizeExceeded || finalSize < 0 || finalSize > req.maxBytes) {
throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure;
}
return { commitSha: actual, dir: checkout };
} finally {
watchdog.stop();
await awaitKillConfirmed(breachKill, `size-breach kill for ${url.host}`);
}
},
};