import { execFile } from 'node:child_process';
import { realpath } from 'node:fs/promises';
import path from 'node:path';
import { OccAbortError } from '../errors.js';
import { abortIfNeeded } from '../utils.js';
export class GitError extends Error {
    code;
    exitCode;
    stderr;
    constructor(code, message, options) {
        super(message, options);
        this.name = 'GitError';
        this.code = code;
        this.exitCode = options?.exitCode;
        this.stderr = options?.stderr;
    }
}
export const GIT_GLOBAL_ARGS = [
    '--no-pager',
    '-c',
    'color.ui=false',
    '-c',
    'core.quotePath=false',
    '-c',
    'core.autocrlf=false',
    '--no-optional-locks',
];
const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 60_000;
const DEFAULT_HEAD = 'HEAD';
const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null';
function resolveCwd(options) {
    return path.resolve(options.repoRoot ?? options.cwd ?? process.cwd());
}
function firstStderrLine(stderr, fallback) {
    const first = stderr
        .split(/\r?\n/)
        .map(line => line.trim())
        .find(Boolean);
    return first ?? fallback;
}
function isAbortError(error) {
    return error.name === 'AbortError' || error.code === 'ABORT_ERR';
}
function classifyGitFailure(error, stderr) {
    if (error.code === 'ENOENT')
        return 'git-missing';
    if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER')
        return 'max-buffer';
    if (error.killed)
        return 'timeout';
    if (/not a git repository/i.test(stderr))
        return 'not-a-repo';
    if (/bad revision|unknown revision|ambiguous argument|Needed a single revision/i.test(stderr))
        return 'bad-ref';
    return 'git-failed';
}
function toGitError(error, stderr) {
    const code = classifyGitFailure(error, stderr);
    const exitCode = typeof error.code === 'number' ? error.code : undefined;
    return new GitError(code, firstStderrLine(stderr, error.message), {
        cause: error,
        exitCode,
        stderr: stderr.trim(),
    });
}
function normalizeStderr(stderr) {
    return Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr;
}
function isAllowedExit(code, allowedExitCodes) {
    return code === 0 || (allowedExitCodes?.includes(code) ?? false);
}
function execGit(args, options, encoding) {
    abortIfNeeded(options.signal);
    const cwd = resolveCwd(options);
    const argv = [...GIT_GLOBAL_ARGS, ...args];
    const maxBuffer = options.maxBuffer ?? DEFAULT_MAX_BUFFER;
    const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
    return new Promise((resolve, reject) => {
        const child = execFile('git', argv, {
            cwd,
            maxBuffer,
            timeout,
            signal: options.signal,
            encoding,
            env: options.physicalObjects
                ? {
                    ...process.env,
                    GIT_GRAFT_FILE: NULL_DEVICE,
                    GIT_NO_LAZY_FETCH: '1',
                    GIT_NO_REPLACE_OBJECTS: '1',
                    GIT_TERMINAL_PROMPT: '0',
                }
                : undefined,
        }, (error, stdout, stderr) => {
            const stderrText = normalizeStderr(stderr);
            if (!error) {
                resolve({ stdout, stderr: stderrText, exitCode: 0, argv, cwd });
                return;
            }
            if (isAbortError(error)) {
                reject(new OccAbortError());
                return;
            }
            const exitCode = typeof error.code === 'number' ? error.code : undefined;
            if (exitCode != null && isAllowedExit(exitCode, options.allowExitCodes)) {
                resolve({ stdout, stderr: stderrText, exitCode, argv, cwd });
                return;
            }
            reject(toGitError(error, stderrText));
        });
        if (options.input != null) {
            // git can exit before draining stdin (a bad object aborts `cat-file
            // --batch`), which surfaces as EPIPE here. The exec callback already
            // reports the real failure, so swallow the write error rather than
            // turning it into an unhandled 'error' event.
            child.stdin?.on('error', () => { });
            child.stdin?.end(options.input);
        }
    });
}
export async function runGit(args, options = {}) {
    return execGit(args, options, 'utf8');
}
export async function runGitRaw(args, options = {}) {
    return execGit(args, options, 'buffer');
}
function fallbackRepoInfo(repoRoot, gitVersionValue) {
    const info = {
        repoRoot: path.resolve(repoRoot),
        isDirty: false,
        shallow: false,
        detached: false,
    };
    if (gitVersionValue)
        info.gitVersion = gitVersionValue;
    return info;
}
export async function preflightRepo(repoRoot = process.cwd(), options = {}) {
    let version;
    try {
        version = await gitVersion(repoRoot, options);
    }
    catch {
        return fallbackRepoInfo(repoRoot);
    }
    const baseOptions = { ...options, repoRoot };
    const inside = await runGit(['rev-parse', '--is-inside-work-tree'], {
        ...baseOptions,
        allowExitCodes: [128],
    }).catch(() => undefined);
    if (!inside || inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
        return fallbackRepoInfo(repoRoot, version);
    }
    const rootResult = await runGit(['rev-parse', '--show-toplevel'], baseOptions).catch(() => undefined);
    const resolvedRoot = rootResult?.stdout.trim()
        ? await realpath(rootResult.stdout.trim()).catch(() => path.resolve(rootResult.stdout.trim()))
        : path.resolve(repoRoot);
    const repoOptions = { ...options, repoRoot: resolvedRoot };
    const headResult = await runGit(['rev-parse', '--verify', '--quiet', 'HEAD'], {
        ...repoOptions,
        allowExitCodes: [1],
    }).catch(() => undefined);
    const head = headResult?.exitCode === 0 ? headResult.stdout.trim() : undefined;
    const branchResult = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], {
        ...repoOptions,
        allowExitCodes: [1],
    }).catch(() => undefined);
    const currentBranch = branchResult?.exitCode === 0 ? branchResult.stdout.trim() : undefined;
    const detached = head != null && branchResult?.exitCode === 1;
    const shallowResult = await runGit(['rev-parse', '--is-shallow-repository'], repoOptions).catch(() => undefined);
    const shallow = shallowResult?.stdout.trim() === 'true';
    const dirtyResult = await runGitRaw(['status', '--porcelain=v1', '-z'], {
        ...repoOptions,
        allowExitCodes: [128],
    }).catch(() => undefined);
    const isDirty = dirtyResult?.exitCode === 0 ? dirtyResult.stdout.length > 0 : false;
    const defaultBranch = await detectDefaultBase(resolvedRoot, options).catch(() => undefined);
    const info = {
        repoRoot: resolvedRoot,
        isDirty,
        shallow,
        detached,
        gitVersion: version,
    };
    if (defaultBranch)
        info.defaultBranch = defaultBranch;
    if (head)
        info.head = head;
    if (currentBranch)
        info.currentBranch = currentBranch;
    return info;
}
export async function gitVersion(repoRoot = process.cwd(), options = {}) {
    const result = await runGit(['version'], { ...options, repoRoot });
    return result.stdout.trim();
}
/**
 * Resolve the git toplevel for a directory, realpathed so callers can compare
 * it against other resolved filesystem paths. Returns undefined when the
 * directory is not inside a work tree.
 */
