import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { TextDecoder } from 'node:util';
import { z } from 'zod';
import { OccAbortError } from '../errors.js';
import { GIT_GLOBAL_ARGS } from './client.js';
const SCHEMA_VERSION = 1;
const HEADER_SENTINEL = 'OCC_GIT_CAPTURE_V1';
const INSTRUMENT_NAME = 'occ.git.capture';
const INSTRUMENT_VERSION = 1;
const UNBOUNDED_COUNTER_LIMIT = Number.MAX_SAFE_INTEGER;
const DEFAULT_BATCH_SIZE = 128;
const DEFAULT_MAX_RECORD_BYTES = 1_048_576;
const DEFAULT_TIMEOUT_MS = 120_000;
const MAX_STDERR_BYTES = 65_536;
const FORCE_KILL_AFTER_MS = 1_000;
const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null';
export const GitCaptureObjectIdSchema = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/);
export const GitCaptureObjectFormatSchema = z.enum(['sha1', 'sha256']);
export const GitCapturePathV1Schema = z.discriminatedUnion('encoding', [
    z.object({ encoding: z.literal('utf8'), value: z.string() }),
    z.object({ encoding: z.literal('base64'), value: z.string() }),
]);
const EventBaseSchema = z.object({ schemaVersion: z.literal(SCHEMA_VERSION) });
export const GitCaptureHeaderV1Schema = EventBaseSchema.extend({
    type: z.literal('header'),
    sourceSystem: z.literal('git'),
    requestedRef: z.string(),
    resolvedHead: GitCaptureObjectIdSchema,
    objectFormat: GitCaptureObjectFormatSchema,
    gitVersion: z.string(),
    shallow: z.boolean(),
    partialClone: z.boolean(),
    replaceRefCount: z.number().int().nonnegative(),
    graftsPresent: z.boolean(),
    provenance: z.object({
        instrument: z.object({
            name: z.literal(INSTRUMENT_NAME),
            version: z.literal(INSTRUMENT_VERSION),
        }),
        occVersion: z.string(),
        nodeVersion: z.string(),
    }),
    limits: z.object({
        maxCommits: z.number().int().positive().nullable(),
        maxChanges: z.number().int().positive().nullable(),
        maxTreeEntries: z.number().int().positive().nullable(),
        batchSize: z.number().int().positive(),
        maxRecordBytes: z.number().int().positive(),
        timeoutMs: z.number().int().positive(),
    }),
    policy: z.object({
        history: z.literal('all-reachable'),
        order: z.literal('parents-before-children'),
        deltas: z.literal('per-parent-raw-no-renames'),
        tree: z.literal('pinned-head-recursive'),
        replacements: z.literal('disabled'),
        grafts: z.literal('disabled'),
        lazyFetch: z.literal('disabled'),
    }),
});
export const GitCaptureActorV1Schema = z.object({
    name: z.string(),
    email: z.string(),
    time: z.string().min(1),
});
export const GitCaptureCommitV1Schema = EventBaseSchema.extend({
    type: z.literal('commit'),
    commitOid: GitCaptureObjectIdSchema,
    treeOid: GitCaptureObjectIdSchema,
    parentOids: z.array(GitCaptureObjectIdSchema),
    author: GitCaptureActorV1Schema,
    committer: GitCaptureActorV1Schema,
    message: z.string(),
    messageEncoding: z.string().nullable(),
    messageDecodeStatus: z.enum(['decoded', 'replacement']),
    identDecodeStatus: z.enum(['decoded', 'replacement']),
});
const DeltaIdentitySchema = EventBaseSchema.extend({
    commitOid: GitCaptureObjectIdSchema,
    parentOid: GitCaptureObjectIdSchema.nullable(),
    parentIndex: z.number().int().nonnegative().nullable(),
});
export const GitCaptureDeltaStartV1Schema = DeltaIdentitySchema.extend({ type: z.literal('delta-start') });
export const GitCaptureChangeV1Schema = DeltaIdentitySchema.extend({
    type: z.literal('change'),
    status: z.string().regex(/^[A-Z]$/),
    oldMode: z.string().regex(/^[0-7]{6}$/),
    newMode: z.string().regex(/^[0-7]{6}$/),
    oldObjectId: GitCaptureObjectIdSchema.optional(),
    newObjectId: GitCaptureObjectIdSchema.optional(),
    path: GitCapturePathV1Schema,
});
export const GitCaptureDeltaEndV1Schema = DeltaIdentitySchema.extend({
    type: z.literal('delta-end'),
    changeCount: z.number().int().nonnegative(),
    complete: z.boolean(),
    empty: z.boolean().nullable(),
});
export const GitCaptureTreeEntryV1Schema = EventBaseSchema.extend({
    type: z.literal('tree-entry'),
    mode: z.string().regex(/^[0-7]{6}$/),
    objectType: z.enum(['blob', 'commit']),
    objectId: GitCaptureObjectIdSchema,
    size: z.number().int().nonnegative().nullable(),
    path: GitCapturePathV1Schema,
});
export const GitCaptureDiagnosticCodeSchema = z.enum([
    'shallow-repository',
    'history-limit',
    'change-limit',
    'tree-limit',
    'git-command-failed',
    'malformed-git-output',
]);
export const GitCaptureDiagnosticV1Schema = EventBaseSchema.extend({
    type: z.literal('diagnostic'),
    severity: z.enum(['warning', 'error']),
    code: GitCaptureDiagnosticCodeSchema,
    message: z.string(),
    limit: z.number().int().nonnegative().optional(),
});
const CoverageStateSchema = z.enum(['complete', 'partial', 'failed']);
export const GitCaptureFooterV1Schema = EventBaseSchema.extend({
    type: z.literal('footer'),
    complete: z.boolean(),
    coverage: z.object({
        history: CoverageStateSchema,
        deltas: CoverageStateSchema,
        tree: CoverageStateSchema,
    }),
    counts: z.object({
        commits: z.number().int().nonnegative(),
        parentDeltas: z.number().int().nonnegative(),
        emptyParentDeltas: z.number().int().nonnegative(),
        changes: z.number().int().nonnegative(),
        treeEntries: z.number().int().nonnegative(),
        diagnostics: z.number().int().nonnegative(),
    }),
});
export const GitCaptureEventV1Schema = z.discriminatedUnion('type', [
    GitCaptureHeaderV1Schema,
    GitCaptureCommitV1Schema,
    GitCaptureDeltaStartV1Schema,
    GitCaptureChangeV1Schema,
    GitCaptureDeltaEndV1Schema,
    GitCaptureTreeEntryV1Schema,
    GitCaptureDiagnosticV1Schema,
    GitCaptureFooterV1Schema,
]);
export const GitCaptureRequestV1Schema = z.object({
    repoPath: z.string().min(1),
    ref: z
        .string()
        .min(1)
        .refine(value => !value.startsWith('-') && !value.includes('\0'), 'invalid Git ref'),
    maxCommits: z.number().int().positive().optional(),
    maxChanges: z.number().int().positive().optional(),
    maxTreeEntries: z.number().int().positive().optional(),
    batchSize: z.number().int().positive().max(1_024).optional(),
    maxRecordBytes: z.number().int().positive().optional(),
    timeoutMs: z.number().int().positive().optional(),
});
export class GitCaptureError extends Error {
    code;
    constructor(code, message, options) {
        super(message, options);
        this.name = 'GitCaptureError';
        this.code = code;
    }
}
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
const captureDirectory = path.dirname(fileURLToPath(import.meta.url));
let cachedOccVersion;
async function readOccVersion() {
    if (cachedOccVersion)
        return cachedOccVersion;
    for (const relative of ['../..', '../../..']) {
        try {
            const raw = await readFile(path.resolve(captureDirectory, relative, 'package.json'), 'utf8');
            const parsed = JSON.parse(raw);
            if (typeof parsed.version === 'string' && parsed.version.length > 0) {
                cachedOccVersion = parsed.version;
                return cachedOccVersion;
            }
        }
        catch {
            // Source and compiled layouts have different depths; try both.
        }
    }
    cachedOccVersion = '0.0.0';
    return cachedOccVersion;
}
function pathRecord(value) {
    try {
        return { encoding: 'utf8', value: utf8Decoder.decode(value) };
    }
    catch {
        return { encoding: 'base64', value: value.toString('base64') };
    }
}
function decodeUtf8(value, label) {
    try {
        return utf8Decoder.decode(value);
    }
    catch (error) {
        throw new GitCaptureError('malformed-output', `Git emitted invalid UTF-8 for ${label}.`, { cause: error });
    }
}
function decodeMessage(value) {
    try {
        return { message: utf8Decoder.decode(value), messageDecodeStatus: 'decoded' };
    }
    catch {
        return { message: value.toString('utf8'), messageDecodeStatus: 'replacement' };
    }
}
/**
 * Pre-Unicode repositories carry raw latin-1 idents that no `encoding` header
 * describes; like messages, they must degrade to replacement characters with
 * an explicit status rather than abort the remaining history.
 */
