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:
Anso
2026-08-28 20:15:37 +00:00
committed by GitHub
parent 7cd42699d1
commit 48f010475b
38 changed files with 1397 additions and 137 deletions
@@ -25,7 +25,7 @@ import os from 'os';
import path from 'path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { classifyGitFailure, isTransportFailure } from '../services/git/errors';
import { nativeGitTransport } from '../services/git/nativeGitTransport';
import { nativeGitTransport, verifyFastForward } from '../services/git/nativeGitTransport';
function gitAvailable(): boolean {
return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
@@ -35,31 +35,74 @@ const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures'
const VALID_TOKEN = 'sencho-integration-test-token-do-not-leak';
const FILE_CONTENT = 'hello from the authenticated fixture repo\n';
/**
* Build a bare repo with one committed file. Mirrors e2e/gitServer.helper.ts's
* fixture builder. Returns both the served bare dir and every scratch
* directory created along the way, so the caller can remove them all.
*/
function buildBareFixtureRepo(): { bareDir: string; scratchDirs: string[] } {
interface RichFixtureRepo {
bare: { bareDir: string; scratchDirs: string[] };
mainSha: string;
annotatedTagSha: string;
lightweightTagSha: string;
pinnedSha: string;
chainTipSha: string;
rewrittenSha: string;
}
function buildRichFixtureRepo(): RichFixtureRepo {
const srcDir = mkdtempSync(path.join(os.tmpdir(), 'sencho-git-auth-src-'));
writeFileSync(path.join(srcDir, 'hello.txt'), FILE_CONTENT);
const run = (args: string[]) => {
const r = spawnSync('git', args, { cwd: srcDir, encoding: 'utf8' });
if (r.status !== 0) throw new Error(`git ${args[0]} failed: ${r.stderr}`);
return r.stdout.trim();
};
run(['init', '-b', 'main']);
run(['config', 'user.email', 'integration-test@sencho.test']);
run(['config', 'user.name', 'Sencho Integration Test']);
run(['add', '-A']);
// Explicitly off: a developer machine or CI runner with commit.gpgsign=true
// in its global gitconfig would otherwise fail this fixture commit.
run(['-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture']);
const mainSha = run(['rev-parse', 'HEAD']);
run(['tag', '-a', 'v-annotated', '-m', 'annotated release']);
const annotatedTagSha = run(['rev-parse', 'v-annotated^{commit}']);
run(['tag', 'v-light']);
const lightweightTagSha = run(['rev-parse', 'v-light']);
writeFileSync(path.join(srcDir, 'second.txt'), 'second fixture file\n');
run(['add', 'second.txt']);
run(['-c', 'commit.gpgsign=false', 'commit', '-m', 'second']);
const pinnedSha = run(['rev-parse', 'HEAD']);
run(['checkout', 'main']);
for (let i = 3; i <= 5; i += 1) {
writeFileSync(path.join(srcDir, `chain-${i}.txt`), `chain file ${i}\n`);
run(['add', `chain-${i}.txt`]);
run(['-c', 'commit.gpgsign=false', 'commit', '-m', `chain-${i}`]);
}
const chainTipSha = run(['rev-parse', 'HEAD']);
run(['checkout', '--orphan', 'rewritten']);
writeFileSync(path.join(srcDir, 'rewritten.txt'), 'rewritten history\n');
run(['add', 'rewritten.txt']);
run(['-c', 'commit.gpgsign=false', 'commit', '-m', 'rewritten']);
const rewrittenSha = run(['rev-parse', 'HEAD']);
const bareRoot = mkdtempSync(path.join(os.tmpdir(), 'sencho-git-auth-bare-'));
const bareDir = path.join(bareRoot, 'repo.git');
const clone = spawnSync('git', ['clone', '--bare', '--quiet', srcDir, bareDir], { encoding: 'utf8' });
if (clone.status !== 0) throw new Error(`git clone --bare failed: ${clone.stderr}`);
return { bareDir, scratchDirs: [srcDir, bareRoot] };
const pushRewritten = spawnSync(
'git',
['push', bareDir, 'rewritten:refs/heads/rewritten'],
{ cwd: srcDir, encoding: 'utf8' },
);
if (pushRewritten.status !== 0) {
throw new Error(`git push rewritten main failed: ${pushRewritten.stderr}`);
}
return {
bare: { bareDir, scratchDirs: [srcDir, bareRoot] },
mainSha,
annotatedTagSha,
lightweightTagSha,
pinnedSha,
chainTipSha,
rewrittenSha,
};
}
/** Serve one bare repo over HTTPS smart-HTTP, rejecting any request without a valid Basic Auth token. */
@@ -136,12 +179,13 @@ describe.skipIf(!gitAvailable())('authenticated native git transport (real git,
let closeServer: () => void;
let prevExtraCaCerts: string | undefined;
let fixtureScratchDirs: string[] = [];
let fixture: RichFixtureRepo;
const workspaces: string[] = [];
beforeAll(async () => {
const { bareDir, scratchDirs } = buildBareFixtureRepo();
fixtureScratchDirs = scratchDirs;
const served = await serveAuthedRepo(bareDir);
fixture = buildRichFixtureRepo();
fixtureScratchDirs = fixture.bare.scratchDirs;
const served = await serveAuthedRepo(fixture.bare.bareDir);
repoUrl = served.url;
closeServer = served.close;
prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS;
@@ -197,6 +241,7 @@ describe.skipIf(!gitAvailable())('authenticated native git transport (real git,
repoUrl,
ref: 'main',
token: VALID_TOKEN,
refKind: 'branch',
commitSha: resolved.commitSha,
timeoutMs: 15_000,
workspaceRoot: fetchWorkspace,
@@ -223,6 +268,7 @@ describe.skipIf(!gitAvailable())('authenticated native git transport (real git,
repoUrl,
ref: 'main',
token: VALID_TOKEN,
refKind: 'branch',
commitSha: resolved.commitSha,
timeoutMs: 15_000,
workspaceRoot: fetchWorkspace,
@@ -277,4 +323,140 @@ describe.skipIf(!gitAvailable())('authenticated native git transport (real git,
expect(serialized).not.toContain(wrongToken);
expect(serialized).not.toContain(VALID_TOKEN);
});
it('resolves and fetches an annotated tag through the peeled commit', async () => {
const workspaceRoot = await makeWorkspace();
const resolved = await nativeGitTransport.resolveRef({
repoUrl,
ref: 'v-annotated',
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
});
expect(resolved).toMatchObject({ commitSha: fixture.annotatedTagSha, kind: 'tag' });
const fetchWorkspace = await makeWorkspace();
const fetched = await nativeGitTransport.fetchAtCommit({
repoUrl,
ref: 'v-annotated',
token: VALID_TOKEN,
refKind: 'tag',
commitSha: resolved.commitSha,
timeoutMs: 15_000,
workspaceRoot: fetchWorkspace,
maxBytes: 10 * 1024 * 1024,
});
expect(fetched.commitSha).toBe(fixture.annotatedTagSha);
expect(await fs.readFile(path.join(fetched.dir, 'hello.txt'), 'utf8')).toBe(FILE_CONTENT);
});
it('resolves and fetches a lightweight tag', async () => {
const workspaceRoot = await makeWorkspace();
const resolved = await nativeGitTransport.resolveRef({
repoUrl,
ref: 'v-light',
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
});
expect(resolved).toMatchObject({ commitSha: fixture.lightweightTagSha, kind: 'tag' });
const fetchWorkspace = await makeWorkspace();
const fetched = await nativeGitTransport.fetchAtCommit({
repoUrl,
ref: 'v-light',
token: VALID_TOKEN,
refKind: 'tag',
commitSha: resolved.commitSha,
timeoutMs: 15_000,
workspaceRoot: fetchWorkspace,
maxBytes: 10 * 1024 * 1024,
});
expect(fetched.commitSha).toBe(fixture.lightweightTagSha);
});
it('resolves and fetches a pinned commit SHA', async () => {
const workspaceRoot = await makeWorkspace();
const resolved = await nativeGitTransport.resolveRef({
repoUrl,
ref: fixture.chainTipSha,
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
});
expect(resolved).toMatchObject({ commitSha: fixture.chainTipSha, kind: 'sha' });
const fetchWorkspace = await makeWorkspace();
const fetched = await nativeGitTransport.fetchAtCommit({
repoUrl,
ref: fixture.chainTipSha,
token: VALID_TOKEN,
refKind: 'sha',
commitSha: resolved.commitSha,
timeoutMs: 15_000,
workspaceRoot: fetchWorkspace,
maxBytes: 10 * 1024 * 1024,
});
expect(fetched.commitSha).toBe(fixture.chainTipSha);
expect(await fs.readFile(path.join(fetched.dir, 'chain-5.txt'), 'utf8')).toBe('chain file 5\n');
});
it('treats a linear branch advance as a fast-forward', async () => {
const workspaceRoot = await makeWorkspace();
const fastForward = await verifyFastForward({
repoUrl,
ancestorSha: fixture.pinnedSha,
descendantSha: fixture.chainTipSha,
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
maxBytes: 10 * 1024 * 1024,
});
expect(fastForward).toBe(true);
});
it('treats a multi-commit branch advance as a fast-forward', async () => {
const workspaceRoot = await makeWorkspace();
const fastForward = await verifyFastForward({
repoUrl,
ancestorSha: fixture.mainSha,
descendantSha: fixture.chainTipSha,
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
maxBytes: 10 * 1024 * 1024,
});
expect(fastForward).toBe(true);
});
it('rejects rewritten history as non-fast-forward', async () => {
const workspaceRoot = await makeWorkspace();
const fastForward = await verifyFastForward({
repoUrl,
ancestorSha: fixture.mainSha,
descendantSha: fixture.rewrittenSha,
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
maxBytes: 10 * 1024 * 1024,
});
expect(fastForward).toBe(false);
});
it('classifies verifyFastForward auth failures without collapsing to non-fast-forward', async () => {
const workspaceRoot = await makeWorkspace();
const failure = await verifyFastForward({
repoUrl,
ancestorSha: fixture.mainSha,
descendantSha: fixture.chainTipSha,
token: 'wrong-token',
timeoutMs: 15_000,
workspaceRoot,
maxBytes: 10 * 1024 * 1024,
}).then(() => null, (e: unknown) => e);
expect(isTransportFailure(failure)).toBe(true);
if (!isTransportFailure(failure)) throw new Error('unreachable');
expect(classifyGitFailure(failure).code).toBe('AUTH_FAILED');
});
});