import { GitError, gitIsAncestor, gitRootCommits, gitVersion, preflightRepo, resolveDiffRange, runGit, runGitRaw, } from '../../git/client.js';
import { assertValidGitPath, decodeGitPath, GitParseError, parseLsTreeZ, parseRawDiffRecordsZ, splitNulStrict, } from '../../git/parse.js';
import { parseGitBatchStream, readGitObjectHeaders, readGitObjects } from '../../git/objects.js';
import { abortIfNeeded } from '../../utils.js';
import { bumpCounter, DEFAULT_ENTITY_BOUNDS, } from './types.js';
const RECORD_SEPARATOR = 0x1e;
const UNIT_SEPARATOR = '\u001f';
const NUL = Buffer.from([0]);
/**
 * Header fields, in the order the `--format` string below requests them.
 * `%x1e` opens each commit record and `%x1f` separates fields, which keeps the
 * framing unambiguous even when a subject contains newlines.
 */
const LOG_FORMAT = 'tformat:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%s';
/**
 * Every git call in this subsystem goes through these two wrappers so the
 * invocation counter cannot drift from reality — a test asserting "git calls
 * stay bounded" is only meaningful if no call can bypass the counter.
 */
async function countedGit(args, options) {
    abortIfNeeded(options.signal);
    bumpCounter(options.counters, 'gitInvocations');
    const result = await runGit(args, {
        repoRoot: options.repoRoot,
        signal: options.signal,
        physicalObjects: true,
    });
    bumpCounter(options.counters, 'gitBytesRead', Buffer.byteLength(result.stdout, 'utf8'));
    return result;
}
async function countedGitRaw(args, options) {
    abortIfNeeded(options.signal);
    bumpCounter(options.counters, 'gitInvocations');
    const result = await runGitRaw(args, {
        repoRoot: options.repoRoot,
        signal: options.signal,
        maxBuffer: options.maxBuffer,
        input: options.input,
        physicalObjects: true,
    });
    bumpCounter(options.counters, 'gitBytesRead', result.stdout.length);
    return result;
}
/** Reject refs that could be read as options; everything else git validates itself. */
function assertSafeRef(ref) {
    if (ref.startsWith('-'))
        throw new GitError('bad-ref', `Invalid ref "${ref}".`);
}
/** The only non-ref arguments `buildDiffRangeArgs` produces. */
const RANGE_FLAGS = new Set(['--merge-base', '--cached']);
export async function readEntityRepoIdentity(options) {
    const info = await preflightRepo(options.repoRoot, { signal: options.signal, physicalObjects: true });
    bumpCounter(options.counters, 'gitInvocations');
    if (!info.head) {
        // An unborn repository has no commits, so it has no commit-derived
        // identity. The resolved root path is the only stable thing about it.
        return { shallow: info.shallow, fallback: `unborn:${info.repoRoot}` };
    }
    const roots = await gitRootCommits(options.repoRoot, { signal: options.signal, physicalObjects: true });
    bumpCounter(options.counters, 'gitInvocations');
    const rootCommit = roots[0];
    return rootCommit
        ? { rootCommit, shallow: info.shallow }
        : { shallow: info.shallow, fallback: `rootless:${info.repoRoot}` };
}
export function repoIdentityKey(identity) {
    return identity.rootCommit ?? identity.fallback ?? 'unknown';
}
/** Resolve every entity-diff endpoint with replacement refs, grafts, and lazy fetches disabled. */
export async function resolveEntityDiffRange(options) {
    // `preflightRepo()` is intentionally best-effort for status UI and therefore
    // swallows probe failures. An evidence-producing entity diff needs the
    // opposite contract: preserve missing-Git, non-repository, timeout, and
    // command failures so the shared mapper can report the truthful typed cause.
    bumpCounter(options.counters, 'gitInvocations');
    const probe = await runGit(['rev-parse', '--git-dir'], {
        repoRoot: options.repoRoot,
        signal: options.signal,
        physicalObjects: true,
    });
    if (probe.stdout.trim().length === 0)
        throw new GitError('git-failed', 'Git emitted an empty repository probe.');
    return resolveDiffRange({
        repoRoot: options.repoRoot,
        signal: options.signal,
        base: options.base,
        head: options.head,
        staged: options.staged,
        rangeMode: options.rangeMode,
        physicalObjects: true,
    });
}
/**
 * Build an entity raw-diff range exclusively from the immutable objects that
 * range resolution captured. No Git-version branch and no symbolic name can
 * change the answer after this point.
 */
