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';
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) => {
        execFile('git', argv, {
            cwd,
            maxBuffer,
            timeout,
            signal: options.signal,
            encoding,
        }, (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));
        });
    });
}
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();
}
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;
}
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;
}
function workingTreeRange() {
    return { kind: 'working-tree', describe: 'working tree' };
}
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();
    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();
    }
    const resolvedBase = await requireCommit(info.repoRoot, base, options);
    const resolvedHead = await requireCommit(info.repoRoot, head, options);
    try {
        const mergeBase = await gitMergeBase(base, head, { ...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