export async function resolveGitToplevel(repoRoot = process.cwd(), options = {}) {
    const result = await runGit(['rev-parse', '--show-toplevel'], { ...options, repoRoot }).catch(() => undefined);
    const raw = result?.stdout.trim();
    if (!raw)
        return undefined;
    return realpath(raw).catch(() => path.resolve(raw));
}
async function verifyCommit(repoRoot, ref, options = {}) {
    if (ref.startsWith('-')) {
        throw new GitError('bad-ref', `Invalid ref "${ref}".`);
    }
    const result = await runGit(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
        ...options,
        repoRoot,
        allowExitCodes: [1],
    });
    if (result.exitCode !== 0)
        return undefined;
    return result.stdout.trim() || undefined;
}
async function requireCommit(repoRoot, ref, options = {}) {
    const resolved = await verifyCommit(repoRoot, ref, options);
    if (!resolved)
        throw new GitError('bad-ref', `Invalid ref "${ref}".`);
    return resolved;
}
async function verifyTree(repoRoot, ref, options = {}) {
    if (ref.startsWith('-')) {
        throw new GitError('bad-ref', `Invalid ref "${ref}".`);
    }
    const result = await runGit(['rev-parse', '--verify', '--quiet', `${ref}^{tree}`], {
        ...options,
        repoRoot,
        allowExitCodes: [1],
    });
    if (result.exitCode !== 0)
        return undefined;
    return result.stdout.trim() || undefined;
}
/**
 * Resolve one diff endpoint to an object id.
 *
 * `git diff` compares tree-ishes, not only commits, and a caller holding
 * `git write-tree` output has exactly a bare tree. Requiring a commit rejected
 * that with `bad-ref` even though the underlying command would have worked. The
 * commit form is tried first because `<commit>^{tree}` also resolves, and a
 * commit endpoint carries ancestry a tree does not.
 */
