// engine/housekeeping/find-stale-branches.ts — SPEC-751 + SPEC-764
// Detects local branches that are fully merged into main.
// Uses `git cherry main <branch>` instead of `git merge-base --is-ancestor`
// because cherry-picked branches don't share ancestor SHAs with main.
// Pass 2 (SPEC-764): cross-references Planu status.json for spec-done detection.
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { unlink, stat, readFile } from 'node:fs/promises';
import { glob } from 'glob';
const execFile = promisify(execFileCb);
const DEFAULT_PATTERNS = [
    'tmp-*',
    'worktree-agent-*',
    'feat/spec-*',
    'fix/spec-*',
    'chore/spec-*',
    'refactor/spec-*',
    'docs/spec-*',
];
/**
 * Convert a glob-style pattern (only `*` is supported as wildcard) to a RegExp.
 */
function patternToRegex(pattern) {
    const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
    return new RegExp(`^${escaped}$`);
}
/**
 * Check if `branch` is fully equivalent to `mainBranch` using git cherry.
 * Returns true when ALL commits in the branch are already in main (by patch-id).
 * This correctly handles cherry-picked commits that share patch content but not SHA.
 */
async function isBranchFullyInMain(projectPath, mainBranch, branch) {
    try {
        const { stdout } = await execFile('git', ['cherry', mainBranch, branch], { cwd: projectPath });
        const lines = stdout.trim().split('\n').filter(Boolean);
        if (lines.length === 0) {
            // No commits ahead of main → safe to delete (already merged or empty)
            return true;
        }
        // Any line starting with '+' means that commit is NOT in main
        const hasUnmergedCommit = lines.some((l) => l.startsWith('+'));
        return !hasUnmergedCommit;
    }
    catch {
        // git cherry failed — conservatively treat branch as not merged
        return false;
    }
}
/**
 * Get the current HEAD branch name. Returns null if detached.
 */
async function getCurrentBranch(projectPath) {
    try {
        const { stdout } = await execFile('git', ['symbolic-ref', '--short', 'HEAD'], {
            cwd: projectPath,
        });
        return stdout.trim() || null;
    }
    catch {
        return null;
    }
}
/**
 * Check if the latest reflog entry for a branch is newer than 1h (sign of active work).
 * Only applied when `aggressive: false`.
 */
async function hasRecentReflog(projectPath, branch, maxAgeSeconds = 3600) {
    try {
        const { stdout } = await execFile('git', ['reflog', '--format=%ct', `refs/heads/${branch}`, '-1'], { cwd: projectPath });
        const ts = parseInt(stdout.trim(), 10);
        if (isNaN(ts)) {
            return false;
        }
        const ageSeconds = Math.floor(Date.now() / 1000) - ts;
        return ageSeconds < maxAgeSeconds;
    }
    catch {
        return false;
    }
}
// ---------------------------------------------------------------------------
// Backup cleanup
// ---------------------------------------------------------------------------
const BACKUP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/**
 * Delete stale .bak.* files from planu/specs/ that are older than 7 days.
 * Best-effort: per-file errors are silently skipped. Returns count of deleted files.
 */
export async function cleanStaleBackups(projectPath) {
    let deleted = 0;
    try {
        const files = await glob(`${projectPath}/planu/specs/**/*.bak.*`);
        const now = Date.now();
        for (const filePath of files) {
            try {
                const s = await stat(filePath);
                if (now - s.mtimeMs > BACKUP_MAX_AGE_MS) {
                    await unlink(filePath);
                    deleted++;
                }
            }
            catch {
                // per-file error — skip
            }
        }
    }
    catch {
        // glob failure — best-effort
    }
    return deleted;
}
// ---------------------------------------------------------------------------
// Pass 2 — Planu status cross-reference (SPEC-764)
// ---------------------------------------------------------------------------
/** Pattern for SPEC-NNN in branch names or commit messages. Case-insensitive. */
const SPEC_ID_RE = /\bSPEC-(\d+)\b/i;
/**
 * Extract a canonical SPEC-NNN id (uppercase) from a branch name.
 * Returns null when the branch name contains no SPEC-NNN pattern.
 *
 * Examples:
 *   'feat/SPEC-762-foo'  → 'SPEC-762'
 *   'fix/spec-763-slug'  → 'SPEC-763'
 *   'tmp-bundle-A'       → null
 */
export function extractSpecIdFromName(branchName) {
    const m = SPEC_ID_RE.exec(branchName);
    return m ? `SPEC-${m[1]}` : null;
}
/**
 * Extract a canonical SPEC-NNN id from the tip commit message of `sha`.
 * Returns null on git error or when no SPEC-NNN pattern is found.
 *
 * Example commit message: 'feat(spec-728): implement foo' → 'SPEC-728'
 */