export function buildEntityDiffRangeArgs(range) {
    switch (range.kind) {
        case 'three-dot':
            if (!range.mergeBase || !range.resolvedHead) {
                throw new GitError('git-failed', 'Resolved three-dot entity range is missing a physical endpoint.');
            }
            return [range.mergeBase, range.resolvedHead];
        case 'two-dot':
            if (!range.resolvedBase || !range.resolvedHead) {
                throw new GitError('git-failed', 'Resolved two-dot entity range is missing a physical endpoint.');
            }
            return [range.resolvedBase, range.resolvedHead];
        case 'staged':
            return range.resolvedHead ? ['--cached', range.resolvedHead] : ['--cached'];
        case 'working-tree':
            return range.resolvedHead ? [range.resolvedHead] : [];
        case 'commit':
            if (!range.resolvedHead) {
                throw new GitError('git-failed', 'Resolved commit entity range is missing a physical endpoint.');
            }
            return [range.resolvedHead];
    }
}
export async function resolveCommit(ref, options) {
    // No `--end-of-options`: it needs Git >= 2.24, and `assertSafeRef` already
    // rejects the leading dash that flag exists to neutralize.
    assertSafeRef(ref);
    bumpCounter(options.counters, 'gitInvocations');
    const result = await runGit(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
        repoRoot: options.repoRoot,
        signal: options.signal,
        allowExitCodes: [1],
        physicalObjects: true,
    });
    const sha = result.stdout.trim();
    return result.exitCode === 0 && sha.length > 0 ? sha : undefined;
}
export async function isAncestorCommit(ancestor, descendant, options) {
    bumpCounter(options.counters, 'gitInvocations');
    return gitIsAncestor(ancestor, descendant, {
        repoRoot: options.repoRoot,
        signal: options.signal,
        physicalObjects: true,
    });
}
function supportsFirstParentDiffMerges(version) {
    const match = version.match(/(\d+)\.(\d+)/);
    if (!match)
        return true;
    const major = Number(match[1]);
    const minor = Number(match[2]);
    return major > 2 || (major === 2 && minor >= 31);
}
/**
 * Read a whole commit window in **one** git invocation.
 *
 * `git log --raw` emits both the commit records and each commit's raw diff —
 * including pre- and post-image blob ids and rename detection — in a single
 * stream. Doing this per commit with `diff-tree` instead would spawn one
 * process per commit, which on a real history is tens of thousands of forks
 * and dominates the wall clock.
 */