async function requireDiffEndpoint(repoRoot, ref, options = {}) {
    const commit = await verifyCommit(repoRoot, ref, options);
    if (commit)
        return { oid: commit, isTree: false };
    const tree = await verifyTree(repoRoot, ref, options);
    if (tree)
        return { oid: tree, isTree: true };
    throw new GitError('bad-ref', `Invalid ref "${ref}".`);
}
export async function detectDefaultBase(repoRoot = process.cwd(), options = {}) {
    const originHead = await runGit(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], {
        ...options,
        repoRoot,
        allowExitCodes: [1],
    }).catch(() => undefined);
    const originHeadRef = originHead?.exitCode === 0 ? originHead.stdout.trim() : undefined;
    if (originHeadRef && (await verifyCommit(repoRoot, originHeadRef, options)))
        return originHeadRef;
    for (const candidate of ['main', 'master', 'origin/main', 'origin/master', 'develop', 'trunk']) {
        if (await verifyCommit(repoRoot, candidate, options))
            return candidate;
    }
    const upstream = await runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], {
        ...options,
        repoRoot,
        allowExitCodes: [128],
    }).catch(() => undefined);
    if (!upstream || upstream.exitCode !== 0)
        return undefined;
    const upstreamRef = upstream.stdout.trim();
    const branch = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], {
        ...options,
        repoRoot,
        allowExitCodes: [1],
    }).catch(() => undefined);
    const branchRemoteRef = branch?.exitCode === 0 ? `origin/${branch.stdout.trim()}` : undefined;
    if (upstreamRef && upstreamRef !== branchRemoteRef && (await verifyCommit(repoRoot, upstreamRef, options))) {
        return upstreamRef;
    }
    return undefined;
}
export async function gitMergeBase(base, head = DEFAULT_HEAD, options = {}) {
    const repoRoot = resolveCwd(options);
    await requireCommit(repoRoot, base, options);
    await requireCommit(repoRoot, head, options);
    const result = await runGit(['merge-base', base, head], {
        ...options,
        repoRoot,
        allowExitCodes: [1],
    });
    const mergeBase = result.stdout.trim();
    if (result.exitCode !== 0 || mergeBase.length === 0) {
        throw new GitError('merge-base-failed', `Could not find a merge base for "${base}" and "${head}".`, {
            exitCode: result.exitCode,
            stderr: result.stderr.trim(),
        });
    }
    return mergeBase;
}
/**
 * Every parentless commit reachable from any ref, oldest first. This is the
 * stable identity of a repository: it survives renames, remote changes, and
 * relocation of the working tree, and it differs between two unrelated repos
 * that happen to share a path. Repos with grafted or unrelated histories have
 * more than one, so the full sorted set is returned rather than just the first.
 *
 * An unborn repository has no commits at all and yields an empty array; callers
 * must fall back to something else (the realpath of the root) for identity.
 */
export async function gitRootCommits(repoRoot = process.cwd(), options = {}) {
    const result = await runGit(['rev-list', '--max-parents=0', '--all'], {
        ...options,
        repoRoot,
        allowExitCodes: [128],
    });
    if (result.exitCode !== 0)
        return [];
    return result.stdout
        .split(/\r?\n/)
        .map(line => line.trim())
        .filter(Boolean)
        .sort();
}
/**
 * True when `ancestor` is reachable from `descendant`. Used to detect a
 * rewritten history: a cache whose recorded head is no longer an ancestor of
 * the current head was built on commits that have been rebased, amended, or
 * dropped. An unresolvable ref is reported as `false` rather than throwing,
 * because a vanished commit is exactly the rewrite case being probed for.
 */
