mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 05:07:59 +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:
@@ -18,7 +18,8 @@
|
||||
export type TransportFacingCode =
|
||||
| 'REPO_NOT_FOUND'
|
||||
| 'AUTH_FAILED'
|
||||
| 'BRANCH_NOT_FOUND'
|
||||
| 'REF_NOT_FOUND'
|
||||
| 'UNSUPPORTED_REF'
|
||||
| 'NETWORK_TIMEOUT'
|
||||
| 'GIT_ERROR';
|
||||
|
||||
@@ -29,6 +30,7 @@ export type TransportFailureReason =
|
||||
| 'git-missing'
|
||||
| 'git-old'
|
||||
| 'ref-not-found'
|
||||
| 'unsupported-ref'
|
||||
| 'tip-changed'
|
||||
| 'size'
|
||||
| 'timeout'
|
||||
@@ -52,6 +54,7 @@ export type TransportFailure = TransportFailureBase & (
|
||||
| { reason: 'git-missing'; stderr?: string }
|
||||
| { reason: 'git-old'; stderr?: string }
|
||||
| { reason: 'ref-not-found' }
|
||||
| { reason: 'unsupported-ref' }
|
||||
| { reason: 'tip-changed' }
|
||||
| { reason: 'size'; maxBytes: number }
|
||||
| { reason: 'timeout' }
|
||||
@@ -103,13 +106,15 @@ export function classifyGitFailure(
|
||||
case 'invalid-url':
|
||||
return { code: 'GIT_ERROR', message: 'Unsupported repository URL. Use an https:// URL without embedded credentials.' };
|
||||
case 'invalid-ref':
|
||||
return { code: 'GIT_ERROR', message: 'Unsupported branch name. Use the branch name as the remote reports it.' };
|
||||
return { code: 'GIT_ERROR', message: 'Unsupported ref name. Use a branch name, a tag name, or a full commit SHA as the remote reports it.' };
|
||||
case 'git-missing':
|
||||
return { code: 'GIT_ERROR', message: failure.stderr || 'The git command was not found on PATH.' };
|
||||
case 'git-old':
|
||||
return { code: 'GIT_ERROR', message: failure.stderr || 'The installed git client is too old.' };
|
||||
case 'ref-not-found':
|
||||
return { code: 'BRANCH_NOT_FOUND', message: 'Branch not found in the repository.' };
|
||||
return { code: 'REF_NOT_FOUND', message: 'The configured branch, tag, or commit was not found in the repository.' };
|
||||
case 'unsupported-ref':
|
||||
return { code: 'UNSUPPORTED_REF', message: 'The configured commit is not reachable on this repository host. Use a branch or tag, or a commit the host advertises.' };
|
||||
case 'tip-changed':
|
||||
return { code: 'GIT_ERROR', message: 'Repository tip changed during fetch; retry the pull.' };
|
||||
case 'size':
|
||||
@@ -145,7 +150,15 @@ export function classifyGitFailure(
|
||||
};
|
||||
}
|
||||
if (/remote branch .+ not found in upstream|branch not found/.test(raw)) {
|
||||
return { code: 'BRANCH_NOT_FOUND', message: 'Branch not found in the repository.' };
|
||||
return { code: 'REF_NOT_FOUND', message: 'The configured branch, tag, or commit was not found in the repository.' };
|
||||
}
|
||||
// A host that refuses to serve an unadvertised object (SHA fetch without
|
||||
// allowAnySHA1InWant/allowReachableSHA1InWant) still exits non-zero, but
|
||||
// the failure is about server capability, not the SHA existing. Hosts word
|
||||
// the refusal differently (GitHub vs GitLab/Gitea), so match stable phrases
|
||||
// rather than one vendor's full sentence.
|
||||
if (/unadvertised object|not our ref/.test(raw)) {
|
||||
return { code: 'UNSUPPORTED_REF', message: 'The configured commit is not reachable on this repository host. Use a branch or tag, or a commit the host advertises.' };
|
||||
}
|
||||
if (/repository[\s\S]*\bnot found\b|not found in upstream/.test(raw)) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user