export async function extractSpecIdFromCommit(projectPath, sha) {
    if (!sha) {
        return null;
    }
    try {
        const { stdout } = await execFile('git', ['log', '-1', '--format=%s', sha], {
            cwd: projectPath,
        });
        const m = SPEC_ID_RE.exec(stdout.trim());
        return m ? `SPEC-${m[1]}` : null;
    }
    catch {
        return null;
    }
}
/** Terminal statuses that mean the branch work is complete. */
const DONE_STATUSES = new Set(['done', 'discarded']);
/**
 * Look up the status of a spec by reading 'planu/specs/<specId>-star/spec.md' frontmatter.
 * Returns the raw status string, or null when not found.
 */
export async function lookupSpecStatus(projectPath, specId) {
    try {
        // glob for any folder starting with the spec id (e.g. SPEC-762-*)
        const pattern = `${projectPath}/planu/specs/${specId}-*/spec.md`;
        const files = await glob(pattern);
        if (files.length === 0) {
            return null;
        }
        const firstFile = files[0];
        /* c8 ignore next 3 */
        if (!firstFile) {
            return null;
        }
        const content = await readFile(firstFile, 'utf8');
        // Extract `status: <value>` from the YAML frontmatter block
        const statusMatch = /^status:\s*(\S+)/m.exec(content);
        return statusMatch?.[1] ?? null;
    }
    catch {
        return null;
    }
}
/**
 * Pass 2: for each branch in `skipped` with reason 'not_merged', check if its
 * SPEC ID (from name or tip commit message) is `done` or `discarded` in Planu.
 * Matching branches are moved from `skipped` to `stale` with reason 'spec-done'.
 * Mutates both arrays in-place.
 */
export async function applySpecDonePass(projectPath, skipped, stale, branchShas) {
    // Fast-path: no not_merged branches → nothing for Pass 2 to do.
    const notMerged = skipped.filter((b) => b.skipReason === 'not_merged');
    if (notMerged.length === 0) {
        return;
    }
    const toRemove = new Set();
    for (const branch of notMerged) {
        // Try branch name first (zero git calls)
        let specId = extractSpecIdFromName(branch.name);
        // Fall back to commit message if name has no spec id (one git call per branch)
        if (!specId) {
            const sha = branchShas.get(branch.name) ?? '';
            specId = await extractSpecIdFromCommit(projectPath, sha);
        }
        if (!specId) {
            continue;
        }
        const status = await lookupSpecStatus(projectPath, specId);
        if (status !== null && DONE_STATUSES.has(status)) {
            const sha = branchShas.get(branch.name) ?? '';
            stale.push({ name: branch.name, sha, reason: 'spec-done' });
            toRemove.add(branch.name);
        }
    }
    // Remove promoted branches from skipped
    for (let i = skipped.length - 1; i >= 0; i--) {
        const entry = skipped[i];
        if (entry && toRemove.has(entry.name)) {
            skipped.splice(i, 1);
        }
    }
}
export async function findStaleBranches(input) {
    const { projectPath, mainBranch = 'main', patterns = DEFAULT_PATTERNS, aggressive = false, } = input;
    const matchers = patterns.map(patternToRegex);
    // Get all local branches with their tip SHAs
    const allBranchLines = await (async () => {
        try {
            const { stdout } = await execFile('git', ['for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads/'], { cwd: projectPath });
            return stdout.trim().split('\n').filter(Boolean);
        }
        catch {
            return null;
        }
    })();
    if (allBranchLines === null) {
        return { stale: [], skipped: [] };
    }
    const currentBranch = await getCurrentBranch(projectPath);
    const protectedBranches = new Set(['main', 'master', 'develop', 'release', mainBranch]);
    const stale = [];
    const skipped = [];
    const branchShas = new Map();
    for (const line of allBranchLines) {
        const parts = line.split(' ');
        const name = parts[0];
        const sha = parts[1] ?? '';
        /* c8 ignore next 3 */
        if (!name) {
            continue;
        }
        // Track SHA for every branch (needed by Pass 2)
        branchShas.set(name, sha);
        // Skip branches that don't match our patterns
        const matchesPattern = matchers.some((re) => re.test(name));
        if (!matchesPattern) {
            continue;
        }
        // Safety: never touch protected base branches
        if (protectedBranches.has(name)) {
            skipped.push({ name, skipReason: 'protected' });
            continue;
        }
        // Safety: never touch currently checked-out branch
        if (name === currentBranch) {
            skipped.push({ name, skipReason: 'current_branch' });
            continue;
        }
        // Safety: skip branches with recent reflog activity (unless aggressive mode)
        if (!aggressive) {
            const recent = await hasRecentReflog(projectPath, name);
            if (recent) {
                skipped.push({ name, skipReason: 'recent_reflog' });
                continue;
            }
        }
        // Check if fully in main using git cherry
        const fullyMerged = await isBranchFullyInMain(projectPath, mainBranch, name);
        if (!fullyMerged) {
            skipped.push({ name, skipReason: 'not_merged' });
            continue;
        }
        stale.push({ name, sha, reason: 'merged' });
    }
    // Pass 2 (SPEC-764): cross-reference Planu status for branches not caught by git cherry
    await applySpecDonePass(projectPath, skipped, stale, branchShas);
    return { stale, skipped };
}
//# sourceMappingURL=find-stale-branches.js.map