function decodeIdent(value) {
    try {
        return { value: utf8Decoder.decode(value), replaced: false };
    }
    catch {
        return { value: value.toString('utf8'), replaced: true };
    }
}
function resolvedOptions(input) {
    const parsed = GitCaptureRequestV1Schema.safeParse(input);
    if (!parsed.success) {
        throw new GitCaptureError('invalid-options', `Invalid Git capture options: ${parsed.error.message}`);
    }
    return {
        ...parsed.data,
        maxCommits: parsed.data.maxCommits ?? UNBOUNDED_COUNTER_LIMIT,
        maxChanges: parsed.data.maxChanges ?? UNBOUNDED_COUNTER_LIMIT,
        maxTreeEntries: parsed.data.maxTreeEntries ?? UNBOUNDED_COUNTER_LIMIT,
        batchSize: parsed.data.batchSize ?? DEFAULT_BATCH_SIZE,
        maxRecordBytes: parsed.data.maxRecordBytes ?? DEFAULT_MAX_RECORD_BYTES,
        timeoutMs: parsed.data.timeoutMs ?? DEFAULT_TIMEOUT_MS,
        configuredMaxCommits: parsed.data.maxCommits ?? null,
        configuredMaxChanges: parsed.data.maxChanges ?? null,
        configuredMaxTreeEntries: parsed.data.maxTreeEntries ?? null,
        signal: input.signal,
    };
}
function captureEnvironment() {
    return {
        ...process.env,
        GIT_NO_LAZY_FETCH: '1',
        GIT_NO_REPLACE_OBJECTS: '1',
        GIT_GRAFT_FILE: NULL_DEVICE,
        GIT_TERMINAL_PROMPT: '0',
        GIT_OPTIONAL_LOCKS: '0',
    };
}
function runGitProcess(args, options) {
    if (options.signal?.aborted)
        throw new OccAbortError();
    const child = spawn('git', [...GIT_GLOBAL_ARGS, '-c', 'i18n.logOutputEncoding=UTF-8', '-c', 'advice.graftFileDeprecated=false', ...args], {
        cwd: options.repoPath,
        env: captureEnvironment(),
        stdio: ['pipe', 'pipe', 'pipe'],
    });
    let stderr = Buffer.alloc(0);
    let timedOut = false;
    let stopped = false;
    let forceKillTimer;
    const terminate = () => {
        if (child.exitCode != null || child.signalCode != null)
            return;
        child.kill('SIGTERM');
        if (!forceKillTimer) {
            forceKillTimer = setTimeout(() => {
                if (child.exitCode == null && child.signalCode == null)
                    child.kill('SIGKILL');
            }, FORCE_KILL_AFTER_MS);
            forceKillTimer.unref();
        }
    };
    const timer = setTimeout(() => {
        timedOut = true;
        terminate();
    }, options.timeoutMs);
    const abort = () => {
        terminate();
    };
    options.signal?.addEventListener('abort', abort, { once: true });
    if (options.signal?.aborted)
        abort();
    child.stderr.on('data', (chunk) => {
        if (stderr.length >= MAX_STDERR_BYTES)
            return;
        stderr = Buffer.concat([stderr, chunk.subarray(0, MAX_STDERR_BYTES - stderr.length)]);
    });
    const completion = new Promise((resolve, reject) => {
        child.once('error', (error) => {
            if (error.code === 'ENOENT')
                reject(new GitCaptureError('git-missing', 'Git is not available.', { cause: error }));
            else
                reject(new GitCaptureError('git-failed', 'Git capture command could not start.', { cause: error }));
        });
        child.once('close', code => {
            clearTimeout(timer);
            if (forceKillTimer)
                clearTimeout(forceKillTimer);
            options.signal?.removeEventListener('abort', abort);
            if (options.signal?.aborted) {
                reject(new OccAbortError());
            }
            else if (timedOut) {
                reject(new GitCaptureError('timeout', `Git capture command exceeded ${options.timeoutMs} ms.`));
            }
            else if (stopped) {
                resolve();
            }
            else if (code === 0) {
                resolve();
            }
            else {
                const detail = stderr
                    .toString('utf8')
                    .split(/\r?\n/)
                    .find(line => line.trim())
                    ?.trim();
                reject(new GitCaptureError('git-failed', detail ? `Git capture command failed: ${detail}` : 'Git capture command failed.'));
            }
        });
    });
    // Record generators await `completion` only on their clean path; when they
    // throw first and the child has already exited, this rejection would
    // otherwise be unobserved and crash the host process.
    completion.catch(() => { });
    return {
        child,
        completion,
        async stop() {
            if (child.exitCode == null && child.signalCode == null) {
                stopped = true;
                terminate();
            }
            await completion;
        },
    };
}
async function collectGitText(args, options, maxBytes = 8_192) {
    const running = runGitProcess(args, options);
    running.child.stdin.end();
    const chunks = [];
    let total = 0;
    try {
        for await (const chunk of running.child.stdout) {
            const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
            total += buffer.length;
            if (total > maxBytes) {
                await running.stop();
                throw new GitCaptureError('malformed-output', 'Git capture metadata exceeded its bound.');
            }
            chunks.push(buffer);
        }
        await running.completion;
        return Buffer.concat(chunks).toString('utf8').trim();
    }
    catch (error) {
        if (running.child.exitCode == null && running.child.signalCode == null)
            await running.stop().catch(() => { });
        throw error;
    }
}
async function collectOptionalGitText(args, options, maxBytes = 8_192) {
    try {
        return await collectGitText(args, options, maxBytes);
    }
    catch (error) {
        // `git config --get-regexp` uses exit 1 for a valid query with no match.
        if (error instanceof GitCaptureError && error.code === 'git-failed')
            return '';
        throw error;
    }
}
async function inspectRepositoryHazards(options) {
    const [partialConfig, replaceRefs, gitCommonDirText] = await Promise.all([
        collectOptionalGitText([
            'config',
            '--local',
            '--get-regexp',
            '^(extensions\\.partialClone|remote\\..*\\.(promisor|partialCloneFilter))$',
        ], options, 1_048_576),
        collectGitText(['for-each-ref', '--format=%(refname)', 'refs/replace/'], options, 8_388_608),
        // Do not use `--git-path info/grafts`: our physical-capture environment
        // intentionally redirects GIT_GRAFT_FILE to the null device, and Git would
        // report that override instead of the repository hazard we need to expose.
        collectGitText(['rev-parse', '--git-common-dir'], options),
    ]);
    const partialClone = partialConfig.split(/\r?\n/).some(line => {
        const separator = line.search(/\s/);
        const key = (separator === -1 ? line : line.slice(0, separator)).toLowerCase();
        const value = (separator === -1 ? '' : line.slice(separator).trim()).toLowerCase();
        return (key.endsWith('.partialclone') ||
            key.endsWith('.partialclonefilter') ||
            (key.endsWith('.promisor') && value === 'true'));
    });
    const replaceRefCount = replaceRefs ? replaceRefs.split(/\r?\n/).filter(Boolean).length : 0;
    const gitCommonDir = path.isAbsolute(gitCommonDirText)
        ? gitCommonDirText
        : path.resolve(options.repoPath, gitCommonDirText);
    const graftPath = path.join(gitCommonDir, 'info', 'grafts');
    const graftsPresent = await stat(graftPath)
        .then(value => value.isFile() && value.size > 0)
        .catch((error) => {
        if (error.code === 'ENOENT')
            return false;
        throw new GitCaptureError('git-failed', `Could not inspect ${graftPath} for the graft hazard.`, {
            cause: error,
        });
    });
    return { partialClone, replaceRefCount, graftsPresent };
}
async function* newlineRecords(running, maxRecordBytes) {
    let held = Buffer.alloc(0);
    try {
        for await (const chunk of running.child.stdout) {
            held = Buffer.concat([held, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
            if (held.length > maxRecordBytes && held.indexOf(0x0a) === -1) {
                throw new GitCaptureError('malformed-output', 'Git emitted an oversized history record.');
            }
            let newline = held.indexOf(0x0a);
            while (newline !== -1) {
                const line = held.subarray(0, newline);
                held = held.subarray(newline + 1);
                if (line.length > maxRecordBytes)
                    throw new GitCaptureError('malformed-output', 'Git emitted an oversized history record.');
                yield line;
                newline = held.indexOf(0x0a);
            }
        }
        if (held.length > 0) {
            if (held.length > maxRecordBytes)
                throw new GitCaptureError('malformed-output', 'Git emitted an oversized history record.');
            yield held;
        }
        await running.completion;
    }
    finally {
        if (running.child.exitCode == null && running.child.signalCode == null)
            await running.stop().catch(() => { });
    }
}
async function* nulRecords(running, maxRecordBytes) {
    let held = Buffer.alloc(0);
    try {
        for await (const chunk of running.child.stdout) {
            held = Buffer.concat([held, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
            if (held.length > maxRecordBytes && held.indexOf(0) === -1) {
                throw new GitCaptureError('malformed-output', 'Git emitted an oversized capture record.');
            }
            let nul = held.indexOf(0);
            while (nul !== -1) {
                const record = held.subarray(0, nul);
                held = held.subarray(nul + 1);
                if (record.length > maxRecordBytes)
                    throw new GitCaptureError('malformed-output', 'Git emitted an oversized capture record.');
                yield record;
                nul = held.indexOf(0);
            }
        }
        if (held.length > 0 && held.some(byte => ![0x0a, 0x0d].includes(byte))) {
            throw new GitCaptureError('malformed-output', 'Git capture output ended mid-record.');
        }
        await running.completion;
    }
    finally {
        if (running.child.exitCode == null && running.child.signalCode == null)
            await running.stop().catch(() => { });
    }
}
function cleanControlPrefix(buffer) {
    let start = 0;
    while (start < buffer.length && (buffer[start] === 0x0a || buffer[start] === 0x0d))
        start++;
    return buffer.subarray(start);
}
function parseObjectId(value, label) {
    if (!GitCaptureObjectIdSchema.safeParse(value).success) {
        throw new GitCaptureError('malformed-output', `Git emitted an invalid ${label}.`);
    }
    return value;
}
function parseObjectFormat(value) {
    const parsed = GitCaptureObjectFormatSchema.safeParse(value);
    if (!parsed.success)
        throw new GitCaptureError('malformed-output', 'Git emitted an unsupported object format.');
    return parsed.data;
}
async function* historyBatches(options, head) {
    const running = runGitProcess(['rev-list', '--reverse', '--topo-order', '--parents', head], options);
    running.child.stdin.end();
    let batch = [];
    for await (const raw of newlineRecords(running, options.maxRecordBytes)) {
        if (raw.length === 0)
            continue;
        const fields = raw.toString('ascii').trim().split(/\s+/).filter(Boolean);
        if (fields.length === 0)
            continue;
        batch.push({
            oid: parseObjectId(fields[0], 'commit object id'),
            parents: fields.slice(1).map(parent => parseObjectId(parent, 'parent object id')),
        });
        if (batch.length >= options.batchSize) {
            yield batch;
            batch = [];
        }
    }
    if (batch.length > 0)
        yield batch;
}
function parseRawHeader(record) {
    const text = cleanControlPrefix(record).toString('ascii');
    if (!text.startsWith(':'))
        throw new GitCaptureError('malformed-output', 'Git emitted an invalid raw change header.');
    const [oldMode, newMode, oldObjectId, newObjectId, rawStatus] = text.slice(1).trim().split(/\s+/);
    if (!oldMode || !newMode || !oldObjectId || !newObjectId || !rawStatus) {
        throw new GitCaptureError('malformed-output', 'Git emitted an incomplete raw change header.');
    }
    if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) {
        throw new GitCaptureError('malformed-output', 'Git unexpectedly performed rename or copy detection.');
    }
    const result = {
        status: rawStatus[0],
        oldMode,
        newMode,
    };
    if (!/^0+$/.test(oldObjectId))
        result.oldObjectId = parseObjectId(oldObjectId, 'old object id');
    if (!/^0+$/.test(newObjectId))
        result.newObjectId = parseObjectId(newObjectId, 'new object id');
    return result;
}
async function* showItems(options, batch) {
    // Every pretty-format field is NUL-delimited. `%B` is followed by a fixed
    // sentinel so an embedded NUL cannot silently turn one message into protocol
    // fields: it makes the next record differ from the sentinel and capture
    // fails as malformed instead.
    // The nonce is internal framing only and never enters an event. A commit is
    // immutable before capture starts, so it cannot pre-contain this value after
    // a NUL and impersonate the terminator.
    const messageEndSentinel = `OCC_GIT_CAPTURE_MESSAGE_END_V1_${randomBytes(32).toString('hex')}`;
    const format = [
        '',
        HEADER_SENTINEL,
        '%H',
        '%P',
        '%T',
        '%an',
        '%ae',
        '%aI',
        '%cn',
        '%ce',
        '%cI',
        '%e',
        '%B',
        messageEndSentinel,
        '',
    ].join('%x00');
    const running = runGitProcess([
        'show',
        '--no-walk=unsorted',
        '--always',
        '--root',
        '--diff-merges=separate',
        '--raw',
        '-r',
        '-z',
        '--no-notes',
        '--no-show-signature',
        '--no-abbrev',
        '--no-renames',
        `-O${NULL_DEVICE}`,
        '--no-ext-diff',
        '--no-textconv',
        `--format=format:${format}`,
        ...batch.map(commit => commit.oid),
    ], options);
    running.child.stdin.end();
    const records = nulRecords(running, options.maxRecordBytes)[Symbol.asyncIterator]();
    try {
        let pending = await records.next();
        while (!pending.done) {
            const record = cleanControlPrefix(pending.value);
            if (record.length === 0) {
                pending = await records.next();
                continue;
            }
            if (record.toString('ascii') !== HEADER_SENTINEL) {
                throw new GitCaptureError('malformed-output', 'Git capture group marker was missing.');
            }
            const oidRecord = await records.next();
            const parentsRecord = await records.next();
            const treeRecord = await records.next();
            const authorNameRecord = await records.next();
            const authorEmailRecord = await records.next();
            const authorTimeRecord = await records.next();
            const committerNameRecord = await records.next();
            const committerEmailRecord = await records.next();
            const committerTimeRecord = await records.next();
            const encodingRecord = await records.next();
            const messageRecord = await records.next();
            const messageEndRecord = await records.next();
            if (oidRecord.done ||
                parentsRecord.done ||
                treeRecord.done ||
                authorNameRecord.done ||
                authorEmailRecord.done ||
                authorTimeRecord.done ||
                committerNameRecord.done ||
                committerEmailRecord.done ||
                committerTimeRecord.done ||
                encodingRecord.done ||
                messageRecord.done ||
                messageEndRecord.done) {
                throw new GitCaptureError('malformed-output', 'Git capture group header was truncated.');
            }
            if (messageEndRecord.value.toString('ascii') !== messageEndSentinel) {
                throw new GitCaptureError('malformed-output', 'Git commit message contained an unsupported NUL byte.');
            }
            const commitOid = parseObjectId(oidRecord.value.toString('ascii'), 'commit object id');
            const parentText = parentsRecord.value.toString('ascii').trim();
            const parentOids = parentText
                ? parentText.split(/\s+/).map(value => parseObjectId(value, 'parent object id'))
                : [];
            const treeOid = parseObjectId(treeRecord.value.toString('ascii'), 'tree object id');
            const decodedMessage = decodeMessage(messageRecord.value);
            const declaredEncoding = decodeUtf8(encodingRecord.value, 'commit message encoding');
            const authorName = decodeIdent(authorNameRecord.value);
            const authorEmail = decodeIdent(authorEmailRecord.value);
            const committerName = decodeIdent(committerNameRecord.value);
            const committerEmail = decodeIdent(committerEmailRecord.value);
            const identReplaced = authorName.replaced || authorEmail.replaced || committerName.replaced || committerEmail.replaced;
            yield {
                type: 'group',
                header: {
                    commitOid,
                    parentOids,
                    treeOid,
                    author: {
                        name: authorName.value,
                        email: authorEmail.value,
                        time: decodeUtf8(authorTimeRecord.value, 'author time'),
                    },
                    committer: {
                        name: committerName.value,
                        email: committerEmail.value,
                        time: decodeUtf8(committerTimeRecord.value, 'committer time'),
                    },
                    message: decodedMessage.message,
                    messageEncoding: declaredEncoding || null,
                    messageDecodeStatus: decodedMessage.messageDecodeStatus,
                    identDecodeStatus: identReplaced ? 'replacement' : 'decoded',
                },
            };
            pending = await records.next();
            while (!pending.done) {
                const next = cleanControlPrefix(pending.value);
                if (next.length === 0) {
                    pending = await records.next();
                    continue;
                }
                if (next.toString('ascii') === HEADER_SENTINEL)
                    break;
                const header = parseRawHeader(next);
                const path = await records.next();
                if (path.done)
                    throw new GitCaptureError('malformed-output', 'Git raw change path was missing.');
                yield { type: 'change', change: { ...header, path: path.value } };
                pending = await records.next();
            }
        }
    }
    finally {
        await records.return?.(undefined);
    }
}
class ShowGroupReader {
    iterator;
    pendingGroup;
    constructor(source) {
        this.iterator = source[Symbol.asyncIterator]();
    }
    async nextGroup() {
        if (this.pendingGroup) {
            const value = this.pendingGroup;
            this.pendingGroup = undefined;
            return value;
        }
        const next = await this.iterator.next();
        if (next.done)
            return undefined;
        if (next.value.type !== 'group')
            throw new GitCaptureError('malformed-output', 'Git change appeared before its group.');
        return next.value.header;
    }
    async *changes() {
        while (true) {
            const next = await this.iterator.next();
            if (next.done)
                return;
            if (next.value.type === 'group') {
                this.pendingGroup = next.value.header;
                return;
            }
            yield next.value.change;
        }
    }
    async close() {
        this.pendingGroup = undefined;
        await this.iterator.return?.(undefined);
    }
}
function deltaIdentity(commitOid, parentOid, parentIndex) {
    return { schemaVersion: SCHEMA_VERSION, commitOid, parentOid, parentIndex };
}
function diagnostic(code, severity, message, limit) {
    return {
        schemaVersion: SCHEMA_VERSION,
        type: 'diagnostic',
        severity,
        code,
        message,
        ...(limit === undefined ? {} : { limit }),
    };
}
async function* treeEvents(options, head, counts) {
    const running = runGitProcess(['ls-tree', '-r', '-z', '-l', '--full-tree', head], options);
    running.child.stdin.end();
    for await (const raw of nulRecords(running, options.maxRecordBytes)) {
        if (raw.length === 0)
            continue;
        if (options.configuredMaxTreeEntries !== null && counts.treeEntries >= options.maxTreeEntries) {
            counts.diagnostics++;
            yield diagnostic('tree-limit', 'error', 'Pinned-head tree capture reached the configured entry limit.', options.configuredMaxTreeEntries);
            return;
        }
        const tab = raw.indexOf(0x09);
        if (tab === -1)
            throw new GitCaptureError('malformed-output', 'Git emitted an invalid tree entry.');
        const [mode, objectType, objectId, sizeText] = raw.subarray(0, tab).toString('ascii').trim().split(/\s+/);
        if (!mode || (objectType !== 'blob' && objectType !== 'commit') || !objectId || !sizeText) {
            throw new GitCaptureError('malformed-output', 'Git emitted an unsupported tree entry.');
        }
        const size = sizeText === '-' ? null : Number(sizeText);
        if (size !== null && (!Number.isSafeInteger(size) || size < 0)) {
            throw new GitCaptureError('malformed-output', 'Git emitted an invalid tree entry size.');
        }
        counts.treeEntries++;
        yield {
            schemaVersion: SCHEMA_VERSION,
            type: 'tree-entry',
            mode,
            objectType,
            objectId: parseObjectId(objectId, 'tree entry object id'),
            size,
            path: pathRecord(raw.subarray(tab + 1)),
        };
    }
}
/**
 * Stream a source-body-free, physical capture of one pinned Git commit and
 * every commit reachable from it. The stream is backpressure-aware and keeps
 * only a bounded commit batch plus the commit-to-tree coordinate map in memory.
 */
export async function* captureGitRepository(input) {
    const options = resolvedOptions(input);
    const resolvedHead = await collectGitText(['rev-parse', '--verify', '--quiet', '--end-of-options', `${options.ref}^{commit}`], options).catch((error) => {
        if (error instanceof OccAbortError)
            throw error;
        if (error instanceof GitCaptureError && error.code !== 'git-failed')
            throw error;
        throw new GitCaptureError('invalid-ref', 'The requested Git ref does not resolve to a commit.', { cause: error });
    });
    const head = parseObjectId(resolvedHead, 'resolved head object id');
    const [objectFormatResult, gitVersion, shallowText, occVersion, hazards] = await Promise.all([
        collectGitText(['rev-parse', '--show-object-format=storage'], options).catch(() => head.length === 64 ? 'sha256' : 'sha1'),
        collectGitText(['version'], options),
        collectGitText(['rev-parse', '--is-shallow-repository'], options),
        readOccVersion(),
        inspectRepositoryHazards(options),
    ]);
    const shallow = shallowText === 'true';
    const objectFormat = parseObjectFormat(objectFormatResult);
    yield {
        schemaVersion: SCHEMA_VERSION,
        type: 'header',
        sourceSystem: 'git',
        requestedRef: options.ref,
        resolvedHead: head,
        objectFormat,
        gitVersion,
        shallow,
        partialClone: hazards.partialClone,
        replaceRefCount: hazards.replaceRefCount,
        graftsPresent: hazards.graftsPresent,
        provenance: {
            instrument: { name: INSTRUMENT_NAME, version: INSTRUMENT_VERSION },
            occVersion,
            nodeVersion: process.versions.node,
        },
        limits: {
            maxCommits: options.configuredMaxCommits,
            maxChanges: options.configuredMaxChanges,
            maxTreeEntries: options.configuredMaxTreeEntries,
            batchSize: options.batchSize,
            maxRecordBytes: options.maxRecordBytes,
            timeoutMs: options.timeoutMs,
        },
        policy: {
            history: 'all-reachable',
            order: 'parents-before-children',
            deltas: 'per-parent-raw-no-renames',
            tree: 'pinned-head-recursive',
            replacements: 'disabled',
            grafts: 'disabled',
            lazyFetch: 'disabled',
        },
    };
    const counts = {
        commits: 0,
        parentDeltas: 0,
        emptyParentDeltas: 0,
        changes: 0,
        treeEntries: 0,
        diagnostics: 0,
    };
    let historyCoverage = shallow ? 'partial' : 'complete';
    let deltaCoverage = shallow ? 'partial' : 'complete';
    let treeCoverage = 'complete';
    let historyStopped = false;
    const treeByCommit = new Map();
    if (shallow) {
        counts.diagnostics++;
        yield diagnostic('shallow-repository', 'warning', 'Repository history is shallow; unreachable ancestors are a known coverage gap.');
    }
    try {
        for await (const discoveredBatch of historyBatches(options, head)) {
            const remainingCommits = options.maxCommits - counts.commits;
            if (options.configuredMaxCommits !== null && remainingCommits <= 0) {
                counts.diagnostics++;
                historyCoverage = 'partial';
                deltaCoverage = 'partial';
                historyStopped = true;
                yield diagnostic('history-limit', 'error', 'History capture reached the configured commit limit.', options.configuredMaxCommits);
                break;
            }
            const batch = discoveredBatch.slice(0, remainingCommits);
            const historyLimitReached = options.configuredMaxCommits !== null && batch.length < discoveredBatch.length;
            const reader = new ShowGroupReader(showItems(options, batch));
            try {
                for (const commit of batch) {
                    const firstGroup = await reader.nextGroup();
                    if (!firstGroup || firstGroup.commitOid !== commit.oid) {
                        throw new GitCaptureError('malformed-output', 'Git show output did not match parents-before-children history.');
                    }
                    if (firstGroup.parentOids.join(' ') !== commit.parents.join(' ')) {
                        throw new GitCaptureError('malformed-output', 'Git show output disagreed with the commit parent list.');
                    }
                    treeByCommit.set(commit.oid, firstGroup.treeOid);
                    counts.commits++;
                    yield {
                        schemaVersion: SCHEMA_VERSION,
                        type: 'commit',
                        commitOid: commit.oid,
                        treeOid: firstGroup.treeOid,
                        parentOids: commit.parents,
                        author: firstGroup.author,
                        committer: firstGroup.committer,
                        message: firstGroup.message,
                        messageEncoding: firstGroup.messageEncoding,
                        messageDecodeStatus: firstGroup.messageDecodeStatus,
                        identDecodeStatus: firstGroup.identDecodeStatus,
                    };
                    const parents = commit.parents.length === 0 ? [null] : commit.parents;
                    const nonEmpty = parents.map(parent => parent === null || treeByCommit.get(parent) !== firstGroup.treeOid);
                    let firstGroupAvailable = true;
                    for (const [parentIndex, parent] of parents.entries()) {
                        const identity = deltaIdentity(commit.oid, parent, parent === null ? null : parentIndex);
                        counts.parentDeltas++;
                        yield { ...identity, type: 'delta-start' };
                        let changeCount = 0;
                        let deltaComplete = true;
                        if (nonEmpty[parentIndex]) {
                            const group = firstGroupAvailable ? firstGroup : await reader.nextGroup();
                            firstGroupAvailable = false;
                            if (!group || group.commitOid !== commit.oid || group.treeOid !== firstGroup.treeOid) {
                                throw new GitCaptureError('malformed-output', 'Git omitted or reordered a non-empty parent delta.');
                            }
                            for await (const change of reader.changes()) {
                                if (options.configuredMaxChanges !== null && counts.changes >= options.maxChanges) {
                                    counts.diagnostics++;
                                    historyCoverage = 'partial';
                                    deltaCoverage = 'partial';
                                    historyStopped = true;
                                    deltaComplete = false;
                                    yield diagnostic('change-limit', 'error', 'Delta capture reached the configured change limit.', options.configuredMaxChanges);
                                    break;
                                }
                                changeCount++;
                                counts.changes++;
                                yield {
                                    ...identity,
                                    type: 'change',
                                    status: change.status,
                                    oldMode: change.oldMode,
                                    newMode: change.newMode,
                                    ...(change.oldObjectId ? { oldObjectId: change.oldObjectId } : {}),
                                    ...(change.newObjectId ? { newObjectId: change.newObjectId } : {}),
                                    path: pathRecord(change.path),
                                };
                            }
                        }
                        if (deltaComplete && changeCount === 0)
                            counts.emptyParentDeltas++;
                        yield {
                            ...identity,
                            type: 'delta-end',
                            changeCount,
                            complete: deltaComplete,
                            empty: deltaComplete ? changeCount === 0 : null,
                        };
                        if (historyStopped)
                            break;
                    }
                    // `--always` emits one empty synthetic group when every parent tree is
                    // identical. Consume it so the next commit remains aligned.
                    if (nonEmpty.every(value => !value)) {
                        for await (const unexpected of reader.changes()) {
                            void unexpected;
                            throw new GitCaptureError('malformed-output', 'Git emitted changes for an equal-tree parent delta.');
                        }
                    }
                    if (historyStopped)
                        break;
                }
                if (historyStopped)
                    break;
                const extra = await reader.nextGroup();
                if (extra)
                    throw new GitCaptureError('malformed-output', 'Git emitted an unclaimed diff group.');
                if (historyLimitReached) {
                    counts.diagnostics++;
                    historyCoverage = 'partial';
                    deltaCoverage = 'partial';
                    historyStopped = true;
                    yield diagnostic('history-limit', 'error', 'History capture reached the configured commit limit.', options.configuredMaxCommits ?? undefined);
                    break;
                }
            }
            finally {
                await reader.close();
            }
        }
    }
    catch (error) {
        if (error instanceof OccAbortError)
            throw error;
        historyCoverage = counts.commits > 0 ? 'partial' : 'failed';
        deltaCoverage = counts.parentDeltas > 0 ? 'partial' : 'failed';
        counts.diagnostics++;
        yield diagnostic(error instanceof GitCaptureError && error.code === 'malformed-output'
            ? 'malformed-git-output'
            : 'git-command-failed', 'error', error instanceof GitCaptureError && error.code === 'malformed-output'
            ? error.message
            : 'Git history or delta capture failed before completion.');
    }
    try {
        const beforeDiagnostics = counts.diagnostics;
        for await (const event of treeEvents(options, head, counts)) {
            if (event.type === 'diagnostic')
                treeCoverage = 'partial';
            yield event;
        }
        if (counts.diagnostics > beforeDiagnostics)
            treeCoverage = 'partial';
    }
    catch (error) {
        if (error instanceof OccAbortError)
            throw error;
        treeCoverage = counts.treeEntries > 0 ? 'partial' : 'failed';
        counts.diagnostics++;
        yield diagnostic(error instanceof GitCaptureError && error.code === 'malformed-output'
            ? 'malformed-git-output'
            : 'git-command-failed', 'error', error instanceof GitCaptureError && error.code === 'malformed-output'
            ? error.message
            : 'Pinned-head tree capture failed before completion.');
    }
    const complete = historyCoverage === 'complete' && deltaCoverage === 'complete' && treeCoverage === 'complete';
    yield {
        schemaVersion: SCHEMA_VERSION,
        type: 'footer',
        complete,
        coverage: { history: historyCoverage, deltas: deltaCoverage, tree: treeCoverage },
        counts,
    };
}
//# sourceMappingURL=capture.js.map