export async function gitIsAncestor(ancestor, descendant = DEFAULT_HEAD, options = {}) {
    const repoRoot = resolveCwd(options);
    if (ancestor.startsWith('-') || descendant.startsWith('-')) {
        throw new GitError('bad-ref', `Invalid ref "${ancestor.startsWith('-') ? ancestor : descendant}".`);
    }
    const result = await runGit(['merge-base', '--is-ancestor', ancestor, descendant], {
        ...options,
        repoRoot,
        allowExitCodes: [1, 128],
    });
    return result.exitCode === 0;
}
function workingTreeRange(head) {
    const range = { kind: 'working-tree', describe: 'working tree' };
    if (head) {
        range.head = DEFAULT_HEAD;
        range.resolvedHead = head;
    }
    return range;
}
function stagedRange(head) {
    const range = { kind: 'staged', describe: 'staged' };
    if (head) {
        range.head = DEFAULT_HEAD;
        range.resolvedHead = head;
    }
    return range;
}
export async function resolveDiffRange(options = {}) {
    const repoRoot = resolveCwd(options);
    const info = await preflightRepo(repoRoot, options);
    if (!info.head && !info.currentBranch) {
        throw new GitError('not-a-repo', 'Not a git repository.');
    }
    if (options.staged)
        return stagedRange(info.head);
    if (!info.head)
        return workingTreeRange();
    const explicitRange = options.base != null || options.head != null;
    if (!explicitRange && info.isDirty)
        return workingTreeRange(info.head);
    const base = options.base ?? (await detectDefaultBase(info.repoRoot, options));
    const head = options.head ?? DEFAULT_HEAD;
    if (!base) {
        if (options.head != null) {
            throw new GitError('bad-ref', `Could not resolve a base ref to diff "${options.head}" against; specify an explicit base.`);
        }
        return workingTreeRange(info.head);
    }
    const baseEndpoint = await requireDiffEndpoint(info.repoRoot, base, options);
    const headEndpoint = await requireDiffEndpoint(info.repoRoot, head, options);
    const resolvedBase = baseEndpoint.oid;
    const resolvedHead = headEndpoint.oid;
    // A bare tree has no ancestry, so there is no merge base to find; asking for
    // one would fail on an endpoint git itself diffs happily.
    if (options.rangeMode === 'exact' || baseEndpoint.isTree || headEndpoint.isTree) {
        return {
            base,
            head,
            kind: 'two-dot',
            resolvedBase,
            resolvedHead,
            describe: `${base}..${head}`,
        };
    }
    try {
        // Both names were resolved above. Computing ancestry from those immutable
        // object ids prevents a branch moving between resolution and merge-base
        // collection from changing the physical range under the caller.
        const mergeBase = await gitMergeBase(resolvedBase, resolvedHead, { ...options, repoRoot: info.repoRoot });
        return {
            base,
            head,
            kind: 'three-dot',
            mergeBase,
            resolvedBase,
            resolvedHead,
            describe: `${base}...${head}`,
        };
    }
    catch (error) {
        if (!(error instanceof GitError) || error.code !== 'merge-base-failed')
            throw error;
        return {
            base,
            head,
            kind: 'two-dot',
            resolvedBase,
            resolvedHead,
            describe: `${base}..${head}`,
        };
    }
}
function parseGitVersion(version) {
    const match = version?.match(/(\d+)\.(\d+)/);
    if (!match)
        return undefined;
    return { major: Number.parseInt(match[1], 10), minor: Number.parseInt(match[2], 10) };
}
function supportsMergeBaseDiff(version) {
    const parsed = parseGitVersion(version);
    if (!parsed)
        return true;
    return parsed.major > 2 || (parsed.major === 2 && parsed.minor >= 30);
}
export function buildDiffRangeArgs(range, version) {
    if (range.kind === 'working-tree')
        return [];
    if (range.kind === 'staged')
        return ['--cached'];
    if (range.kind === 'three-dot') {
        if (supportsMergeBaseDiff(version) && range.base && range.head) {
            return ['--merge-base', range.base, range.head];
        }
        if (range.mergeBase)
            return [range.mergeBase, range.head ?? DEFAULT_HEAD];
    }
    if (range.base && range.head)
        return [range.base, range.head];
    if (range.resolvedBase && range.resolvedHead)
        return [range.resolvedBase, range.resolvedHead];
    if (range.head)
        return [range.head];
    return [];
}
//# sourceMappingURL=client.js.map