mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-02 21:58:06 +00:00
feat(git): swap isomorphic-git for native git transport behind clone seam (#1849)
* feat(git): swap isomorphic-git for native git transport behind clone seam Replace the isomorphic-git engine (HTTP-only, single importer) with the native git CLI behind the existing withClonedRepo seam, so SSH deploy keys, ref semantics, and private CAs become reachable in later PRs. - resolve-before-fetch: ls-remote pins the branch to an immutable SHA, then rev-parse verifies the checkout against it; tip races refuse - hardened spawns: argv arrays only, protocol allowlist (https only), neutralized hooks, isolated HOME and all config channels, no prompts - token reaches git only via a credential helper reading SENCHO_GIT_TOKEN from the child env; never argv or URL - size cap becomes a workspace watchdog (on-disk measure) keeping the same knob and breach message; deterministic final gate added - Windows: pin http.sslBackend=openssl (schannel ignores sslCAInfo) and anchor to Git's bundled CA; NODE_EXTRA_CA_CERTS combines with platform defaults instead of replacing them - error classification retargets to exit code + stderr while preserving the contractual mappings (AUTH_FAILED maps to 400, never 401; unauthenticated refusals mask as REPO_NOT_FOUND) - runtime image installs git; tests re-pointed at the transport boundary plus a new engine suite (classifier corpus, argv hardening, watchdog) Zero externally visible behavior change except two edge cases: an empty branch now surfaces BRANCH_NOT_FOUND, and a mid-fetch force push refuses instead of materializing the moved tip. * fix(git): unblock CI on linux kill-path test and codeql log warning Two CI-only findings from the first pipeline run: - The scripted spawn child in the transport tests lacked the kill method that killTree's POSIX fallback reaches when a fake process group does not exist; Linux runs crashed inside the timeout tests while Windows (taskkill branch) could not reproduce it. Give the fixture the method the real ChildProcess always has. - CodeQL flagged the workspace-removal warning that interpolated the NODE_EXTRA_CA_CERTS path (environment-sourced values are treated as sensitive at log sinks). Reword the warning to name the variable instead of its value; operators know their own environment. * fix(git): collapse remaining duplicated test setup so the shared helper is used * fix(git): close watchdog, size-gate, ref-validator, and kill-ordering gaps in native transport Resolves the release-blocking findings from an independent pre-merge audit of the native git transport swap: - A watchdog-triggered kill mid-clone was misclassified as a generic exit failure instead of a size breach, because runGit resolves (not rejects) when the child is killed via SIGKILL. - The final on-disk size measurement failed open when it could not be read (workspace removed mid-walk, permissions), letting an unmeasured clone through as a success. Now fails closed and logs the real cause. - The ref-name validator was an overly restrictive allow-list that rejected valid branch names (leading underscore, non-ASCII, '#'). Replaced with a deny-list matching real `git check-ref-format --branch` semantics, verified against the git binary, including a per-path-segment `.lock` check the first pass missed. - runGit's timeout handler settled as soon as a kill was issued rather than confirmed, racing workspace cleanup against a still-alive child tree. It now waits for the child's close event, with a bounded fallback if termination is never confirmed, and preserves the timeout classification if 'error' fires after the kill. - Windows killTree now also falls back to child.kill() when taskkill itself exits non-zero, not just when it fails to spawn. - Added a real, non-mocked integration test that drives the credential helper through the actual git binary against a local HTTPS server with Basic Auth checking. It caught a genuine bug the mocked suite could not see: the credential.helper config value was quoted in a way that broke git's own absolute-path helper detection, failing every authenticated clone. Fixed by removing the quotes. - Migrated a separately developed test file's mocks off the deleted isomorphic-git module onto the native transport seam, matching the pattern already used elsewhere, after merging with main pulled in that feature. Also updates two stale comments left over from the isomorphic-git era and adds a git version check to the Docker runtime image smoke tests. * fix(git): make credential-helper path safe, unify ref length, and fix Windows kill ordering Addresses three PR 1 correction items from pre-merge audit: - credential.helper is a shell string, not argv: interpolating the helper's workspace-relative path broke authenticated fetches whenever the workspace sat under a directory with a space in its name. The config value is now a fixed string that names an environment variable instead, so no workspace path character can affect how git's shell parses it. - The transport rejected branch names over 200 characters while the route accepted up to 256 and real git has no comparable limit. REF_MAX_LEN is now a single exported constant shared by the transport and both routes. - On Windows, taskkill runs as a separate process and could still be walking a killed process tree after the direct git child reported closed, letting the caller delete the workspace early. Kill operations are now awaited to completion (bounded by a timeout) before a timed-out or size-breached run settles, on both the close and error event paths. Verified against a real authenticated git server inside the built runtime image: public HTTPS, private HTTPS with a valid PAT, invalid PAT, a deleted branch, an oversized repository, and the awkward workspace-path case, including from a workspace path containing spaces and shell metacharacters. * fix(git): reap killed helpers and classify curl refusals
This commit is contained in:
@@ -28,6 +28,8 @@ import type { ComposeInputEntry, GitProjectManifest, GitSourceManifestState, Inv
|
||||
import type { GitChangePlan, PublicGitChangePlan, GitChangePlanCounts, PublicGitChangePlanOperation } from '../types/gitChangePlan';
|
||||
import { GIT_CHANGE_PLAN_SCHEMA_VERSION } from '../types/gitChangePlan';
|
||||
import type { NotificationCategory } from './NotificationService';
|
||||
import { classifyGitFailure, isTransportFailure } from './git/errors';
|
||||
import { nativeGitTransport } from './git/nativeGitTransport';
|
||||
import { GitOpsStore } from './gitops/store';
|
||||
import { GitOpsTransitions, GitOpsTransitionError } from './gitops/transitions';
|
||||
import {
|
||||
@@ -42,136 +44,6 @@ import type { GitOpsApplicationRow } from './gitops/types';
|
||||
import { appliedRelPathFor, candidateRelPathForSha, deleteStagingMarker, readStagingMarker, writeStagingMarker } from './gitops/createStagingMarker';
|
||||
import { cleanupUnclaimedManagedRoot, removeOperationOwnedPaths } from './gitops/createCleanup';
|
||||
import { managedAreaBase } from './gitops/managedPaths';
|
||||
import type { GitHttpRequest, GitHttpResponse, HttpClient } from 'isomorphic-git/http/node';
|
||||
|
||||
// isomorphic-git is the heaviest dependency in the backend (~5 MB) and only
|
||||
// fires when a stack is created from a Git source. Lazy-load it so cold
|
||||
// boots without any Git-sourced stacks never parse the module.
|
||||
type IsomorphicGit = typeof import('isomorphic-git')['default'];
|
||||
type IsomorphicGitHttp = typeof import('isomorphic-git/http/node')['default'];
|
||||
|
||||
let cachedGit: IsomorphicGit | undefined;
|
||||
let cachedGitHttp: IsomorphicGitHttp | undefined;
|
||||
|
||||
async function loadIsomorphicGit(): Promise<{ git: IsomorphicGit; gitHttp: IsomorphicGitHttp }> {
|
||||
if (!cachedGit || !cachedGitHttp) {
|
||||
const [gitMod, gitHttpMod] = await Promise.all([
|
||||
import('isomorphic-git'),
|
||||
import('isomorphic-git/http/node'),
|
||||
]);
|
||||
cachedGit = gitMod.default;
|
||||
cachedGitHttp = gitHttpMod.default;
|
||||
}
|
||||
return { git: cachedGit, gitHttp: cachedGitHttp };
|
||||
}
|
||||
|
||||
function cloneTimeoutError(): Error & { code: string } {
|
||||
return Object.assign(new Error('Clone timed out'), { code: 'ETIMEDOUT' });
|
||||
}
|
||||
|
||||
async function collectGitBody(body: AsyncIterableIterator<Uint8Array>, signal: AbortSignal): Promise<Uint8Array> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of body) {
|
||||
if (signal.aborted) throw cloneTimeoutError();
|
||||
chunks.push(chunk);
|
||||
size += chunk.byteLength;
|
||||
}
|
||||
const result = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function responseBodyIterator(body: ReadableStream<Uint8Array> | null): AsyncIterableIterator<Uint8Array> {
|
||||
async function* iterate(): AsyncIterableIterator<Uint8Array> {
|
||||
if (!body) return;
|
||||
const reader = body.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
return iterate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable counter shared across every request in one clone so the size cap
|
||||
* is cumulative, and so the caller can tell a size abort from a timeout via
|
||||
* `exceeded` rather than the thrown error (which isomorphic-git may wrap).
|
||||
*/
|
||||
interface CloneSizeState {
|
||||
exceeded: boolean;
|
||||
received: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a response-body iterator with a cumulative byte counter shared
|
||||
* across every request in one clone. When the running total crosses
|
||||
* `maxBytes`, flip `state.exceeded`, abort the transport (closing the
|
||||
* socket so the download stops), and throw to unwind the stream. The
|
||||
* caller distinguishes a size abort from a timeout/transport abort via
|
||||
* `state.exceeded` rather than the thrown error, which isomorphic-git may
|
||||
* wrap.
|
||||
*/
|
||||
export function countingBodyIterator(
|
||||
src: AsyncIterableIterator<Uint8Array>,
|
||||
controller: AbortController,
|
||||
maxBytes: number,
|
||||
state: CloneSizeState,
|
||||
): AsyncIterableIterator<Uint8Array> {
|
||||
async function* iterate(): AsyncIterableIterator<Uint8Array> {
|
||||
for await (const chunk of src) {
|
||||
state.received += chunk.byteLength;
|
||||
if (state.received > maxBytes) {
|
||||
state.exceeded = true;
|
||||
controller.abort();
|
||||
throw new Error('Clone exceeded the maximum allowed size');
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
return iterate();
|
||||
}
|
||||
|
||||
function createAbortableGitHttp(
|
||||
controller: AbortController,
|
||||
maxBytes: number,
|
||||
state: CloneSizeState,
|
||||
): HttpClient {
|
||||
const signal = controller.signal;
|
||||
return {
|
||||
async request(request: GitHttpRequest): Promise<GitHttpResponse> {
|
||||
if (signal.aborted) {
|
||||
throw cloneTimeoutError();
|
||||
}
|
||||
|
||||
const response = await fetch(request.url, {
|
||||
method: request.method ?? 'GET',
|
||||
headers: request.headers,
|
||||
body: request.body ? await collectGitBody(request.body, signal) : undefined,
|
||||
signal,
|
||||
});
|
||||
|
||||
return {
|
||||
url: response.url,
|
||||
method: request.method,
|
||||
statusCode: response.status,
|
||||
statusMessage: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: countingBodyIterator(responseBodyIterator(response.body), controller, maxBytes, state),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GitSourceService - fetch compose files from a Git repository and apply
|
||||
@@ -362,17 +234,19 @@ const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
||||
const TEMP_DIR_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
||||
const WEBHOOK_DEBOUNCE_MS = 10_000;
|
||||
|
||||
// Ceiling on how many bytes a single clone may download (the compressed pack
|
||||
// from the Git host) before it is aborted. This bounds network transfer and
|
||||
// abuse, not the decompressed on-disk checkout; it is paired with
|
||||
// MAX_REPO_FILE_BYTES and the 30s timeout. Generous default; operators with a
|
||||
// legitimately large monorepo can raise it via GITSOURCE_MAX_CLONE_BYTES.
|
||||
// Ceiling on the on-disk size of a single clone workspace before the fetch
|
||||
// is killed. Enforced by a watchdog that stats the workspace during the
|
||||
// clone (the native git transport streams the pack straight to disk, so
|
||||
// unlike the previous HTTP client there is no byte counter to hook). Paired
|
||||
// with MAX_REPO_FILE_BYTES and the timeout. Generous default; operators
|
||||
// with a legitimately large monorepo can raise it via GITSOURCE_MAX_CLONE_BYTES.
|
||||
const DEFAULT_MAX_CLONE_BYTES = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
// Per-file ceiling for the compose/env file read into memory after the clone.
|
||||
// These files are KB-scale in practice; the clone byte cap bounds the
|
||||
// compressed download, not the decompressed working tree, so this guards the
|
||||
// in-memory read against a single huge (or highly compressible) file.
|
||||
// These files are KB-scale in practice; the clone byte cap bounds the total
|
||||
// on-disk workspace, not any single file within it, so this guards the
|
||||
// in-memory read against one outsized file inside an otherwise in-budget
|
||||
// checkout.
|
||||
const MAX_REPO_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
function maxCloneBytes(): number {
|
||||
@@ -391,10 +265,9 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
/**
|
||||
* Remove any inline credentials and Authorization headers from an error
|
||||
* message before it lands in a log or an API response. isomorphic-git
|
||||
* tends to include the fetch URL in thrown errors; if a PAT ever leaks
|
||||
* into that URL (we try to avoid it via `onAuth`, but be defensive),
|
||||
* strip it here.
|
||||
* message before it lands in a log or an API response. Git errors tend to
|
||||
* include the fetch URL; if a PAT ever leaks into a URL (we never send one,
|
||||
* but be defensive), strip it here.
|
||||
*/
|
||||
function scrubCredentials(message: string): string {
|
||||
return message
|
||||
@@ -452,63 +325,10 @@ export function repoHost(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Node's global `fetch()` reports every transport-level failure as a bare
|
||||
* `TypeError('fetch failed')` and hides the real reason on `error.cause`
|
||||
* (occasionally nested one level deeper through undici). Walk the cause
|
||||
* chain for the first Node error code so DNS / connection / TLS failures
|
||||
* can be translated into an actionable message instead of "fetch failed",
|
||||
* which reads like an internal Sencho bug.
|
||||
*/
|
||||
function findCauseCode(err: unknown): { code?: string } {
|
||||
let cur: unknown = err;
|
||||
for (let depth = 0; depth < 5 && cur; depth++) {
|
||||
const code = (cur as { code?: unknown }).code;
|
||||
if (typeof code === 'string' && code) {
|
||||
return { code };
|
||||
}
|
||||
cur = (cur as { cause?: unknown }).cause;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a Node transport error code into a GitSourceError with a
|
||||
* host-qualified, user-actionable message. Returns null for codes we do
|
||||
* not specifically recognise so the caller can fall through to its generic
|
||||
* handling. `host` is the bare hostname from `repoHost()` (never carries a
|
||||
* credential), so it is safe to surface.
|
||||
*/
|
||||
function transportError(code: string, host: string): GitSourceError | null {
|
||||
const dest = host && host !== 'unknown' ? ` ${host}` : ' the repository host';
|
||||
switch (code) {
|
||||
case 'ENOTFOUND':
|
||||
case 'EAI_AGAIN':
|
||||
return new GitSourceError('NETWORK_TIMEOUT', `Could not resolve${dest}. Check the repository URL and your network or DNS.`);
|
||||
case 'ECONNREFUSED':
|
||||
return new GitSourceError('NETWORK_TIMEOUT', `Connection refused by${dest}.`);
|
||||
case 'ECONNRESET':
|
||||
return new GitSourceError('NETWORK_TIMEOUT', `Connection to${dest} was reset. Retry; if it persists, check the host.`);
|
||||
case 'ETIMEDOUT':
|
||||
case 'UND_ERR_CONNECT_TIMEOUT':
|
||||
case 'UND_ERR_HEADERS_TIMEOUT':
|
||||
case 'UND_ERR_BODY_TIMEOUT':
|
||||
return new GitSourceError('NETWORK_TIMEOUT', `Timed out reaching${dest}.`);
|
||||
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
||||
case 'SELF_SIGNED_CERT_IN_CHAIN':
|
||||
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
|
||||
case 'CERT_HAS_EXPIRED':
|
||||
case 'ERR_TLS_CERT_ALTNAME_INVALID':
|
||||
return new GitSourceError('GIT_ERROR', `TLS certificate error reaching${dest} (${code}). The host certificate could not be verified.`);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git LFS stores large files as small pointer stubs in the working tree.
|
||||
* The pointer is a short text file that always begins with this line.
|
||||
* isomorphic-git does not resolve LFS, so if the compose or env file is
|
||||
* The fetch does not resolve LFS, so if the compose or env file is
|
||||
* tracked through LFS we would silently write the pointer as content.
|
||||
* Detect this and refuse, with a clear error, before it ever lands on
|
||||
* disk.
|
||||
@@ -524,7 +344,7 @@ export function isLfsPointer(content: string): boolean {
|
||||
|
||||
/**
|
||||
* Check whether the cloned tree references Git submodules. We do not
|
||||
* fetch submodule contents (isomorphic-git does not support them), so
|
||||
* fetch submodule contents (clones run with --no-recurse-submodules), so
|
||||
* warn the caller that any paths inside submodule directories will be
|
||||
* empty at deploy time.
|
||||
*/
|
||||
@@ -557,8 +377,8 @@ async function readRepoFile(rootDir: string, relPath: string, label: string): Pr
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', `${label} cannot be a symbolic link.`);
|
||||
}
|
||||
// Bound the in-memory read. The clone byte cap only limits the compressed
|
||||
// download; a single decompressed file can still be large, so reject an
|
||||
// Bound the in-memory read. The clone byte cap only limits the total
|
||||
// on-disk workspace, not any single file within it, so reject an
|
||||
// oversized compose/env file before reading it into a string.
|
||||
if (stat.size > MAX_REPO_FILE_BYTES) {
|
||||
throw new GitSourceError('GIT_ERROR', `${label} is too large (${formatBytes(stat.size)}); the maximum is ${formatBytes(MAX_REPO_FILE_BYTES)}.`);
|
||||
@@ -1087,10 +907,14 @@ export class GitSourceService {
|
||||
// ─── Fetch ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clone a repo into a throwaway temp dir, run `fn` against the checkout, and
|
||||
* always clean up. Centralizes the clone timeout, size cap, commit-sha read,
|
||||
* and submodule warning so both fetchFromGit (reads compose/env files) and
|
||||
* listRepoTree (lists the working tree) share one hardened clone path.
|
||||
* Resolve the configured branch to an immutable commit, clone exactly that
|
||||
* snapshot into a throwaway workspace, run `fn` against the checkout, and
|
||||
* always clean up. Centralizes resolution, the fetch timeout, the size
|
||||
* watchdog, commit verification, and the submodule warning so both
|
||||
* fetchFromGit (reads compose/env files) and listRepoTree (lists the
|
||||
* working tree) share one hardened path. Transport mechanics live in
|
||||
* `./git/nativeGitTransport`; failures arrive pre-classified or as
|
||||
* structured transport failures mapped below.
|
||||
*/
|
||||
private async withClonedRepo<T>(
|
||||
params: { repoUrl: string; branch: string; token?: string | null; timeoutMs?: number },
|
||||
@@ -1098,76 +922,55 @@ export class GitSourceService {
|
||||
): Promise<T> {
|
||||
const { repoUrl, branch, token } = params;
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
||||
const dir = await createTempDir();
|
||||
|
||||
// isomorphic-git's onAuth callback hands credentials to the HTTP layer
|
||||
// without them touching the URL string, keeping tokens out of any error
|
||||
// messages generated during the clone.
|
||||
const onAuth = token
|
||||
? () => ({ username: 'x-access-token', password: token })
|
||||
: undefined;
|
||||
const root = await createTempDir();
|
||||
|
||||
try {
|
||||
const { git } = await loadIsomorphicGit();
|
||||
// Bound clone duration and abort the HTTP transport so timed-out
|
||||
// fetches do not keep sockets and packfile streams alive.
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const controller = new AbortController();
|
||||
const maxBytes = maxCloneBytes();
|
||||
const sizeState = { exceeded: false, received: 0 };
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(cloneTimeoutError());
|
||||
}, timeoutMs);
|
||||
const resolved = await nativeGitTransport.resolveRef({
|
||||
repoUrl,
|
||||
ref: branch,
|
||||
token,
|
||||
timeoutMs,
|
||||
workspaceRoot: root,
|
||||
});
|
||||
const fetched = await nativeGitTransport.fetchAtCommit({
|
||||
repoUrl,
|
||||
ref: branch,
|
||||
token,
|
||||
timeoutMs,
|
||||
commitSha: resolved.commitSha,
|
||||
workspaceRoot: root,
|
||||
maxBytes: maxCloneBytes(),
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
git.clone({
|
||||
fs: { promises: fsPromises },
|
||||
http: createAbortableGitHttp(controller, maxBytes, sizeState),
|
||||
dir,
|
||||
url: repoUrl,
|
||||
ref: branch,
|
||||
singleBranch: true,
|
||||
depth: 1,
|
||||
noTags: true,
|
||||
onAuth,
|
||||
}),
|
||||
timeout,
|
||||
]);
|
||||
} catch (e) {
|
||||
// A size abort surfaces as a generic transport error once
|
||||
// isomorphic-git unwinds, so detect it via the shared flag.
|
||||
if (sizeState.exceeded) {
|
||||
throw new GitSourceError(
|
||||
'GIT_ERROR',
|
||||
`Repository exceeds the maximum clone size of ${formatBytes(maxBytes)}.`,
|
||||
);
|
||||
}
|
||||
throw this.mapGitError(e as Error, Boolean(token), repoHost(repoUrl));
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
|
||||
const log = await git.log({ fs: { promises: fsPromises }, dir, ref: branch, depth: 1 });
|
||||
if (!log.length) {
|
||||
throw new GitSourceError('GIT_ERROR', 'Repository has no commits on the requested branch.');
|
||||
}
|
||||
const commitSha = log[0].oid;
|
||||
|
||||
// Submodule detection: non-fatal, surfaced as a warning. isomorphic-git
|
||||
// does not recursively clone submodules, so any path that lives inside
|
||||
// a submodule directory will be empty after apply. Users need to know.
|
||||
// Submodule detection: non-fatal, surfaced as a warning. Clones run
|
||||
// with --no-recurse-submodules, so any path that lives inside a
|
||||
// submodule directory will be empty after apply. Users need to know.
|
||||
const warnings: string[] = [];
|
||||
if (await hasSubmodules(dir)) {
|
||||
if (await hasSubmodules(fetched.dir)) {
|
||||
console.warn(`[GitSource] Submodules detected in ${repoHost(repoUrl)}; contents not cloned.`);
|
||||
warnings.push(SUBMODULE_WARNING);
|
||||
}
|
||||
|
||||
return await fn(dir, commitSha, warnings);
|
||||
return await fn(fetched.dir, fetched.commitSha, warnings);
|
||||
} catch (e) {
|
||||
if (isTransportFailure(e)) {
|
||||
// The classified message operators see is deliberately
|
||||
// sanitized and may be generic (unrecognized stderr); always
|
||||
// keep the raw reason and scrubbed stderr tail in the server
|
||||
// log so new git wording is diagnosable.
|
||||
const detail = scrubCredentials(
|
||||
`reason=${e.reason} exit=${'exitCode' in e ? e.exitCode : '-'} stderr=${('stderr' in e && e.stderr ? e.stderr : '').slice(-600)}`,
|
||||
);
|
||||
console.error(`[GitSource:transport] host=${sanitizeForLog(e.host)} ${detail}`);
|
||||
if (isDebugEnabled() && 'argv' in e && e.argv?.length) {
|
||||
console.error(`[GitSource:transport] argv=[${e.argv.map((a) => sanitizeForLog(a)).join(' ')}]`);
|
||||
}
|
||||
const classified = classifyGitFailure(e);
|
||||
throw new GitSourceError(classified.code, classified.message);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
await removeTempDir(dir);
|
||||
await removeTempDir(root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1297,72 +1100,6 @@ export class GitSourceService {
|
||||
return { files, truncated };
|
||||
}
|
||||
|
||||
private mapGitError(err: Error, hasToken: boolean, host = 'unknown'): GitSourceError {
|
||||
const raw = scrubCredentials(err.message || String(err));
|
||||
const code = (err as Error & { code?: string }).code;
|
||||
// isomorphic-git's HttpError exposes the numeric status on .data; inspect
|
||||
// it directly so a 404 is not misclassified as auth failure. GitHub hides
|
||||
// private-repo existence by returning 404 to unauthenticated requests, so
|
||||
// we also treat 401/403 without a supplied token as "not found or private"
|
||||
// to guide the user to add a token rather than "check your token" when
|
||||
// they never provided one.
|
||||
const statusCode = (err as Error & { data?: { statusCode?: number } }).data?.statusCode;
|
||||
|
||||
// GitHub returns 404 for both "repo genuinely missing" and "private repo
|
||||
// the caller cannot see". We cannot distinguish the two without a second
|
||||
// probe, so tailor the hint by whether credentials were supplied:
|
||||
// - no token: suggest adding one for private repos
|
||||
// - token present: suggest checking the URL and token scopes, since a
|
||||
// valid token against a missing or wrong-scoped repo also lands here
|
||||
if (statusCode === 404) {
|
||||
if (hasToken) {
|
||||
return new GitSourceError('REPO_NOT_FOUND', 'Repository not found. Verify the URL and that your token has read access to this repo.');
|
||||
}
|
||||
return new GitSourceError('REPO_NOT_FOUND', 'Repository not found, or it is private. Add a Personal Access Token if the repo is private.');
|
||||
}
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
if (hasToken) {
|
||||
return new GitSourceError('AUTH_FAILED', 'Repository authentication failed. Check your token.');
|
||||
}
|
||||
return new GitSourceError('REPO_NOT_FOUND', 'Repository not found, or it is private. Add a Personal Access Token if the repo is private.');
|
||||
}
|
||||
|
||||
// Transport failures: Node's fetch() throws a bare "fetch failed"
|
||||
// TypeError with the real reason (ENOTFOUND, ECONNREFUSED, TLS, ...)
|
||||
// on err.cause. Translate the underlying code before falling through
|
||||
// to the generic branches, which only see the useless "fetch failed".
|
||||
if (!statusCode) {
|
||||
const cause = findCauseCode(err);
|
||||
if (cause.code) {
|
||||
const mapped = transportError(cause.code, host);
|
||||
if (mapped) return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallbacks for errors without a numeric status attached (e.g. git CLI
|
||||
// output, DNS/lib errors, or future isomorphic-git transports that do
|
||||
// not populate err.data.statusCode). Kept as defense-in-depth.
|
||||
if (/401|403|authentication/i.test(raw)) {
|
||||
return hasToken
|
||||
? new GitSourceError('AUTH_FAILED', 'Repository authentication failed. Check your token.')
|
||||
: new GitSourceError('REPO_NOT_FOUND', 'Repository not found, or it is private. Add a Personal Access Token if the repo is private.');
|
||||
}
|
||||
if (code === 'NotFoundError' || /404|not found|could not resolve/i.test(raw)) {
|
||||
return new GitSourceError('REPO_NOT_FOUND', 'Repository not found or not accessible.');
|
||||
}
|
||||
if (code === 'ResolveRefError' || /resolve ref|unknown ref|couldn't find remote ref|reference not found/i.test(raw)) {
|
||||
return new GitSourceError('BRANCH_NOT_FOUND', 'Branch not found in the repository.');
|
||||
}
|
||||
if (code === 'ECONNABORTED' || /timeout|timed out|ETIMEDOUT|ENOTFOUND|ECONNREFUSED/i.test(raw)) {
|
||||
return new GitSourceError('NETWORK_TIMEOUT', 'Network timeout or host unreachable.');
|
||||
}
|
||||
// Last-resort: an HttpError with a status we did not specifically handle.
|
||||
if (code === 'HttpError') {
|
||||
return new GitSourceError('GIT_ERROR', `Unexpected HTTP response from git host${statusCode ? ` (${statusCode})` : ''}.`);
|
||||
}
|
||||
return new GitSourceError('GIT_ERROR', raw);
|
||||
}
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user