/** * @file Preflight checks before destructive lane operations (reset, remove, purge). * Queries the current state without mutations: git status, unpushed commits, sessions * to be purged, and blockers (adopted lanes, missing directories, unpushed work). * Every result is read-only; the route and action layer decide what to do with blocks. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const { db } = require("../db"); const wt = require("./worktree"); const lanesLib = require("./lanes"); const { resolveProfile } = require("./lane-profile"); const { dbName } = require("./lane-slots"); /** * Preflight for reset, remove, or purge. Returns an object describing what will happen: * - reset/remove: {action, lane, kind, branch, dirty, untracked, unpushed, head, blocked, warnings} * - purge: {action, lane, sessions, events, tokenRows, bytesEstimate, activeSessionSkipped} * * blocked[] holds only conditions that genuinely prevent the action: "adopted" (not * managed), "missing" (dir gone), "unreadable" (git failed against the directory), and * "unpushed-commits" (unpushed > 0 — the only one a `force: true` overrides). warnings[] * holds purely informational facts that never gate the action, starting with "no-remote" * (no git remote configured — nothing is backed up, but the action proceeds). Both are * data, not an exception. * * @param {object} lane - The lane to check * @param {string} action - One of "reset", "remove", "purge" * @returns {Promise} Preflight report */ async function preflight(lane, action) { const blocked = []; const warnings = []; // Check if lane is adopted (not managed) if (lane.kind === "adopted") { blocked.push("adopted"); } // For reset/remove, return git status and blockers if (action === "reset" || action === "remove") { let dirty = 0; let untracked = 0; let unpushed = 0; let head = null; // First check if directory exists if (!fs.existsSync(lane.cwd)) { blocked.push("missing"); } else { // Directory exists, try to read git status try { // Get status counts const status = await wt.statusCounts(lane.cwd); dirty = status.dirty; untracked = status.untracked; head = status.head; // Get unpushed count — measured against the lane's own base branch when // there is no remote, so it counts this lane's work, not the whole repo. unpushed = await wt.unpushedCount(lane.cwd, lane.base_branch); if (unpushed > 0) { blocked.push("unpushed-commits"); } // Detect no remotes configured at all — informational only, never a blocker: // a perfectly ordinary local-only managed lane has no remote at all. const noRemote = await wt.hasNoRemotes(lane.cwd); if (noRemote) { warnings.push("no-remote"); } } catch (err) { // Directory exists but git failed (corrupt repo, permission denied, etc.) blocked.push("unreadable"); } } // The database name this action would drop (reset unless --keep-db, // remove always) — echoed the same way `head`/`dirty` are, so the // confirmation dialog names the destructive fact rather than leaving it a // surprise. Null when the lane has no slot yet or the profile declares no // DB_PREFIX — nothing has been derived to drop. const profile = resolveProfile(lane); const database = profile && lane.slot ? dbName(profile, lane.slot) : null; return { action, lane: lane.id, kind: lane.kind, branch: lane.branch, dirty, untracked, unpushed, head: head || null, database, blocked, warnings, }; } // For purge, count sessions and events to be deleted if (action === "purge") { const counts = countPurgeSessions(lane); return { action, lane: lane.id, sessions: counts.sessions, events: counts.events, tokenRows: counts.tokenRows, bytesEstimate: (counts.events + counts.tokenRows) * 512, activeSessionSkipped: counts.activeSessionSkipped, }; } // Unknown action should never reach here (route validates) throw Object.assign(new Error(`unknown action: ${action}`), { code: "EBADACTION" }); } /** * Count sessions, events, and token_usage rows that would be deleted by purgeLaneSessions. * Uses the shared purgeCandidateSessions helper to ensure the counts match exactly what * gets deleted, so the confirmation dialog's numbers are truthful. */ function countPurgeSessions(lane) { const result = { sessions: 0, events: 0, tokenRows: 0, activeSessionSkipped: false }; // Use the shared helpers so the path matching (and its LIKE escaping) has // exactly one definition, in server/lib/lanes.js. const sessionsToDelete = lanesLib.purgeCandidateSessions(lane); result.activeSessionSkipped = lanesLib.hasActiveLaneSession(lane); if (sessionsToDelete.length === 0) { return result; } // Count events for those sessions const sessionIds = sessionsToDelete.map((s) => s.id); const eventsCount = db .prepare( `SELECT COUNT(*) as count FROM events WHERE session_id IN (${sessionIds.map(() => "?").join(",")})` ) .get(...sessionIds); result.events = eventsCount ? eventsCount.count : 0; // Count orphaned token_usage rows const tokenCount = db .prepare( `SELECT COUNT(*) as count FROM token_usage WHERE session_id IN (${sessionIds.map(() => "?").join(",")})` ) .get(...sessionIds); result.tokenRows = tokenCount ? tokenCount.count : 0; // Count sessions (for completeness) result.sessions = sessionsToDelete.length; return result; } module.exports = { preflight };