export async function walkCommitWindow(options) {
    const head = options.head ?? 'HEAD';
    assertSafeRef(head);
    if (options.sinceCommit)
        assertSafeRef(options.sinceCommit);
    const version = await gitVersion(options.repoRoot, { signal: options.signal, physicalObjects: true });
    bumpCounter(options.counters, 'gitInvocations');
    const args = [
        'log',
        '--no-color',
        '-z',
        '--no-abbrev',
        '--raw',
        '-M',
        '--first-parent',
        '--reverse',
        '--topo-order',
        `--max-count=${options.bounds.maxCommits + 1}`,
        `--format=${LOG_FORMAT}`,
    ];
    // Without this a merge commit emits no raw diff at all, so changes that
    // first appear on the first-parent chain at a merge would be invisible.
    if (supportsFirstParentDiffMerges(version))
        args.push('--diff-merges=first-parent');
    args.push(options.sinceCommit ? `${options.sinceCommit}..${head}` : head);
    const result = await countedGitRaw(args, { ...options, maxBuffer: 512 * 1024 * 1024 });
    return parseCommitWindow(result.stdout, options.bounds, options.orderOffset);
}
export function parseCommitWindow(buffer, bounds, orderOffset = 0) {
    const parsed = [];
    const fields = splitNulStrict(buffer, 'Entity commit-window output');
    let index = 0;
    const stripLeadingLineFeeds = (field) => {
        let start = 0;
        while (field[start] === 0x0a)
            start++;
        return field.subarray(start);
    };
    const isCommitHeader = (field) => stripLeadingLineFeeds(field)[0] === RECORD_SEPARATOR;
    while (index < fields.length) {
        const headerField = stripLeadingLineFeeds(fields[index]);
        if (headerField[0] !== RECORD_SEPARATOR) {
            throw new GitParseError('Entity commit-window output contains data outside a commit record.');
        }
        index++;
        const headerParts = headerField.subarray(1).toString('utf8').split(UNIT_SEPARATOR);
        if (headerParts.length < 7)
            throw new GitParseError('Malformed entity commit-window header.');
        const [sha, parentList, authorName, authorEmail, authoredAt, committedAt, ...subjectParts] = headerParts;
        const subject = subjectParts.join(UNIT_SEPARATOR);
        if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(sha)) {
            throw new GitParseError('Entity commit-window header contains an invalid commit object id.');
        }
        const parents = parentList.split(' ').filter(Boolean);
        if (parents.some(parent => parent.length !== sha.length || !/^[0-9a-f]+$/.test(parent))) {
            throw new GitParseError('Entity commit-window header contains an invalid parent object id.');
        }
        const records = [];
        while (index < fields.length && !isCommitHeader(fields[index])) {
            const rawHeader = stripLeadingLineFeeds(fields[index]);
            if (rawHeader.length === 0) {
                index++;
                continue;
            }
            const lastSpace = rawHeader.lastIndexOf(0x20);
            const statusByte = lastSpace === -1 ? undefined : rawHeader[lastSpace + 1];
            const pathCount = statusByte === 0x52 || statusByte === 0x43 ? 2 : 1;
            if (index + pathCount >= fields.length) {
                throw new GitParseError('Entity commit-window raw diff record is truncated.');
            }
            const recordFields = [rawHeader, ...fields.slice(index + 1, index + 1 + pathCount)];
            const framed = Buffer.concat(recordFields.flatMap(field => [field, NUL]));
            const record = parseRawDiffRecordsZ(framed);
            if (record.length !== 1)
                throw new GitParseError('Entity commit-window raw diff record was ambiguous.');
            records.push(record[0]);
            index += pathCount + 1;
        }
        const filesTruncated = records.length > bounds.maxFilesPerCommit;
        parsed.push({
            record: {
                sha,
                parents,
                authorName,
                authorEmail,
                authoredAt,
                committedAt,
                subject,
                isMerge: parents.length > 1,
            },
            changes: filesTruncated ? records.slice(0, bounds.maxFilesPerCommit) : records,
            filesTruncated,
        });
    }
    // git applies `--max-count` while walking newest-first and only then honours
    // `--reverse`, so this stream is already the newest `maxCommits + 1` commits
    // in oldest-first order. The surplus therefore has to come off the *front*:
    // trimming the back would drop HEAD, which is the commit every live entity is
    // attributed against, leaving an index whose own head is missing from it.
    let truncated = false;
    if (parsed.length > bounds.maxCommits) {
        truncated = true;
        parsed.splice(0, parsed.length - bounds.maxCommits);
    }
    const commits = [];
    const changesByCommit = new Map();
    for (const [index, entry] of parsed.entries()) {
        // The bound counts this walk's commits; the order numbers them within the
        // whole index, so the two deliberately differ once a walk is an extension.
        commits.push({ ...entry.record, order: orderOffset + index });
        changesByCommit.set(entry.record.sha, entry.changes);
        if (entry.filesTruncated)
            truncated = true;
    }
    return { commits, changesByCommit, truncated };
}
/**
 * The commits that touched one path, newest first, following renames.
 *
 * This is the plumbing behind attribution outside the indexed window. `--raw`
 * carries both pre- and post-image blob ids, so the whole walk can be evaluated
 * from one invocation plus one batched blob read — the alternative, resolving
 * `<commit>:<path>` per commit, is a process per commit for the same bytes.
 *
 * `--follow` is what lets it cross a rename, and it is the reason the pathspec
 * is a single path: git supports the option no other way.
 *
 * The returned `order` numbers this walk alone, newest first, and has nothing to
 * do with the index's own commit ordering.
 */
export async function walkFileHistory(options) {
    const start = options.startRef ?? 'HEAD';
    assertSafeRef(start);
    assertValidGitPath(options.relativePath);
    if (options.relativePath.startsWith('-')) {
        throw new GitError('bad-ref', `Invalid path "${options.relativePath}".`);
    }
    const args = [
        'log',
        '--no-color',
        '-z',
        '--no-abbrev',
        '--raw',
        '-M',
        '--follow',
        `--max-count=${options.limit}`,
        `--format=${LOG_FORMAT}`,
        start,
        '--',
        options.relativePath,
    ];
    const result = await countedGitRaw(args, { ...options, maxBuffer: 128 * 1024 * 1024 });
    return parseCommitWindow(result.stdout, {
        ...DEFAULT_ENTITY_BOUNDS,
        maxCommits: options.limit,
        // One path was asked for, so `--follow` reports one record per commit; the
        // per-commit file bound has nothing to cut here.
        maxFilesPerCommit: DEFAULT_ENTITY_BOUNDS.maxFilesPerCommit,
    });
}
/**
 * Sizes first, contents second.
 *
 * `--batch-check` is tiny and lets oversized blobs be dropped *before* they are
 * read, and lets the content pass be packed to an exact byte budget instead of
 * guessing how much a batch will return.
 */
