mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-02 21:58:06 +00:00
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.
This commit is contained in:
@@ -29,7 +29,8 @@ import type { GitChangePlan, PublicGitChangePlan, GitChangePlanCounts, PublicGit
|
||||
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 type { RefKind } from './git/types';
|
||||
import { nativeGitTransport, verifyFastForward } from './git/nativeGitTransport';
|
||||
import { GitOpsStore } from './gitops/store';
|
||||
import { GitOpsTransitions, GitOpsTransitionError } from './gitops/transitions';
|
||||
import {
|
||||
@@ -58,7 +59,9 @@ import { managedAreaBase } from './gitops/managedPaths';
|
||||
export type GitSourceErrorCode =
|
||||
| 'REPO_NOT_FOUND'
|
||||
| 'AUTH_FAILED'
|
||||
| 'BRANCH_NOT_FOUND'
|
||||
| 'REF_NOT_FOUND'
|
||||
| 'REF_DELETED'
|
||||
| 'UNSUPPORTED_REF'
|
||||
| 'FILE_NOT_FOUND'
|
||||
| 'NETWORK_TIMEOUT'
|
||||
| 'GIT_ERROR'
|
||||
@@ -111,12 +114,29 @@ export interface FetchParams {
|
||||
* Used by the pull/create paths for complete-project materialization.
|
||||
*/
|
||||
onClone?: (cloneDir: string, commitSha: string, envContent: string | null) => Promise<unknown>;
|
||||
/**
|
||||
* True when this source has fetched successfully before. Turns a ref that
|
||||
* now fails to resolve into REF_DELETED (removed or force-pushed) instead
|
||||
* of a plain REF_NOT_FOUND, which would read as a mis-typed ref.
|
||||
*/
|
||||
hasPriorHistory?: boolean;
|
||||
/**
|
||||
* The commit and resolved namespace from the last successful fetch. Used to
|
||||
* detect force-pushes and ref-kind changes when the symbolic ref still exists.
|
||||
*/
|
||||
priorIdentity?: { commitSha: string; kind: RefKind };
|
||||
}
|
||||
|
||||
export interface FetchResult {
|
||||
composeFiles: ComposeFile[];
|
||||
envContent: string | null;
|
||||
commitSha: string;
|
||||
/**
|
||||
* The namespace the configured ref resolved through. Recorded wherever the
|
||||
* commit is persisted so "tag v1 -> <sha>" and "branch v1 -> <sha>" stay
|
||||
* distinguishable in revision state.
|
||||
*/
|
||||
resolvedRefKind: RefKind;
|
||||
/**
|
||||
* Non-fatal issues detected during the fetch (e.g. the repo uses
|
||||
* submodules that are not cloned). The stack is still usable but the
|
||||
@@ -407,6 +427,24 @@ async function readRepoFile(rootDir: string, relPath: string, label: string): Pr
|
||||
const SUBMODULE_WARNING =
|
||||
'Repository contains Git submodules. Their contents are not cloned; any paths referenced from them will be missing at deploy time.';
|
||||
|
||||
const REF_DELETED_MESSAGE =
|
||||
'The configured branch, tag, or commit no longer points at the same revision as before. It may have been deleted, force-pushed, or moved to a different commit (for example a retagged release).';
|
||||
|
||||
function priorFetchIdentity(app: GitOpsApplicationRow | null | undefined): FetchParams['priorIdentity'] {
|
||||
if (!app?.fetched_commit_sha) return undefined;
|
||||
let kind = app.fetched_resolved_ref_kind;
|
||||
if (!kind) {
|
||||
const genId = app.candidate_generation_id ?? app.accepted_generation_id;
|
||||
if (genId) {
|
||||
kind = GitOpsStore.getInstance().getGeneration(genId)?.resolved_ref_kind ?? null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
commitSha: app.fetched_commit_sha,
|
||||
kind: kind ?? 'branch',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject any relative path that resolves into the `.git` metadata
|
||||
* directory. The path-traversal check in `fetchFromGit` already bounds
|
||||
@@ -917,12 +955,20 @@ export class GitSourceService {
|
||||
* structured transport failures mapped below.
|
||||
*/
|
||||
private async withClonedRepo<T>(
|
||||
params: { repoUrl: string; branch: string; token?: string | null; timeoutMs?: number },
|
||||
fn: (dir: string, commitSha: string, warnings: string[]) => Promise<T>,
|
||||
params: {
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
token?: string | null;
|
||||
timeoutMs?: number;
|
||||
hasPriorHistory?: boolean;
|
||||
priorIdentity?: { commitSha: string; kind: RefKind };
|
||||
},
|
||||
fn: (dir: string, commitSha: string, warnings: string[], resolvedRefKind: RefKind) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { repoUrl, branch, token } = params;
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
||||
const root = await createTempDir();
|
||||
const hasPriorHistory = params.hasPriorHistory === true || params.priorIdentity != null;
|
||||
|
||||
try {
|
||||
const resolved = await nativeGitTransport.resolveRef({
|
||||
@@ -932,9 +978,30 @@ export class GitSourceService {
|
||||
timeoutMs,
|
||||
workspaceRoot: root,
|
||||
});
|
||||
if (params.priorIdentity) {
|
||||
const prior = params.priorIdentity;
|
||||
if (prior.kind !== resolved.kind) {
|
||||
throw new GitSourceError('REF_DELETED', REF_DELETED_MESSAGE);
|
||||
}
|
||||
if (prior.kind !== 'sha' && prior.commitSha !== resolved.commitSha) {
|
||||
const fastForward = await verifyFastForward({
|
||||
repoUrl,
|
||||
ancestorSha: prior.commitSha,
|
||||
descendantSha: resolved.commitSha,
|
||||
token,
|
||||
timeoutMs,
|
||||
workspaceRoot: root,
|
||||
maxBytes: maxCloneBytes(),
|
||||
});
|
||||
if (!fastForward) {
|
||||
throw new GitSourceError('REF_DELETED', REF_DELETED_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
const fetched = await nativeGitTransport.fetchAtCommit({
|
||||
repoUrl,
|
||||
ref: branch,
|
||||
refKind: resolved.kind,
|
||||
token,
|
||||
timeoutMs,
|
||||
commitSha: resolved.commitSha,
|
||||
@@ -951,7 +1018,7 @@ export class GitSourceService {
|
||||
warnings.push(SUBMODULE_WARNING);
|
||||
}
|
||||
|
||||
return await fn(fetched.dir, fetched.commitSha, warnings);
|
||||
return await fn(fetched.dir, fetched.commitSha, warnings, resolved.kind);
|
||||
} catch (e) {
|
||||
if (isTransportFailure(e)) {
|
||||
// The classified message operators see is deliberately
|
||||
@@ -966,6 +1033,11 @@ export class GitSourceService {
|
||||
console.error(`[GitSource:transport] argv=[${e.argv.map((a) => sanitizeForLog(a)).join(' ')}]`);
|
||||
}
|
||||
const classified = classifyGitFailure(e);
|
||||
// A ref that resolved before but no longer does is a deletion
|
||||
// or force-push, distinct from a mis-typed ref on first link.
|
||||
if (classified.code === 'REF_NOT_FOUND' && hasPriorHistory) {
|
||||
throw new GitSourceError('REF_DELETED', REF_DELETED_MESSAGE);
|
||||
}
|
||||
throw new GitSourceError(classified.code, classified.message);
|
||||
}
|
||||
throw e;
|
||||
@@ -993,7 +1065,14 @@ export class GitSourceService {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.withClonedRepo({ repoUrl, branch, token, timeoutMs: params.timeoutMs }, async (dir, commitSha, warnings) => {
|
||||
return await this.withClonedRepo({
|
||||
repoUrl,
|
||||
branch,
|
||||
token,
|
||||
timeoutMs: params.timeoutMs,
|
||||
hasPriorHistory: params.hasPriorHistory,
|
||||
priorIdentity: params.priorIdentity,
|
||||
}, async (dir, commitSha, warnings, resolvedRefKind) => {
|
||||
const composeFiles: ComposeFile[] = [];
|
||||
for (const composePath of composePaths) {
|
||||
const content = await readRepoFile(dir, composePath, 'Compose path');
|
||||
@@ -1040,7 +1119,7 @@ export class GitSourceService {
|
||||
`[GitSource:diag] fetch ok host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} sha=${commitSha.slice(0, 7)} files=${composeFiles.length} env=${envContent !== null ? 'present' : 'absent'} warnings=${warnings.length} materialized=${materialization !== null} elapsedMs=${Date.now() - startedAt}`
|
||||
);
|
||||
}
|
||||
return { composeFiles, envContent, commitSha, warnings, materialization };
|
||||
return { composeFiles, envContent, commitSha, resolvedRefKind, warnings, materialization };
|
||||
});
|
||||
} catch (err) {
|
||||
if (diag) {
|
||||
@@ -1739,12 +1818,15 @@ export class GitSourceService {
|
||||
const materialization: { value: MaterializationResult | null } = { value: null };
|
||||
// Every throw from here on, including this fetch, is closed by the
|
||||
// caller's handler, so nothing is recorded locally.
|
||||
const priorIdentity = priorFetchIdentity(gitopsApp);
|
||||
const fetched: FetchResult = await this.fetchFromGit({
|
||||
repoUrl: src.repo_url,
|
||||
branch: src.branch,
|
||||
composePaths: src.compose_paths,
|
||||
envPath: src.sync_env ? src.env_path : null,
|
||||
token,
|
||||
hasPriorHistory: priorIdentity != null,
|
||||
priorIdentity,
|
||||
onClone: async (cloneDir, commitSha, envContent) => {
|
||||
materialization.value = await this.buildMaterialization(stackName, cloneDir, commitSha, src, envContent);
|
||||
},
|
||||
@@ -1792,10 +1874,10 @@ export class GitSourceService {
|
||||
// generation while the projection reports the older commit.
|
||||
DatabaseService.getInstance().getDb().transaction(() => {
|
||||
if (!validation.ok) {
|
||||
tx.fetchedInvalid(gitopsApp.id, fetched.commitSha, gitopsEnv);
|
||||
tx.fetchedInvalid(gitopsApp.id, fetched.commitSha, gitopsEnv, fetched.resolvedRefKind);
|
||||
return;
|
||||
}
|
||||
tx.fetched(gitopsApp.id, fetched.commitSha, gitopsEnv);
|
||||
tx.fetched(gitopsApp.id, fetched.commitSha, gitopsEnv, fetched.resolvedRefKind);
|
||||
if (!materialization.value) return;
|
||||
const identity = directSourceIdentity({
|
||||
repoUrl: src.repo_url,
|
||||
@@ -1831,6 +1913,7 @@ export class GitSourceService {
|
||||
commitSha: fetched.commitSha,
|
||||
identity,
|
||||
configuredRef: src.branch,
|
||||
resolvedRefKind: fetched.resolvedRefKind,
|
||||
candidateRelPath: materialization.value.candidateRelPath,
|
||||
appliedRelPath: appliedRelPathFor(fetched.commitSha, nextManifestVersion),
|
||||
manifestVersion: nextManifestVersion,
|
||||
@@ -2725,6 +2808,7 @@ export class GitSourceService {
|
||||
commitSha: fetched.commitSha,
|
||||
identity: gitopsIdentity,
|
||||
configuredRef: input.branch,
|
||||
resolvedRefKind: fetched.resolvedRefKind,
|
||||
candidateRelPath: staged.candidateRelPath,
|
||||
appliedRelPath: appliedRelPathFor(fetched.commitSha, completeProjectManifest.manifestVersion),
|
||||
manifestVersion: completeProjectManifest.manifestVersion,
|
||||
|
||||
Reference in New Issue
Block a user