diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 315bb285..f23b21eb 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -184,7 +184,10 @@ export async function main(): Promise { } // Check prerequisites (git, jj, arr initialized) + const debug = !!parsed.flags.debug; + let t0 = Date.now(); const prereqs = await checkContext(); + if (debug) console.log(` checkContext: ${Date.now() - t0}ms`); if (!isContextValid(prereqs, requiredLevel)) { printContextError(prereqs, requiredLevel); process.exit(1); @@ -193,12 +196,16 @@ export async function main(): Promise { // Initialize context with engine let context: ArrContext | null = null; try { + t0 = Date.now(); context = await initContext(); + if (debug) console.log(` initContext: ${Date.now() - t0}ms`); // Trigger background PR refresh (rate-limited) triggerBackgroundRefresh(context.cwd); + t0 = Date.now(); await handler(parsed, context); + if (debug) console.log(` handler: ${Date.now() - t0}ms`); } finally { // Auto-persist engine changes context?.engine.persist(); diff --git a/apps/cli/src/commands/log.ts b/apps/cli/src/commands/log.ts index 936cd89a..e7cae72f 100644 --- a/apps/cli/src/commands/log.ts +++ b/apps/cli/src/commands/log.ts @@ -112,10 +112,8 @@ export async function log( } // Extract data directly from jj output (no extra jj calls needed) - const { unsyncedBookmarks, behindTrunkChanges, wcParentBookmark } = extractTemplateData( - result.value.stdout, - trackedBookmarks, - ); + const { unsyncedBookmarks, behindTrunkChanges, wcParentBookmark } = + extractTemplateData(result.value.stdout, trackedBookmarks); // Build enhancement data t0 = Date.now(); @@ -547,7 +545,6 @@ function buildPRInfoMap( return prInfoMap; } - function formatChangeId(changeId: string, prefix: string): string { const short = changeId.slice(0, 8); if (prefix && short.startsWith(prefix)) { diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index af0d27af..e6e64aea 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,4 +1,3 @@ -import { hasResolvedConflict } from "@array/core/commands/resolve"; import { status as statusCmd } from "@array/core/commands/status"; import { COMMANDS } from "../registry"; import { @@ -18,11 +17,14 @@ import { } from "../utils/output"; import { unwrap } from "../utils/run"; -export async function status(): Promise { - const { info, stats } = unwrap(await statusCmd()); +export async function status(options: { debug?: boolean } = {}): Promise { + const debug = options.debug ?? false; + const { + info, + stats, + hasResolvedConflict: hasResolved, + } = unwrap(await statusCmd({ debug })); const statsStr = stats ? ` ${formatDiffStats(stats)}` : ""; - const resolvedResult = await hasResolvedConflict(); - const hasResolved = resolvedResult.ok && resolvedResult.value; // Check if on main with no stack above (fresh start) const isOnMainFresh = diff --git a/apps/cli/src/registry.ts b/apps/cli/src/registry.ts index 632fb3dd..1fa2fdd3 100644 --- a/apps/cli/src/registry.ts +++ b/apps/cli/src/registry.ts @@ -119,7 +119,7 @@ export const HANDLERS: Record = { init: (p) => init(p.flags), auth: () => auth(), config: () => config(), - status: () => status(), + status: (p) => status({ debug: !!p.flags.debug }), create: (p, ctx) => create(p.args.join(" "), ctx!), submit: (p, ctx) => submit(p.flags, ctx!), get: (p, ctx) => get(ctx!, p.args[0]), diff --git a/packages/core/src/commands/squash.ts b/packages/core/src/commands/squash.ts index 5c90b893..43c795d4 100644 --- a/packages/core/src/commands/squash.ts +++ b/packages/core/src/commands/squash.ts @@ -34,33 +34,31 @@ export async function squash( ): Promise> { const { id, engine } = options; - const statusBefore = await status(); - if (!statusBefore.ok) return statusBefore; - - // Resolve the change - let change: Changeset; - if (id) { - const findResult = await findChange(id, { includeBookmarks: true }); - if (!findResult.ok) return findResult; - if (findResult.value.status === "none") { - return err(createError("INVALID_REVISION", `Change not found: ${id}`)); - } - if (findResult.value.status === "multiple") { - return err( - createError( - "AMBIGUOUS_REVISION", - `Multiple changes match "${id}". Use a more specific identifier.`, - ), - ); - } - change = findResult.value.change; - } else { - // Use current working copy - change = statusBefore.value.workingCopy; + // Resolve the change - always use findChange for full Changeset info + const targetRevset = id || "@-"; // @- is the parent (current change), @ is WC + const findResult = await findChange(targetRevset, { includeBookmarks: true }); + if (!findResult.ok) return findResult; + if (findResult.value.status === "none") { + return err( + createError("INVALID_REVISION", `Change not found: ${id || "current"}`), + ); + } + if (findResult.value.status === "multiple") { + return err( + createError( + "AMBIGUOUS_REVISION", + `Multiple changes match "${id}". Use a more specific identifier.`, + ), + ); } + const change = findResult.value.change; + // Check if we're currently on this change (need status for WC info) + const statusBefore = await status(); + if (!statusBefore.ok) return statusBefore; const wasOnChange = - statusBefore.value.workingCopy.changeId === change.changeId; + statusBefore.value.workingCopy.changeId === change.changeId || + statusBefore.value.parents[0]?.changeId === change.changeId; const parentId = change.parents[0]; const childrenResult = await list({ diff --git a/packages/core/src/commands/status.ts b/packages/core/src/commands/status.ts index ea65a73d..31098917 100644 --- a/packages/core/src/commands/status.ts +++ b/packages/core/src/commands/status.ts @@ -1,11 +1,4 @@ -import { - getBookmarkTracking, - getDiffStats, - getTrunk, - status as jjStatus, - list, -} from "../jj"; -import { buildTree, flattenTree } from "../log"; +import { getDiffStats, getTrunk, status as jjStatus } from "../jj"; import { ok, type Result } from "../result"; import type { DiffStats, NextAction, StatusInfo } from "../types"; import type { Command } from "./types"; @@ -13,54 +6,54 @@ import type { Command } from "./types"; interface StatusResult { info: StatusInfo; stats: DiffStats | null; + hasResolvedConflict: boolean; } /** * Get current status information including working copy state, * stack path, conflicts, modified files, and diff stats. */ -export async function status(): Promise> { - const statusResult = await jjStatus(); - if (!statusResult.ok) return statusResult; - - // Fetch all mutable changes plus trunk for log - const logResult = await list({ revset: "mutable() | trunk()" }); - if (!logResult.ok) return logResult; - - const trunkBranch = await getTrunk(); - const trunk = - logResult.value.find( - (c) => c.bookmarks.includes(trunkBranch) && c.isImmutable, - ) ?? null; - const workingCopy = logResult.value.find((c) => c.isWorkingCopy) ?? null; - const allChanges = logResult.value.filter((c) => !c.isImmutable); - const trunkId = trunk?.changeId ?? ""; - - // Current change is the parent (@-) - const currentChangeId = workingCopy?.parents[0] ?? null; - const isOnTrunk = currentChangeId === trunkId; - - // Filter changes - exclude the WC itself (it's always empty/scratch) - const wcChangeId = workingCopy?.changeId ?? null; - const changes = allChanges.filter((c) => { - if (c.description.trim() !== "" || c.hasConflicts) return true; - if (c.changeId === wcChangeId) return false; - return !c.isEmpty; - }); - - // Get bookmark tracking to find modified bookmarks - const trackingResult = await getBookmarkTracking(); - const modifiedBookmarks = new Set(); - if (trackingResult.ok) { - for (const s of trackingResult.value) { - if (s.aheadCount > 0) modifiedBookmarks.add(s.name); - } +export async function status( + options: { debug?: boolean } = {}, +): Promise> { + const debug = options.debug ?? false; + const t0 = Date.now(); + + // Run jjStatus (single template call) and getDiffStats in parallel + const [statusResult, statsResult, trunkBranch] = await Promise.all([ + (async () => { + const t = Date.now(); + const r = await jjStatus(); + if (debug) console.log(` jjStatus: ${Date.now() - t}ms`); + return r; + })(), + (async () => { + const t = Date.now(); + const r = await getDiffStats("@"); + if (debug) console.log(` getDiffStats: ${Date.now() - t}ms`); + return r; + })(), + (async () => { + const t = Date.now(); + const r = await getTrunk(); + if (debug) console.log(` getTrunk: ${Date.now() - t}ms`); + return r; + })(), + ]); + + if (debug) { + console.log(` parallel calls: ${Date.now() - t0}ms`); } - const roots = buildTree(changes, trunkId); - const entries = flattenTree(roots, currentChangeId, modifiedBookmarks); + if (!statusResult.ok) return statusResult; - const { modifiedFiles, conflicts, parents } = statusResult.value; + const { + workingCopy, + parents, + modifiedFiles, + conflicts, + hasResolvedConflict, + } = statusResult.value; // Current change is the parent, not the WC const currentChange = parents[0] ?? null; @@ -68,20 +61,13 @@ export async function status(): Promise> { const hasConflicts = conflicts.length > 0; const parentHasConflicts = currentChange?.hasConflicts ?? false; const isUndescribed = currentChange?.description.trim() === ""; + const isOnTrunk = currentChange?.bookmarks.includes(trunkBranch) ?? false; - // Build stack path + // Build stack path from parent bookmarks const stackPath: string[] = []; - const parentIds = new Set(parents.map((p) => p.changeId)); - for (const entry of entries) { - if (parentIds.has(entry.change.changeId)) { - const label = entry.change.bookmarks[0] || entry.change.description; - if (label) stackPath.push(label); - for (const pid of entry.change.parents) parentIds.add(pid); - } - if (stackPath.length >= 3) { - stackPath.push("..."); - break; - } + if (currentChange) { + const label = currentChange.bookmarks[0] || currentChange.description || ""; + if (label) stackPath.push(label); } stackPath.push(trunkBranch); @@ -96,15 +82,11 @@ export async function status(): Promise> { } else if (isOnTrunk) { nextAction = { action: "create", reason: "on_trunk" }; } else { - const currentEntry = entries.find((e) => e.isCurrent); - const hasBookmark = - currentEntry && currentEntry.change.bookmarks.length > 0; - const currentModified = currentEntry?.isModified ?? false; - - if (modifiedFiles.length > 0 || currentModified) { + const hasBookmark = currentChange && currentChange.bookmarks.length > 0; + if (modifiedFiles.length > 0) { nextAction = { action: "submit", - reason: hasBookmark && currentModified ? "update_pr" : "create_pr", + reason: hasBookmark ? "update_pr" : "create_pr", }; } else { nextAction = { action: "up", reason: "start_new" }; @@ -126,14 +108,16 @@ export async function status(): Promise> { nextAction, }; - // Get diff stats for uncommitted work in WC - const statsResult = await getDiffStats("@"); const stats = statsResult.ok ? statsResult.value : null; - return ok({ info, stats }); + if (debug) { + console.log(` TOTAL: ${Date.now() - t0}ms`); + } + + return ok({ info, stats, hasResolvedConflict }); } -export const statusCommand: Command = { +export const statusCommand: Command = { meta: { name: "status", description: "Show the current change and working copy modifications", diff --git a/packages/core/src/commands/track.ts b/packages/core/src/commands/track.ts index 792b8ab3..0597fc87 100644 --- a/packages/core/src/commands/track.ts +++ b/packages/core/src/commands/track.ts @@ -1,5 +1,5 @@ import type { Engine } from "../engine"; -import { ensureBookmark, findChange, getTrunk, list, status } from "../jj"; +import { ensureBookmark, findChange, getTrunk, list } from "../jj"; import { createError, err, ok, type Result } from "../result"; import { datePrefixedLabel } from "../slugify"; import type { Command } from "./types"; @@ -42,13 +42,15 @@ export async function track( if (!target) { // No target - use current change (@-) - const statusResult = await status(); - if (!statusResult.ok) return statusResult; - - const current = statusResult.value.parents[0]; - if (!current) { + const findResult = await findChange("@-", { includeBookmarks: true }); + if (!findResult.ok) return findResult; + if (findResult.value.status === "none") { return err(createError("INVALID_STATE", "No current change")); } + if (findResult.value.status === "multiple") { + return err(createError("INVALID_STATE", "Unexpected multiple matches")); + } + const current = findResult.value.change; changeId = current.changeId; description = current.description; existingBookmark = current.bookmarks[0]; diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 2c26da23..837f5420 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -3,7 +3,7 @@ import { deleteMetadata, listTrackedBranches, type PRInfo, - readMetadata, + readMetadataBatch, writeMetadata, } from "../git/metadata"; import { getTrunk, list } from "../jj"; @@ -84,13 +84,11 @@ export function createEngine(cwd: string = process.cwd()): Engine { load(): void { if (loaded) return; - // Load metadata from git refs + // Load metadata from git refs - single git call for all branches const tracked = listTrackedBranches(cwd); - for (const [bookmarkName] of tracked) { - const meta = readMetadata(bookmarkName, cwd); - if (meta) { - branches.set(bookmarkName, meta); - } + const metadataMap = readMetadataBatch(tracked, cwd); + for (const [bookmarkName, meta] of metadataMap) { + branches.set(bookmarkName, meta); } loaded = true; diff --git a/packages/core/src/git/metadata.ts b/packages/core/src/git/metadata.ts index 7b5afdaf..26448b18 100644 --- a/packages/core/src/git/metadata.ts +++ b/packages/core/src/git/metadata.ts @@ -88,6 +88,89 @@ export function readMetadata( } } +/** + * Batch read metadata for multiple branches in a single git call. + * Much faster than calling readMetadata() for each branch individually. + */ +export function readMetadataBatch( + branches: Map, + cwd?: string, +): Map { + const result = new Map(); + if (branches.size === 0) return result; + + // Build input: one object ID per line + const objectIds = Array.from(branches.values()); + const input = objectIds.join("\n"); + + // Run git cat-file --batch + const output = runGitSync(["cat-file", "--batch"], { + cwd, + input, + onError: "ignore", + }); + + if (!output) return result; + + // Parse batch output format: + // blob + // + // (blank line or next header) + const branchNames = Array.from(branches.keys()); + const lines = output.split("\n"); + let lineIdx = 0; + let branchIdx = 0; + + while (lineIdx < lines.length && branchIdx < branchNames.length) { + const headerLine = lines[lineIdx]; + if (!headerLine || headerLine.includes("missing")) { + // Object not found, skip this branch + lineIdx++; + branchIdx++; + continue; + } + + // Parse header: + const headerMatch = headerLine.match(/^([a-f0-9]+) (\w+) (\d+)$/); + if (!headerMatch) { + lineIdx++; + branchIdx++; + continue; + } + + const size = parseInt(headerMatch[3], 10); + lineIdx++; // Move past header + + // Read content (may span multiple lines) + let content = ""; + let remaining = size; + while (remaining > 0 && lineIdx < lines.length) { + const line = lines[lineIdx]; + content += line; + remaining -= line.length; + lineIdx++; + if (remaining > 0) { + content += "\n"; + remaining -= 1; // Account for newline + } + } + + // Parse JSON and validate + try { + const parsed = branchMetaSchema.safeParse(JSON.parse(content)); + if (parsed.success) { + result.set(branchNames[branchIdx], parsed.data); + } + } catch { + // Invalid JSON, skip + } + + branchIdx++; + } + + return result; +} + /** * Delete metadata for a branch. */ diff --git a/packages/core/src/jj/status.ts b/packages/core/src/jj/status.ts index b98fd81d..84189900 100644 --- a/packages/core/src/jj/status.ts +++ b/packages/core/src/jj/status.ts @@ -1,39 +1,168 @@ -import { parseConflicts, parseFileChanges } from "../parser"; -import { createError, err, ok, type Result } from "../result"; -import type { ChangesetStatus } from "../types"; -import { list } from "./list"; +import { parseConflicts } from "../parser"; +import { ok, type Result } from "../result"; +import type { ChangesetStatus, FileChange } from "../types"; import { runJJ } from "./runner"; +// Single template that gets all status info in one jj call +const STATUS_TEMPLATE = [ + '"CHANGE:"', + "change_id.short()", + '"|"', + "change_id.shortest().prefix()", + '"|"', + 'if(current_working_copy, "wc", "")', + '"|"', + 'bookmarks.join(",")', + '"|"', + "description.first_line()", + '"|"', + 'if(conflict, "1", "0")', + '"|"', + 'if(empty, "1", "0")', + '"|"', + "self.diff().summary()", + '"\\n"', +].join(" ++ "); + +interface ParsedChange { + changeId: string; + changeIdPrefix: string; + isWorkingCopy: boolean; + bookmarks: string[]; + description: string; + hasConflicts: boolean; + isEmpty: boolean; + diffSummary: string; +} + +function parseStatusLine(line: string): ParsedChange | null { + if (!line.startsWith("CHANGE:")) return null; + const data = line.slice(7); + const parts = data.split("|"); + + return { + changeId: parts[0] || "", + changeIdPrefix: parts[1] || "", + isWorkingCopy: parts[2] === "wc", + bookmarks: (parts[3] || "").split(",").filter(Boolean), + description: parts[4] || "", + hasConflicts: parts[5] === "1", + isEmpty: parts[6] === "1", + diffSummary: parts[7] || "", + }; +} + +function parseModifiedFiles(diffSummary: string): FileChange[] { + if (!diffSummary.trim()) return []; + + return diffSummary + .split("\n") + .filter(Boolean) + .map((line) => { + const status = line[0]; + const path = line.slice(2).trim(); + const statusMap: Record = { + M: "modified", + A: "added", + D: "deleted", + R: "renamed", + C: "copied", + }; + return { path, status: statusMap[status] || "modified" }; + }); +} + +/** + * Get working copy status in a single jj call. + */ export async function status( cwd = process.cwd(), ): Promise> { - const changesResult = await list({ revset: "(@ | @-)" }, cwd); - if (!changesResult.ok) return changesResult; + // Single jj call with template - gets WC, parent, and grandparent for stack path + const result = await runJJ( + ["log", "-r", "@ | @- | @--", "--no-graph", "-T", STATUS_TEMPLATE], + cwd, + ); - const workingCopy = changesResult.value.find((c) => c.isWorkingCopy); - if (!workingCopy) { - return err(createError("PARSE_ERROR", "Could not find working copy")); - } + if (!result.ok) return result; - const parents = changesResult.value.filter((c) => !c.isWorkingCopy); + const lines = result.value.stdout.split("\n").filter(Boolean); + const changes = lines.map(parseStatusLine).filter(Boolean) as ParsedChange[]; - const [diffResult, statusResult] = await Promise.all([ - runJJ(["diff", "--summary"], cwd), - runJJ(["status"], cwd), - ]); + const workingCopy = changes.find((c) => c.isWorkingCopy); + const parent = changes.find((c) => !c.isWorkingCopy); - const modifiedFiles = diffResult.ok - ? parseFileChanges(diffResult.value.stdout) - : ok([]); + // For hasResolvedConflict, we still need jj status output + // But only if parent has conflicts - otherwise skip it + let hasResolvedConflict = false; + if (parent?.hasConflicts) { + const statusResult = await runJJ(["status"], cwd); + if (statusResult.ok) { + hasResolvedConflict = statusResult.value.stdout.includes( + "Conflict in parent commit has been resolved in working copy", + ); + } + } - const conflicts = statusResult.ok - ? parseConflicts(statusResult.value.stdout) - : ok([]); + // Parse conflicts from jj status if there are any + let conflicts: { path: string; type: "content" | "delete" | "rename" }[] = []; + if (workingCopy?.hasConflicts || parent?.hasConflicts) { + const statusResult = await runJJ(["status"], cwd); + if (statusResult.ok) { + const parsed = parseConflicts(statusResult.value.stdout); + if (parsed.ok) conflicts = parsed.value; + } + } return ok({ - workingCopy, - parents, - modifiedFiles: modifiedFiles.ok ? modifiedFiles.value : [], - conflicts: conflicts.ok ? conflicts.value : [], + workingCopy: workingCopy + ? { + changeId: workingCopy.changeId, + changeIdPrefix: workingCopy.changeIdPrefix, + commitId: "", + commitIdPrefix: "", + description: workingCopy.description, + bookmarks: workingCopy.bookmarks, + parents: parent ? [parent.changeId] : [], + isWorkingCopy: true, + isImmutable: false, + isEmpty: workingCopy.isEmpty, + hasConflicts: workingCopy.hasConflicts, + } + : { + changeId: "", + changeIdPrefix: "", + commitId: "", + commitIdPrefix: "", + description: "", + bookmarks: [], + parents: [], + isWorkingCopy: true, + isImmutable: false, + isEmpty: true, + hasConflicts: false, + }, + parents: parent + ? [ + { + changeId: parent.changeId, + changeIdPrefix: parent.changeIdPrefix, + commitId: "", + commitIdPrefix: "", + description: parent.description, + bookmarks: parent.bookmarks, + parents: [], + isWorkingCopy: false, + isImmutable: false, + isEmpty: parent.isEmpty, + hasConflicts: parent.hasConflicts, + }, + ] + : [], + modifiedFiles: workingCopy + ? parseModifiedFiles(workingCopy.diffSummary) + : [], + conflicts, + hasResolvedConflict, }); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f928855f..930986eb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -10,11 +10,27 @@ export interface ConflictInfo { type: "content" | "delete" | "rename"; } +/** Lightweight changeset info for status display */ +export interface StatusChangeset { + changeId: string; + changeIdPrefix: string; + commitId: string; + commitIdPrefix: string; + description: string; + bookmarks: string[]; + parents: string[]; + isWorkingCopy: boolean; + isImmutable: boolean; + isEmpty: boolean; + hasConflicts: boolean; +} + export interface ChangesetStatus { - workingCopy: Changeset; - parents: Changeset[]; + workingCopy: StatusChangeset; + parents: StatusChangeset[]; modifiedFiles: FileChange[]; conflicts: ConflictInfo[]; + hasResolvedConflict: boolean; } export interface FileChange {