diff --git a/packages/core/src/jj/abandon.ts b/packages/core/src/jj/abandon.ts new file mode 100644 index 00000000..d822afd7 --- /dev/null +++ b/packages/core/src/jj/abandon.ts @@ -0,0 +1,9 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +export async function abandon( + changeId: string, + cwd = process.cwd(), +): Promise> { + return runJJVoid(["abandon", changeId], cwd); +} diff --git a/packages/core/src/jj/bookmark-create.ts b/packages/core/src/jj/bookmark-create.ts new file mode 100644 index 00000000..bafdfd6f --- /dev/null +++ b/packages/core/src/jj/bookmark-create.ts @@ -0,0 +1,24 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +async function createBookmark( + name: string, + revision?: string, + cwd = process.cwd(), +): Promise> { + const args = ["bookmark", "create", name]; + if (revision) { + args.push("-r", revision); + } + return runJJVoid(args, cwd); +} + +export async function ensureBookmark( + name: string, + changeId: string, + cwd = process.cwd(), +): Promise> { + const create = await createBookmark(name, changeId, cwd); + if (create.ok) return create; + return runJJVoid(["bookmark", "move", name, "-r", changeId], cwd); +} diff --git a/packages/core/src/jj/bookmark-delete.ts b/packages/core/src/jj/bookmark-delete.ts new file mode 100644 index 00000000..de8953b6 --- /dev/null +++ b/packages/core/src/jj/bookmark-delete.ts @@ -0,0 +1,9 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +export async function deleteBookmark( + name: string, + cwd = process.cwd(), +): Promise> { + return runJJVoid(["bookmark", "delete", name], cwd); +} diff --git a/packages/core/src/jj/bookmark-tracking.ts b/packages/core/src/jj/bookmark-tracking.ts new file mode 100644 index 00000000..2572a0d7 --- /dev/null +++ b/packages/core/src/jj/bookmark-tracking.ts @@ -0,0 +1,102 @@ +import { ok, type Result } from "../result"; +import type { BookmarkTrackingStatus } from "../types"; +import { runJJ } from "./runner"; + +export async function getBookmarkTracking( + cwd = process.cwd(), +): Promise> { + // Template to get bookmark name + tracking status from origin + const template = `if(remote == "origin", name ++ "\\t" ++ tracking_ahead_count.exact() ++ "/" ++ tracking_behind_count.exact() ++ "\\n")`; + const result = await runJJ(["bookmark", "list", "-T", template], cwd); + if (!result.ok) return result; + + const statuses: BookmarkTrackingStatus[] = []; + const lines = result.value.stdout.trim().split("\n").filter(Boolean); + + for (const line of lines) { + const parts = line.split("\t"); + if (parts.length !== 2) continue; + const [name, counts] = parts; + const [ahead, behind] = counts.split("/").map(Number); + if (!Number.isNaN(ahead) && !Number.isNaN(behind)) { + statuses.push({ name, aheadCount: ahead, behindCount: behind }); + } + } + + return ok(statuses); +} + +/** + * Clean up orphaned bookmarks: + * 1. Local bookmarks marked as deleted (no target) + * 2. Local bookmarks without origin pointing to empty changes + */ +export async function cleanupOrphanedBookmarks( + cwd = process.cwd(), +): Promise> { + // Get all bookmarks with their remote status and target info + // Format: name\tremote_or_local\thas_target\tis_empty + const template = + 'name ++ "\\t" ++ if(remote, remote, "local") ++ "\\t" ++ if(normal_target, "target", "no_target") ++ "\\t" ++ if(normal_target, normal_target.empty(), "") ++ "\\n"'; + const result = await runJJ( + ["bookmark", "list", "--all", "-T", template], + cwd, + ); + if (!result.ok) return result; + + // Parse bookmarks and group by name + const bookmarksByName = new Map< + string, + { hasOrigin: boolean; hasLocalTarget: boolean; isEmpty: boolean } + >(); + + for (const line of result.value.stdout.trim().split("\n")) { + if (!line) continue; + const [name, remote, hasTarget, isEmpty] = line.split("\t"); + if (!name) continue; + + const existing = bookmarksByName.get(name); + if (remote === "origin") { + if (existing) { + existing.hasOrigin = true; + } else { + bookmarksByName.set(name, { + hasOrigin: true, + hasLocalTarget: false, + isEmpty: false, + }); + } + } else if (remote === "local") { + const localHasTarget = hasTarget === "target"; + const localIsEmpty = isEmpty === "true"; + if (existing) { + existing.hasLocalTarget = localHasTarget; + existing.isEmpty = localIsEmpty; + } else { + bookmarksByName.set(name, { + hasOrigin: false, + hasLocalTarget: localHasTarget, + isEmpty: localIsEmpty, + }); + } + } + } + + // Find bookmarks to forget: + // 1. Deleted bookmarks (local has no target) - these show as "(deleted)" + // 2. Orphaned bookmarks (no origin AND empty change) + const forgotten: string[] = []; + for (const [name, info] of bookmarksByName) { + const isDeleted = !info.hasLocalTarget; + const isOrphaned = !info.hasOrigin && info.isEmpty; + + if (isDeleted || isOrphaned) { + const forgetResult = await runJJ(["bookmark", "forget", name], cwd); + if (forgetResult.ok) { + forgotten.push(name); + } + } + } + + return ok(forgotten); +} 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/diff.ts b/packages/core/src/jj/diff.ts new file mode 100644 index 00000000..04c97336 --- /dev/null +++ b/packages/core/src/jj/diff.ts @@ -0,0 +1,54 @@ +import { ok, type Result } from "../result"; +import type { DiffStats } from "../types"; +import { runJJ } from "./runner"; + +function parseDiffStats(stdout: string): DiffStats { + // Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)" + // or just "X file changed, ..." for single file + const summaryMatch = stdout.match( + /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/, + ); + + if (summaryMatch) { + return { + filesChanged: parseInt(summaryMatch[1], 10), + insertions: summaryMatch[2] ? parseInt(summaryMatch[2], 10) : 0, + deletions: summaryMatch[3] ? parseInt(summaryMatch[3], 10) : 0, + }; + } + + // No changes + return { filesChanged: 0, insertions: 0, deletions: 0 }; +} + +/** + * Get diff stats for a revision. + * If fromBookmark is provided, compares against the remote version of that bookmark. + */ +export async function getDiffStats( + revision: string, + options?: { fromBookmark?: string }, + cwd = process.cwd(), +): Promise> { + if (options?.fromBookmark) { + const result = await runJJ( + [ + "diff", + "--from", + `${options.fromBookmark}@origin`, + "--to", + revision, + "--stat", + ], + cwd, + ); + if (!result.ok) { + // If remote doesn't exist, fall back to total diff + return getDiffStats(revision, undefined, cwd); + } + return ok(parseDiffStats(result.value.stdout)); + } + const result = await runJJ(["diff", "-r", revision, "--stat"], cwd); + if (!result.ok) return result; + return ok(parseDiffStats(result.value.stdout)); +} diff --git a/packages/core/src/jj/edit.ts b/packages/core/src/jj/edit.ts new file mode 100644 index 00000000..570bdff7 --- /dev/null +++ b/packages/core/src/jj/edit.ts @@ -0,0 +1,9 @@ +import type { Result } from "../result"; +import { runJJWithMutableConfigVoid } from "./runner"; + +export async function edit( + revision: string, + cwd = process.cwd(), +): Promise> { + return runJJWithMutableConfigVoid(["edit", revision], cwd); +} diff --git a/packages/core/src/jj/find.ts b/packages/core/src/jj/find.ts new file mode 100644 index 00000000..62a77de5 --- /dev/null +++ b/packages/core/src/jj/find.ts @@ -0,0 +1,86 @@ +import type { Changeset } from "../parser"; +import { createError, err, ok, type Result } from "../result"; +import type { FindResult } from "../types"; +import { list } from "./list"; + +export async function findChange( + query: string, + options: { includeBookmarks?: boolean } = {}, + cwd = process.cwd(), +): Promise> { + // 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] }); + } + } + + // Search by description and bookmarks + // Escape backslashes first, then quotes + const escaped = query.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const revset = options.includeBookmarks + ? `description(substring-i:"${escaped}") | bookmarks(substring-i:"${escaped}")` + : `description(substring-i:"${escaped}")`; + + const listResult = await list({ revset }, cwd); + if (!listResult.ok) { + return ok({ status: "none" }); + } + + const matches = listResult.value.filter( + (cs) => !cs.changeId.startsWith("zzzzzzzz"), + ); + + if (matches.length === 0) { + return ok({ status: "none" }); + } + + // Check for exact bookmark match first + if (options.includeBookmarks) { + const exactBookmark = matches.find((cs) => + cs.bookmarks.some((b) => b.toLowerCase() === query.toLowerCase()), + ); + if (exactBookmark) { + return ok({ status: "found", change: exactBookmark }); + } + } + + if (matches.length === 1) { + return ok({ status: "found", change: matches[0] }); + } + + return ok({ status: "multiple", matches }); +} + +/** + * Resolve a target to a single Changeset, returning an error for not-found or ambiguous. + * This is a convenience wrapper around findChange that handles the common error patterns. + */ +export async function resolveChange( + target: string, + options: { includeBookmarks?: boolean } = {}, + cwd = process.cwd(), +): Promise> { + const findResult = await findChange(target, options, cwd); + if (!findResult.ok) return findResult; + + if (findResult.value.status === "none") { + return err(createError("INVALID_REVISION", `Change not found: ${target}`)); + } + if (findResult.value.status === "multiple") { + return err( + createError( + "AMBIGUOUS_REVISION", + `Multiple changes match "${target}". Use a more specific identifier.`, + ), + ); + } + + return ok(findResult.value.change); +} diff --git a/packages/core/src/jj/index.ts b/packages/core/src/jj/index.ts new file mode 100644 index 00000000..9ac5b5f2 --- /dev/null +++ b/packages/core/src/jj/index.ts @@ -0,0 +1,22 @@ +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, resolveChange } from "./find"; +export { list } from "./list"; +export { getLog } from "./log"; +export { jjNew } from "./new"; +export { push } from "./push"; +export { rebase } from "./rebase"; +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/list.ts b/packages/core/src/jj/list.ts new file mode 100644 index 00000000..9ebdb238 --- /dev/null +++ b/packages/core/src/jj/list.ts @@ -0,0 +1,24 @@ +import { type Changeset, parseChangesets } from "../parser"; +import type { Result } from "../result"; +import { CHANGESET_JSON_TEMPLATE } from "../templates"; +import type { ListOptions } from "../types"; +import { runJJ } from "./runner"; + +export async function list( + options?: ListOptions, + cwd = process.cwd(), +): Promise> { + const args = ["log", "--no-graph", "-T", CHANGESET_JSON_TEMPLATE]; + + if (options?.revset) { + args.push("-r", options.revset); + } + if (options?.limit) { + args.push("-n", String(options.limit)); + } + + const result = await runJJ(args, cwd); + if (!result.ok) return result; + + return parseChangesets(result.value.stdout); +} diff --git a/packages/core/src/jj/log.ts b/packages/core/src/jj/log.ts new file mode 100644 index 00000000..f6703001 --- /dev/null +++ b/packages/core/src/jj/log.ts @@ -0,0 +1,91 @@ +import { buildTree, flattenTree, type LogResult } from "../log"; +import { ok, type Result } from "../result"; +import { getBookmarkTracking } from "./bookmark-tracking"; +import { getDiffStats } from "./diff"; +import { list } from "./list"; +import { getTrunk } from "./runner"; +import { status } from "./status"; + +export async function getLog(cwd = process.cwd()): Promise> { + // Fetch all mutable changes (all stacks) plus trunk + const result = await list({ revset: "mutable() | trunk()" }, cwd); + if (!result.ok) return result; + + // Get status for modified files info + const statusResult = await status(cwd); + const modifiedFiles = statusResult.ok ? statusResult.value.modifiedFiles : []; + const hasUncommittedWork = modifiedFiles.length > 0; + + const trunkBranch = await getTrunk(cwd); + const trunk = + result.value.find( + (c) => c.bookmarks.includes(trunkBranch) && c.isImmutable, + ) ?? null; + const workingCopy = result.value.find((c) => c.isWorkingCopy) ?? null; + const allChanges = result.value.filter((c) => !c.isImmutable); + const trunkId = trunk?.changeId ?? ""; + const wcChangeId = workingCopy?.changeId ?? null; + + // Current change is the parent of WC + const currentChangeId = workingCopy?.parents[0] ?? null; + const isOnTrunk = currentChangeId === trunkId; + + // Filter changes to display in the log - exclude the WC itself + const changes = allChanges.filter((c) => { + if (c.description.trim() !== "" || c.hasConflicts) { + return true; + } + if (c.changeId === wcChangeId) { + return false; + } + return !c.isEmpty; + }); + + // Get bookmark tracking to find modified (unpushed) bookmarks + const trackingResult = await getBookmarkTracking(cwd); + const modifiedBookmarks = new Set(); + if (trackingResult.ok) { + for (const statusItem of trackingResult.value) { + if (statusItem.aheadCount > 0) { + modifiedBookmarks.add(statusItem.name); + } + } + } + + const roots = buildTree(changes, trunkId); + const entries = flattenTree(roots, currentChangeId, modifiedBookmarks); + + // Fetch diff stats for uncommitted work if present + let uncommittedWork: LogResult["uncommittedWork"] = null; + if (hasUncommittedWork && workingCopy) { + const statsResult = await getDiffStats( + workingCopy.changeId, + undefined, + cwd, + ); + uncommittedWork = { + changeId: workingCopy.changeId, + changeIdPrefix: workingCopy.changeIdPrefix, + isOnTrunk, + diffStats: statsResult.ok ? statsResult.value : null, + }; + } + + return ok({ + entries, + trunk: { + name: trunkBranch, + commitId: trunk?.commitId ?? "", + commitIdPrefix: trunk?.commitIdPrefix ?? "", + description: trunk?.description ?? "", + timestamp: trunk?.timestamp ?? new Date(), + }, + currentChangeId, + currentChangeIdPrefix: + changes.find((c) => c.changeId === currentChangeId)?.changeIdPrefix ?? + null, + isOnTrunk, + hasEmptyWorkingCopy: false, // Always false now - WC is always empty on top + uncommittedWork, + }); +} diff --git a/packages/core/src/jj/new.ts b/packages/core/src/jj/new.ts new file mode 100644 index 00000000..2eb18dd5 --- /dev/null +++ b/packages/core/src/jj/new.ts @@ -0,0 +1,29 @@ +import { ok, type Result } from "../result"; +import type { NewOptions } from "../types"; +import { runJJ } from "./runner"; +import { status } from "./status"; + +export async function jjNew( + options?: NewOptions, + cwd = process.cwd(), +): Promise> { + const args = ["new"]; + + if (options?.parents && options.parents.length > 0) { + args.push(...options.parents); + } + if (options?.message) { + args.push("-m", options.message); + } + if (options?.noEdit) { + args.push("--no-edit"); + } + + const result = await runJJ(args, cwd); + if (!result.ok) return result; + + const statusResult = await status(cwd); + if (!statusResult.ok) return statusResult; + + return ok(statusResult.value.workingCopy.changeId); +} diff --git a/packages/core/src/jj/push.ts b/packages/core/src/jj/push.ts new file mode 100644 index 00000000..19afd0f5 --- /dev/null +++ b/packages/core/src/jj/push.ts @@ -0,0 +1,26 @@ +import type { Result } from "../result"; +import type { PushOptions } from "../types"; +import { runJJ, runJJVoid } from "./runner"; + +export async function push( + options?: PushOptions, + cwd = process.cwd(), +): Promise> { + const remote = options?.remote ?? "origin"; + + // Track the bookmark on the remote if specified (required for new bookmarks) + if (options?.bookmark) { + // Track ignores already-tracked bookmarks, so safe to call always + await runJJ(["bookmark", "track", `${options.bookmark}@${remote}`], cwd); + } + + const args = ["git", "push"]; + if (options?.remote) { + args.push("--remote", options.remote); + } + if (options?.bookmark) { + args.push("--bookmark", options.bookmark); + } + + return runJJVoid(args, cwd); +} diff --git a/packages/core/src/jj/rebase.ts b/packages/core/src/jj/rebase.ts new file mode 100644 index 00000000..d7040536 --- /dev/null +++ b/packages/core/src/jj/rebase.ts @@ -0,0 +1,29 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +interface RebaseOptions { + /** The bookmark or revision to rebase */ + source: string; + /** The destination to rebase onto */ + destination: string; + /** + * Rebase mode: + * - "branch" (-b): Rebase source and all ancestors not in destination (default) + * - "revision" (-r): Rebase only the source commit, not its ancestors + */ + mode?: "branch" | "revision"; +} + +/** + * Rebase a bookmark/revision onto a new destination. + */ +export async function rebase( + options: RebaseOptions, + cwd = process.cwd(), +): Promise> { + const flag = options.mode === "revision" ? "-r" : "-b"; + return runJJVoid( + ["rebase", flag, options.source, "-d", options.destination], + cwd, + ); +} diff --git a/packages/core/src/jj/runner.ts b/packages/core/src/jj/runner.ts new file mode 100644 index 00000000..c5503b11 --- /dev/null +++ b/packages/core/src/jj/runner.ts @@ -0,0 +1,101 @@ +import { type CommandResult, shellExecutor } from "../executor"; +import { detectError } from "../parser"; +import { createError, err, type JJErrorCode, ok, type Result } from "../result"; + +// Module-level trunk cache (per cwd) +const trunkCache = new Map(); + +export async function getTrunk(cwd = process.cwd()): Promise { + const cached = trunkCache.get(cwd); + if (cached) return cached; + + const result = await shellExecutor.execute( + "jj", + ["config", "get", 'revset-aliases."trunk()"'], + { cwd }, + ); + if (result.exitCode === 0 && result.stdout.trim()) { + const trunk = result.stdout.trim(); + trunkCache.set(cwd, trunk); + return trunk; + } + throw new Error("Trunk branch not configured. Run `arr init` first."); +} + +export async function runJJ( + args: string[], + cwd = process.cwd(), +): Promise> { + try { + const result = await shellExecutor.execute("jj", args, { cwd }); + + if (result.exitCode !== 0) { + const detected = detectError(result.stderr); + if (detected) { + return err( + createError(detected.code as JJErrorCode, detected.message, { + command: `jj ${args.join(" ")}`, + stderr: result.stderr, + }), + ); + } + return err( + createError("COMMAND_FAILED", `jj command failed: ${result.stderr}`, { + command: `jj ${args.join(" ")}`, + stderr: result.stderr, + }), + ); + } + + return ok(result); + } catch (e) { + return err( + createError("COMMAND_FAILED", `Failed to execute jj: ${e}`, { + command: `jj ${args.join(" ")}`, + }), + ); + } +} + +/** + * Run a jj command that returns no meaningful output. + */ +export async function runJJVoid( + args: string[], + cwd = process.cwd(), +): Promise> { + const result = await runJJ(args, cwd); + 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/jj/stack.ts b/packages/core/src/jj/stack.ts new file mode 100644 index 00000000..848ff64a --- /dev/null +++ b/packages/core/src/jj/stack.ts @@ -0,0 +1,19 @@ +import type { Changeset } from "../parser"; +import { ok, type Result } from "../result"; +import { list } from "./list"; + +export async function getStack( + cwd = process.cwd(), +): Promise> { + // Get the current stack from trunk to the current head(s) + // This shows the linear path from trunk through current position to its descendants + const result = await list({ revset: "trunk()..heads(descendants(@))" }, cwd); + if (!result.ok) return result; + + // Filter out empty changes without descriptions, but always keep the working copy + const filtered = result.value.filter( + (cs) => cs.isWorkingCopy || cs.description.trim() !== "" || !cs.isEmpty, + ); + + return ok(filtered); +} diff --git a/packages/core/src/jj/status.ts b/packages/core/src/jj/status.ts new file mode 100644 index 00000000..9da54140 --- /dev/null +++ b/packages/core/src/jj/status.ts @@ -0,0 +1,176 @@ +import { parseConflicts } from "../parser"; +import { ok, type Result } from "../result"; +import type { ChangesetStatus, FileChange } from "../types"; +import { runJJ } from "./runner"; + +// Single template that gets all status info in one jj call +// Diff summary is multi-line, so we put markers around it: DIFF_START and END_CHANGE +const STATUS_TEMPLATE = [ + '"CHANGE:"', + "change_id.short()", + '"|"', + "change_id.shortest().prefix()", + '"|"', + 'if(current_working_copy, "wc", "")', + '"|"', + 'bookmarks.join(",")', + '"|"', + "description.first_line()", + '"|"', + 'if(conflict, "1", "0")', + '"|"', + 'if(empty, "1", "0")', + '"\\nDIFF_START\\n"', + "self.diff().summary()", + '"END_CHANGE\\n"', +].join(" ++ "); + +interface ParsedChange { + changeId: string; + changeIdPrefix: string; + isWorkingCopy: boolean; + bookmarks: string[]; + description: string; + hasConflicts: boolean; + isEmpty: boolean; + diffSummary: string; +} + +function parseModifiedFiles(diffSummary: string): FileChange[] { + if (!diffSummary.trim()) return []; + + return diffSummary + .split("\n") + .filter(Boolean) + .map((line) => { + const status = line[0]; + const path = line.slice(2).trim(); + const statusMap: Record = { + M: "modified", + A: "added", + D: "deleted", + R: "renamed", + C: "copied", + }; + return { path, status: statusMap[status] || "modified" }; + }); +} + +/** + * Get working copy status in a single jj call. + */ +export async function status( + cwd = process.cwd(), +): Promise> { + // Single jj call with template - gets WC, parent, and grandparent for stack path + const result = await runJJ( + ["log", "-r", "@ | @- | @--", "--no-graph", "-T", STATUS_TEMPLATE], + cwd, + ); + + if (!result.ok) return result; + + // Split by END_CHANGE marker to handle multi-line diff summaries + const blocks = result.value.stdout.split("END_CHANGE").filter(Boolean); + const changes = blocks + .map((block) => { + // Split block into metadata and diff parts using DIFF_START marker + const [metaPart, diffPart] = block.split("DIFF_START"); + if (!metaPart) return null; + + const changeLine = metaPart.trim(); + if (!changeLine.startsWith("CHANGE:")) return null; + + const data = changeLine.slice(7); + const parts = data.split("|"); + + return { + changeId: parts[0] || "", + changeIdPrefix: parts[1] || "", + isWorkingCopy: parts[2] === "wc", + bookmarks: (parts[3] || "").split(",").filter(Boolean), + description: parts[4] || "", + hasConflicts: parts[5] === "1", + isEmpty: parts[6] === "1", + diffSummary: diffPart?.trim() || "", + }; + }) + .filter(Boolean) as ParsedChange[]; + + const workingCopy = changes.find((c) => c.isWorkingCopy); + const parent = changes.find((c) => !c.isWorkingCopy); + + // For hasResolvedConflict, we still need jj status output + // But only if parent has conflicts - otherwise skip it + let hasResolvedConflict = false; + if (parent?.hasConflicts) { + const statusResult = await runJJ(["status"], cwd); + if (statusResult.ok) { + hasResolvedConflict = statusResult.value.stdout.includes( + "Conflict in parent commit has been resolved in working copy", + ); + } + } + + // Parse conflicts from jj status if there are any + let conflicts: { path: string; type: "content" | "delete" | "rename" }[] = []; + if (workingCopy?.hasConflicts || parent?.hasConflicts) { + const statusResult = await runJJ(["status"], cwd); + if (statusResult.ok) { + const parsed = parseConflicts(statusResult.value.stdout); + if (parsed.ok) conflicts = parsed.value; + } + } + + return ok({ + workingCopy: workingCopy + ? { + changeId: workingCopy.changeId, + changeIdPrefix: workingCopy.changeIdPrefix, + commitId: "", + commitIdPrefix: "", + description: workingCopy.description, + bookmarks: workingCopy.bookmarks, + parents: parent ? [parent.changeId] : [], + isWorkingCopy: true, + isImmutable: false, + isEmpty: workingCopy.isEmpty, + hasConflicts: workingCopy.hasConflicts, + } + : { + changeId: "", + changeIdPrefix: "", + commitId: "", + commitIdPrefix: "", + description: "", + bookmarks: [], + parents: [], + isWorkingCopy: true, + isImmutable: false, + isEmpty: true, + hasConflicts: false, + }, + parents: parent + ? [ + { + changeId: parent.changeId, + changeIdPrefix: parent.changeIdPrefix, + commitId: "", + commitIdPrefix: "", + description: parent.description, + bookmarks: parent.bookmarks, + parents: [], + isWorkingCopy: false, + isImmutable: false, + isEmpty: parent.isEmpty, + hasConflicts: parent.hasConflicts, + }, + ] + : [], + modifiedFiles: workingCopy + ? parseModifiedFiles(workingCopy.diffSummary) + : [], + conflicts, + hasResolvedConflict, + }); +} diff --git a/packages/core/src/jj/sync.ts b/packages/core/src/jj/sync.ts new file mode 100644 index 00000000..b21618b5 --- /dev/null +++ b/packages/core/src/jj/sync.ts @@ -0,0 +1,66 @@ +import { ok, type Result } from "../result"; +import type { SyncResult } from "../types"; +import { abandon } from "./abandon"; +import { cleanupOrphanedBookmarks } from "./bookmark-tracking"; +import { list } from "./list"; +import { getTrunk, runJJ, runJJVoid } from "./runner"; +import { status } from "./status"; + +async function rebaseOntoTrunk(cwd = process.cwd()): Promise> { + return runJJVoid(["rebase", "-s", "roots(trunk()..@)", "-d", "trunk()"], cwd); +} + +export async function sync(cwd = process.cwd()): Promise> { + const fetchResult = await runJJ(["git", "fetch"], cwd); + if (!fetchResult.ok) return fetchResult; + + // Update local trunk bookmark to match remote (so trunk() points to latest) + // Intentionally ignore errors - remote may not exist for new repos + const trunk = await getTrunk(cwd); + await runJJ(["bookmark", "set", trunk, "-r", `${trunk}@origin`], cwd); + + const rebaseResult = await rebaseOntoTrunk(cwd); + + // Check for conflicts - jj rebase succeeds even with conflicts, so check status + let hasConflicts = false; + if (rebaseResult.ok) { + const statusResult = await status(cwd); + if (statusResult.ok) { + hasConflicts = statusResult.value.workingCopy.hasConflicts; + } + } else { + hasConflicts = rebaseResult.error.message.includes("conflict"); + } + + // Find empty changes, but exclude the current working copy if it's empty + // (jj would just recreate it, and it's not really "cleaned up") + const emptyResult = await list( + { revset: "(trunk()..@) & empty() & ~@" }, + cwd, + ); + const abandoned: Array<{ changeId: string; reason: "empty" | "merged" }> = []; + + if (emptyResult.ok) { + for (const change of emptyResult.value) { + const abandonResult = await abandon(change.changeId, cwd); + if (abandonResult.ok) { + // Empty changes with descriptions are likely merged (content now in trunk) + // Empty changes without descriptions are just staging area WCs + const reason = change.description.trim() !== "" ? "merged" : "empty"; + abandoned.push({ changeId: change.changeId, reason }); + } + } + } + + // Clean up local bookmarks whose remote was deleted and change is empty + const cleanupResult = await cleanupOrphanedBookmarks(cwd); + const forgottenBookmarks = cleanupResult.ok ? cleanupResult.value : []; + + return ok({ + fetched: true, + rebased: rebaseResult.ok, + abandoned, + forgottenBookmarks, + hasConflicts, + }); +}