diff --git a/apps/cli/src/commands/bottom.ts b/apps/cli/src/commands/bottom.ts index bf794233..6022091a 100644 --- a/apps/cli/src/commands/bottom.ts +++ b/apps/cli/src/commands/bottom.ts @@ -1,7 +1,7 @@ import { bottom as coreBottom } from "@array/core/commands/bottom"; -import { printNav } from "../utils/output"; +import { printNavResult } from "../utils/output"; import { unwrap } from "../utils/run"; export async function bottom(): Promise { - printNav("down", unwrap(await coreBottom())); + printNavResult(unwrap(await coreBottom())); } diff --git a/apps/cli/src/commands/checkout.ts b/apps/cli/src/commands/checkout.ts index 95ccdf7f..342c35be 100644 --- a/apps/cli/src/commands/checkout.ts +++ b/apps/cli/src/commands/checkout.ts @@ -1,23 +1,8 @@ import { checkout as checkoutCmd } from "@array/core/commands/checkout"; -import { changeLabel } from "@array/core/slugify"; -import { cyan, dim, formatSuccess, message } from "../utils/output"; +import { printNavResult } from "../utils/output"; import { requireArg, unwrap } from "../utils/run"; export async function checkout(id: string): Promise { requireArg(id, "Usage: arr checkout "); - - const result = unwrap(await checkoutCmd(id)); - - // Handle trunk checkout - creates new empty change on main - if (id === "main" || id === "master" || id === "trunk") { - message(formatSuccess(`Switched to ${cyan(id)}`)); - return; - } - - const label = changeLabel(result.change.description, result.change.changeId); - message( - formatSuccess( - `Switched to ${cyan(label)}: ${result.change.description || dim("(no description)")}`, - ), - ); + printNavResult(unwrap(await checkoutCmd(id))); } diff --git a/apps/cli/src/commands/down.ts b/apps/cli/src/commands/down.ts index 3f9f9dc2..a23dc0f4 100644 --- a/apps/cli/src/commands/down.ts +++ b/apps/cli/src/commands/down.ts @@ -1,28 +1,7 @@ import { down as coreDown } from "@array/core/commands/down"; -import { COMMANDS } from "../registry"; -import { - arr, - cyan, - dim, - formatChangeId, - green, - hint, - message, -} from "../utils/output"; +import { printNavResult } from "../utils/output"; import { unwrap } from "../utils/run"; export async function down(): Promise { - const result = unwrap(await coreDown()); - - if (result.createdOnTrunk) { - message(`${green("◉")} Started fresh on ${cyan("main")}`); - hint(`Run ${arr(COMMANDS.top)} to go back to your stack`); - } else { - const shortId = formatChangeId( - result.changeId.slice(0, 8), - result.changeIdPrefix, - ); - const desc = result.description || dim("(empty)"); - message(`↓ ${green(desc)} ${shortId}`); - } + printNavResult(unwrap(await coreDown())); } diff --git a/apps/cli/src/commands/get.ts b/apps/cli/src/commands/get.ts new file mode 100644 index 00000000..b6142524 --- /dev/null +++ b/apps/cli/src/commands/get.ts @@ -0,0 +1,58 @@ +import { get as coreGet } from "@array/core/commands/get"; +import type { CommandMeta } from "@array/core/commands/types"; +import type { ArrContext } from "@array/core/engine"; +import { + cyan, + dim, + formatError, + formatSuccess, + hint, + magenta, + message, + status, +} from "../utils/output"; + +export const meta: CommandMeta = { + name: "get", + args: "", + description: + "Restore a branch and its stack from remote by name or PR number", + category: "workflow", +}; + +export async function get(ctx: ArrContext, target?: string): Promise { + if (!target) { + console.error(formatError("Missing argument: branch name or PR number")); + hint("Usage: arr get or arr get "); + process.exit(1); + } + + status(`Getting ${target} from remote...`); + + const result = await coreGet({ + target, + engine: ctx.engine, + cwd: ctx.cwd, + }); + + if (!result.ok) { + console.error(formatError(result.error.message)); + process.exit(1); + } + + const { stack, targetBranch } = result.value; + + message(formatSuccess(`Restored and switched to ${cyan(targetBranch)}`)); + + if (stack.length > 1) { + message(dim("Stack:")); + for (const branch of stack) { + const isTarget = branch.branchName === targetBranch; + const prLabel = magenta(`PR #${branch.prNumber}`); + const branchLabel = cyan(branch.branchName); + const marker = isTarget ? " ← you are here" : ""; + + message(` ${branchLabel} ${dim(`(${prLabel})`)}${marker}`); + } + } +} diff --git a/apps/cli/src/commands/hidden/dump-refs.ts b/apps/cli/src/commands/hidden/dump-refs.ts index 0cabde5d..c5d891b5 100644 --- a/apps/cli/src/commands/hidden/dump-refs.ts +++ b/apps/cli/src/commands/hidden/dump-refs.ts @@ -1,4 +1,4 @@ -import { $ } from "bun"; +import { getTrackedBranchNames, readMetadata } from "@array/core/git/metadata"; /** * Hidden debug command to dump all arr refs metadata. @@ -8,23 +8,20 @@ import { $ } from "bun"; * metadata about changes (PR info, etc.). */ export async function dumpRefs(): Promise { - const result = - await $`git for-each-ref refs/arr --format='%(refname:short)'`.quiet(); - const refs = result.stdout.toString().trim().split("\n").filter(Boolean); + const branches = getTrackedBranchNames(); - if (refs.length === 0) { + if (branches.length === 0) { console.log("No arr refs found."); return; } - for (const ref of refs) { - console.log(`=== ${ref} ===`); - const blob = await $`git cat-file blob refs/${ref}`.quiet(); - const content = blob.stdout.toString().trim(); - try { - console.log(JSON.stringify(JSON.parse(content), null, 2)); - } catch { - console.log(content); + for (const branchName of branches) { + console.log(`=== arr/${branchName} ===`); + const meta = readMetadata(branchName); + if (meta) { + console.log(JSON.stringify(meta, null, 2)); + } else { + console.log("(failed to read metadata)"); } console.log(); } diff --git a/apps/cli/src/commands/log.ts b/apps/cli/src/commands/log.ts index 2de3c24e..824d7348 100644 --- a/apps/cli/src/commands/log.ts +++ b/apps/cli/src/commands/log.ts @@ -1,6 +1,8 @@ -import { log as logCmd } from "@array/core/commands/log"; -import type { ArrContext } from "@array/core/engine"; -import type { LogGraphData, PRInfo } from "@array/core/log-graph"; +import type { ArrContext, Engine } from "@array/core/engine"; +import { getCurrentGitBranch } from "@array/core/git/status"; +import { batchGetPRsForBranches } from "@array/core/github/pr-status"; +import { getBookmarkTracking, list, status } from "@array/core/jj"; +import type { Changeset } from "@array/core/parser"; import { COMMANDS } from "../registry"; import { arr, @@ -16,225 +18,493 @@ import { red, yellow, } from "../utils/output"; -import { unwrap } from "../utils/run"; - -export async function log(ctx: ArrContext): Promise { - const result = unwrap( - await logCmd({ - engine: ctx.engine, - trunk: ctx.trunk, - cwd: ctx.cwd, - }), - ); - const { data, unmanagedBranch } = result; +/** PR info for log display */ +interface PRInfo { + number: number; + state: "OPEN" | "CLOSED" | "MERGED"; + url: string; +} + +/** Intermediate data structure for log rendering */ +interface LogData { + /** All changes in the stack (excluding trunk) */ + changes: Changeset[]; + /** Working copy info */ + workingCopy: Changeset | null; + /** WC's parent(s) */ + parents: Changeset[]; + /** Trunk change */ + trunk: Changeset | null; + /** Trunk branch name */ + trunkName: string; + /** Bookmarks with unpushed changes */ + modifiedBookmarks: Set; + /** PR info by bookmark name */ + prInfoMap: Map; + /** Whether currently on an unmanaged git branch */ + unmanagedBranch: string | null; + /** Tracked bookmarks from engine */ + trackedBookmarks: string[]; + /** All changes from revset (for debugging) */ + allChanges: Changeset[]; +} + +interface LogFlags { + debug?: boolean; +} + +/** + * Fetch and render the log graph. + * All logic consolidated here for easier debugging. + */ +export async function log( + ctx: ArrContext, + flags: LogFlags = {}, +): Promise { + const { engine, trunk: trunkName, cwd } = ctx; + const debug = flags.debug ?? false; + + // Fetch all data needed for the log + const data = await fetchLogData(engine, trunkName, cwd); + + if (debug) { + printDebugInfo(data); + } + + // Check for unmanaged git branch + if (data.unmanagedBranch !== null) { + renderUnmanagedBranch(data.unmanagedBranch, trunkName); + return; + } - // isEmpty means: on trunk with empty working copy and no tracked branches - // In this case, show a simplified view - if (data.isEmpty) { - message(`${green("◉")} ${ctx.trunk} ${dim("(current)")}`); + // Check if on trunk with no changes + if (isEmptyState(data)) { + message(`${green("◉")} ${trunkName} ${dim("(current)")}`); hint(`Run ${cyan("arr create")} to start a new stack`); return; } - const output = renderLogGraph(data, ctx.trunk, unmanagedBranch === null); + // Render the log graph + const output = renderGraph(data); message(output); - if (data.modifiedCount > 0) { - const changeWord = data.modifiedCount === 1 ? "change has" : "changes have"; + // Show hint about unpushed changes + const modifiedCount = countModifiedChanges(data); + if (modifiedCount > 0) { + const changeWord = modifiedCount === 1 ? "change has" : "changes have"; message( - `${data.modifiedCount} ${changeWord} unpushed commits. Run ${arr(COMMANDS.submit)} to update PRs.`, + `${modifiedCount} ${changeWord} unpushed commits. Run ${arr(COMMANDS.submit)} to update PRs.`, ); } +} + +async function fetchLogData( + engine: Engine, + trunkName: string, + cwd: string, +): Promise { + // Get tracked bookmarks with OPEN PRs + const trackedBookmarks = engine.getTrackedBookmarks().filter((bookmark) => { + const meta = engine.getMeta(bookmark); + if (!meta?.prInfo) return true; + return meta.prInfo.state === "OPEN"; + }); + + // Build revset: tracked bookmarks + trunk + working copy + let revset: string; + if (trackedBookmarks.length === 0) { + revset = `${trunkName} | @`; + } else { + const bookmarkRevsets = trackedBookmarks + .map((b) => `bookmarks(exact:"${b}")`) + .join(" | "); + revset = `(${bookmarkRevsets}) | ${trunkName} | @`; + } - if (unmanagedBranch !== null) { - blank(); - message(yellow(`⚠ You're on git branch '${unmanagedBranch}'.`)); - blank(); - hint( - `To use arr, run ${arr(COMMANDS.checkout, ctx.trunk)} or ${arr(COMMANDS.checkout, "")}.`, - ); - hint("To continue with git, use git commands."); + // Fetch changes + const listResult = await list({ revset }, cwd); + const allChanges = listResult.ok ? listResult.value : []; + + // Get status for WC and parents + const statusResult = await status(cwd); + const wc = statusResult.ok ? statusResult.value.workingCopy : null; + const parents = statusResult.ok ? statusResult.value.parents : []; + + // Find trunk + const trunk = + allChanges.find((c) => c.bookmarks.includes(trunkName) && c.isImmutable) ?? + null; + + // Filter to non-trunk changes, but keep tracked bookmarks even if immutable (pushed) + const trackedSet = new Set(trackedBookmarks); + const changes = allChanges.filter((c) => { + // Always include WC + if (c.isWorkingCopy) return true; + // Skip trunk + if (c.bookmarks.includes(trunkName)) return false; + // Include tracked bookmarks even if immutable + if (c.bookmarks.some((b) => trackedSet.has(b))) return true; + // Include mutable changes + return !c.isImmutable; + }); + + // Get bookmark tracking for modified status + const trackingResult = await getBookmarkTracking(cwd); + const modifiedBookmarks = new Set(); + if (trackingResult.ok) { + for (const t of trackingResult.value) { + if (t.aheadCount > 0) { + modifiedBookmarks.add(t.name); + } + } + } + + // Build PR info map from engine cache + const prInfoMap = new Map(); + for (const bookmark of trackedBookmarks) { + const meta = engine.getMeta(bookmark); + if (meta?.prInfo) { + prInfoMap.set(bookmark, { + number: meta.prInfo.number, + state: meta.prInfo.state, + url: meta.prInfo.url, + }); + } } + + // If no cached PR info, fetch from GitHub + if (prInfoMap.size === 0 && trackedBookmarks.length > 0) { + const prsResult = await batchGetPRsForBranches(trackedBookmarks); + if (prsResult.ok) { + for (const [bookmark, pr] of prsResult.value) { + prInfoMap.set(bookmark, { + number: pr.number, + state: pr.state, + url: pr.url, + }); + } + } + } + + // Check for unmanaged git branch + const gitBranch = await getCurrentGitBranch(cwd); + const unmanagedBranch = + gitBranch !== null && + gitBranch !== trunkName && + !engine.isTracked(gitBranch) + ? gitBranch + : null; + + return { + changes, + workingCopy: wc, + parents, + trunk, + trunkName, + modifiedBookmarks, + prInfoMap, + unmanagedBranch, + trackedBookmarks, + allChanges, // For debugging + }; } -function renderLogGraph( - data: LogGraphData, - trunk: string, - inSync: boolean, -): string { - const output = data.rawOutput; - - // Process each line to handle placeholders - const lines = output.split("\n"); - const processedLines: string[] = []; - - for (const line of lines) { - let processed = line; - - // {{LABEL:changeId|prefix|timestamp|description|conflict|wc|empty|immutable|localBookmarks|remoteBookmarks}} - // Note: jj outputs the graph marker (@, ○, ◆), we just output the label content - processed = processed.replace(/\{\{LABEL:([^}]+)\}\}/g, (_, content) => { - const parts = content.split("|"); - const [ - changeId, - prefix, - timestamp, - description, - conflict, - _wc, - empty, - _immutable, - localBookmarks, - _remoteBookmarks, - ] = parts; - - const hasConflicts = conflict === "1"; - const isEmpty = empty === "1"; - const bookmarks = localBookmarks - ? localBookmarks.split(",").filter(Boolean) - : []; - - // Check if modified - const isModified = bookmarks.some((b: string) => - data.modifiedBookmarks.has(b), +function isEmptyState(data: LogData): boolean { + const { workingCopy, changes, trunk, trackedBookmarks } = data; + + if (!workingCopy) return false; + + const wcIsEmpty = + workingCopy.isEmpty && workingCopy.description.trim() === ""; + const wcParentIsTrunk = + trunk !== null && workingCopy.parents[0] === trunk.changeId; + const noTrackedBranches = trackedBookmarks.length === 0; + const noStackChanges = changes.filter((c) => !c.isWorkingCopy).length === 0; + + return wcIsEmpty && wcParentIsTrunk && noTrackedBranches && noStackChanges; +} + +function countModifiedChanges(data: LogData): number { + let count = 0; + for (const change of data.changes) { + if (change.bookmarks.some((b) => data.modifiedBookmarks.has(b))) { + count++; + } + } + return count; +} + +/** + * Determine if the WC should be shown as a separate line. + * + * Always show WC so users can see uncommitted work and decide to: + * - `arr create` - create a new change + * - `arr modify` - add changes to the parent + * + * Only hide WC when it has a bookmark (user is editing that branch directly via jj edit). + */ +function shouldShowWorkingCopy(data: LogData): boolean { + const { workingCopy } = data; + if (!workingCopy) return false; + + // If WC has bookmarks, user is "on" that branch (via jj edit) - hide WC + if (workingCopy.bookmarks.length > 0) { + return false; + } + + return true; +} + +/** + * Determine the "logical current" change for highlighting. + * + * If WC is shown, nothing else is "current" (WC handles its own marker). + * If WC is hidden, the parent is "current" (green marker on the branch). + */ +function getLogicalCurrentId(data: LogData): string | null { + const { workingCopy, parents } = data; + if (!workingCopy) return null; + + // If WC has bookmarks, it IS the current position (user did jj edit) + if (workingCopy.bookmarks.length > 0) { + return workingCopy.changeId; + } + + // If WC is shown, it gets its own green marker - don't mark parent + if (shouldShowWorkingCopy(data)) { + return null; + } + + // WC is hidden - parent is the logical current + if (parents.length > 0) { + return parents[0].changeId; + } + + return null; +} + +function renderGraph(data: LogData): string { + const { + changes, + workingCopy, + parents, + trunk, + trunkName, + modifiedBookmarks, + prInfoMap, + } = data; + + const showWC = shouldShowWorkingCopy(data); + const logicalCurrentId = getLogicalCurrentId(data); + const hasUncommittedChanges = + workingCopy && + !workingCopy.isEmpty && + workingCopy.description.trim() === ""; + + // Build tree from changes + const trunkId = trunk?.changeId ?? ""; + + // Filter changes for display + const displayChanges = changes.filter((c) => { + // Skip WC if we're not showing it separately + if (c.isWorkingCopy && !showWC) { + return false; + } + // Skip empty undescribed non-WC changes UNLESS they have a bookmark + // (branches with PRs but no current diff should still show) + if ( + !c.isWorkingCopy && + c.isEmpty && + c.description.trim() === "" && + c.bookmarks.length === 0 + ) { + return false; + } + return true; + }); + + // Build parent -> children map for tree structure + const childrenMap = new Map(); + for (const change of displayChanges) { + for (const parentId of change.parents) { + if (!childrenMap.has(parentId)) { + childrenMap.set(parentId, []); + } + childrenMap.get(parentId)?.push(change); + } + } + + // Find heads (changes with no children in our set) + const hasChild = new Set(); + for (const change of displayChanges) { + for (const parentId of change.parents) { + hasChild.add(parentId); + } + } + + const heads = displayChanges.filter((c) => !hasChild.has(c.changeId)); + + // Sort heads by timestamp (newest first) + heads.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + + const lines: string[] = []; + + // Render each stack from head to trunk + for (let stackIdx = 0; stackIdx < heads.length; stackIdx++) { + const prefix = stackIdx === 0 ? "" : "│ "; + let current = heads[stackIdx]; + + while (current) { + const isCurrent = current.changeId === logicalCurrentId; + const isWC = current.isWorkingCopy; + const isModified = current.bookmarks.some((b) => + modifiedBookmarks.has(b), ); - // Format change ID with colors - const shortId = formatChangeId(changeId, prefix); + // Determine marker + let marker: string; + if (isCurrent) { + marker = green("◉"); + } else if (isWC) { + // WC shown means user is ready for new work - show as current (green filled) + marker = green("◉"); + } else { + marker = "◯"; + } // Build label - let label = - description || (isEmpty ? dim("(empty)") : dim("(no description)")); - - // Add date prefix for older changes - const date = new Date(Number(timestamp) * 1000); - const now = new Date(); - const diffDays = Math.floor( - (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24), - ); - if (diffDays >= 1 && description) { - const month = date.toLocaleString("en-US", { month: "short" }); - const day = date.getDate(); - label = `[${month} ${day}] ${description}`; + let label: string; + if (current.bookmarks.length > 0) { + label = current.bookmarks[0]; + } else if (current.description) { + label = current.description; + } else if (isWC && current.isEmpty) { + label = dim("(working copy)"); + } else if (current.isEmpty) { + label = dim("(empty)"); + } else { + label = dim("(no description)"); } + // Build change ID + const shortId = formatChangeId( + current.changeId.slice(0, 8), + current.changeIdPrefix, + ); + // Build badges const badges: string[] = []; - if (isModified) badges.push(yellow("unpushed")); - if (hasConflicts) badges.push(yellow("conflicts")); + if (isCurrent && hasUncommittedChanges && !isWC) { + badges.push(yellow("uncommitted")); + } + if (isModified) { + badges.push(yellow("unpushed")); + } + if (current.hasConflicts) { + badges.push(yellow("conflicts")); + } const badgeStr = badges.length > 0 ? ` ${dim("(")}${badges.join(", ")}${dim(")")}` : ""; - return `${label} ${shortId}${badgeStr}`; - }); - - // {{TIME:timestamp}} - processed = processed.replace(/\{\{TIME:([^}]+)\}\}/g, (_, timestamp) => { - const date = new Date(Number(timestamp) * 1000); - return dim(formatRelativeTime(date)); - }); - - // {{HINT_EMPTY}} - only show if in sync - processed = processed.replace(/\{\{HINT_EMPTY\}\}/g, () => { - if (!inSync) return ""; - return `${dim("Run")} ${arr(COMMANDS.create)} ${dim('"message"')} ${dim("to save as a change")}`; - }); - - // {{HINT_UNCOMMITTED}} - only show if in sync - processed = processed.replace(/\{\{HINT_UNCOMMITTED\}\}/g, () => { - if (!inSync) return ""; - return `${dim("Run")} ${arr(COMMANDS.create)} ${dim('"message"')} ${dim("to save as a change")}`; - }); - - // {{HINT_SUBMIT}} - only show if in sync - processed = processed.replace(/\{\{HINT_SUBMIT\}\}/g, () => { - if (!inSync) return ""; - return `${dim("Run")} ${arr(COMMANDS.submit)} ${dim("to create a PR")}`; - }); - - // {{PR:bookmarks|description}} - processed = processed.replace( - /\{\{PR:([^|]+)\|([^}]*)\}\}/g, - (_, bookmarksStr, description) => { - const bookmarks = bookmarksStr.split(",").filter(Boolean); - const bookmark = bookmarks[0]; - if (!bookmark) return ""; - - const prInfo = data.prInfoMap.get(bookmark); - if (!prInfo) { - return dim("Not submitted"); + // Main line + const isEmptyWC = + isWC && current.isEmpty && current.description.trim() === ""; + if (isEmptyWC) { + // Empty WC shown minimally + lines.push(`${prefix}${marker} ${label}`); + // Add hints for empty WC + lines.push( + `${prefix}│ ${arr(COMMANDS.create)} ${dim('"message"')} ${dim("- save as new change")}`, + ); + // Show modify hint with parent name if available + const parentName = + parents.length > 0 + ? parents[0].bookmarks[0] || + parents[0].description || + parents[0].changeId.slice(0, 8) + : null; + if (parentName) { + lines.push( + `${prefix}│ ${arr(COMMANDS.modify)} ${dim(`- update downstack change (${parentName})`)}`, + ); + } + } else { + lines.push(`${prefix}${marker} ${label} ${shortId}${badgeStr}`); + + // Add timestamp + const timeStr = formatRelativeTime(current.timestamp); + lines.push(`${prefix}│ ${dim(timeStr)}`); + + // Add PR info if this change has a bookmark with a PR + const bookmark = current.bookmarks[0]; + if (bookmark && bookmark !== trunkName) { + const prInfo = prInfoMap.get(bookmark); + if (prInfo) { + const prLine = formatPRLine(prInfo, current.description); + lines.push(`${prefix}│ ${prLine}`); + lines.push(`${prefix}│ ${cyan(prInfo.url)}`); + } else { + lines.push(`${prefix}│ ${dim("Not submitted")}`); + lines.push( + `${prefix}│ ${dim("Run")} ${arr(COMMANDS.submit)} ${dim("to create a PR")}`, + ); + } } - return formatPRLine(prInfo, description); - }, - ); - - // {{PRURL:bookmarks}} - processed = processed.replace( - /\{\{PRURL:([^}]+)\}\}/g, - (_, bookmarksStr) => { - const bookmarks = bookmarksStr.split(",").filter(Boolean); - const bookmark = bookmarks[0]; - if (!bookmark) return ""; - - const prInfo = data.prInfoMap.get(bookmark); - if (!prInfo) return ""; + // Add commit ID + const commitId = formatCommitId( + current.commitId.slice(0, 8), + current.commitIdPrefix, + ); + lines.push( + `${prefix}│ ${commitId} ${dim(`- ${current.description || "(no description)"}`)}`, + ); + } - return cyan(prInfo.url); - }, - ); + lines.push(`${prefix}│`); - // {{COMMIT:commitId|prefix|description}} - processed = processed.replace( - /\{\{COMMIT:([^|]+)\|([^|]+)\|([^}]*)\}\}/g, - (_, commitId, prefix, description) => { - const shortCommitId = formatCommitId(commitId, prefix); - return `${shortCommitId} ${dim(`- ${description || "(no description)"}`)}`; - }, - ); + // Move to parent + const parentId = current.parents[0]; + if (!parentId || parentId === trunkId) { + break; + } - // {{TRUNK:bookmark}} - trunk label (prefer actual trunk name if present) - processed = processed.replace( - /\{\{TRUNK:([^}]*)\}\}/g, - (_, bookmarksStr) => { - const bookmarks = bookmarksStr.split(",").filter(Boolean); - // Prefer the actual trunk name if this commit has multiple bookmarks - if (bookmarks.includes(trunk)) { - return trunk; - } - return bookmarks[0] || "trunk"; - }, - ); + const parent = displayChanges.find((c) => c.changeId === parentId); + if (!parent) { + break; + } - processedLines.push(processed); + current = parent; + } } - let result = processedLines.join("\n"); - - // Replace jj's graph markers with styled versions - // @ = working copy (green ◉, or ◯ if out of sync) - // ○ = mutable commit (◯) - // ◆ = immutable commit (◯ - same as mutable, we don't distinguish) - // × = conflict (red ×) - // ~ = elided (│) - result = result.replace(/^(@)(\s+)/gm, inSync ? `${green("◉")}$2` : "◯$2"); - result = result.replace(/^(○)(\s+)/gm, "◯$2"); - result = result.replace(/^(◆)(\s+)/gm, "◯$2"); - result = result.replace(/^(×)(\s+)/gm, `${red("×")}$2`); - result = result.replace(/^(~)(\s+)/gm, "│$2"); + // Add trunk + const trunkIsCurrent = logicalCurrentId === trunk?.changeId; + const trunkMarker = trunkIsCurrent ? green("◉") : "◯"; + lines.push( + `${trunkMarker} ${trunkName}${trunkIsCurrent ? ` ${dim("(current)")}` : ""}`, + ); - // Remove trailing newlines - result = result.trimEnd(); + return lines.join("\n"); +} - return result; +function renderUnmanagedBranch(branch: string, trunkName: string): void { + message(`${green("◉")} ${trunkName} ${dim("(current)")}`); + blank(); + message(yellow(`⚠ You're on git branch '${branch}'.`)); + blank(); + hint( + `To use arr, run ${arr(COMMANDS.checkout, trunkName)} or ${arr(COMMANDS.checkout, "")}.`, + ); + hint("To continue with git, use git commands."); } function formatPRLine(prInfo: PRInfo, description: string): string { const stateColor = - prInfo.state === "merged" ? magenta : prInfo.state === "open" ? green : red; + prInfo.state === "MERGED" ? magenta : prInfo.state === "OPEN" ? green : red; const stateLabel = - prInfo.state.charAt(0).toUpperCase() + prInfo.state.slice(1); + prInfo.state.charAt(0) + prInfo.state.slice(1).toLowerCase(); return `${stateColor(`PR #${prInfo.number}`)} ${dim(`(${stateLabel})`)} ${description}`; } @@ -258,3 +528,74 @@ function formatRelativeTime(date: Date): string { return `${diffWeeks} week${diffWeeks === 1 ? "" : "s"} ago`; return `${diffMonths} month${diffMonths === 1 ? "" : "s"} ago`; } + +function printDebugInfo(data: LogData): void { + console.log("\n=== DEBUG: Log Data ===\n"); + + console.log("Working Copy:"); + if (data.workingCopy) { + const wc = data.workingCopy; + console.log(` changeId: ${wc.changeId}`); + console.log(` isEmpty: ${wc.isEmpty}`); + console.log(` description: "${wc.description}"`); + console.log(` bookmarks: [${wc.bookmarks.join(", ")}]`); + console.log(` parents: [${wc.parents.join(", ")}]`); + console.log(` isWorkingCopy: ${wc.isWorkingCopy}`); + } else { + console.log(" (none)"); + } + + console.log("\nParents:"); + for (const p of data.parents) { + console.log( + ` ${p.changeId.slice(0, 8)} - ${p.description || "(no description)"} [${p.bookmarks.join(", ")}]`, + ); + } + + console.log("\nAll Changes (from revset):"); + for (const c of data.allChanges) { + const flags = []; + if (c.isWorkingCopy) flags.push("WC"); + if (c.isEmpty) flags.push("empty"); + if (c.isImmutable) flags.push("immutable"); + console.log( + ` ${c.changeId.slice(0, 8)} - ${c.description || "(no description)"} [${c.bookmarks.join(", ")}] ${flags.length > 0 ? `(${flags.join(", ")})` : ""}`, + ); + } + + console.log("\nFiltered Changes:"); + for (const c of data.changes) { + const flags = []; + if (c.isWorkingCopy) flags.push("WC"); + if (c.isEmpty) flags.push("empty"); + if (c.isImmutable) flags.push("immutable"); + console.log( + ` ${c.changeId.slice(0, 8)} - ${c.description || "(no description)"} [${c.bookmarks.join(", ")}] ${flags.length > 0 ? `(${flags.join(", ")})` : ""}`, + ); + } + + console.log("\nTrunk:"); + if (data.trunk) { + console.log(` ${data.trunkName} (${data.trunk.changeId.slice(0, 8)})`); + } else { + console.log(` ${data.trunkName} (not found)`); + } + + console.log( + "\nModified Bookmarks:", + [...data.modifiedBookmarks].join(", ") || "(none)", + ); + console.log( + "Tracked Bookmarks:", + data.trackedBookmarks.join(", ") || "(none)", + ); + console.log("Unmanaged Branch:", data.unmanagedBranch || "(none)"); + + console.log("\nVisibility:"); + console.log(` shouldShowWorkingCopy: ${shouldShowWorkingCopy(data)}`); + console.log( + ` logicalCurrentId: ${getLogicalCurrentId(data)?.slice(0, 8) || "(none)"}`, + ); + + console.log("\n=== END DEBUG ===\n"); +} diff --git a/apps/cli/src/commands/modify.ts b/apps/cli/src/commands/modify.ts new file mode 100644 index 00000000..8f602bca --- /dev/null +++ b/apps/cli/src/commands/modify.ts @@ -0,0 +1,10 @@ +import { modify as coreModify } from "@array/core/commands/modify"; +import { green, message } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function modify(): Promise { + const result = unwrap(await coreModify()); + const label = + result.bookmark || result.description || result.changeId.slice(0, 8); + message(`Modified ${green(label)}`); +} diff --git a/apps/cli/src/commands/sync.ts b/apps/cli/src/commands/sync.ts index a10c26d7..ebe3e215 100644 --- a/apps/cli/src/commands/sync.ts +++ b/apps/cli/src/commands/sync.ts @@ -1,7 +1,7 @@ import { restack as coreRestack } from "@array/core/commands/restack"; import { sync as coreSync } from "@array/core/commands/sync"; import type { ArrContext, Engine } from "@array/core/engine"; -import { cleanupMergedChange, type MergedChange } from "@array/core/stacks"; +import { type MergedChange, reparentAndCleanup } from "@array/core/stacks"; import { COMMANDS } from "../registry"; import { arr, @@ -16,37 +16,50 @@ import { import { confirm } from "../utils/prompt"; import { unwrap } from "../utils/run"; +interface CleanupStats { + cleanedUp: number; + reparented: number; + prBasesUpdated: number; +} + /** - * Prompt user for each merged PR and cleanup if confirmed. - * Returns number of PRs cleaned up. + * Prompt user for each merged/closed PR and cleanup if confirmed. + * Reparents children to grandparent before cleanup. */ async function promptAndCleanupMerged( pending: MergedChange[], engine: Engine, -): Promise { - if (pending.length === 0) return 0; +): Promise { + if (pending.length === 0) { + return { cleanedUp: 0, reparented: 0, prBasesUpdated: 0 }; + } let cleanedUp = 0; + let reparented = 0; + let prBasesUpdated = 0; for (const change of pending) { const prLabel = magenta(`PR #${change.prNumber}`); const branchLabel = dim(`(${change.bookmark})`); const desc = change.description || "(no description)"; + const stateLabel = change.reason === "merged" ? "merged" : "closed"; const confirmed = await confirm( - `Delete merged branch ${prLabel} ${branchLabel}: ${desc}?`, + `Delete ${stateLabel} branch ${prLabel} ${branchLabel}: ${desc}?`, { default: true }, ); if (confirmed) { - const result = await cleanupMergedChange(change, engine); + const result = await reparentAndCleanup(change, engine); if (result.ok) { cleanedUp++; + reparented += result.value.reparentedChildren.length; + prBasesUpdated += result.value.prBasesUpdated; } } } - return cleanedUp; + return { cleanedUp, reparented, prBasesUpdated }; } export async function sync(ctx: ArrContext): Promise { @@ -84,14 +97,26 @@ export async function sync(ctx: ArrContext): Promise { } } - // Prompt for each merged PR cleanup - const cleanedUp = await promptAndCleanupMerged( + // Prompt for each merged/closed PR cleanup + const cleanupStats = await promptAndCleanupMerged( result.pendingCleanup, ctx.engine, ); - if (cleanedUp > 0) { - message(formatSuccess(`Cleaned up ${cleanedUp} merged PR(s)`)); + if (cleanupStats.cleanedUp > 0) { + const prLabel = cleanupStats.cleanedUp === 1 ? "PR" : "PRs"; + message(formatSuccess(`Cleaned up ${cleanupStats.cleanedUp} ${prLabel}`)); + + if (cleanupStats.reparented > 0) { + const childLabel = cleanupStats.reparented === 1 ? "child" : "children"; + hint(`Reparented ${cleanupStats.reparented} ${childLabel} to new parent`); + } + + if (cleanupStats.prBasesUpdated > 0) { + const baseLabel = + cleanupStats.prBasesUpdated === 1 ? "PR base" : "PR bases"; + hint(`Updated ${cleanupStats.prBasesUpdated} ${baseLabel} on GitHub`); + } } if (result.updatedComments > 0) { diff --git a/apps/cli/src/commands/top.ts b/apps/cli/src/commands/top.ts index c72ffaab..f4d23ca7 100644 --- a/apps/cli/src/commands/top.ts +++ b/apps/cli/src/commands/top.ts @@ -1,7 +1,7 @@ import { top as coreTop } from "@array/core/commands/top"; -import { printNav } from "../utils/output"; +import { printNavResult } from "../utils/output"; import { unwrap } from "../utils/run"; export async function top(): Promise { - printNav("up", unwrap(await coreTop())); + printNavResult(unwrap(await coreTop())); } diff --git a/apps/cli/src/commands/trunk.ts b/apps/cli/src/commands/trunk.ts index 9f5eea97..f7d55b1b 100644 --- a/apps/cli/src/commands/trunk.ts +++ b/apps/cli/src/commands/trunk.ts @@ -1,10 +1,7 @@ import { trunk as coreTrunk } from "@array/core/commands/trunk"; -import { COMMANDS } from "../registry"; -import { arr, cyan, green, hint, message } from "../utils/output"; +import { printNavResult } from "../utils/output"; import { unwrap } from "../utils/run"; export async function trunk(): Promise { - unwrap(await coreTrunk()); - message(`${green("◉")} Started fresh on ${cyan("main")}`); - hint(`Run ${arr(COMMANDS.top)} to go back to your stack`); + printNavResult(unwrap(await coreTrunk())); } diff --git a/apps/cli/src/commands/up.ts b/apps/cli/src/commands/up.ts index 7ff1c4a6..cbd3ebbb 100644 --- a/apps/cli/src/commands/up.ts +++ b/apps/cli/src/commands/up.ts @@ -1,7 +1,7 @@ import { up as coreUp } from "@array/core/commands/up"; -import { printNav } from "../utils/output"; +import { printNavResult } from "../utils/output"; import { unwrap } from "../utils/run"; export async function up(): Promise { - printNav("up", unwrap(await coreUp())); + printNavResult(unwrap(await coreUp())); } diff --git a/apps/cli/src/registry.ts b/apps/cli/src/registry.ts index c5a1f477..608fb9c9 100644 --- a/apps/cli/src/registry.ts +++ b/apps/cli/src/registry.ts @@ -3,8 +3,9 @@ import { checkoutCommand } from "@array/core/commands/checkout"; import { createCommand } from "@array/core/commands/create"; import { deleteCommand } from "@array/core/commands/delete"; import { downCommand } from "@array/core/commands/down"; -import { logCommand } from "@array/core/commands/log"; +import { getCommand } from "@array/core/commands/get"; import { mergeCommand } from "@array/core/commands/merge"; +import { modifyCommand } from "@array/core/commands/modify"; import { restackCommand } from "@array/core/commands/restack"; import { squashCommand } from "@array/core/commands/squash"; import { statusCommand } from "@array/core/commands/status"; @@ -27,9 +28,11 @@ import { create } from "./commands/create"; import { deleteChange } from "./commands/delete"; import { down } from "./commands/down"; import { exit, meta as exitMeta } from "./commands/exit"; +import { get } from "./commands/get"; import { init, meta as initMeta } from "./commands/init"; import { log } from "./commands/log"; import { merge } from "./commands/merge"; +import { modify } from "./commands/modify"; import { restack } from "./commands/restack"; import { squash } from "./commands/squash"; import { status } from "./commands/status"; @@ -69,6 +72,14 @@ const versionMeta: CommandMeta = { category: "setup", }; +const logMeta: CommandMeta = { + name: "log", + description: "Show a visual overview of the current stack with PR status", + aliases: ["l"], + category: "info", + core: true, +}; + export const COMMANDS = { auth: authMeta, init: initMeta, @@ -76,6 +87,7 @@ export const COMMANDS = { submit: submitCommand.meta, sync: syncCommand.meta, restack: restackCommand.meta, + get: getCommand.meta, track: trackCommand.meta, bottom: bottomCommand.meta, checkout: checkoutCommand.meta, @@ -83,9 +95,10 @@ export const COMMANDS = { top: topCommand.meta, trunk: trunkCommand.meta, up: upCommand.meta, - log: logCommand.meta, + log: logMeta, status: statusCommand.meta, delete: deleteCommand.meta, + modify: modifyCommand.meta, squash: squashCommand.meta, merge: mergeCommand.meta, undo: undoCommand.meta, @@ -103,18 +116,20 @@ export const HANDLERS: Record = { status: () => status(), create: (p, ctx) => create(p.args.join(" "), ctx!), submit: (p, ctx) => submit(p.flags, ctx!), + get: (p, ctx) => get(ctx!, p.args[0]), track: (p, ctx) => track(p.args[0], ctx!), up: () => up(), down: () => down(), top: () => top(), trunk: () => trunk(), bottom: () => bottom(), - log: (_p, ctx) => log(ctx!), + log: (p, ctx) => log(ctx!, { debug: !!p.flags.debug }), sync: (_p, ctx) => sync(ctx!), restack: () => restack(), checkout: (p) => checkout(p.args[0]), delete: (p, ctx) => deleteChange(p.args[0], ctx!, { yes: !!p.flags.yes || !!p.flags.y }), + modify: () => modify(), squash: (p, ctx) => squash(p.args[0], ctx!), merge: (p, ctx) => merge(p.flags, ctx!), undo: () => undo(), diff --git a/apps/cli/src/utils/output.ts b/apps/cli/src/utils/output.ts index 50ef63e3..7af25d40 100644 --- a/apps/cli/src/utils/output.ts +++ b/apps/cli/src/utils/output.ts @@ -1,4 +1,5 @@ import type { CommandMeta } from "@array/core/commands/types"; +import type { NavigationResult } from "@array/core/types"; const colors = { reset: "\x1b[0m", @@ -70,19 +71,28 @@ export function formatSuccess(message: string): string { return `${green("✓")} ${message}`; } -interface NavInfo { - changeId: string; - changeIdPrefix?: string; - description: string; -} - -export function printNav(direction: "up" | "down", nav: NavInfo): void { - const arrow = direction === "up" ? "↑" : "↓"; - const shortId = nav.changeIdPrefix - ? formatChangeId(nav.changeId.slice(0, 8), nav.changeIdPrefix) - : dim(nav.changeId.slice(0, 8)); - const desc = nav.description || dim("(empty)"); - console.log(`${arrow} ${green(desc)} ${shortId}`); +/** + * Print navigation result with consistent formatting. + * + * Messages: + * - editing: "Editing branch-name" + * - on-top: "Working on top of branch-name" + * - on-trunk: "Starting fresh on main" + */ +export function printNavResult(nav: NavigationResult): void { + const label = nav.bookmark || nav.description || nav.changeId.slice(0, 8); + + switch (nav.position) { + case "editing": + console.log(`Editing ${green(label)}`); + break; + case "on-top": + console.log(`Working on top of ${green(label)}`); + break; + case "on-trunk": + console.log(`Starting fresh on ${cyan(label)}`); + break; + } } export function blank(): void { diff --git a/packages/core/src/bookmark-utils.ts b/packages/core/src/bookmark-utils.ts index d737d6e7..d9222b89 100644 --- a/packages/core/src/bookmark-utils.ts +++ b/packages/core/src/bookmark-utils.ts @@ -1,4 +1,4 @@ -import { getPRForBranch, type PRStatus } from "./github/pr-status"; +import { getPRForBranch, type PRInfo } from "./github/pr-status"; import { createError, err, ok, type Result } from "./result"; /** Maximum number of suffix attempts before giving up on conflict resolution */ @@ -27,12 +27,12 @@ interface BookmarkConflictResult { */ export async function resolveBookmarkConflict( bookmark: string, - prCache?: Map, + prCache?: Map, assignedNames?: Set, cwd = process.cwd(), ): Promise> { // Check cache first, otherwise fetch from GitHub - let existingPR: PRStatus | null = null; + let existingPR: PRInfo | null = null; if (prCache) { existingPR = prCache.get(bookmark) ?? null; } else { @@ -42,7 +42,7 @@ export async function resolveBookmarkConflict( } // No conflict if PR doesn't exist or is open - if (!existingPR || existingPR.state === "open") { + if (!existingPR || existingPR.state === "OPEN") { return ok({ originalName: bookmark, resolvedName: bookmark, @@ -64,7 +64,7 @@ export async function resolveBookmarkConflict( } // Check if this candidate has an existing PR - let candidatePR: PRStatus | null = null; + let candidatePR: PRInfo | null = null; if (prCache) { candidatePR = prCache.get(candidateName) ?? null; } else { diff --git a/packages/core/src/commands/bottom.ts b/packages/core/src/commands/bottom.ts index de26ba3d..ad016a2b 100644 --- a/packages/core/src/commands/bottom.ts +++ b/packages/core/src/commands/bottom.ts @@ -1,24 +1,48 @@ -import { runJJ } from "../jj"; +import { list, status } from "../jj"; import { createError, err, type Result } from "../result"; import type { NavigationResult } from "../types"; -import { getNavigationResult } from "./navigation"; +import { navigateTo } from "./navigation"; import type { Command } from "./types"; /** * Navigate to the bottom of the current stack. */ export async function bottom(): Promise> { - // Use roots(trunk()..@) to find the bottom of the current stack - const editResult = await runJJ(["edit", "roots(trunk()..@)"]); - if (!editResult.ok) { - if (editResult.error.message.includes("empty revision")) { + // Find roots of the current stack (changes between trunk and @) + const rootsResult = await list({ revset: "roots(trunk()..@)" }); + if (!rootsResult.ok) return rootsResult; + + const roots = rootsResult.value.filter( + (c) => !c.changeId.startsWith("zzzzzzzz"), + ); + + if (roots.length === 0) { + // Check if we're already at the bottom + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + // If we're on an empty undescribed WC directly on trunk, that's the bottom + const wc = statusResult.value.workingCopy; + if (wc.isEmpty && wc.description.trim() === "") { return err( createError("NAVIGATION_FAILED", "Already at bottom of stack"), ); } - return editResult; + + return err(createError("NAVIGATION_FAILED", "Already at bottom of stack")); } - return getNavigationResult(); + + if (roots.length > 1) { + return err( + createError( + "NAVIGATION_FAILED", + "Stack has multiple roots - cannot determine bottom", + ), + ); + } + + // Navigate to the root (handles immutability correctly) + return navigateTo(roots[0]); } export const bottomCommand: Command = { diff --git a/packages/core/src/commands/checkout.ts b/packages/core/src/commands/checkout.ts index 821ba100..c573c9d1 100644 --- a/packages/core/src/commands/checkout.ts +++ b/packages/core/src/commands/checkout.ts @@ -1,38 +1,20 @@ -import { edit, findChange, jjNew, status } from "../jj"; -import type { Changeset } from "../parser"; -import { createError, err, ok, type Result } from "../result"; +import { findChange } from "../jj"; +import { createError, err, type Result } from "../result"; +import type { NavigationResult } from "../types"; +import { navigateTo, newOnTrunk } from "./navigation"; import type { Command } from "./types"; -interface CheckoutResult { - changeId: string; - description: string; - createdNew: boolean; - /** The change that was checked out (for CLI display) */ - change: Changeset; -} - /** * Checkout a change by its ID, bookmark, or search query. * If checking out trunk/main/master, creates a new empty change on top. */ export async function checkout( target: string, -): Promise> { +): Promise> { // Handle trunk checkout - creates new empty change on main if (target === "main" || target === "master" || target === "trunk") { - const revision = target === "trunk" ? "trunk()" : target; - const result = await jjNew({ parents: [revision] }); - if (!result.ok) return result; - - const statusResult = await status(); - if (!statusResult.ok) return statusResult; - - return ok({ - changeId: statusResult.value.workingCopy.changeId, - description: "", - createdNew: true, - change: statusResult.value.workingCopy, - }); + const trunkName = target === "trunk" ? "main" : target; + return newOnTrunk(trunkName); } // Resolve the change @@ -51,22 +33,11 @@ export async function checkout( } const change = findResult.value.change; - // Regular checkout - edit the change - const editResult = await edit(change.changeId); - if (!editResult.ok) return editResult; - - const statusResult = await status(); - if (!statusResult.ok) return statusResult; - - return ok({ - changeId: statusResult.value.workingCopy.changeId, - description: statusResult.value.workingCopy.description, - createdNew: false, - change: statusResult.value.workingCopy, - }); + // Navigate to the change (handles immutability correctly) + return navigateTo(change); } -export const checkoutCommand: Command = { +export const checkoutCommand: Command = { meta: { name: "checkout", args: "[id]", diff --git a/packages/core/src/commands/create.ts b/packages/core/src/commands/create.ts index 5e42bf74..9e7c5dcb 100644 --- a/packages/core/src/commands/create.ts +++ b/packages/core/src/commands/create.ts @@ -73,8 +73,12 @@ export async function create( const exportResult = await runJJ(["git", "export"]); if (!exportResult.ok) return exportResult; - // Track the new bookmark in the engine - await engine.track(bookmarkName); + // Track the new bookmark in the engine by refreshing from jj + const refreshResult = await engine.refreshFromJJ(bookmarkName); + if (!refreshResult.ok) { + // This shouldn't happen since we just created the bookmark, but handle gracefully + return refreshResult; + } return ok({ changeId: createdChangeId, bookmarkName }); } diff --git a/packages/core/src/commands/delete.ts b/packages/core/src/commands/delete.ts index 599efbba..e8afc7df 100644 --- a/packages/core/src/commands/delete.ts +++ b/packages/core/src/commands/delete.ts @@ -1,5 +1,11 @@ import type { Engine } from "../engine"; -import { abandon, edit, findChange, list, runJJ, status } from "../jj"; +import { + edit, + findChange, + list, + runJJWithMutableConfigVoid, + status, +} from "../jj"; import type { Changeset } from "../parser"; import { createError, err, ok, type Result } from "../result"; import type { Command } from "./types"; @@ -56,8 +62,9 @@ export async function deleteChange( }); const hasChildren = childrenResult.ok && childrenResult.value.length > 0; + // Use mutable config for operations on potentially pushed commits if (hasChildren) { - const rebaseResult = await runJJ([ + const rebaseResult = await runJJWithMutableConfigVoid([ "rebase", "-s", `children(${change.changeId})`, @@ -68,14 +75,17 @@ export async function deleteChange( } // Discard work by restoring - const restoreResult = await runJJ([ + const restoreResult = await runJJWithMutableConfigVoid([ "restore", "--changes-in", change.changeId, ]); if (!restoreResult.ok) return restoreResult; - const abandonResult = await abandon(change.changeId); + const abandonResult = await runJJWithMutableConfigVoid([ + "abandon", + change.changeId, + ]); if (!abandonResult.ok) return abandonResult; // Untrack any bookmarks on the deleted change diff --git a/packages/core/src/commands/down.ts b/packages/core/src/commands/down.ts index 3bd30f32..54e6c41e 100644 --- a/packages/core/src/commands/down.ts +++ b/packages/core/src/commands/down.ts @@ -1,36 +1,65 @@ -import { getTrunk, runJJ, status } from "../jj"; +import { getTrunk, list, status } from "../jj"; import type { Result } from "../result"; import type { NavigationResult } from "../types"; -import { getNavigationResult, newOnTrunk } from "./navigation"; +import { navigateTo, newOnTrunk } from "./navigation"; import type { Command } from "./types"; /** - * Navigate down in the stack (to the parent of current change). - * If at bottom of stack (parent is trunk), creates new change on trunk. + * Navigate down in the stack (to the parent of the logical current change). + * + * The "logical current" is: + * - If on empty undescribed WC: the WC's parent (e.g., change A) + * - Otherwise: the WC itself + * + * So "down" from change A goes to change A's parent, not the WC's parent. */ export async function down(): Promise> { const statusResult = await status(); if (!statusResult.ok) return statusResult; const trunk = await getTrunk(); - const parents = statusResult.value.parents; - if (parents.length > 0) { - const isParentTrunk = - parents[0].bookmarks.includes(trunk) || parents[0].isImmutable; - if (isParentTrunk) return newOnTrunk(); - } + const wc = statusResult.value.workingCopy; + const wcParents = statusResult.value.parents; + + // Determine logical current position + const isOnEmptyUndescribedWC = wc.isEmpty && wc.description.trim() === ""; + + if (isOnEmptyUndescribedWC && wcParents.length > 0) { + // We're logically "on" the WC's parent (e.g., change A) + // Going "down" means going to change A's parent + const logicalCurrent = wcParents[0]; + + // Get the logical current's parents + const parentsResult = await list({ revset: `${logicalCurrent.changeId}-` }); + if (!parentsResult.ok) return parentsResult; + + const logicalParents = parentsResult.value.filter( + (c) => !c.changeId.startsWith("zzzzzzzz"), + ); - const result = await runJJ(["prev", "--edit"]); - if (!result.ok) { if ( - result.error.message.includes("No ancestor") || - result.error.message.includes("immutable") + logicalParents.length === 0 || + logicalParents[0].bookmarks.includes(trunk) ) { - return newOnTrunk(); + return newOnTrunk(trunk); } - return result; + + return navigateTo(logicalParents[0]); } - return getNavigationResult(); + + // WC has content or description - it IS the logical current + // Going down means going to WC's parent + if (wcParents.length === 0) { + return newOnTrunk(trunk); + } + + const parent = wcParents[0]; + + if (parent.bookmarks.includes(trunk)) { + return newOnTrunk(trunk); + } + + return navigateTo(parent); } export const downCommand: Command = { diff --git a/packages/core/src/commands/get.ts b/packages/core/src/commands/get.ts new file mode 100644 index 00000000..ad3c501d --- /dev/null +++ b/packages/core/src/commands/get.ts @@ -0,0 +1,310 @@ +import type { Engine } from "../engine"; +import type { BranchMeta, PRInfo } from "../git/metadata"; +import { getMultiplePRInfos, getPRForBranch } from "../github/pr-status"; +import { edit, getTrunk, list, runJJ, runJJWithMutableConfigVoid } from "../jj"; +import { createError, err, ok, type Result } from "../result"; +import type { Command } from "./types"; + +export interface GetOptions { + /** Branch name or PR number to get */ + target: string; + engine: Engine; + cwd?: string; +} + +export interface GetResult { + /** All branches restored in the stack (trunk -> target order) */ + stack: RestoredBranch[]; + /** The target branch that was requested */ + targetBranch: string; +} + +export interface RestoredBranch { + branchName: string; + prNumber: number; + meta: BranchMeta; +} + +const MAX_STACK_DEPTH = 20; + +/** + * Walk up the PR base chain from a starting PR to trunk. + * Returns PRs in trunk->target order (parent first). + */ +async function walkBaseChain( + startPR: PRInfo, + trunk: string, + cwd: string, +): Promise> { + const stack: PRInfo[] = [startPR]; + const visited = new Set(); + visited.add(startPR.head ?? ""); + + let current = startPR; + + while (current.base !== trunk && stack.length < MAX_STACK_DEPTH) { + const baseBranch = current.base; + + // Cycle detection + if (visited.has(baseBranch)) { + return err( + createError( + "INVALID_STATE", + `Cycle detected in PR chain: ${baseBranch} already visited`, + ), + ); + } + visited.add(baseBranch); + + // Get PR for the parent branch + const parentResult = await getPRForBranch(baseBranch, cwd); + if (!parentResult.ok) return parentResult; + + const parentPR = parentResult.value; + if (!parentPR) { + // No PR for parent - assume it's trunk or an untracked branch + break; + } + + // Stop if parent PR is closed/merged - can't have valid stack with gap + if (parentPR.state !== "OPEN") { + // We still include this PR info for warning purposes + stack.unshift(parentPR); + break; + } + + stack.unshift(parentPR); + current = parentPR; + } + + if (stack.length >= MAX_STACK_DEPTH) { + return err( + createError( + "INVALID_STATE", + `Stack depth exceeded ${MAX_STACK_DEPTH} levels. Possible cycle or malformed stack.`, + ), + ); + } + + return ok(stack); +} + +/** + * Restore a single branch from remote and track it. + * Returns the metadata for the restored branch. + */ +async function restoreBranch( + branchName: string, + prInfo: PRInfo, + engine: Engine, + cwd: string, +): Promise> { + // Check if remote bookmark exists + const checkResult = await runJJ( + ["log", "-r", `${branchName}@origin`, "--no-graph", "-T", "change_id"], + cwd, + ); + + if (!checkResult.ok) { + return err( + createError( + "NOT_FOUND", + `Remote branch ${branchName}@origin not found. It may have been deleted.`, + ), + ); + } + + // Track the remote bookmark to create local bookmark + const trackResult = await runJJWithMutableConfigVoid( + ["bookmark", "track", `${branchName}@origin`], + cwd, + ); + + // If tracking fails, try setting the bookmark directly + if (!trackResult.ok) { + const setResult = await runJJWithMutableConfigVoid( + ["bookmark", "set", branchName, "-r", `${branchName}@origin`], + cwd, + ); + if (!setResult.ok) { + return err( + createError( + "COMMAND_FAILED", + `Failed to restore bookmark ${branchName}: ${setResult.error.message}`, + ), + ); + } + } + + // Get the change info from jj + const listResult = await list({ revset: branchName, limit: 1 }, cwd); + if (!listResult.ok) return listResult; + + if (listResult.value.length === 0) { + return err( + createError("NOT_FOUND", `Could not find changeset for ${branchName}`), + ); + } + + const change = listResult.value[0]; + + // Build metadata + const meta: BranchMeta = { + changeId: change.changeId, + commitId: change.commitId, + parentBranchName: prInfo.base, + prInfo, + }; + + // Store in engine + engine.setMeta(branchName, meta); + + return ok(meta); +} + +/** + * Get a branch (and its stack) from remote by name or PR number. + * Restores the entire downstack from trunk to target. + * + * Flow: + * 1. Resolve target to PR (by PR# or branch name) + * 2. Validate PR is OPEN + * 3. Walk base chain to trunk via GitHub API + * 4. Fetch from remote + * 5. Restore each branch in stack (trunk -> target order) + */ +export async function get(options: GetOptions): Promise> { + const { target, engine, cwd = process.cwd() } = options; + + // Get trunk name + const trunk = await getTrunk(cwd); + + // Resolve target to PRInfo + const isNumeric = /^\d+$/.test(target); + let targetPR: PRInfo; + + if (isNumeric) { + // Target is PR number + const prNumber = Number.parseInt(target, 10); + const prResult = await getMultiplePRInfos([prNumber], cwd); + if (!prResult.ok) return prResult; + + const info = prResult.value.get(prNumber); + if (!info) { + return err(createError("NOT_FOUND", `PR #${prNumber} not found`)); + } + targetPR = info; + } else { + // Target is branch name + const prResult = await getPRForBranch(target, cwd); + if (!prResult.ok) return prResult; + + if (!prResult.value) { + return err(createError("NOT_FOUND", `No PR found for branch: ${target}`)); + } + targetPR = prResult.value; + } + + // Validate PR is open + if (targetPR.state !== "OPEN") { + return err( + createError( + "INVALID_STATE", + `PR #${targetPR.number} is ${targetPR.state.toLowerCase()}. Cannot restore closed/merged PRs.`, + ), + ); + } + + // Walk base chain to build stack + const stackResult = await walkBaseChain(targetPR, trunk, cwd); + if (!stackResult.ok) return stackResult; + + const prStack = stackResult.value; + + // Check for closed/merged parent PRs + const closedParent = prStack.find( + (pr) => pr.state !== "OPEN" && pr.number !== targetPR.number, + ); + if (closedParent) { + // Filter out closed parent and warn + const openStack = prStack.filter((pr) => pr.state === "OPEN"); + if (openStack.length === 0) { + return err( + createError( + "INVALID_STATE", + `Parent PR #${closedParent.number} (${closedParent.head}) is ${closedParent.state.toLowerCase()}. Run arr sync to rebase your stack.`, + ), + ); + } + } + + // Fetch from git remote + const fetchResult = await runJJ(["git", "fetch"], cwd); + if (!fetchResult.ok) return fetchResult; + + // Restore each branch in stack (from trunk toward target) + const restoredStack: RestoredBranch[] = []; + + for (const pr of prStack) { + // Skip non-open PRs (they're in the chain for warning purposes) + if (pr.state !== "OPEN") continue; + + const branchName = pr.head; + if (!branchName) { + // Skip PRs without head (shouldn't happen with valid data) + continue; + } + + const restoreResult = await restoreBranch(branchName, pr, engine, cwd); + if (!restoreResult.ok) { + // Log warning but continue - partial stack is still useful + continue; + } + + restoredStack.push({ + branchName, + prNumber: pr.number, + meta: restoreResult.value, + }); + } + + if (restoredStack.length === 0) { + return err( + createError( + "COMMAND_FAILED", + "Failed to restore any branches from stack", + ), + ); + } + + // Find target branch name + const targetBranch = + targetPR.head ?? restoredStack[restoredStack.length - 1].branchName; + + // Switch to the target branch + const editResult = await edit(targetBranch, cwd); + if (!editResult.ok) { + return err( + createError( + "COMMAND_FAILED", + `Restored stack but failed to switch to ${targetBranch}: ${editResult.error.message}`, + ), + ); + } + + return ok({ + stack: restoredStack, + targetBranch, + }); +} + +export const getCommand: Command = { + meta: { + name: "get", + args: "", + description: + "Restore a branch and its stack from remote by name or PR number", + category: "workflow", + }, + run: get, +}; diff --git a/packages/core/src/commands/log.ts b/packages/core/src/commands/log.ts deleted file mode 100644 index 165703eb..00000000 --- a/packages/core/src/commands/log.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Engine } from "../engine"; -import { getCurrentGitBranch } from "../git/status"; -import { - type CachedPRInfo, - getLogGraphData, - type LogGraphData, -} from "../log-graph"; -import { ok, type Result } from "../result"; -import type { Command } from "./types"; - -export type { CachedPRInfo }; - -export interface LogOptions { - trunk?: string; - engine: Engine; - cwd?: string; -} - -export interface LogResult { - data: LogGraphData; - /** Git branch name if on an unmanaged branch, null otherwise */ - unmanagedBranch: string | null; -} - -/** - * Get log graph data for rendering the stack view. - * Returns raw jj output with placeholders + PR info for the CLI to render. - * Only shows tracked bookmarks from the engine (plus working copy). - */ -export async function log(options: LogOptions): Promise> { - const { engine, trunk, cwd = process.cwd() } = options; - - const trackedBookmarks = engine.getTrackedBookmarks(); - - // Check if on an unmanaged git branch - const gitBranch = await getCurrentGitBranch(cwd); - const isOnUnmanagedBranch = - gitBranch !== null && gitBranch !== trunk && !engine.isTracked(gitBranch); - - // Build cache from engine - const cachedPRInfo = new Map(); - for (const bookmark of trackedBookmarks) { - const meta = engine.getMeta(bookmark); - if (meta?.prInfo) { - cachedPRInfo.set(bookmark, { - number: meta.prInfo.number, - state: meta.prInfo.state, - url: meta.prInfo.url, - }); - } - } - - const dataResult = await getLogGraphData({ - trunk, - trackedBookmarks, - cachedPRInfo: cachedPRInfo.size > 0 ? cachedPRInfo : undefined, - }); - - if (!dataResult.ok) { - return dataResult; - } - - return ok({ - data: dataResult.value, - unmanagedBranch: isOnUnmanagedBranch ? gitBranch : null, - }); -} - -export const logCommand: Command = { - meta: { - name: "log", - description: "Show a visual overview of the current stack with PR status", - aliases: ["l"], - category: "info", - core: true, - }, - run: log, -}; diff --git a/packages/core/src/commands/modify.ts b/packages/core/src/commands/modify.ts new file mode 100644 index 00000000..5122ca67 --- /dev/null +++ b/packages/core/src/commands/modify.ts @@ -0,0 +1,54 @@ +import { runJJWithMutableConfigVoid, status } from "../jj"; +import { createError, err, ok, type Result } from "../result"; +import type { NavigationResult } from "../types"; +import type { Command } from "./types"; + +/** + * Modify the parent change by squashing the current working copy into it. + * This is useful when you want to add changes to an existing branch + * instead of creating a new one. + */ +export async function modify(): Promise> { + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const wc = statusResult.value.workingCopy; + const parents = statusResult.value.parents; + + if (wc.isEmpty) { + return err( + createError( + "INVALID_STATE", + "No changes to modify. Edit some files first.", + ), + ); + } + + if (parents.length === 0) { + return err(createError("INVALID_STATE", "No parent to modify.")); + } + + const parent = parents[0]; + + // Squash WC into parent + const squashResult = await runJJWithMutableConfigVoid(["squash"]); + if (!squashResult.ok) return squashResult; + + return ok({ + changeId: parent.changeId, + changeIdPrefix: parent.changeIdPrefix, + description: parent.description, + bookmark: parent.bookmarks[0], + position: "editing", + }); +} + +export const modifyCommand: Command = { + meta: { + name: "modify", + description: "Add current changes to the parent (squash into parent)", + aliases: ["m"], + category: "management", + }, + run: modify, +}; diff --git a/packages/core/src/commands/navigation.ts b/packages/core/src/commands/navigation.ts index a6a637df..ff8cb9e3 100644 --- a/packages/core/src/commands/navigation.ts +++ b/packages/core/src/commands/navigation.ts @@ -1,25 +1,77 @@ -import { getTrunk, jjNew, runJJ, status } from "../jj"; +import { edit, getTrunk, jjNew, runJJ, status } from "../jj"; +import type { Changeset } from "../parser"; import { ok, type Result } from "../result"; import type { NavigationResult } from "../types"; /** - * Get navigation result from current working copy. + * Navigate to a change, handling immutability correctly. + * + * - Mutable commits: use `jj edit` to edit directly (position: "editing") + * - Immutable commits (pushed): use `jj new` to create working copy on top (position: "editing") + * + * Returns info about the change we're now "on". + * + * Note: jj automatically abandons empty undescribed changes when we navigate away, + * so no explicit cleanup is needed. */ -export async function getNavigationResult(): Promise> { - const statusResult = await status(); - if (!statusResult.ok) return statusResult; +export async function navigateTo( + change: Changeset, +): Promise> { + const bookmark = change.bookmarks[0]; + + if (change.isImmutable) { + // Check if we're already on an empty WC with this as parent + const statusResult = await status(); + if (statusResult.ok) { + const wc = statusResult.value.workingCopy; + const parents = statusResult.value.parents; + const isParent = parents.some((p) => p.changeId === change.changeId); + const isEmptyUndescribed = wc.isEmpty && wc.description.trim() === ""; + + if (isParent && isEmptyUndescribed) { + // Already positioned - return target info + return ok({ + changeId: change.changeId, + changeIdPrefix: change.changeIdPrefix, + description: change.description, + bookmark, + position: "editing", + }); + } + } + + // Create new working copy on top (jj auto-abandons empty undescribed WC) + const newResult = await jjNew({ parents: [change.changeId] }); + if (!newResult.ok) return newResult; + + // Return the target change info (what we're logically "on") + return ok({ + changeId: change.changeId, + changeIdPrefix: change.changeIdPrefix, + description: change.description, + bookmark, + position: "editing", + }); + } + + // For mutable commits, edit directly (jj auto-abandons empty undescribed WC) + const editResult = await edit(change.changeId); + if (!editResult.ok) return editResult; + return ok({ - changeId: statusResult.value.workingCopy.changeId, - changeIdPrefix: statusResult.value.workingCopy.changeIdPrefix, - description: statusResult.value.workingCopy.description, + changeId: change.changeId, + changeIdPrefix: change.changeIdPrefix, + description: change.description, + bookmark, + position: "editing", }); } /** - * Get navigation result from the parent of the working copy. - * Used when we've created a new empty WC and want to report the parent. + * Get navigation result for "on-top" position (ready for new work). + * Returns info about the parent (the branch we're on top of). */ -export async function getParentNavigationResult(): Promise< +export async function getOnTopNavigationResult(): Promise< Result > { const result = await runJJ([ @@ -28,23 +80,37 @@ export async function getParentNavigationResult(): Promise< "@-", "--no-graph", "-T", - 'change_id.short() ++ "\\t" ++ change_id.shortest().prefix() ++ "\\t" ++ description.first_line()', + 'change_id.short() ++ "\\t" ++ change_id.shortest().prefix() ++ "\\t" ++ description.first_line() ++ "\\t" ++ bookmarks.join(",")', ]); if (!result.ok) return result; - const [changeId, changeIdPrefix, description] = result.value.stdout - .trim() - .split("\t"); - return ok({ changeId, changeIdPrefix, description: description || "" }); + const [changeId, changeIdPrefix, description, bookmarksStr] = + result.value.stdout.trim().split("\t"); + const bookmarks = bookmarksStr ? bookmarksStr.split(",") : []; + return ok({ + changeId, + changeIdPrefix, + description: description || "", + bookmark: bookmarks[0], + position: "on-top", + }); } /** * Create a new change on trunk and return navigation result. + * Note: jj automatically abandons empty undescribed changes when we navigate away. */ -export async function newOnTrunk(): Promise> { - const trunk = await getTrunk(); +export async function newOnTrunk( + trunkName?: string, +): Promise> { + const trunk = trunkName ?? (await getTrunk()); const newResult = await jjNew({ parents: [trunk] }); if (!newResult.ok) return newResult; - const navResult = await getNavigationResult(); - if (!navResult.ok) return navResult; - return ok({ ...navResult.value, createdOnTrunk: true }); + + return ok({ + changeId: "", + changeIdPrefix: "", + description: "", + bookmark: trunk, + position: "on-trunk", + }); } diff --git a/packages/core/src/commands/restack.ts b/packages/core/src/commands/restack.ts index 6f405bba..5a053891 100644 --- a/packages/core/src/commands/restack.ts +++ b/packages/core/src/commands/restack.ts @@ -1,5 +1,9 @@ -import { fetchMetadataRefs } from "../git/refs"; -import { getBookmarkTracking, push, runJJ } from "../jj"; +import { + getBookmarkTracking, + push, + runJJ, + runJJWithMutableConfigVoid, +} from "../jj"; import { ok, type Result } from "../result"; import type { Command } from "./types"; @@ -38,7 +42,8 @@ async function restackAll(): Promise> { return ok({ restacked: 0 }); } - const result = await runJJ([ + // Use mutable config for rebase on potentially pushed commits + const result = await runJJWithMutableConfigVoid([ "rebase", "-s", "roots(mutable())", @@ -78,9 +83,6 @@ export async function restack(): Promise> { const fetchResult = await runJJ(["git", "fetch"]); if (!fetchResult.ok) return fetchResult; - // Fetch arr metadata refs from remote - fetchMetadataRefs(); - // Restack all changes onto trunk const restackResult = await restackAll(); if (!restackResult.ok) return restackResult; diff --git a/packages/core/src/commands/squash.ts b/packages/core/src/commands/squash.ts index d80b8079..5c90b893 100644 --- a/packages/core/src/commands/squash.ts +++ b/packages/core/src/commands/squash.ts @@ -1,5 +1,11 @@ import type { Engine } from "../engine"; -import { abandon, edit, findChange, list, runJJ, status } from "../jj"; +import { + edit, + findChange, + list, + runJJWithMutableConfigVoid, + status, +} from "../jj"; import type { Changeset } from "../parser"; import { createError, err, ok, type Result } from "../result"; import type { Command } from "./types"; @@ -62,8 +68,9 @@ export async function squash( }); const hasChildren = childrenResult.ok && childrenResult.value.length > 0; + // Use mutable config for operations on potentially pushed commits if (hasChildren) { - const rebaseResult = await runJJ([ + const rebaseResult = await runJJWithMutableConfigVoid([ "rebase", "-s", `children(${change.changeId})`, @@ -74,7 +81,10 @@ export async function squash( } // Squash preserves work - just abandon without restoring - const abandonResult = await abandon(change.changeId); + const abandonResult = await runJJWithMutableConfigVoid([ + "abandon", + change.changeId, + ]); if (!abandonResult.ok) return abandonResult; // Untrack any bookmarks on the squashed change diff --git a/packages/core/src/commands/submit.ts b/packages/core/src/commands/submit.ts index 5fdf5b1e..a13811db 100644 --- a/packages/core/src/commands/submit.ts +++ b/packages/core/src/commands/submit.ts @@ -37,13 +37,19 @@ export async function submit( const result = await submitStack({ draft: options.draft }); if (!result.ok) return result; - // Track all submitted PRs with their PR info + // Update PR info for all submitted PRs + // The bookmark should already exist in jj after submission, so refresh from jj + // then update with PR info for (const pr of result.value.prs) { - await engine.track(pr.bookmarkName, { + // Refresh from jj to get latest changeId/commitId/parentBranchName + await engine.refreshFromJJ(pr.bookmarkName); + // Update PR info + engine.updatePRInfo(pr.bookmarkName, { number: pr.prNumber, state: "OPEN", url: pr.prUrl, base: pr.base, + title: pr.title, }); } diff --git a/packages/core/src/commands/sync-pr-info.ts b/packages/core/src/commands/sync-pr-info.ts index 25abe31c..1a21402c 100644 --- a/packages/core/src/commands/sync-pr-info.ts +++ b/packages/core/src/commands/sync-pr-info.ts @@ -1,4 +1,4 @@ -import type { Engine, PRInfo } from "../engine"; +import type { Engine } from "../engine"; import { batchGetPRsForBranches } from "../github/pr-status"; import { ok, type Result } from "../result"; @@ -36,27 +36,7 @@ export async function syncPRInfo( // Update engine with fresh PR info const updated: string[] = []; - for (const [bookmark, prStatus] of prsResult.value) { - const prInfo: PRInfo = { - number: prStatus.number, - state: - prStatus.state === "open" - ? "OPEN" - : prStatus.state === "merged" - ? "MERGED" - : "CLOSED", - url: prStatus.url, - base: prStatus.baseRefName, - title: prStatus.title, - reviewDecision: - prStatus.reviewDecision === "approved" - ? "APPROVED" - : prStatus.reviewDecision === "changes_requested" - ? "CHANGES_REQUESTED" - : prStatus.reviewDecision === "review_required" - ? "REVIEW_REQUIRED" - : undefined, - }; + for (const [bookmark, prInfo] of prsResult.value) { engine.updatePRInfo(bookmark, prInfo); updated.push(bookmark); } diff --git a/packages/core/src/commands/sync.ts b/packages/core/src/commands/sync.ts index 9ed7eee3..fccda8c9 100644 --- a/packages/core/src/commands/sync.ts +++ b/packages/core/src/commands/sync.ts @@ -1,6 +1,11 @@ import type { Engine } from "../engine"; -import { fetchMetadataRefs } from "../git/refs"; -import { abandon, getTrunk, list, runJJ, status } from "../jj"; +import { + getTrunk, + list, + runJJ, + runJJWithMutableConfigVoid, + status, +} from "../jj"; import { ok, type Result } from "../result"; import { findMergedChanges, @@ -126,15 +131,12 @@ export async function sync(options: SyncOptions): Promise> { const fetchResult = await runJJ(["git", "fetch"]); if (!fetchResult.ok) return fetchResult; - // Fetch arr metadata refs from remote - fetchMetadataRefs(); - // Update local trunk bookmark to match remote const trunk = await getTrunk(); await runJJ(["bookmark", "set", trunk, "-r", `${trunk}@origin`]); - // Rebase onto trunk - const rebaseResult = await runJJ([ + // Rebase onto trunk (use mutable config for potentially pushed commits) + const rebaseResult = await runJJWithMutableConfigVoid([ "rebase", "-s", "roots(trunk()..@)", @@ -159,7 +161,23 @@ export async function sync(options: SyncOptions): Promise> { if (emptyResult.ok) { for (const change of emptyResult.value) { - const abandonResult = await abandon(change.changeId); + // If change has bookmarks, check cached PR info from engine + if (change.bookmarks.length > 0) { + const hasOpenPR = change.bookmarks.some((bookmark) => { + const meta = engine.getMeta(bookmark); + return meta?.prInfo?.state === "OPEN"; + }); + + // Skip if there's still an open PR + if (hasOpenPR) { + continue; + } + } + + const abandonResult = await runJJWithMutableConfigVoid([ + "abandon", + change.changeId, + ]); if (abandonResult.ok) { const reason = change.description.trim() !== "" ? "merged" : "empty"; abandoned.push({ changeId: change.changeId, reason }); diff --git a/packages/core/src/commands/top.ts b/packages/core/src/commands/top.ts index ecad559b..a0930dc5 100644 --- a/packages/core/src/commands/top.ts +++ b/packages/core/src/commands/top.ts @@ -1,7 +1,7 @@ -import { runJJ } from "../jj"; +import { jjNew, list, status } from "../jj"; import { createError, err, type Result } from "../result"; import type { NavigationResult } from "../types"; -import { getParentNavigationResult } from "./navigation"; +import { getOnTopNavigationResult, navigateTo } from "./navigation"; import type { Command } from "./types"; /** @@ -9,49 +9,73 @@ import type { Command } from "./types"; * Creates a new empty change at the top for new work. */ export async function top(): Promise> { - // Check if @ is empty, undescribed, and has no children (already at top ready for work) - const [wcResult, childrenResult] = await Promise.all([ - runJJ([ - "log", - "-r", - "@", - "--no-graph", - "-T", - 'empty ++ "\\t" ++ description.first_line()', - ]), - runJJ(["log", "-r", "@+", "--no-graph", "-T", "change_id.short()"]), - ]); + const statusResult = await status(); + if (!statusResult.ok) return statusResult; - if (wcResult.ok && childrenResult.ok) { - const [empty, desc = ""] = wcResult.value.stdout.trim().split("\t"); - const hasChildren = childrenResult.value.stdout.trim() !== ""; - if (empty === "true" && desc === "" && !hasChildren) { - return getParentNavigationResult(); - } - } + const wc = statusResult.value.workingCopy; + const parents = statusResult.value.parents; + const isUndescribed = wc.description.trim() === ""; - // Navigate to the head of the stack - const editResult = await runJJ(["edit", "heads(descendants(@))"]); - if (!editResult.ok) { - if (editResult.error.message.includes("more than one revision")) { - return err( - createError( - "NAVIGATION_FAILED", - "Stack has multiple heads - cannot determine top", - ), + // Check if already at top (empty undescribed WC with no children) + if (wc.isEmpty && isUndescribed) { + const childrenResult = await list({ revset: "@+" }); + if (childrenResult.ok) { + const children = childrenResult.value.filter( + (c) => !c.changeId.startsWith("zzzzzzzz"), ); + if (children.length === 0) { + return getOnTopNavigationResult(); + } } - if (!editResult.error.message.includes("No descendant")) { - return editResult; - } - // "No descendant" means already at head - fall through to create empty @ } - // Create a new empty change above the head for new work - const newResult = await runJJ(["new"]); + // Check if we're "on" a branch (hidden WC) - need to find heads from parent + const isOnBranch = + isUndescribed && + parents.length > 0 && + (parents[0].description.trim() !== "" || parents[0].bookmarks.length > 0); + + let headsRevset: string; + if (isOnBranch) { + // Look for heads from the parent (the branch we're "on") + headsRevset = `heads(descendants(${parents[0].changeId}))`; + } else { + // Look for heads from current position + headsRevset = "heads(descendants(@))"; + } + + const headsResult = await list({ revset: headsRevset }); + if (!headsResult.ok) return headsResult; + + const heads = headsResult.value.filter( + (c) => !c.changeId.startsWith("zzzzzzzz") && c.changeId !== wc.changeId, + ); + + if (heads.length === 0) { + // Already at top + return getOnTopNavigationResult(); + } + + if (heads.length > 1) { + return err( + createError( + "NAVIGATION_FAILED", + "Stack has multiple heads - cannot determine top", + ), + ); + } + + const head = heads[0]; + + // Navigate to head (handles immutability correctly) + const navResult = await navigateTo(head); + if (!navResult.ok) return navResult; + + // Create new WC for new work + const newResult = await jjNew(); if (!newResult.ok) return newResult; - return getParentNavigationResult(); + return getOnTopNavigationResult(); } export const topCommand: Command = { diff --git a/packages/core/src/commands/track.ts b/packages/core/src/commands/track.ts index 899c2895..94166d6f 100644 --- a/packages/core/src/commands/track.ts +++ b/packages/core/src/commands/track.ts @@ -113,8 +113,11 @@ export async function track( } } - // Track the bookmark - await engine.track(bookmark); + // Track the bookmark by refreshing from jj + const refreshResult = await engine.refreshFromJJ(bookmark); + if (!refreshResult.ok) { + return refreshResult; + } return ok({ bookmark, parent: parentBranch }); } diff --git a/packages/core/src/commands/trunk.ts b/packages/core/src/commands/trunk.ts index 1f59b6ff..ac55c4e9 100644 --- a/packages/core/src/commands/trunk.ts +++ b/packages/core/src/commands/trunk.ts @@ -1,25 +1,13 @@ -import { getTrunk, jjNew, status } from "../jj"; -import { ok, type Result } from "../result"; +import type { Result } from "../result"; import type { NavigationResult } from "../types"; +import { newOnTrunk } from "./navigation"; import type { Command } from "./types"; /** * Navigate to trunk and create a fresh change for new work. */ export async function trunk(): Promise> { - const trunkBranch = await getTrunk(); - const newResult = await jjNew({ parents: [trunkBranch] }); - if (!newResult.ok) return newResult; - - const statusResult = await status(); - if (!statusResult.ok) return statusResult; - - return ok({ - changeId: statusResult.value.workingCopy.changeId, - changeIdPrefix: statusResult.value.workingCopy.changeIdPrefix, - description: statusResult.value.workingCopy.description, - createdOnTrunk: true, - }); + return newOnTrunk(); } export const trunkCommand: Command = { diff --git a/packages/core/src/commands/up.ts b/packages/core/src/commands/up.ts index 0fa06661..589f79ca 100644 --- a/packages/core/src/commands/up.ts +++ b/packages/core/src/commands/up.ts @@ -1,45 +1,71 @@ -import { jjNew, runJJ, status } from "../jj"; +import { jjNew, list, status } from "../jj"; import { createError, err, type Result } from "../result"; import type { NavigationResult } from "../types"; -import { getNavigationResult } from "./navigation"; +import { getOnTopNavigationResult, navigateTo } from "./navigation"; import type { Command } from "./types"; export async function up(): Promise> { - // First check if we're on an undescribed working copy const statusResult = await status(); if (!statusResult.ok) return statusResult; const wc = statusResult.value.workingCopy; + const parents = statusResult.value.parents; const isUndescribed = wc.description.trim() === ""; const hasChanges = !wc.isEmpty; - if (isUndescribed) { - if (hasChanges) { + // Check if we're "on" a branch (WC on top of a described/bookmarked change) + const isOnBranch = + isUndescribed && + parents.length > 0 && + (parents[0].description.trim() !== "" || parents[0].bookmarks.length > 0); + + if (isOnBranch) { + // We're "on" a branch - look for children of the parent (the branch), not the WC + const parent = parents[0]; + const childrenResult = await list({ revset: `${parent.changeId}+` }); + if (!childrenResult.ok) return childrenResult; + + // Filter out the current WC and root commit + const children = childrenResult.value.filter( + (c) => !c.changeId.startsWith("zzzzzzzz") && c.changeId !== wc.changeId, + ); + + if (children.length === 0) { + // Already at top of stack + return getOnTopNavigationResult(); + } + + if (children.length > 1) { return err( createError( "NAVIGATION_FAILED", - 'You have unsaved changes. Run `arr create "message"` to save them.', + "Multiple children - navigation is ambiguous", ), ); } - return err( - createError( - "NAVIGATION_FAILED", - 'No changes yet. Edit files, then run `arr create "message"`.', - ), - ); + + // Navigate to the child branch + return navigateTo(children[0]); } - // Try normal navigation - const result = await runJJ(["next", "--edit"]); - if (!result.ok) { - if (result.error.message.includes("No descendant")) { + // WC has a description - it's a real change, not a scratch WC + if (!isUndescribed) { + // Find children of current change + const childrenResult = await list({ revset: "@+" }); + if (!childrenResult.ok) return childrenResult; + + const children = childrenResult.value.filter( + (c) => !c.changeId.startsWith("zzzzzzzz"), + ); + + if (children.length === 0) { // At top of stack - create new empty WC const newResult = await jjNew(); if (!newResult.ok) return newResult; - return getNavigationResult(); + return getOnTopNavigationResult(); } - if (result.error.message.includes("ambiguous")) { + + if (children.length > 1) { return err( createError( "NAVIGATION_FAILED", @@ -47,9 +73,26 @@ export async function up(): Promise> { ), ); } - return result; + + // Navigate to the child (handles immutability correctly) + return navigateTo(children[0]); + } + + // WC is undescribed and parent has no description/bookmark - this is an orphan WC + if (hasChanges) { + return err( + createError( + "NAVIGATION_FAILED", + 'You have unsaved changes. Run `arr create "message"` to save them.', + ), + ); } - return getNavigationResult(); + return err( + createError( + "NAVIGATION_FAILED", + 'No changes yet. Edit files, then run `arr create "message"`.', + ), + ); } export const upCommand: Command = { diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index ef87caea..2c26da23 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -7,6 +7,7 @@ import { writeMetadata, } from "../git/metadata"; import { getTrunk, list } from "../jj"; +import { createError, ok, type Result } from "../result"; import type { TreeNode } from "./types"; export type { PRInfo }; @@ -32,9 +33,34 @@ export interface Engine { getParent(bookmark: string): string | null; getChildren(bookmark: string): string[]; - // Mutations - engine derives changeId/commitId/parentBranchName internally + // Mutations + /** + * Set metadata directly for a bookmark. + * Use this when you already have the full metadata (e.g., from remote refs). + */ + setMeta(bookmark: string, meta: BranchMeta): void; + + /** + * Refresh a bookmark's changeId/commitId/parentBranchName from jj. + * Preserves existing prInfo if present. + * Returns error if bookmark not found in jj. + */ + refreshFromJJ(bookmark: string): Promise>; + + /** + * Track a new bookmark by looking up its info from jj. + * @deprecated Use setMeta() for explicit metadata or refreshFromJJ() for jj lookup + */ track(bookmark: string, prInfo?: PRInfo): Promise; + + /** + * Untrack a bookmark (delete metadata). + */ untrack(bookmark: string): void; + + /** + * Update PR info for a tracked bookmark. + */ updatePRInfo(bookmark: string, prInfo: PRInfo): void; // Tree building @@ -133,9 +159,96 @@ export function createEngine(cwd: string = process.cwd()): Engine { return children; }, + /** + * Set metadata directly for a bookmark. + * Use this when you already have the full metadata (e.g., from remote refs). + */ + setMeta(bookmark: string, meta: BranchMeta): void { + branches.set(bookmark, meta); + dirty.add(bookmark); + deleted.delete(bookmark); + }, + + /** + * Refresh a bookmark's changeId/commitId/parentBranchName from jj. + * Preserves existing prInfo if present. + * Returns error if bookmark not found in jj. + */ + async refreshFromJJ(bookmark: string): Promise> { + const trunk = await getTrunk(cwd); + const existing = branches.get(bookmark); + + // Get the change for this bookmark + const changeResult = await list( + { revset: `bookmarks(exact:"${bookmark}")`, limit: 1 }, + cwd, + ); + if (!changeResult.ok) { + return { + ok: false, + error: createError("COMMAND_FAILED", changeResult.error.message), + }; + } + if (changeResult.value.length === 0) { + return { + ok: false, + error: createError( + "NOT_FOUND", + `Bookmark "${bookmark}" not found in jj`, + ), + }; + } + + const change = changeResult.value[0]; + const parentChangeId = change.parents[0]; + + // Determine parent branch name + let parentBranchName = trunk; + + if (parentChangeId) { + // Check if parent is trunk + const trunkResult = await list( + { revset: `bookmarks(exact:"${trunk}")`, limit: 1 }, + cwd, + ); + const isTrunkParent = + trunkResult.ok && + trunkResult.value.length > 0 && + trunkResult.value[0].changeId === parentChangeId; + + if (!isTrunkParent) { + // Find parent's bookmark + const parentResult = await list( + { revset: parentChangeId, limit: 1 }, + cwd, + ); + if (parentResult.ok && parentResult.value.length > 0) { + const parentBookmark = parentResult.value[0].bookmarks[0]; + if (parentBookmark) { + parentBranchName = parentBookmark; + } + } + } + } + + const meta: BranchMeta = { + changeId: change.changeId, + commitId: change.commitId, + parentBranchName, + prInfo: existing?.prInfo, // Preserve existing prInfo + }; + + branches.set(bookmark, meta); + dirty.add(bookmark); + deleted.delete(bookmark); + + return ok(undefined); + }, + /** * Track a bookmark. Derives changeId, commitId, parentBranchName from jj. * If already tracked, updates the metadata (upsert behavior). + * @deprecated Use setMeta() for explicit metadata or refreshFromJJ() for jj lookup */ async track(bookmark: string, prInfo?: PRInfo): Promise { const trunk = await getTrunk(cwd); diff --git a/packages/core/src/engine/types.ts b/packages/core/src/engine/types.ts index 044705b2..531c7fe0 100644 --- a/packages/core/src/engine/types.ts +++ b/packages/core/src/engine/types.ts @@ -1,40 +1,12 @@ -import { z } from "zod"; +// Re-export types from the single source of truth +import type { BranchMeta } from "../git/metadata"; -/** - * PR information cached from GitHub. - * Stored in git refs, refreshed on submit/sync. - */ -const prInfoSchema = z.object({ - number: z.number(), - url: z.string(), - state: z.enum(["OPEN", "CLOSED", "MERGED"]), - base: z.string(), - title: z.string().optional(), - reviewDecision: z - .enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]) - .optional(), - isDraft: z.boolean().optional(), -}); - -export type PRInfo = z.infer; - -/** - * Branch metadata stored in git refs at refs/arr/. - * This is the authoritative tracking data that persists across machines. - */ -const branchMetaSchema = z.object({ - // Identity - changeId: z.string(), - commitId: z.string(), - - // Stack relationship - the key field for PR base chains - parentBranchName: z.string(), - - // PR info (cached from GitHub) - prInfo: prInfoSchema.optional(), -}); - -export type BranchMeta = z.infer; +export type { + BranchMeta, + PRInfo, + PRState, + ReviewDecision, +} from "../git/metadata"; /** * Tree node for rendering arr log. diff --git a/packages/core/src/git/metadata.ts b/packages/core/src/git/metadata.ts index 358d2008..7b5afdaf 100644 --- a/packages/core/src/git/metadata.ts +++ b/packages/core/src/git/metadata.ts @@ -1,17 +1,37 @@ import { z } from "zod"; import { REFS_PREFIX, runGitSync, runGitSyncLines } from "./runner"; +/** + * PR state - matches GitHub GraphQL API (uppercase) + */ +export type PRState = "OPEN" | "CLOSED" | "MERGED"; + +/** + * Review decision - matches GitHub GraphQL API (uppercase) + */ +export type ReviewDecision = + | "APPROVED" + | "REVIEW_REQUIRED" + | "CHANGES_REQUESTED"; + const prInfoSchema = z.object({ + // Required fields number: z.number(), url: z.string(), state: z.enum(["OPEN", "CLOSED", "MERGED"]), base: z.string(), - title: z.string().optional(), + title: z.string(), + + // Optional fields + head: z.string().optional(), body: z.string().optional(), reviewDecision: z .enum(["APPROVED", "REVIEW_REQUIRED", "CHANGES_REQUESTED"]) + .nullable() .optional(), isDraft: z.boolean().optional(), + /** Number of times PR was submitted (1 = initial, 2+ = updated via force-push) */ + version: z.number().optional(), }); const branchMetaSchema = z.object({ diff --git a/packages/core/src/git/refs.ts b/packages/core/src/git/refs.ts deleted file mode 100644 index 065b509c..00000000 --- a/packages/core/src/git/refs.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { runGitSync } from "./runner"; - -const REFS_PREFIX = "refs/arr"; - -/** - * Push all arr metadata refs to remote. - */ -export function pushMetadataRefs(remote = "origin", cwd?: string): void { - runGitSync(["push", remote, `${REFS_PREFIX}/*:${REFS_PREFIX}/*`], { - cwd, - onError: "ignore", - }); -} - -/** - * Fetch all arr metadata refs from remote. - */ -export function fetchMetadataRefs(remote = "origin", cwd?: string): void { - runGitSync(["fetch", remote, `${REFS_PREFIX}/*:${REFS_PREFIX}/*`], { - cwd, - onError: "ignore", - }); -} - -/** - * Push metadata ref for a single branch. - */ -export function pushBranchMetadata( - branchName: string, - remote = "origin", - cwd?: string, -): void { - const ref = `${REFS_PREFIX}/${branchName}`; - runGitSync(["push", remote, `${ref}:${ref}`], { cwd, onError: "ignore" }); -} - -/** - * Fetch metadata ref for a single branch. - */ -export function fetchBranchMetadata( - branchName: string, - remote = "origin", - cwd?: string, -): void { - const ref = `${REFS_PREFIX}/${branchName}`; - runGitSync(["fetch", remote, `${ref}:${ref}`], { cwd, onError: "ignore" }); -} diff --git a/packages/core/src/github/pr-status.ts b/packages/core/src/github/pr-status.ts index 2d5c9ad4..f799e3bb 100644 --- a/packages/core/src/github/pr-status.ts +++ b/packages/core/src/github/pr-status.ts @@ -1,18 +1,11 @@ import { graphql } from "@octokit/graphql"; import type { PullRequestReviewState } from "@octokit/graphql-schema"; +import type { PRInfo, ReviewDecision } from "../git/metadata"; import { createError, err, ok, type Result } from "../result"; import { getRepoInfo, getToken } from "./client"; -export interface PRStatus { - number: number; - state: "open" | "closed" | "merged"; - reviewDecision: "approved" | "changes_requested" | "review_required" | null; - title: string; - baseRefName: string; - url: string; - /** Number of times PR was submitted (1 = initial, 2+ = updated via force-push) */ - version: number; -} +// Re-export PRInfo as the unified type for PR data +export type { PRInfo }; /** GraphQL fields for fetching PR status - shared between queries */ const PR_STATUS_FIELDS = ` @@ -21,6 +14,7 @@ const PR_STATUS_FIELDS = ` state merged baseRefName + headRefName url reviews(last: 50) { nodes { @@ -40,6 +34,7 @@ interface GraphQLPRNode { state: "OPEN" | "CLOSED" | "MERGED"; merged: boolean; baseRefName: string; + headRefName: string; url: string; reviews: { nodes: Array<{ @@ -54,7 +49,7 @@ interface GraphQLPRNode { function computeReviewDecision( reviews: GraphQLPRNode["reviews"]["nodes"], -): PRStatus["reviewDecision"] { +): ReviewDecision | null { const latestByUser = new Map(); for (const review of reviews) { if (review.state !== "PENDING" && review.state !== "COMMENTED") { @@ -63,43 +58,44 @@ function computeReviewDecision( } const states = [...latestByUser.values()]; - if (states.includes("CHANGES_REQUESTED")) return "changes_requested"; - if (states.includes("APPROVED")) return "approved"; + if (states.includes("CHANGES_REQUESTED")) return "CHANGES_REQUESTED"; + if (states.includes("APPROVED")) return "APPROVED"; return null; } -/** Map a GraphQL PR node to our PRStatus type */ -function mapPRNodeToStatus(pr: GraphQLPRNode): PRStatus { +/** Map a GraphQL PR node to our PRInfo type */ +function mapPRNodeToInfo(pr: GraphQLPRNode): PRInfo { const forcePushCount = pr.timelineItems?.totalCount ?? 0; return { number: pr.number, title: pr.title, - state: pr.merged ? "merged" : (pr.state.toLowerCase() as "open" | "closed"), + state: pr.merged ? "MERGED" : pr.state, reviewDecision: computeReviewDecision(pr.reviews.nodes), - baseRefName: pr.baseRefName, + base: pr.baseRefName, + head: pr.headRefName, url: pr.url, version: 1 + forcePushCount, }; } -async function _getPRStatus( +async function _getPRInfo( prNumber: number, cwd = process.cwd(), -): Promise> { - const result = await getMultiplePRStatuses([prNumber], cwd); +): Promise> { + const result = await getMultiplePRInfos([prNumber], cwd); if (!result.ok) return result; - const status = result.value.get(prNumber); - if (!status) { + const info = result.value.get(prNumber); + if (!info) { return err(createError("COMMAND_FAILED", `PR #${prNumber} not found`)); } - return ok(status); + return ok(info); } -export async function getMultiplePRStatuses( +export async function getMultiplePRInfos( prNumbers: number[], cwd = process.cwd(), -): Promise>> { +): Promise>> { if (prNumbers.length === 0) { return ok(new Map()); } @@ -137,26 +133,24 @@ export async function getMultiplePRStatuses( headers: { authorization: `token ${token}` }, }); - const statuses = new Map(); + const infos = new Map(); for (let i = 0; i < prNumbers.length; i++) { const pr = response.repository[`pr${i}`]; if (pr) { - statuses.set(pr.number, mapPRNodeToStatus(pr)); + infos.set(pr.number, mapPRNodeToInfo(pr)); } } - return ok(statuses); + return ok(infos); } catch (e) { - return err( - createError("COMMAND_FAILED", `Failed to get PR statuses: ${e}`), - ); + return err(createError("COMMAND_FAILED", `Failed to get PR info: ${e}`)); } } export async function getPRForBranch( branchName: string, cwd = process.cwd(), -): Promise> { +): Promise> { const result = await batchGetPRsForBranches([branchName], cwd); if (!result.ok) return result; return ok(result.value.get(branchName) ?? null); @@ -165,7 +159,7 @@ export async function getPRForBranch( export async function batchGetPRsForBranches( branchNames: string[], cwd = process.cwd(), -): Promise>> { +): Promise>> { if (branchNames.length === 0) { return ok(new Map()); } @@ -205,14 +199,14 @@ export async function batchGetPRsForBranches( headers: { authorization: `token ${token}` }, }); - const prMap = new Map(); + const prMap = new Map(); for (let i = 0; i < branchNames.length; i++) { const branchData = response.repository[`branch${i}`]; const prs = branchData?.nodes ?? []; // Prefer open PR, otherwise take first (most recent) const pr = prs.find((p) => p.state === "OPEN") ?? prs[0]; if (pr) { - prMap.set(branchNames[i], mapPRNodeToStatus(pr)); + prMap.set(branchNames[i], mapPRNodeToInfo(pr)); } } diff --git a/packages/core/src/jj/describe.ts b/packages/core/src/jj/describe.ts new file mode 100644 index 00000000..a364f3b6 --- /dev/null +++ b/packages/core/src/jj/describe.ts @@ -0,0 +1,15 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +/** + * Set the description of a change. + * @param description The new description + * @param revision The revision to describe (default: @) + */ +export async function describe( + description: string, + revision = "@", + cwd = process.cwd(), +): Promise> { + return runJJVoid(["describe", "-m", description, revision], cwd); +} diff --git a/packages/core/src/jj/edit.ts b/packages/core/src/jj/edit.ts index 53bfbe8b..570bdff7 100644 --- a/packages/core/src/jj/edit.ts +++ b/packages/core/src/jj/edit.ts @@ -1,9 +1,9 @@ import type { Result } from "../result"; -import { runJJVoid } from "./runner"; +import { runJJWithMutableConfigVoid } from "./runner"; export async function edit( revision: string, cwd = process.cwd(), ): Promise> { - return runJJVoid(["edit", revision], cwd); + return runJJWithMutableConfigVoid(["edit", revision], cwd); } diff --git a/packages/core/src/jj/find.ts b/packages/core/src/jj/find.ts index 203be153..0ee19907 100644 --- a/packages/core/src/jj/find.ts +++ b/packages/core/src/jj/find.ts @@ -7,10 +7,13 @@ export async function findChange( options: { includeBookmarks?: boolean } = {}, cwd = process.cwd(), ): Promise> { - // First, try direct revset lookup (handles change IDs, shortest prefixes, etc.) - // Only try if query looks like it could be a change ID (lowercase alphanumeric) - const isRevsetLike = /^[a-z][a-z0-9]*$/.test(query); - if (isRevsetLike) { + // First, try direct revset lookup (handles change IDs, commit IDs, shortest prefixes, etc.) + // Change IDs: lowercase letters + digits (e.g., xnkxvwyk) + // Commit IDs: hex digits (e.g., 1af471ab) + const isChangeId = /^[a-z][a-z0-9]*$/.test(query); + const isCommitId = /^[0-9a-f]+$/.test(query); + + if (isChangeId || isCommitId) { const idResult = await list({ revset: query, limit: 1 }, cwd); if (idResult.ok && idResult.value.length === 1) { return ok({ status: "found", change: idResult.value[0] }); diff --git a/packages/core/src/jj/index.ts b/packages/core/src/jj/index.ts index 3142f11b..15ea1aa5 100644 --- a/packages/core/src/jj/index.ts +++ b/packages/core/src/jj/index.ts @@ -2,6 +2,7 @@ export { abandon } from "./abandon"; export { ensureBookmark } from "./bookmark-create"; export { deleteBookmark } from "./bookmark-delete"; export { getBookmarkTracking } from "./bookmark-tracking"; +export { describe } from "./describe"; export { getDiffStats } from "./diff"; export { edit } from "./edit"; export { findChange } from "./find"; @@ -9,7 +10,12 @@ export { list } from "./list"; export { getLog } from "./log"; export { jjNew } from "./new"; export { push } from "./push"; -export { getTrunk, runJJ } from "./runner"; +export { + getTrunk, + runJJ, + runJJWithMutableConfig, + runJJWithMutableConfigVoid, +} from "./runner"; export { getStack } from "./stack"; export { status } from "./status"; export { sync } from "./sync"; diff --git a/packages/core/src/jj/push.ts b/packages/core/src/jj/push.ts index 804e2cc5..19afd0f5 100644 --- a/packages/core/src/jj/push.ts +++ b/packages/core/src/jj/push.ts @@ -1,4 +1,3 @@ -import { pushBranchMetadata } from "../git/refs"; import type { Result } from "../result"; import type { PushOptions } from "../types"; import { runJJ, runJJVoid } from "./runner"; @@ -23,12 +22,5 @@ export async function push( args.push("--bookmark", options.bookmark); } - const result = await runJJVoid(args, cwd); - - // Push metadata ref for the bookmark if push succeeded - if (result.ok && options?.bookmark) { - pushBranchMetadata(options.bookmark, remote, cwd); - } - - return result; + return runJJVoid(args, cwd); } diff --git a/packages/core/src/jj/runner.ts b/packages/core/src/jj/runner.ts index 4873ce91..c5503b11 100644 --- a/packages/core/src/jj/runner.ts +++ b/packages/core/src/jj/runner.ts @@ -68,3 +68,34 @@ export async function runJJVoid( if (!result.ok) return result; return ok(undefined); } + +/** + * Config override to make remote bookmarks mutable. + * Only trunk and tags remain immutable. + */ +const MUTABLE_CONFIG = + 'revset-aliases."immutable_heads()"="present(trunk()) | tags()"'; + +/** + * Run a JJ command with immutability override via --config. + * Use when operating on commits that may have been pushed to remote. + * This is a fallback for repos that weren't initialized with arr init. + */ +export async function runJJWithMutableConfig( + args: string[], + cwd = process.cwd(), +): Promise> { + return runJJ(["--config", MUTABLE_CONFIG, ...args], cwd); +} + +/** + * Run a JJ command with immutability override, returning void. + */ +export async function runJJWithMutableConfigVoid( + args: string[], + cwd = process.cwd(), +): Promise> { + const result = await runJJWithMutableConfig(args, cwd); + if (!result.ok) return result; + return ok(undefined); +} diff --git a/packages/core/src/log-graph.ts b/packages/core/src/log-graph.ts deleted file mode 100644 index de0148b5..00000000 --- a/packages/core/src/log-graph.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { batchGetPRsForBranches } from "./github/pr-status"; -import { getLog, getTrunk } from "./jj"; -import type { Result } from "./result"; -import { createError, ok } from "./result"; -import { LOG_GRAPH_TEMPLATE } from "./templates"; - -export interface PRInfo { - number: number; - state: string; - url: string; - version: number; -} - -export interface CachedPRInfo { - number: number; - state: "OPEN" | "CLOSED" | "MERGED"; - url: string; -} - -export interface LogGraphData { - /** Raw output from jj log with placeholders */ - rawOutput: string; - /** Map of bookmark name -> PR info */ - prInfoMap: Map; - /** Set of change IDs that have unpushed changes */ - modifiedChangeIds: Set; - /** Set of bookmark names that have unpushed changes */ - modifiedBookmarks: Set; - /** Whether the current working copy is on trunk */ - isOnTrunk: boolean; - /** Number of changes with unpushed commits */ - modifiedCount: number; - /** True if there are no changes in the stack */ - isEmpty: boolean; -} - -export interface LogGraphOptions { - trunk?: string; - /** Tracked bookmarks from engine - only these are shown */ - trackedBookmarks?: string[]; - /** Cached PR info from engine - if provided, skips GitHub API call */ - cachedPRInfo?: Map; -} - -/** - * Fetch all data needed to render the log graph. - * Returns structured data that the CLI formats. - * - * If cachedPRInfo is provided, uses it instead of fetching from GitHub. - * This significantly speeds up the command (from ~2-4s to ~250ms). - */ -export async function getLogGraphData( - options: LogGraphOptions = {}, -): Promise> { - const trunk = options.trunk ?? (await getTrunk()); - - // Build revset: show only tracked bookmarks + trunk - // This matches Graphite behavior - untracked branches are not shown - const trackedBookmarks = options.trackedBookmarks ?? []; - - let revset: string; - if (trackedBookmarks.length === 0) { - // No tracked branches - show trunk + working copy - revset = `${trunk} | @`; - } else { - // Show tracked bookmarks (only mutable ones) + trunk + working copy - // Immutable tracked bookmarks are stale (merged) and shouldn't be shown - const bookmarkRevsets = trackedBookmarks - .map((b) => `(bookmarks(exact:"${b}") & mutable())`) - .join(" | "); - revset = `(${bookmarkRevsets}) | ${trunk} | @`; - } - - const result = Bun.spawnSync( - ["jj", "log", "--color=never", "-r", revset, "-T", LOG_GRAPH_TEMPLATE], - { cwd: process.cwd() }, - ); - - if (result.exitCode !== 0) { - return { - ok: false, - error: createError("COMMAND_FAILED", result.stderr.toString()), - }; - } - - const rawOutput = result.stdout.toString(); - - // Extract bookmark names from PR placeholders - const prPlaceholderRegex = /\{\{PR:([^|]+)\|[^}]*\}\}/g; - const bookmarks = new Set(); - for (const match of rawOutput.matchAll(prPlaceholderRegex)) { - for (const b of match[1].split(",")) { - bookmarks.add(b.trim()); - } - } - - // Build PR info map - use cache if provided, otherwise fetch from GitHub - const prInfoMap = new Map(); - - if (options.cachedPRInfo && options.cachedPRInfo.size > 0) { - // Use cached PR info - much faster path - for (const bookmark of bookmarks) { - const cached = options.cachedPRInfo.get(bookmark); - if (cached) { - prInfoMap.set(bookmark, { - number: cached.number, - state: cached.state.toLowerCase(), - url: cached.url, - version: 0, // Cache doesn't track version - }); - } - } - } else if (bookmarks.size > 0) { - // Fetch from GitHub - slower path - const prsResult = await batchGetPRsForBranches(Array.from(bookmarks)); - if (prsResult.ok) { - for (const [bookmark, pr] of prsResult.value) { - prInfoMap.set(bookmark, { - number: pr.number, - state: pr.state, - url: pr.url, - version: pr.version, - }); - } - } - } - - // Get log data for modified status (much faster than getEnrichedLog) - const logResult = await getLog(); - - // Build modified changes and bookmarks sets - const modifiedChangeIds = new Set(); - const modifiedBookmarks = new Set(); - if (logResult.ok) { - for (const entry of logResult.value.entries) { - if (entry.isModified) { - modifiedChangeIds.add(entry.change.changeId); - for (const bookmark of entry.change.bookmarks) { - modifiedBookmarks.add(bookmark); - } - } - } - } - - const logData = logResult.ok ? logResult.value : null; - const isOnTrunk = logData?.isOnTrunk ?? false; - const modifiedCount = logData - ? logData.entries.filter((e) => e.isModified).length - : 0; - const isEmpty = logData - ? logData.entries.length === 0 && isOnTrunk && !logData.uncommittedWork - : false; - - return ok({ - rawOutput, - prInfoMap, - modifiedChangeIds, - modifiedBookmarks, - isOnTrunk, - modifiedCount, - isEmpty, - }); -} diff --git a/packages/core/src/log.ts b/packages/core/src/log.ts index 10268a2f..4ecf9e5e 100644 --- a/packages/core/src/log.ts +++ b/packages/core/src/log.ts @@ -15,14 +15,15 @@ interface LogEntry { isModified: boolean; } -export interface PRInfo { +/** Minimal PR info for log display */ +export interface LogPRInfo { number: number; - state: "open" | "merged" | "closed"; + state: "OPEN" | "MERGED" | "CLOSED"; url: string; } export interface EnrichedLogEntry extends LogEntry { - prInfo: PRInfo | null; + prInfo: LogPRInfo | null; diffStats: { filesChanged: number; insertions: number; diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts index 4842284f..a6d5ad90 100644 --- a/packages/core/src/result.ts +++ b/packages/core/src/result.ts @@ -31,6 +31,7 @@ export type JJErrorCode = | "NAVIGATION_FAILED" | "MERGE_BLOCKED" | "ALREADY_MERGED" + | "NOT_FOUND" | "UNKNOWN"; export function createError( diff --git a/packages/core/src/stacks/enriched-log.ts b/packages/core/src/stacks/enriched-log.ts index f188b21f..79bcec0d 100644 --- a/packages/core/src/stacks/enriched-log.ts +++ b/packages/core/src/stacks/enriched-log.ts @@ -1,6 +1,6 @@ import { batchGetPRsForBranches } from "../github/pr-status"; import { getDiffStats, getLog } from "../jj"; -import type { EnrichedLogEntry, EnrichedLogResult, PRInfo } from "../log"; +import type { EnrichedLogEntry, EnrichedLogResult, LogPRInfo } from "../log"; import { ok, type Result } from "../result"; export async function getEnrichedLog(): Promise> { @@ -26,7 +26,7 @@ export async function getEnrichedLog(): Promise> { } const bookmarksList = Array.from(bookmarkToChangeId.keys()); - const prInfoMap = new Map(); + const prInfoMap = new Map(); if (bookmarksList.length > 0) { const prsResult = await batchGetPRsForBranches(bookmarksList); if (prsResult.ok) { diff --git a/packages/core/src/stacks/index.ts b/packages/core/src/stacks/index.ts index f70ddbac..a3ae6119 100644 --- a/packages/core/src/stacks/index.ts +++ b/packages/core/src/stacks/index.ts @@ -5,5 +5,7 @@ export { cleanupMergedChange, findMergedChanges, type MergedChange, + type ReparentResult, + reparentAndCleanup, } from "./merged"; export { submitStack } from "./submit"; diff --git a/packages/core/src/stacks/merge.ts b/packages/core/src/stacks/merge.ts index d37e330d..26505560 100644 --- a/packages/core/src/stacks/merge.ts +++ b/packages/core/src/stacks/merge.ts @@ -73,33 +73,26 @@ export async function getMergeStack(): Promise> { ); } - if (prItem.state === "merged") { + // Both merged and closed PRs signal the end of the chain. + // Closed PRs are treated like merged - the sync command will handle cleanup. + if (prItem.state === "MERGED" || prItem.state === "CLOSED") { break; } - if (prItem.state === "closed") { - return err( - createError( - "INVALID_STATE", - `PR #${prItem.number} is closed (not merged)`, - ), - ); - } - prsToMerge.unshift({ prNumber: prItem.number, prTitle: prItem.title, prUrl: prItem.url, bookmarkName: currentBookmark, changeId: currentChangeId, - baseRefName: prItem.baseRefName, + baseRefName: prItem.base, }); - if (prItem.baseRefName === trunk) { + if (prItem.base === trunk) { break; } - currentBookmark = prItem.baseRefName; + currentBookmark = prItem.base; currentChangeId = null; } diff --git a/packages/core/src/stacks/merged.ts b/packages/core/src/stacks/merged.ts index 12319865..8dd35ab6 100644 --- a/packages/core/src/stacks/merged.ts +++ b/packages/core/src/stacks/merged.ts @@ -1,7 +1,13 @@ import { isTrackingBookmark } from "../bookmark-utils"; import type { Engine } from "../engine"; -import { batchGetPRsForBranches } from "../github/pr-status"; -import { abandon, list } from "../jj"; +import { updatePR } from "../github/pr-actions"; +import { batchGetPRsForBranches, getPRForBranch } from "../github/pr-status"; +import { + deleteBookmark, + getTrunk, + list, + runJJWithMutableConfigVoid, +} from "../jj"; import { ok, type Result } from "../result"; export interface MergedChange { @@ -9,10 +15,11 @@ export interface MergedChange { bookmark: string; prNumber: number; description: string; + reason: "merged" | "closed"; } /** - * Find changes with merged PRs that can be cleaned up. + * Find changes with merged or closed PRs that can be cleaned up. * Does NOT abandon them - caller should prompt user first. */ export async function findMergedChanges(): Promise> { @@ -51,12 +58,13 @@ export async function findMergedChanges(): Promise> { for (const [bookmark, change] of bookmarkToChange) { const prItem = prCache.get(bookmark); - if (prItem && prItem.state === "merged") { + if (prItem && (prItem.state === "MERGED" || prItem.state === "CLOSED")) { merged.push({ changeId: change.changeId, bookmark, prNumber: prItem.number, description: change.description, + reason: prItem.state === "MERGED" ? "merged" : "closed", }); } } @@ -64,14 +72,125 @@ export async function findMergedChanges(): Promise> { return ok(merged); } +export interface ReparentResult { + reparentedChildren: Array<{ + changeId: string; + bookmarks: string[]; + }>; + prBasesUpdated: number; +} + +/** + * Reparent children of a merged/closed change to its parent, then clean up. + * This handles the case where A > B > C and B is merged/closed: C becomes child of A. + * + * Steps: + * 1. Find children of the change being removed + * 2. Get the parent of the change (the grandparent of children) + * 3. Rebase children onto grandparent + * 4. Update PR bases on GitHub for affected children + * 5. Abandon the merged/closed change + * 6. Delete the bookmark locally + * 7. Untrack from engine + */ +export async function reparentAndCleanup( + change: MergedChange, + engine: Engine, + cwd = process.cwd(), +): Promise> { + const trunk = await getTrunk(cwd); + + // 1. Find children of this change + const childrenResult = await list( + { revset: `children(${change.changeId})` }, + cwd, + ); + if (!childrenResult.ok) return childrenResult; + + // 2. Get the parent of the change being removed (grandparent of children) + const changeResult = await list({ revset: change.changeId }, cwd); + if (!changeResult.ok) return changeResult; + + const parentId = changeResult.value[0]?.parents[0] ?? `${trunk}@origin`; + + // Find the bookmark for the parent (for PR base updates) + const parentResult = await list({ revset: parentId }, cwd); + const parentBookmark = parentResult.ok + ? parentResult.value[0]?.bookmarks[0] + : null; + + const reparentedChildren: ReparentResult["reparentedChildren"] = []; + let prBasesUpdated = 0; + + // 3. Rebase children onto grandparent (with mutable config) + if (childrenResult.value.length > 0) { + const rebaseResult = await runJJWithMutableConfigVoid( + ["rebase", "-s", `children(${change.changeId})`, "-d", parentId], + cwd, + ); + if (!rebaseResult.ok) return rebaseResult; + + // Track which children were reparented + for (const child of childrenResult.value) { + reparentedChildren.push({ + changeId: child.changeId, + bookmarks: child.bookmarks, + }); + } + + // 4. Update PR bases on GitHub for affected children + for (const child of childrenResult.value) { + for (const bookmark of child.bookmarks) { + if (isTrackingBookmark(bookmark)) continue; + + const prResult = await getPRForBranch(bookmark, cwd); + if (prResult.ok && prResult.value && prResult.value.state === "OPEN") { + // New base is the grandparent's bookmark, or trunk if none + const newBase = parentBookmark ?? trunk; + const updateResult = await updatePR( + prResult.value.number, + { base: newBase }, + cwd, + ); + if (updateResult.ok) { + prBasesUpdated++; + } + } + } + } + } + + // 5. Abandon the merged/closed change (with mutable config) + const abandonResult = await runJJWithMutableConfigVoid( + ["abandon", change.changeId], + cwd, + ); + if (!abandonResult.ok) return abandonResult; + + // 6. Delete the bookmark locally + await deleteBookmark(change.bookmark, cwd); + + // 7. Untrack from engine + if (engine.isTracked(change.bookmark)) { + engine.untrack(change.bookmark); + } + + return ok({ reparentedChildren, prBasesUpdated }); +} + /** * Clean up a single merged change by abandoning it and untracking from engine. + * @deprecated Use reparentAndCleanup instead for proper child reparenting. */ export async function cleanupMergedChange( change: MergedChange, engine: Engine, + cwd = process.cwd(), ): Promise> { - const abandonResult = await abandon(change.changeId); + const abandonResult = await runJJWithMutableConfigVoid( + ["abandon", change.changeId], + cwd, + ); if (!abandonResult.ok) return abandonResult; // Untrack the bookmark from the engine diff --git a/packages/core/src/stacks/submit.ts b/packages/core/src/stacks/submit.ts index 4eb2e715..4fcc914a 100644 --- a/packages/core/src/stacks/submit.ts +++ b/packages/core/src/stacks/submit.ts @@ -3,7 +3,7 @@ import { upsertStackComment } from "../github/comments"; import { closePR, createPR, updatePR } from "../github/pr-actions"; import { batchGetPRsForBranches, - getMultiplePRStatuses, + getMultiplePRInfos, } from "../github/pr-status"; import { deleteBookmark, @@ -209,13 +209,15 @@ export async function submitStack( prNumber: existingPR.number, prUrl: existingPR.url, base: previousBookmark, + title: existingPR.title, position: i, status: prStatus, }); } else { + const title = change.description || "Untitled"; const prResult = await createPR({ head: bookmark, - title: change.description || "Untitled", + title, base: previousBookmark, draft: options?.draft, }); @@ -241,6 +243,7 @@ export async function submitStack( prNumber: prResult.value.number, prUrl: prResult.value.url, base: previousBookmark, + title, position: i, status: prStatus, }); @@ -263,20 +266,20 @@ async function addStackComments( if (prs.length === 0) return { succeeded: 0, failed: 0 }; const prNumbersList = prs.map((p) => p.prNumber); - const statusesResult = await getMultiplePRStatuses(prNumbersList); - const statuses = statusesResult.ok ? statusesResult.value : new Map(); + const infosResult = await getMultiplePRInfos(prNumbersList); + const infos = infosResult.ok ? infosResult.value : new Map(); const commentUpserts = prs.map((prItem, i) => { const stackEntries: StackEntry[] = prs.map((p, idx) => { - const prStatus = statuses.get(p.prNumber); + const prInfo = infos.get(p.prNumber); let entryStatus: StackEntry["status"] = "waiting"; if (idx === i) { entryStatus = "this"; - } else if (prStatus) { + } else if (prInfo) { entryStatus = mapReviewDecisionToStatus( - prStatus.reviewDecision, - prStatus.state, + prInfo.reviewDecision ?? null, + prInfo.state, ); } diff --git a/packages/core/src/templates.ts b/packages/core/src/templates.ts index 647660ba..b798ec96 100644 --- a/packages/core/src/templates.ts +++ b/packages/core/src/templates.ts @@ -17,22 +17,21 @@ export const CHANGESET_JSON_TEMPLATE = * We replace jj's markers with styled versions in post-processing. * * Output format per change: - * - Immutable (trunk): {{TRUNK:bookmark}} + time + commit (no trailing blank line) - * - Mutable: {{LABEL:...}} + time + hints + PR info + commit + * {{LABEL:changeId|prefix|timestamp|description|conflict|wc|empty|immutable|localBookmarks|remoteBookmarks}} + * + * The CLI handles: + * - Hiding empty undescribed WC (abstracted away) + * - Showing green marker on "current" branch (parent of empty WC) + * - Showing "(uncommitted)" badge when WC has changes */ export const LOG_GRAPH_TEMPLATE = ` -if(immutable, - "{{TRUNK:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n" ++ - "{{TIME:" ++ committer.timestamp().format("%s") ++ "}}\\n" ++ - "{{COMMIT:" ++ commit_id.short(8) ++ "|" ++ commit_id.shortest().prefix() ++ "|" ++ if(description, description.first_line(), "") ++ "}}", - "{{LABEL:" ++ change_id.short(8) ++ "|" ++ change_id.shortest().prefix() ++ "|" ++ committer.timestamp().format("%s") ++ "|" ++ if(description, description.first_line(), "") ++ "|" ++ if(conflict, "1", "0") ++ "|" ++ if(current_working_copy, "1", "0") ++ "|" ++ if(empty, "1", "0") ++ "|" ++ if(immutable, "1", "0") ++ "|" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "|" ++ remote_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n" ++ - "{{TIME:" ++ committer.timestamp().format("%s") ++ "}}\\n" ++ - if(current_working_copy && empty && !description, "{{HINT_EMPTY}}\\n", "") ++ - if(current_working_copy && !empty && !description, "{{HINT_UNCOMMITTED}}\\n", "") ++ - if(current_working_copy && description && local_bookmarks, "{{HINT_SUBMIT}}\\n", "") ++ - "\\n" ++ - if(local_bookmarks, "{{PR:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "|" ++ if(description, description.first_line(), "") ++ "}}\\n{{PRURL:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n", "") ++ - "{{COMMIT:" ++ commit_id.short(8) ++ "|" ++ commit_id.shortest().prefix() ++ "|" ++ if(description, description.first_line(), "") ++ "}}\\n" ++ - "\\n" -) +"{{LABEL:" ++ change_id.short(8) ++ "|" ++ change_id.shortest().prefix() ++ "|" ++ committer.timestamp().format("%s") ++ "|" ++ if(description, description.first_line(), "") ++ "|" ++ if(conflict, "1", "0") ++ "|" ++ if(current_working_copy, "1", "0") ++ "|" ++ if(empty, "1", "0") ++ "|" ++ if(immutable, "1", "0") ++ "|" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "|" ++ remote_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n" ++ +"{{TIME:" ++ committer.timestamp().format("%s") ++ "}}\\n" ++ +if(current_working_copy && empty && !description, "{{HINT_EMPTY}}\\n", "") ++ +if(current_working_copy && !empty && !description, "{{HINT_UNCOMMITTED}}\\n", "") ++ +if(current_working_copy && description && local_bookmarks, "{{HINT_SUBMIT}}\\n", "") ++ +"\\n" ++ +if(local_bookmarks, "{{PR:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "|" ++ if(description, description.first_line(), "") ++ "}}\\n{{PRURL:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n", "") ++ +"{{COMMIT:" ++ commit_id.short(8) ++ "|" ++ commit_id.shortest().prefix() ++ "|" ++ if(description, description.first_line(), "") ++ "}}\\n" ++ +"\\n" `.trim(); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0c55615d..f928855f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -97,6 +97,7 @@ export interface StackPR { prNumber: number; prUrl: string; base: string; + title: string; position: number; status: PRSubmitStatus; } @@ -126,11 +127,19 @@ export interface SyncResult { hasConflicts: boolean; } +export type NavigationPosition = + | "editing" // On a branch, editing it directly + | "on-top" // At top of stack, ready for new work + | "on-trunk"; // On trunk, starting fresh + export interface NavigationResult { changeId: string; changeIdPrefix: string; description: string; - createdOnTrunk?: boolean; + /** The bookmark/branch name if on a tracked branch */ + bookmark?: string; + /** Where we ended up */ + position: NavigationPosition; } export interface FindOptions {