// engine/git/planu-autocommit.ts — Idempotent git commit helper for staged planu/ docs
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { access } from 'node:fs/promises';
import { join } from 'node:path';
const execFile = promisify(execFileCb);
export function isPlanuAutocommitEnabled() {
    return process.env.PLANU_ENABLE_AUTOCOMMIT === 'true';
}
/** Run git with execFile (no shell injection risk). */
async function runGit(cwd, args) {
    const result = await execFile('git', args, {
        cwd,
        maxBuffer: 1024 * 1024,
        timeout: 15_000,
    });
    return { stdout: result.stdout, stderr: result.stderr };
}
/** Build commit message based on reason and optional specId. */
function buildCommitMessage(reason, specId) {
    if (reason === 'sync-release') {
        return 'docs(planu): sync spec statuses before release';
    }
    if (reason === 'status-update') {
        return specId !== undefined
            ? `docs(planu): update status for ${specId}`
            : 'docs(planu): update spec status';
    }
    if (reason === 'session-checkpoint') {
        return 'docs(planu): update session-context';
    }
    // reason === 'mark-done'
    return specId !== undefined
        ? `docs(planu): mark ${specId} as done`
        : 'docs(planu): mark spec as done';
}
/**
 * Check whether .git/MERGE_HEAD exists — indicates an in-progress merge.
 * Returns true when a merge is in progress.
 */
async function isMidMerge(projectPath) {
    try {
        await access(join(projectPath, '.git', 'MERGE_HEAD'));
        return true;
    }
    catch {
        return false;
    }
}
/**
 * Check whether HEAD is detached.
 * Returns true when git is in detached HEAD state.
 */
async function isDetachedHead(projectPath) {
    try {
        const { stdout } = await runGit(projectPath, ['symbolic-ref', '--quiet', 'HEAD']);
        return stdout.trim() === '';
    }
    catch {
        // symbolic-ref exits non-zero when detached
        return true;
    }
}
/**
 * Return the list of staged planu/ files (ACMR filter = Added/Copied/Modified/Renamed).
 * An empty array means nothing is staged under planu/.
 */
async function getStagedPlanuFiles(projectPath) {
    try {
        const { stdout } = await runGit(projectPath, [
            'diff',
            '--cached',
            '--name-only',
            '--',
            'planu/',
        ]);
        return stdout
            .trim()
            .split('\n')
            .filter((line) => line.trim().length > 0);
    }
    catch {
        return [];
    }
}
/**
 * Unstage all planu/ files to avoid leaving partial state on error.
 */
async function unstageFiles(projectPath) {
    try {
        await runGit(projectPath, ['reset', 'HEAD', '--', 'planu/']);
    }
    catch {
        // best-effort — never throws
    }
}
/**
 * Idempotent autocommit for staged planu/ docs.
 *
 * - Skips silently when no planu/ files are staged.
 * - Skips when mid-merge or detached HEAD to avoid corrupt state.
 * - On git commit failure: unstages planu/ and returns a warning.
 * - Never blocks the caller — all errors are surfaced as warnings, not throws.
 */
export async function planuAutoCommit(opts) {
    const { projectPath, specId, reason } = opts;
    if (!isPlanuAutocommitEnabled()) {
        return { committed: false, skipped: 'disabled' };
    }
    // 1. Safety check: refuse if mid-merge
    const midMerge = await isMidMerge(projectPath);
    if (midMerge) {
        return { committed: false, skipped: 'mid-merge' };
    }
    // 2. Safety check: refuse if detached HEAD
    const detached = await isDetachedHead(projectPath);
    if (detached) {
        return { committed: false, skipped: 'detached-head' };
    }
    // 3. Ensure all planu/ changes are staged (covers untracked + modified files)
    try {
        await runGit(projectPath, ['add', '--', 'planu/']);
    }
    catch {
        // best-effort — if add fails, diff below will be empty and we skip
    }
    // 4. Check if there are staged planu/ files
    const stagedFiles = await getStagedPlanuFiles(projectPath);
    if (stagedFiles.length === 0) {
        return { committed: false, skipped: 'no-staged-changes' };
    }
    // 5. Build commit message
    const message = buildCommitMessage(reason, specId);
    // 6. Attempt commit, scoped to planu/ only.
    //    Without the trailing `-- planu/`, `git commit` includes every staged
    //    path — any user/teammate work staged in parallel would be swept into
    //    this autopilot commit under a misleading "docs(planu):" message and
    //    end up on whichever branch the autopilot runs on.  Scoping the commit
    //    to planu/ keeps user-staged changes outside planu/ intact.
    try {
        await runGit(projectPath, ['commit', '-m', message, '--no-verify', '--', 'planu/']);
    }
    catch (err) {
        // Unstage to avoid leaving partial state
        await unstageFiles(projectPath);
        const errMsg = err instanceof Error ? err.message : String(err);
        return {
            committed: false,
            warning: `planuAutoCommit: git commit failed — ${errMsg}. Staged planu/ files have been unstaged.`,
        };
    }
    // 6. Read commit SHA
    let commitSha;
    try {
        const { stdout } = await runGit(projectPath, ['rev-parse', 'HEAD']);
        commitSha = stdout.trim();
    }
    catch {
        // Not critical — commit succeeded even without SHA
    }
    return { committed: true, commitSha };
}
//# sourceMappingURL=planu-autocommit.js.map