"use strict";
// src/orchestrator/util/GitOps.ts
// v1.17.0 — Git utility for the Autonomous Story Loop.
//
// Scope (deliberately narrow):
//   - Read current branch / dirty files / log
//   - Create + checkout a feature branch
//   - Stage + commit (LOCAL ONLY)
//
// Hard boundary:
//   - NEVER `git push` / `git push --force` / `gh pr create`
//   - Those are @vortex's exclusive authority per
//     .openlife/method/agents/vortex.md:50
//
// All git invocations use execFile (no shell) to prevent injection.
Object.defineProperty(exports, "__esModule", { value: true });
exports.GitOps = void 0;
const child_process_1 = require("child_process");
const util_1 = require("util");
const execFileP = (0, util_1.promisify)(child_process_1.execFile);
const PROTECTED_BRANCHES = new Set(['main', 'master', 'develop', 'production']);
/**
 * v1.18.0 — Module-level toggle that authorizes a single `push` invocation
 * from the explicit `openlife ship` path. ShipCommand sets this to true
 * for the duration of its push call only. All other callers (story
 * executor, story state tracker, etc.) MUST never touch this flag —
 * push remains @vortex-exclusive everywhere else.
 */
let _pushAuthorized = false;
async function git(args, opts = {}) {
    // Defensive whitelist — refuse any args that look like push/PR ops
    // UNLESS the caller is the explicit ship escape hatch.
    for (const a of args) {
        const norm = a.toLowerCase();
        if ((norm === 'push' || norm === 'push-force' || norm === '--force-push') && !_pushAuthorized) {
            return { ok: false, stdout: '', stderr: `gitops_refused: push operations are @vortex-exclusive (arg=${a})` };
        }
    }
    try {
        const { stdout, stderr } = await execFileP('git', args, {
            cwd: opts.cwd || process.cwd(),
            timeout: opts.timeoutMs ?? 30_000,
            encoding: 'utf-8',
        });
        return { ok: true, stdout: String(stdout).trim(), stderr: String(stderr).trim() };
    }
    catch (err) {
        const e = err;
        return { ok: false, stdout: String(e.stdout || '').trim(), stderr: String(e.stderr || e.message || '').trim() };
    }
}
class GitOps {
    cwd;
    constructor(cwd = process.cwd()) {
        this.cwd = cwd;
    }
    /** Returns the current branch name, or 'HEAD' when detached. */
    async getCurrentBranch() {
        const r = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: this.cwd });
        if (!r.ok)
            throw new Error(`gitops_branch_failed: ${r.stderr}`);
        return r.stdout || 'HEAD';
    }
    /** Returns absolute paths of files modified/staged but not yet committed. */
    async getDirtyFiles() {
        const r = await git(['status', '--porcelain=v1'], { cwd: this.cwd });
        if (!r.ok)
            throw new Error(`gitops_status_failed: ${r.stderr}`);
        return r.stdout
            .split('\n')
            .filter(line => line.length > 0)
            .map(line => line.slice(3)); // strip "XY " prefix
    }
    /** True iff the working tree has no uncommitted changes (staged or unstaged). */
    async isClean() {
        const files = await this.getDirtyFiles();
        return files.length === 0;
    }
    /** Returns true when on a protected branch (main/master/etc.). */
    async isOnProtectedBranch() {
        const b = await this.getCurrentBranch();
        return PROTECTED_BRANCHES.has(b);
    }
    /**
     * Create AND checkout a new branch from the current HEAD.
     * Refuses if a branch with the same name already exists.
     */
    async createBranch(name) {
        if (!/^[a-zA-Z0-9._/-]+$/.test(name)) {
            return { ok: false, error: `invalid_branch_name: ${name}` };
        }
        // Check for existing branch first — git checkout -b errors here, but the
        // error message varies between git versions. Make our own check.
        const check = await git(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], { cwd: this.cwd });
        if (check.ok && check.stdout.length > 0) {
            return { ok: false, error: `branch_exists: ${name}` };
        }
        const r = await git(['checkout', '-b', name], { cwd: this.cwd });
        if (!r.ok)
            return { ok: false, error: `checkout_failed: ${r.stderr}` };
        return { ok: true };
    }
    /**
     * Stage the listed paths. Errors if any path is absent from the working
     * tree. Paths can be absolute or cwd-relative.
     */
    async stageFiles(paths) {
        if (paths.length === 0)
            return { ok: true };
        // git add validates existence via its own --intent-to-add path, but we
        // prefer a deterministic error before the call.
        const r = await git(['add', '--', ...paths], { cwd: this.cwd });
        if (!r.ok)
            return { ok: false, error: `stage_failed: ${r.stderr}` };
        return { ok: true };
    }
    /**
     * Create a commit with the supplied message. Requires staged changes
     * (or it errors with `nothing_to_commit`). Returns the commit SHA on
     * success.
     */
    async commit(message) {
        if (!message || !message.trim()) {
            return { ok: false, filesCommitted: 0, error: 'empty_message' };
        }
        // Count staged files before commit (for the result struct).
        const staged = await git(['diff', '--cached', '--name-only'], { cwd: this.cwd });
        const stagedCount = staged.ok ? staged.stdout.split('\n').filter(Boolean).length : 0;
        if (stagedCount === 0) {
            return { ok: false, filesCommitted: 0, error: 'nothing_to_commit' };
        }
        const r = await git(['commit', '-m', message], { cwd: this.cwd });
        if (!r.ok)
            return { ok: false, filesCommitted: 0, error: `commit_failed: ${r.stderr}` };
        const shaRes = await git(['rev-parse', 'HEAD'], { cwd: this.cwd });
        return {
            ok: true,
            sha: shaRes.ok ? shaRes.stdout : undefined,
            message,
            filesCommitted: stagedCount,
        };
    }
    /**
     * Slugify a story id + title into a branch name.
     * Example: ('1.1', '[BUG] Fix the gateway') → 'story-1.1-bug-fix-the-gateway'
     */
    static branchNameFromStory(storyId, title) {
        const slug = title
            .toLowerCase()
            .replace(/^\[([^\]]+)\]\s*/, '$1-') // [BUG] X → bug-X
            .replace(/[^a-z0-9]+/g, '-')
            .replace(/^-+|-+$/g, '')
            .slice(0, 50);
        const safeId = storyId.replace(/[^a-z0-9.-]/gi, '-');
        return `story-${safeId}-${slug}`;
    }
    /**
     * v1.18.0 — Push the current branch to a remote. THIS IS THE @VORTEX
     * ESCAPE HATCH — only `openlife ship` should call it. Refuses to push
     * to a protected branch, refuses if no upstream tracking is set, and
     * refuses without --upstream when current branch has no remote.
     *
     * Anyone else calling this gets `gitops_refused: not_authorized` —
     * the caller must set _pushAuthorized=true BEFORE the call and
     * reset to false immediately after (try/finally pattern).
     */
    async push(opts = {}) {
        if (!_pushAuthorized) {
            return { ok: false, remote: '', branch: '', error: 'gitops_refused: not_authorized (only openlife ship may push)' };
        }
        const branch = await this.getCurrentBranch();
        if (PROTECTED_BRANCHES.has(branch)) {
            return { ok: false, remote: '', branch, error: `gitops_refused: cannot push from protected branch ${branch}` };
        }
        const remote = opts.remote || 'origin';
        const pushArgs = opts.setUpstream
            ? ['push', '--set-upstream', remote, branch]
            : ['push', remote, branch];
        const r = await git(pushArgs, { cwd: this.cwd });
        if (!r.ok)
            return { ok: false, remote, branch, error: `push_failed: ${r.stderr}` };
        return { ok: true, remote, branch };
    }
    /**
     * v1.18.0 — Authorize a single push operation. Returns a `release`
     * function the caller MUST invoke in a finally block. Pattern:
     *
     *   const release = GitOps._authorizePush();
     *   try { await gitops.push(...); } finally { release(); }
     *
     * The flag is module-level so anyone holding it during the call
     * window can push. Use sparingly. ShipCommand is the only sanctioned
     * caller today.
     */
    static _authorizePush() {
        _pushAuthorized = true;
        return () => { _pushAuthorized = false; };
    }
    /** Exposed for tests so they can override the protected list. */
    static _protectedBranches() {
        return Array.from(PROTECTED_BRANCHES);
    }
}
exports.GitOps = GitOps;