export async function readBlobHeaders(oids, options) {
    return readGitObjectHeaders(oids, {
        repoRoot: options.repoRoot,
        signal: options.signal,
        physicalObjects: true,
        onCommandStart: () => bumpCounter(options.counters, 'gitInvocations'),
        onBytesRead: bytes => bumpCounter(options.counters, 'gitBytesRead', bytes),
    });
}
/**
 * Read blob contents in byte-budgeted batches.
 *
 * The response stream is `<oid> <type> <size>\n<content>\n`; content is taken
 * by its declared byte length and never by scanning for a delimiter, because a
 * blob may contain anything including the delimiter itself.
 */
export async function readBlobs(oids, headers, options) {
    return readGitObjects(oids, headers, {
        repoRoot: options.repoRoot,
        signal: options.signal,
        maxBatchBytes: options.maxBatchBytes,
        physicalObjects: true,
        onCommandStart: () => bumpCounter(options.counters, 'gitInvocations'),
        onBytesRead: bytes => bumpCounter(options.counters, 'gitBytesRead', bytes),
    });
}
export function parseBatchStream(buffer, into) {
    parseGitBatchStream(buffer, into);
}
/**
 * One diff's changed paths, with the blob id on each side.
 *
 * `--raw` is what makes entity diff cheap: it reports both pre- and post-image
 * blob ids alongside rename detection, so each side can be fetched by content
 * address instead of by re-resolving `<ref>:<path>` per file. A worktree
 * comparison reports an all-zero post-image, meaning "read it from disk".
 */
export async function readRawDiff(rangeArgs, options) {
    // `buildDiffRangeArgs` mixes flags with refs. Allowlisting the two flags it
    // can emit keeps every other element subject to ref validation, rather than
    // exempting everything that merely looks like a flag.
    for (const arg of rangeArgs) {
        if (RANGE_FLAGS.has(arg))
            continue;
        assertSafeRef(arg);
    }
    for (const relativePath of options.paths ?? [])
        assertValidGitPath(relativePath);
    const args = ['diff', '--no-color', '-z', '--no-abbrev', '--raw', '-M', '-C', '--find-copies-harder', ...rangeArgs];
    if (options.paths && options.paths.length > 0)
        args.push('--', ...options.paths);
    const result = await countedGitRaw(args, { ...options, maxBuffer: 128 * 1024 * 1024 });
    return parseRawDiffRecordsZ(result.stdout);
}
/** Every blob in a tree, for snapshotting a ref that is not a commit boundary. */
export async function listTreeBlobs(ref, options) {
    assertSafeRef(ref);
    const result = await countedGitRaw(['ls-tree', '-r', '-z', ref], { ...options, maxBuffer: 128 * 1024 * 1024 });
    return parseLsTreeZ(result.stdout);
}
/**
 * Blobs staged in the index. Entries at a nonzero stage are unmerged, and are
 * skipped rather than guessed at — picking a conflict side would silently
 * invent a state the developer never had.
 */
export async function listIndexBlobs(options) {
    const result = await countedGitRaw(['ls-files', '-s', '-z'], options);
    const entries = [];
    const unmerged = [];
    for (const field of splitNulStrict(result.stdout, 'Index listing output')) {
        const tab = field.indexOf(0x09);
        if (tab <= 0 || tab === field.length - 1)
            throw new GitParseError('Malformed index listing record.');
        const headerBytes = field.subarray(0, tab);
        if (headerBytes.some(byte => byte > 0x7f))
            throw new GitParseError('Index listing header is not ASCII.');
        const match = headerBytes.toString('ascii').match(/^([0-7]{6}) ([0-9a-f]{40}|[0-9a-f]{64}) ([0-3])$/);
        if (!match)
            throw new GitParseError('Malformed index listing header.');
        const [, mode, blobSha, stage] = match;
        const path = decodeGitPath(field.subarray(tab + 1));
        if (stage !== '0') {
            if (!unmerged.includes(path))
                unmerged.push(path);
            continue;
        }
        entries.push({ mode, blobSha, path });
    }
    return { entries, unmerged };
}
export { countedGit as runEntityGit };
//# sourceMappingURL=git.js.map