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
@@ -19,10 +19,15 @@ describe('gitSourceStatus', () => {
it('maps resource-missing codes to 404', () => {
expect(gitSourceStatus('REPO_NOT_FOUND')).toBe(404);
expect(gitSourceStatus('BRANCH_NOT_FOUND')).toBe(404);
expect(gitSourceStatus('REF_NOT_FOUND')).toBe(404);
expect(gitSourceStatus('REF_DELETED')).toBe(404);
expect(gitSourceStatus('FILE_NOT_FOUND')).toBe(404);
});
it('maps UNSUPPORTED_REF to 400', () => {
expect(gitSourceStatus('UNSUPPORTED_REF')).toBe(400);
});
it('maps NETWORK_TIMEOUT to 504', () => {
expect(gitSourceStatus('NETWORK_TIMEOUT')).toBe(504);
});
@@ -46,6 +46,7 @@ function directApplicationFixture(id: string, stackName: string): GitOpsApplicat
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -6,7 +6,7 @@
* - validateCompose YAML pre-check (empty / non-object / syntax error)
* - Token round-trip via upsert: encryption, has_token projection, undefined/null/empty/non-empty semantics
* - Apply-matrix rejection (auto_deploy requires auto_apply)
* - Error code mapping from native-git transport failures (REPO_NOT_FOUND, AUTH_FAILED, BRANCH_NOT_FOUND, NETWORK_TIMEOUT)
* - Error code mapping from native-git transport failures (REPO_NOT_FOUND, AUTH_FAILED, REF_NOT_FOUND, REF_DELETED, UNSUPPORTED_REF, NETWORK_TIMEOUT)
* - Credential scrubbing in surfaced error messages
* - Pending state lifecycle (setPending -> apply clears -> dismissPending clears)
* - Webhook debounce enforcement
@@ -29,9 +29,10 @@ import {
// ── Hoisted mocks ──────────────────────────────────────────────────────
const { mockResolveRef, mockFetchAtCommit, mockGitClone, mockGitLog } = vi.hoisted(() => ({
const { mockResolveRef, mockFetchAtCommit, mockVerifyFastForward, mockGitClone, mockGitLog } = vi.hoisted(() => ({
mockResolveRef: vi.fn(),
mockFetchAtCommit: vi.fn(),
mockVerifyFastForward: vi.fn(),
mockGitClone: vi.fn(),
mockGitLog: vi.fn(),
}));
@@ -44,6 +45,7 @@ vi.mock('../services/git/nativeGitTransport', () => ({
resolveRef: mockResolveRef,
fetchAtCommit: mockFetchAtCommit,
},
verifyFastForward: mockVerifyFastForward,
}));
@@ -102,6 +104,7 @@ afterAll(() => {
beforeEach(() => {
mockResolveRef.mockReset();
mockFetchAtCommit.mockReset();
mockVerifyFastForward.mockReset();
mockGitClone.mockReset();
mockGitLog.mockReset();
wireTransportDefaults();
@@ -136,10 +139,11 @@ beforeEach(() => {
* at the workspace checkout.
*/
function wireTransportDefaults(): void {
mockVerifyFastForward.mockResolvedValue(true);
mockResolveRef.mockImplementation(async () => {
const log = await mockGitLog({});
const oid = Array.isArray(log) ? log[0]?.oid : undefined;
return { commitSha: oid ?? '' };
return { commitSha: oid ?? '', kind: 'branch' as const, ref: 'main' };
});
mockFetchAtCommit.mockImplementation(async (req: { workspaceRoot: string; commitSha: string }) => {
const path = await import('path');
@@ -708,6 +712,7 @@ describe('GitSourceService error mapping', () => {
expect(mockFetchAtCommit.mock.calls[0][0]).toMatchObject({
commitSha: sha,
ref: 'main',
refKind: 'branch',
repoUrl: 'https://github.com/example/repo.git',
token: 'tok-abc',
workspaceRoot: expect.any(String),
@@ -727,12 +732,12 @@ describe('GitSourceService error mapping', () => {
expect(fsMod.existsSync(failureRoot)).toBe(false);
});
it('reports BRANCH_NOT_FOUND for a branch with no commits', async () => {
// Resolve-first turns an empty branch into a missing remote head.
it('reports REF_NOT_FOUND for a branch with no commits', async () => {
// Resolve-first turns an empty branch into a missing remote ref.
mockGitLog.mockResolvedValue([]);
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({
code: 'BRANCH_NOT_FOUND',
message: expect.stringMatching(/Branch not found/),
code: 'REF_NOT_FOUND',
message: expect.stringMatching(/was not found/),
});
});
@@ -741,12 +746,102 @@ describe('GitSourceService error mapping', () => {
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'REPO_NOT_FOUND' });
});
it('maps remote-branch-not-found to BRANCH_NOT_FOUND', async () => {
it('maps remote-branch-not-found to REF_NOT_FOUND', async () => {
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
'fatal: Remote branch nonexistent not found in upstream origin',
false,
));
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'BRANCH_NOT_FOUND' });
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'REF_NOT_FOUND' });
});
it('upgrades REF_NOT_FOUND to REF_DELETED when the source has prior history', async () => {
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
'fatal: Remote branch nonexistent not found in upstream origin',
false,
));
await expect(svc().fetchFromGit({ ...fetchParams, hasPriorHistory: true }))
.rejects.toMatchObject({ code: 'REF_DELETED' });
});
it('returns REF_DELETED when a resolved ref changes namespace', async () => {
mockResolveRef.mockResolvedValueOnce({ commitSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', kind: 'tag' });
await expect(svc().fetchFromGit({
...fetchParams,
priorIdentity: { commitSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', kind: 'branch' },
})).rejects.toMatchObject({ code: 'REF_DELETED' });
expect(mockFetchAtCommit).not.toHaveBeenCalled();
});
it('returns REF_DELETED when a branch tip moves by force-push', async () => {
mockResolveRef.mockResolvedValueOnce({ commitSha: 'cccccccccccccccccccccccccccccccccccccccc', kind: 'branch' });
mockVerifyFastForward.mockResolvedValueOnce(false);
await expect(svc().fetchFromGit({
...fetchParams,
priorIdentity: { commitSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', kind: 'branch' },
})).rejects.toMatchObject({ code: 'REF_DELETED' });
expect(mockFetchAtCommit).not.toHaveBeenCalled();
});
it('propagates verifyFastForward transport failures without upgrading to REF_DELETED', async () => {
mockResolveRef.mockResolvedValueOnce({ commitSha: 'cccccccccccccccccccccccccccccccccccccccc', kind: 'branch' });
mockVerifyFastForward.mockRejectedValueOnce({
transportFailure: true as const,
reason: 'timeout',
host: 'github.com',
hasToken: true,
});
await expect(svc().fetchFromGit({
...fetchParams,
priorIdentity: { commitSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', kind: 'branch' },
})).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
expect(mockFetchAtCommit).not.toHaveBeenCalled();
});
it('maps a host refusal to serve a pinned SHA to UNSUPPORTED_REF', async () => {
// A SHA fetch requires the host to serve unadvertised objects; a
// refusal (allowAnySHA1InWant off) is a server-capability failure,
// not a missing commit. GitLab/Gitea word it differently from GitHub,
// so the classifier matches the stable "unadvertised object" phrase.
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
'fatal: upload-pack: unable to find 0123456789abcdef0123456789abcdef01234567, does not allow request for unadvertised object',
false,
));
await expect(svc().fetchFromGit({ ...fetchParams, branch: '0123456789abcdef0123456789abcdef01234567' }))
.rejects.toMatchObject({ code: 'UNSUPPORTED_REF' });
});
it('keeps NETWORK_TIMEOUT on a timed-out fetch even with prior history', async () => {
// The REF_DELETED upgrade fires only on a classified REF_NOT_FOUND.
// A network timeout on a source that previously resolved must stay a
// timeout, not read as "the ref vanished".
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
"fatal: unable to access 'https://github.com/example/repo.git/': Failed to connect to github.com port 443: Connection timed out",
false,
));
await expect(svc().fetchFromGit({ ...fetchParams, hasPriorHistory: true }))
.rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
});
it('maps GitHub not-our-ref SHA refusal to UNSUPPORTED_REF', async () => {
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
'fatal: remote error: upload-pack: not our ref 0123456789abcdef0123456789abcdef01234567',
false,
));
await expect(svc().fetchFromGit({ ...fetchParams, branch: '0123456789abcdef0123456789abcdef01234567' }))
.rejects.toMatchObject({ code: 'UNSUPPORTED_REF' });
});
it('leaves a missing pinned SHA as GIT_ERROR, not UNSUPPORTED_REF', async () => {
// A SHA the host simply has never seen surfaces as "couldn't find
// remote ref". That is a missing object, not a server-capability
// refusal, and it is deliberately not collapsed into the delete/force
// upgrade: there is no evidence the ref ever existed.
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
'fatal: couldn\'t find remote ref 0123456789abcdef0123456789abcdef01234567',
false,
));
await expect(svc().fetchFromGit({ ...fetchParams, branch: '0123456789abcdef0123456789abcdef01234567' }))
.rejects.toMatchObject({ code: 'GIT_ERROR' });
});
it('maps connection timeouts to NETWORK_TIMEOUT', async () => {
@@ -2965,6 +3060,7 @@ function seedDirectCandidate(stackName: string): { appId: string; generationId:
commitSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
identity,
configuredRef: 'main',
resolvedRefKind: 'branch',
candidateRelPath: 'generations/cand',
appliedRelPath: 'applied/1',
manifestVersion: 1,
@@ -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');
});
});
+417 -3
View File
@@ -9,7 +9,7 @@
* flow, and the size watchdog.
*/
import { EventEmitter } from 'events';
import { promises as fs, rmSync } from 'fs';
import { promises as fs, rmSync, existsSync } from 'fs';
import os from 'os';
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -39,7 +39,7 @@ import {
writeCredentialHelper,
} from '../services/git/credentialHelper';
import * as gitBinary from '../services/git/gitBinary';
import { nativeGitTransport, REF_MAX_LEN, startSizeWatchdog } from '../services/git/nativeGitTransport';
import { nativeGitTransport, REF_MAX_LEN, startSizeWatchdog, verifyFastForward } from '../services/git/nativeGitTransport';
const GIT_EXEC_PATH_STUB = 'C:/Program Files/Git/mingw64/libexec/git-core';
@@ -185,12 +185,37 @@ describe('classifyGitFailure (native git stderr corpus)', () => {
it.each([
['size', { transportFailure: true as const, reason: 'size', maxBytes: 5 * 1024 * 1024, host: 'h', hasToken: false }, 'Repository exceeds the maximum clone size of 5 MB.'],
['tip-changed', { transportFailure: true as const, reason: 'tip-changed', host: 'h', hasToken: false }, 'Repository tip changed during fetch; retry the pull.'],
['ref-not-found', { transportFailure: true as const, reason: 'ref-not-found', host: 'h', hasToken: false }, 'Branch not found in the repository.'],
['ref-not-found', { transportFailure: true as const, reason: 'ref-not-found', host: 'h', hasToken: false }, 'The configured branch, tag, or commit was not found in the repository.'],
['unsupported-ref', { transportFailure: true as const, reason: 'unsupported-ref', host: 'h', hasToken: false }, 'The configured commit is not reachable on this repository host. Use a branch or tag, or a commit the host advertises.'],
['timeout', { transportFailure: true as const, reason: 'timeout', host: 'github.com', hasToken: false }, 'Timed out reaching github.com.'],
] as const)('maps structured reason %s verbatim', (_label, failure, message) => {
expect(classifyGitFailure(failure)).toMatchObject({ message });
});
it('classifies a server SHA-fetch refusal as UNSUPPORTED_REF', () => {
const c = classifyGitFailure({
transportFailure: true as const,
reason: 'exit',
stderr: "error: Server does not allow request for unadvertised object 3b18e5d",
exitCode: 128,
host: 'github.com',
hasToken: false,
});
expect(c.code).toBe('UNSUPPORTED_REF');
});
it('classifies GitHub not-our-ref SHA refusal as UNSUPPORTED_REF', () => {
const c = classifyGitFailure({
transportFailure: true as const,
reason: 'exit',
stderr: 'fatal: remote error: upload-pack: not our ref abcdef0123456789abcdef0123456789abcdef',
exitCode: 128,
host: 'github.com',
hasToken: true,
});
expect(c.code).toBe('UNSUPPORTED_REF');
});
it('scrubs credentials from the generic fallback tail', () => {
const c = classifyGitFailure({
transportFailure: true as const,
@@ -570,6 +595,7 @@ describe('resolve/fetch/verify flow', () => {
const result = await nativeGitTransport.fetchAtCommit({
repoUrl: 'https://github.com/example/repo.git',
ref: 'main',
refKind: 'branch',
commitSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
@@ -601,6 +627,7 @@ describe('resolve/fetch/verify flow', () => {
await expect(nativeGitTransport.fetchAtCommit({
repoUrl: 'https://github.com/example/repo.git',
ref: 'main',
refKind: 'branch',
commitSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
@@ -611,6 +638,64 @@ describe('resolve/fetch/verify flow', () => {
}
});
it('fetches a tag through a bare --branch name', async () => {
scriptSpawn([
{ code: 0 }, // clone
{ stdout: `${SHA_A}\n` }, // rev-parse HEAD
]);
const root = await makeWorkspace();
try {
const result = await nativeGitTransport.fetchAtCommit({
repoUrl: 'https://github.com/example/repo.git',
ref: 'v1',
refKind: 'tag',
commitSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
maxBytes: 100 * 1024 * 1024,
});
expect(result.commitSha).toBe(SHA_A);
const cloneArgs = spawnArgs(0);
const branchIdx = cloneArgs.indexOf('--branch');
expect(branchIdx).toBeGreaterThan(-1);
expect(cloneArgs[branchIdx + 1]).toBe('v1');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('fetches a pinned SHA via init + fetch + detached checkout', async () => {
scriptSpawn([
{ code: 0 }, // init
{ code: 0 }, // fetch <sha>
{ code: 0 }, // checkout --detach
{ stdout: `${SHA_A}\n` }, // rev-parse HEAD
]);
const root = await makeWorkspace();
try {
const result = await nativeGitTransport.fetchAtCommit({
repoUrl: 'https://github.com/example/repo.git',
ref: SHA_A,
refKind: 'sha',
commitSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
maxBytes: 100 * 1024 * 1024,
});
expect(result.commitSha).toBe(SHA_A);
const initArgs = spawnArgs(0);
expect(initArgs).toContain('init');
const fetchArgs = spawnArgs(1);
expect(fetchArgs).toContain('fetch');
expect(fetchArgs).toContain(SHA_A);
const checkoutArgs = spawnArgs(2);
expect(checkoutArgs).toContain('checkout');
expect(checkoutArgs).toContain('--detach');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('raises ref-not-found when ls-remote lists no matching head', async () => {
scriptSpawn([{ stdout: '' }]);
const root = await makeWorkspace();
@@ -627,6 +712,73 @@ describe('resolve/fetch/verify flow', () => {
}
});
it('resolves a branch over a tag of the same name and records kind branch', async () => {
scriptSpawn([{
stdout: `${SHA_A}\trefs/heads/release\n${SHA_B}\trefs/tags/release\n`,
}]);
const root = await makeWorkspace();
try {
await expect(nativeGitTransport.resolveRef({
repoUrl: 'https://github.com/example/repo.git',
ref: 'release',
timeoutMs: 5000,
workspaceRoot: root,
})).resolves.toMatchObject({ commitSha: SHA_A, kind: 'branch' });
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('resolves an annotated tag through the peeled ^{} commit', async () => {
scriptSpawn([{
stdout: `${SHA_B}\trefs/tags/v1\n${SHA_A}\trefs/tags/v1^{}\n`,
}]);
const root = await makeWorkspace();
try {
await expect(nativeGitTransport.resolveRef({
repoUrl: 'https://github.com/example/repo.git',
ref: 'v1',
timeoutMs: 5000,
workspaceRoot: root,
})).resolves.toMatchObject({ commitSha: SHA_A, kind: 'tag' });
const lsRemoteArgs = mockSpawn.mock.calls[0][1] as string[];
expect(lsRemoteArgs).toContain('refs/tags/v1^{}');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('resolves a lightweight tag from its raw ref line', async () => {
scriptSpawn([{ stdout: `${SHA_A}\trefs/tags/v1\n` }]);
const root = await makeWorkspace();
try {
await expect(nativeGitTransport.resolveRef({
repoUrl: 'https://github.com/example/repo.git',
ref: 'v1',
timeoutMs: 5000,
workspaceRoot: root,
})).resolves.toMatchObject({ commitSha: SHA_A, kind: 'tag' });
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('self-resolves a full SHA without a network round trip', async () => {
const root = await makeWorkspace();
try {
await expect(nativeGitTransport.resolveRef({
repoUrl: 'https://github.com/example/repo.git',
ref: SHA_A.toUpperCase(),
timeoutMs: 5000,
workspaceRoot: root,
})).resolves.toMatchObject({ commitSha: SHA_A, kind: 'sha' });
// The SHA needs no ls-remote: the identity IS the value.
expect(mockSpawn).not.toHaveBeenCalled();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
// Real clocks, short budget: a hanging child settles only through the
// timeout path. (Fake timers here would freeze every later real-timer
// test in this file if this test ever failed mid-way.)
@@ -733,6 +885,7 @@ describe('clone failure classification and final size gate', () => {
await expect(nativeGitTransport.fetchAtCommit({
repoUrl: 'https://github.com/example/repo.git',
ref: 'main',
refKind: 'branch',
commitSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
@@ -759,6 +912,7 @@ describe('clone failure classification and final size gate', () => {
await expect(nativeGitTransport.fetchAtCommit({
repoUrl: 'https://github.com/example/repo.git',
ref: 'main',
refKind: 'branch',
commitSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
@@ -789,6 +943,7 @@ describe('clone failure classification and final size gate', () => {
await expect(nativeGitTransport.fetchAtCommit({
repoUrl: 'https://github.com/example/repo.git',
ref: 'main',
refKind: 'branch',
commitSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
@@ -803,6 +958,261 @@ describe('clone failure classification and final size gate', () => {
}
});
it('enforces the size cap during fast-forward verification', async () => {
scriptSpawn([
{ code: 0 },
{ code: 0 },
{ stdout: '1\n' },
{ code: 1 },
{ stdout: 'true\n' },
{ code: 0 },
{ stdout: '2\n' },
{ code: 0 },
{ code: 0 },
]);
const root = await makeWorkspace();
await fs.writeFile(path.join(root, 'blob.bin'), 'x'.repeat(64));
try {
await expect(verifyFastForward({
repoUrl: 'https://github.com/example/repo.git',
ancestorSha: SHA_B,
descendantSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
maxBytes: 8,
})).rejects.toMatchObject({
transportFailure: true as const,
reason: 'size',
maxBytes: 8,
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('deepens history exponentially with a bounded number of remote fetches', async () => {
scriptSpawn([
{ code: 0 },
{ code: 0 },
{ stdout: '1\n' },
{ code: 1 },
{ stdout: 'true\n' },
{ code: 0 },
{ stdout: '2\n' },
{ code: 1 },
{ stdout: 'true\n' },
{ code: 0 },
{ stdout: '4\n' },
{ code: 0 },
{ code: 0 },
]);
const root = await makeWorkspace();
try {
await expect(verifyFastForward({
repoUrl: 'https://github.com/example/repo.git',
ancestorSha: SHA_B,
descendantSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
maxBytes: 100 * 1024 * 1024,
})).resolves.toBe(true);
const deepenArgs = mockSpawn.mock.calls
.map((call) => call[1] as string[])
.filter((args) => args.includes('fetch'));
expect(deepenArgs).toHaveLength(3);
expect(deepenArgs[1]).toContain('--deepen=1');
expect(deepenArgs[2]).toContain('--deepen=2');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('throws a timeout when fetch rounds are exhausted before ancestry is proven', async () => {
const scripted: ScriptedOutput[] = [{ code: 0 }, { code: 0 }, { stdout: '1\n' }];
for (let i = 0; i < 13; i += 1) {
scripted.push(
{ code: 1 },
{ stdout: 'true\n' },
{ code: 0 },
{ stdout: `${i + 2}\n` },
);
}
scripted.push({ code: 1 }, { stdout: 'true\n' });
scriptSpawn(scripted);
const root = await makeWorkspace();
try {
await expect(verifyFastForward({
repoUrl: 'https://github.com/example/repo.git',
ancestorSha: SHA_B,
descendantSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
maxBytes: 100 * 1024 * 1024,
})).rejects.toMatchObject({
transportFailure: true as const,
reason: 'timeout',
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('classifies a hanging ancestry probe as a timeout transport failure', async () => {
mockSpawn.mockImplementation((_cmd, args) => {
const argv = args as string[];
const child = fakeChild();
if (argv.includes('cat-file')) {
return child;
}
queueMicrotask(() => {
if (argv.includes('rev-list')) {
child.stdout.emit('data', Buffer.from('1\n'));
}
if (argv.includes('rev-parse') && argv.includes('--is-shallow-repository')) {
child.stdout.emit('data', Buffer.from('true\n'));
}
child.emit('close', 0);
});
return child;
});
const root = await makeWorkspace();
try {
await expect(verifyFastForward({
repoUrl: 'https://github.com/example/repo.git',
ancestorSha: SHA_B,
descendantSha: SHA_A,
timeoutMs: 250,
workspaceRoot: root,
maxBytes: 100 * 1024 * 1024,
})).rejects.toMatchObject({
transportFailure: true as const,
reason: 'timeout',
});
} finally {
mockSpawn.mockReset();
await fs.rm(root, { recursive: true, force: true });
}
});
it('treats an invalid shallow-repository probe as a transport failure', async () => {
scriptSpawn([
{ code: 0 },
{ code: 0 },
{ stdout: '1\n' },
{ code: 1 },
{ stdout: 'maybe\n' },
]);
const root = await makeWorkspace();
try {
await expect(verifyFastForward({
repoUrl: 'https://github.com/example/repo.git',
ancestorSha: SHA_B,
descendantSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
maxBytes: 100 * 1024 * 1024,
})).rejects.toMatchObject({
transportFailure: true as const,
reason: 'exit',
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('treats merge-base operational failures as transport failures', async () => {
scriptSpawn([
{ code: 0 },
{ code: 0 },
{ stdout: '1\n' },
{ code: 0 },
{ code: 128, stderr: 'fatal: bad object\n' },
]);
const root = await makeWorkspace();
try {
await expect(verifyFastForward({
repoUrl: 'https://github.com/example/repo.git',
ancestorSha: SHA_B,
descendantSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
maxBytes: 100 * 1024 * 1024,
})).rejects.toMatchObject({
transportFailure: true as const,
reason: 'exit',
exitCode: 128,
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('throws a timeout when deepen stagnates while history remains shallow', async () => {
scriptSpawn([
{ code: 0 },
{ code: 0 },
{ stdout: '1\n' },
{ code: 1 },
{ stdout: 'true\n' },
{ code: 0 },
{ stdout: '1\n' },
{ stdout: 'true\n' },
]);
const root = await makeWorkspace();
try {
await expect(verifyFastForward({
repoUrl: 'https://github.com/example/repo.git',
ancestorSha: SHA_B,
descendantSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
maxBytes: 100 * 1024 * 1024,
})).rejects.toMatchObject({
transportFailure: true as const,
reason: 'timeout',
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('removes the fast-forward scratch repo so shared workspace size checks stay accurate', async () => {
scriptSpawn([
{ code: 0 },
{ code: 0 },
{ stdout: '1\n' },
{ code: 1 },
{ stdout: 'true\n' },
{ code: 0 },
{ stdout: '2\n' },
{ code: 0 },
{ code: 0 },
]);
const root = await makeWorkspace();
try {
await expect(verifyFastForward({
repoUrl: 'https://github.com/example/repo.git',
ancestorSha: SHA_B,
descendantSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
maxBytes: 100 * 1024 * 1024,
})).resolves.toBe(true);
expect(existsSync(path.join(root, 'ff-check'))).toBe(false);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('reports a clone-phase timeout and kills the child', async () => {
scriptSpawnHanging();
const root = await makeWorkspace();
@@ -811,6 +1221,7 @@ describe('clone failure classification and final size gate', () => {
await expect(nativeGitTransport.fetchAtCommit({
repoUrl: 'https://github.com/example/repo.git',
ref: 'main',
refKind: 'branch',
commitSha: SHA_A,
timeoutMs: 250,
workspaceRoot: root,
@@ -849,6 +1260,7 @@ describe('clone failure classification and final size gate', () => {
await expect(nativeGitTransport.fetchAtCommit({
repoUrl: 'https://github.com/example/repo.git',
ref: 'main',
refKind: 'branch',
commitSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
@@ -887,6 +1299,7 @@ describe('clone failure classification and final size gate', () => {
await expect(nativeGitTransport.fetchAtCommit({
repoUrl: 'https://github.com/example/repo.git',
ref: 'main',
refKind: 'branch',
commitSha: SHA_A,
timeoutMs: 5000,
workspaceRoot: root,
@@ -1087,6 +1500,7 @@ describe('clone failure classification and final size gate', () => {
await expect(nativeGitTransport.fetchAtCommit({
repoUrl: 'https://github.com/example/repo.git',
ref: 'main',
refKind: 'branch',
commitSha: SHA_A,
timeoutMs: 30_000,
workspaceRoot: root,
@@ -286,6 +286,7 @@ function directApp(id: string, stackName: string): GitOpsApplicationRow {
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -333,6 +334,7 @@ function generation(id: string, applicationId: string): GitOpsGenerationRow {
application_id: applicationId,
commit_sha: id,
repo_url: 'https://github.com/org/repo.git',
resolved_ref_kind: 'branch',
configured_ref: 'main',
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
manifest_version: 0,
@@ -586,6 +586,7 @@ function inlineApp(id: string, blueprintId: number): GitOpsApplicationRow {
materialization_fingerprint: null,
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -376,6 +376,7 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow {
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -423,6 +424,7 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
application_id: applicationId,
commit_sha: SHA,
repo_url: 'https://github.com/org/repo.git',
resolved_ref_kind: 'branch',
configured_ref: 'main',
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
manifest_version: 1,
@@ -61,6 +61,7 @@ describe('gitops create-from-git', () => {
expect(app.lifecycle_status).toBe('creating');
expect(app.desired_commit_sha).toBe(SHA);
expect(app.fetched_commit_sha).toBe(SHA);
expect(app.fetched_resolved_ref_kind).toBe('branch');
expect(app.candidate_generation_id).toBe('gen-create');
expect(app.accepted_generation_id).toBeNull();
expect(app.source_acceptance_ref).toBeNull();
@@ -679,6 +680,7 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow {
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -726,6 +728,7 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
application_id: applicationId,
commit_sha: SHA,
repo_url: 'https://github.com/org/repo.git',
resolved_ref_kind: 'branch',
configured_ref: 'main',
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
manifest_version: 0,
@@ -292,6 +292,7 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -339,6 +340,7 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
application_id: applicationId,
commit_sha: 'abc123',
repo_url: 'https://github.com/org/repo.git',
resolved_ref_kind: 'branch',
configured_ref: 'main',
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
manifest_version: 0,
@@ -940,6 +940,7 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -992,6 +993,7 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
application_id: applicationId,
commit_sha: 'abc123',
repo_url: 'https://github.com/org/repo.git',
resolved_ref_kind: 'branch',
configured_ref: 'main',
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
manifest_version: 0,
@@ -21,9 +21,10 @@ import path from 'path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
const { mockResolveRef, mockFetchAtCommit, mockGitClone, mockGitLog, compose } = vi.hoisted(() => ({
const { mockResolveRef, mockFetchAtCommit, mockVerifyFastForward, mockGitClone, mockGitLog, compose } = vi.hoisted(() => ({
mockResolveRef: vi.fn(),
mockFetchAtCommit: vi.fn(),
mockVerifyFastForward: vi.fn(),
mockGitClone: vi.fn(),
mockGitLog: vi.fn(),
/** Exit code the next daemon-dependent compose command reports. */
@@ -39,6 +40,7 @@ vi.mock('../services/git/nativeGitTransport', () => ({
resolveRef: mockResolveRef,
fetchAtCommit: mockFetchAtCommit,
},
verifyFastForward: mockVerifyFastForward,
}));
/**
@@ -147,10 +149,11 @@ let projectApplication: typeof import('../services/gitops/derive').projectApplic
* wireTransportDefaults.
*/
function wireTransportDefaults(): void {
mockVerifyFastForward.mockResolvedValue(true);
mockResolveRef.mockImplementation(async () => {
const log = await mockGitLog({});
const oid = Array.isArray(log) ? log[0]?.oid : undefined;
return { commitSha: oid ?? '' };
return { commitSha: oid ?? '', kind: 'branch' as const };
});
mockFetchAtCommit.mockImplementation(async (req: { workspaceRoot: string; commitSha: string }) => {
const dir = path.join(req.workspaceRoot, 'repo');
@@ -202,6 +205,7 @@ describe('Direct Git producers drive the revision state', () => {
beforeEach(() => {
mockResolveRef.mockReset();
mockFetchAtCommit.mockReset();
mockVerifyFastForward.mockReset();
mockGitClone.mockReset();
mockGitLog.mockReset();
wireTransportDefaults();
@@ -508,6 +508,7 @@ function application(): GitOpsApplicationRow {
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -140,6 +140,7 @@ function creatingApp(id: string, stackName: string): GitOpsApplicationRow {
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -124,6 +124,7 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -171,6 +172,7 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
application_id: applicationId,
commit_sha: id,
repo_url: 'https://github.com/org/repo.git',
resolved_ref_kind: 'branch',
configured_ref: 'main',
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
manifest_version: 0,
@@ -438,6 +438,7 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -485,6 +486,7 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
application_id: applicationId,
commit_sha: 'abc123',
repo_url: 'https://github.com/org/repo.git',
resolved_ref_kind: 'branch',
configured_ref: 'main',
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
manifest_version: 0,
@@ -0,0 +1,65 @@
/**
* Legacy installations regain gitops_generations.resolved_ref_kind and
* gitops_applications.fetched_resolved_ref_kind through initSchema's maybeAddCol.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { BASELINE_DB_PATH } from './helpers/testConstants';
import { DatabaseService } from '../services/DatabaseService';
function resetDatabaseSingleton(): void {
const holder = DatabaseService as unknown as { instance?: DatabaseService };
const existing = holder.instance;
if (existing) {
try {
existing.getDb().close();
} catch {
// already closed
}
holder.instance = undefined;
}
}
let tmpDir: string;
beforeAll(async () => {
vi.resetModules();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-gitops-ref-mig-'));
process.env.DATA_DIR = tmpDir;
const composeDir = path.join(tmpDir, 'compose');
fs.mkdirSync(composeDir, { recursive: true });
process.env.COMPOSE_DIR = composeDir;
fs.copyFileSync(BASELINE_DB_PATH, path.join(tmpDir, 'sencho.db'));
const Database = (await import('better-sqlite3')).default;
const raw = new Database(path.join(tmpDir, 'sencho.db'));
raw.exec('ALTER TABLE gitops_generations DROP COLUMN resolved_ref_kind');
raw.exec('ALTER TABLE gitops_applications DROP COLUMN fetched_resolved_ref_kind');
raw.close();
const { DatabaseService } = await import('../services/DatabaseService');
resetDatabaseSingleton();
DatabaseService.getInstance();
});
afterAll(() => {
resetDatabaseSingleton();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('resolved ref kind schema migration', () => {
it('re-adds generation and application columns through DatabaseService initSchema', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance().getDb();
const generationCols = new Set(
(db.pragma('table_info(gitops_generations)') as Array<{ name: string }>).map((c) => c.name),
);
const applicationCols = new Set(
(db.pragma('table_info(gitops_applications)') as Array<{ name: string }>).map((c) => c.name),
);
expect(generationCols.has('resolved_ref_kind')).toBe(true);
expect(applicationCols.has('fetched_resolved_ref_kind')).toBe(true);
});
});
@@ -221,6 +221,36 @@ describe('gitops schema', () => {
});
expect(store.getArtifactSet('art-1')?.qualification).toBe('unresolved');
});
it('round-trips resolved_ref_kind for a tag-resolved generation', async () => {
const store = GitOpsStore.getInstance();
store.insertApplication(directApp('app-tag', 'tag-web'));
const { DatabaseService } = await import('../services/DatabaseService');
const raw = DatabaseService.getInstance().getDb();
raw.prepare("UPDATE gitops_applications SET configured_ref = 'v1' WHERE id = 'app-tag'").run();
store.insertGeneration({
...generation('gen-tag', 'app-tag'),
commit_sha: 'abc123',
configured_ref: 'v1',
resolved_ref_kind: 'tag',
});
const row = store.getGeneration('gen-tag');
expect(row?.resolved_ref_kind).toBe('tag');
expect(row?.configured_ref).toBe('v1');
});
it('round-trips fetched_resolved_ref_kind on application fetch transitions', async () => {
const store = GitOpsStore.getInstance();
store.insertApplication(directApp('app-fetch-kind', 'fetch-web'));
const { GitOpsTransitions } = await import('../services/gitops/transitions');
const tx = GitOpsTransitions.getInstance();
const env = { operationId: 'op-fetch-kind', actor: 'tester', trigger: 'manual', at: Date.now() };
tx.fetchStarted('app-fetch-kind', env);
tx.fetched('app-fetch-kind', 'abc123', env, 'tag');
const app = store.getApplication('app-fetch-kind');
expect(app?.fetched_commit_sha).toBe('abc123');
expect(app?.fetched_resolved_ref_kind).toBe('tag');
});
});
function directApp(id: string, stackName: string): GitOpsApplicationRow {
@@ -241,6 +271,7 @@ function directApp(id: string, stackName: string): GitOpsApplicationRow {
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -314,6 +345,7 @@ function generation(id: string, applicationId: string): GitOpsGenerationRow {
application_id: applicationId,
commit_sha: 'abc123',
repo_url: 'https://github.com/org/repo.git',
resolved_ref_kind: 'branch',
configured_ref: 'main',
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
manifest_version: 0,
@@ -574,6 +574,7 @@ function app(id: string, stackName: string): GitOpsApplicationRow {
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,
@@ -621,6 +622,7 @@ function gen(id: string, applicationId: string): GitOpsGenerationRow {
application_id: applicationId,
commit_sha: 'abc123',
repo_url: 'https://github.com/org/repo.git',
resolved_ref_kind: 'branch',
configured_ref: 'main',
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
manifest_version: 0,
@@ -34,6 +34,7 @@ export function directApplicationFixture(id: string, stackName: string): GitOpsA
materialization_fingerprint: 'a'.repeat(64),
desired_commit_sha: null,
fetched_commit_sha: null,
fetched_resolved_ref_kind: null,
candidate_generation_id: null,
accepted_generation_id: null,
candidate_plan_blocked: 0,