diff --git a/apps/cli/src/commands/assign.ts b/apps/cli/src/commands/assign.ts new file mode 100644 index 00000000..dd1a1106 --- /dev/null +++ b/apps/cli/src/commands/assign.ts @@ -0,0 +1,125 @@ +import { + assignFiles, + assignFilesToNewWorkspace, + listUnassigned, +} from "@array/core/commands/assign"; +import { cyan, dim, formatSuccess, green, message } from "../utils/output"; +import { requireArg, unwrap } from "../utils/run"; + +export async function assign(args: string[]): Promise { + if (args.length === 0) { + message("Usage: arr assign "); + message(" arr assign --new "); + message(""); + message("Examples:"); + message(" arr assign config.json agent-a"); + message(" arr assign file1.txt file2.txt agent-b"); + message(' arr assign "src/**/*.ts" --new refactor'); + return; + } + + // Check for --new flag + const newIndex = args.indexOf("--new"); + const nIndex = args.indexOf("-n"); + const newFlagIndex = newIndex !== -1 ? newIndex : nIndex; + + if (newFlagIndex !== -1) { + // Everything before --new is files, next arg is workspace name + const files = args.slice(0, newFlagIndex); + const newWorkspaceName = args[newFlagIndex + 1]; + + requireArg(files[0], "Usage: arr assign --new "); + requireArg( + newWorkspaceName, + "Usage: arr assign --new ", + ); + + const result = unwrap( + await assignFilesToNewWorkspace(files, newWorkspaceName), + ); + + if (result.files.length === 1) { + message( + formatSuccess( + `Assigned ${cyan(result.files[0])} to new workspace ${green(result.to)}`, + ), + ); + } else { + message( + formatSuccess( + `Assigned ${result.files.length} files to new workspace ${green(result.to)}`, + ), + ); + for (const file of result.files) { + message(` ${cyan(file)}`); + } + } + return; + } + + // Regular assign to existing workspace + // Last arg is workspace, everything else is files + if (args.length < 2) { + message("Usage: arr assign "); + return; + } + + const files = args.slice(0, -1); + const targetWorkspace = args[args.length - 1]; + + requireArg(files[0], "Usage: arr assign "); + requireArg(targetWorkspace, "Usage: arr assign "); + + const result = unwrap(await assignFiles(files, targetWorkspace)); + + if (result.files.length === 1) { + message( + formatSuccess(`Assigned ${cyan(result.files[0])} to ${green(result.to)}`), + ); + } else { + message( + formatSuccess( + `Assigned ${result.files.length} files to ${green(result.to)}`, + ), + ); + for (const file of result.files) { + message(` ${cyan(file)}`); + } + } +} + +export async function unassigned( + subcommand: string, + _args: string[], +): Promise { + switch (subcommand) { + case "list": + case "ls": { + const result = unwrap(await listUnassigned()); + + if (result.files.length === 0) { + message(dim("No unassigned files")); + return; + } + + message( + `${result.files.length} unassigned file${result.files.length === 1 ? "" : "s"}:`, + ); + message(""); + + for (const file of result.files) { + message(` ${cyan(file)}`); + } + + message(""); + message(`Assign files: ${dim("arr assign ")}`); + break; + } + + default: + message("Usage: arr unassigned "); + message(""); + message("Subcommands:"); + message(" list List files in unassigned workspace"); + } +} diff --git a/apps/cli/src/commands/daemon.ts b/apps/cli/src/commands/daemon.ts new file mode 100644 index 00000000..b890e660 --- /dev/null +++ b/apps/cli/src/commands/daemon.ts @@ -0,0 +1,72 @@ +import { + daemonRestart, + daemonStart, + daemonStatus, + daemonStop, +} from "@array/core/commands/daemon"; +import { cyan, dim, formatSuccess, green, message, red } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function daemon(subcommand: string): Promise { + switch (subcommand) { + case "start": { + unwrap(await daemonStart()); + message(formatSuccess("Daemon started")); + message(dim(" Watching workspaces for file changes")); + message(dim(" Stop with: arr daemon stop")); + break; + } + + case "stop": { + unwrap(await daemonStop()); + message(formatSuccess("Daemon stopped")); + break; + } + + case "restart": { + unwrap(await daemonRestart()); + message(formatSuccess("Daemon restarted")); + break; + } + + case "status": { + const status = unwrap(await daemonStatus()); + if (status.running) { + message( + `${green("●")} Daemon is ${green("running")} (PID: ${status.pid})`, + ); + if (status.repos.length > 0) { + message(""); + message("Watching repos:"); + for (const repo of status.repos) { + message(` ${dim(repo.path)}`); + for (const ws of repo.workspaces) { + message(` └─ ${ws}`); + } + } + } else { + message(""); + message( + dim("No repos registered. Use arr preview to register workspaces."), + ); + } + message(""); + message(`Logs: ${dim(status.logPath)}`); + } else { + message(`${red("○")} Daemon is ${dim("not running")}`); + message(""); + message(`Start with: ${cyan("arr daemon start")}`); + } + break; + } + + default: + message("Usage: arr daemon "); + message(""); + message("Subcommands:"); + message(" start Start the workspace sync daemon"); + message(" stop Stop the daemon"); + message(" restart Restart the daemon"); + message(" status Check if daemon is running"); + } +} diff --git a/apps/cli/src/commands/enter.ts b/apps/cli/src/commands/enter.ts new file mode 100644 index 00000000..7a591cda --- /dev/null +++ b/apps/cli/src/commands/enter.ts @@ -0,0 +1,23 @@ +import { enter } from "@array/core/commands/enter"; +import type { CommandMeta } from "@array/core/commands/types"; +import { unwrap } from "@array/core/result"; +import { blank, dim, green, hint, message } from "../utils/output"; + +export const meta: CommandMeta = { + name: "enter", + description: "Enter jj mode from git", + context: "none", + category: "management", +}; + +export async function run(): Promise { + const result = unwrap(await enter(process.cwd())); + + message(`${green(">")} jj ready`); + if (result.bookmark) { + message(dim(`On branch: ${result.bookmark}`)); + } + message(dim(`Working copy: ${result.workingCopyChangeId}`)); + blank(); + hint("Run `arr exit` to switch git to a branch"); +} diff --git a/apps/cli/src/commands/exit.ts b/apps/cli/src/commands/exit.ts index 8b27fa6b..c5e695c8 100644 --- a/apps/cli/src/commands/exit.ts +++ b/apps/cli/src/commands/exit.ts @@ -1,23 +1,55 @@ +import { exit } from "@array/core/commands/exit"; +import { focusNone, focusStatus } from "@array/core/commands/focus"; import type { CommandMeta } from "@array/core/commands/types"; -import { exitToGit } from "@array/core/git/branch"; -import { getTrunk } from "@array/core/jj"; -import { unwrap as coreUnwrap } from "@array/core/result"; -import { COMMANDS } from "../registry"; -import { arr, blank, cyan, green, hint, message } from "../utils/output"; +import { unwrap } from "@array/core/result"; +import { + blank, + cyan, + dim, + formatSuccess, + green, + hint, + message, + warning, +} from "../utils/output"; export const meta: CommandMeta = { name: "exit", - description: "Exit to plain git on trunk (escape hatch if you need git)", + description: "Exit focus mode, or exit to plain git if not in focus", context: "jj", category: "management", }; -export async function exit(): Promise { - const trunk = await getTrunk(); - const result = coreUnwrap(await exitToGit(process.cwd(), trunk)); +export async function run(): Promise { + // Check if we're in focus mode - exit that first + const status = await focusStatus(); + if (status.ok && status.value.isFocused) { + unwrap(await focusNone()); + message(formatSuccess("Exited focus mode")); + } + + // Exit to git + const result = unwrap(await exit(process.cwd())); + + if (result.alreadyInGitMode) { + message(dim(`Already on git branch '${result.branch}'`)); + return; + } + + message(`${green(">")} Switched to git branch ${cyan(result.branch)}`); + + if (result.syncedFiles > 0) { + message( + dim(`Synced ${result.syncedFiles} file(s) from unassigned workspace`), + ); + } + + if (result.usedFallback) { + blank(); + warning("No bookmark found in ancestors, switched to trunk."); + } - message(`${green(">")} Switched to git branch ${cyan(result.trunk)}`); blank(); hint("You're now using plain git. Your jj changes are still safe."); - hint(`To return to arr/jj, run: ${arr(COMMANDS.init)}`); + hint("Run `arr enter` to return to jj."); } diff --git a/apps/cli/src/commands/focus.ts b/apps/cli/src/commands/focus.ts new file mode 100644 index 00000000..8c57c59c --- /dev/null +++ b/apps/cli/src/commands/focus.ts @@ -0,0 +1,219 @@ +import { + type FocusStatus, + focusAdd, + focusAll, + focusEdit, + focusNone, + focusOnly, + focusRemove, + focusStatus, +} from "@array/core/commands/focus"; +import { + listConflicts, + resolveConflictsBatch, +} from "@array/core/commands/focus-resolve"; +import { + cmd, + cyan, + dim, + formatSuccess, + green, + message, + red, + yellow, +} from "../utils/output"; +import { select } from "../utils/prompt"; +import { requireArg, unwrap } from "../utils/run"; + +function displayFocusStatus(status: FocusStatus): void { + if (!status.isFocused) { + message(dim("Not in focus mode")); + message(""); + if (status.allWorkspaces.length > 0) { + message( + `Available workspaces: ${status.allWorkspaces.map((ws) => cyan(ws.name)).join(", ")}`, + ); + message(""); + message(`Start focus with: ${cmd("arr focus add ")}`); + message(`Or focus all: ${cmd("arr focus all")}`); + } else { + message(dim("No workspaces available")); + message(`Create one with: ${cmd("arr workspace add ")}`); + } + return; + } + + message(`${green("Focus mode")}`); + message(""); + message(`Focusing: ${status.workspaces.map((ws) => cyan(ws)).join(", ")}`); + + // Show workspaces not in focus + const notInFocus = status.allWorkspaces.filter( + (ws) => !status.workspaces.includes(ws.name), + ); + if (notInFocus.length > 0) { + message(dim(`Not in focus: ${notInFocus.map((ws) => ws.name).join(", ")}`)); + } + + // Show conflicts + if (status.conflicts.length > 0) { + message(""); + message(`${red("⚠")} ${red("Conflicts detected:")}`); + for (const conflict of status.conflicts) { + const wsNames = + conflict.workspaces.length > 0 + ? conflict.workspaces.map((ws) => yellow(ws)).join(", ") + : dim("unknown"); + message(` ${conflict.file} ${dim("←")} ${wsNames}`); + } + message(""); + message(`${dim("Resolve with:")} ${cmd("arr focus resolve")}`); + } +} + +export async function focus( + subcommand: string | undefined, + args: string[], +): Promise { + // No subcommand = show status + if (!subcommand || subcommand === "status") { + const status = unwrap(await focusStatus()); + displayFocusStatus(status); + return; + } + + switch (subcommand) { + case "add": { + requireArg(args[0], "Usage: arr focus add "); + const result = unwrap(await focusAdd(args)); + message(formatSuccess(`Added ${args.join(", ")} to focus`)); + message(""); + displayFocusStatus(result); + break; + } + + case "remove": + case "rm": { + requireArg(args[0], "Usage: arr focus remove "); + const result = unwrap(await focusRemove(args)); + message(formatSuccess(`Removed ${args.join(", ")} from focus`)); + message(""); + displayFocusStatus(result); + break; + } + + case "only": { + requireArg(args[0], "Usage: arr focus only "); + const result = unwrap(await focusOnly(args[0])); + message(formatSuccess(`Now focusing only ${cyan(args[0])}`)); + message(""); + displayFocusStatus(result); + break; + } + + case "all": { + const result = unwrap(await focusAll()); + message(formatSuccess("Now focusing all workspaces")); + message(""); + displayFocusStatus(result); + break; + } + + case "edit": { + requireArg(args[0], "Usage: arr focus edit "); + const result = unwrap(await focusEdit(args[0])); + message(formatSuccess(`Editing ${cyan(args[0])} (files are writable)`)); + message(""); + displayFocusStatus(result); + break; + } + + case "none": + case "exit": { + unwrap(await focusNone()); + message(formatSuccess("Exited focus mode")); + break; + } + + case "conflicts": { + const conflicts = unwrap(await listConflicts()); + + if (conflicts.length === 0) { + message(green("No conflicts")); + return; + } + + message( + `${red("Conflicts:")} ${conflicts.length} file${conflicts.length === 1 ? "" : "s"}`, + ); + message(""); + for (const conflict of conflicts) { + message(` ${conflict.file}`); + message(dim(` Modified by: ${conflict.workspaces.join(", ")}`)); + } + message(""); + message(`${dim("Resolve with:")} ${cmd("arr focus resolve")}`); + break; + } + + case "resolve": { + const conflicts = unwrap(await listConflicts()); + + if (conflicts.length === 0) { + message(green("No conflicts to resolve")); + return; + } + + // Collect user choices for each conflict + const choices = new Map(); + + for (const conflict of conflicts) { + message(`${yellow("Conflict:")} ${cyan(conflict.file)}`); + message(dim(` Modified by: ${conflict.workspaces.join(", ")}`)); + message(""); + + const choice = await select( + "Which version do you want to keep in focus?", + conflict.workspaces.map((ws) => ({ label: ws, value: ws })), + ); + + if (!choice) { + message(dim("Cancelled")); + return; + } + + choices.set(conflict.file, choice); + message(""); + } + + // Resolve all conflicts in batch + const results = unwrap(await resolveConflictsBatch(choices)); + + const removedWorkspaces = new Set(results.flatMap((r) => r.removed)); + message( + formatSuccess( + `Resolved ${results.length} conflict${results.length === 1 ? "" : "s"}, removed ${[...removedWorkspaces].join(", ")} from focus`, + ), + ); + break; + } + + default: + message( + "Usage: arr focus [add|remove|only|all|edit|none|resolve] [workspace...]", + ); + message(""); + message("Subcommands:"); + message(" (none) Show current focus state"); + message(" add Add workspaces to focus"); + message(" remove Remove workspaces from focus"); + message(" only Focus only this workspace"); + message(" all Focus all workspaces"); + message( + " edit Edit mode (single workspace, files writable)", + ); + message(" none Exit focus mode"); + message(" conflicts List file conflicts"); + message(" resolve Resolve a file conflict interactively"); + } +} diff --git a/apps/cli/src/commands/submit.ts b/apps/cli/src/commands/submit.ts index c004b293..ad35f96b 100644 --- a/apps/cli/src/commands/submit.ts +++ b/apps/cli/src/commands/submit.ts @@ -1,11 +1,14 @@ import { isGhInstalled } from "@array/core/auth"; import { submit as submitCmd } from "@array/core/commands/submit"; +import { submitWorkspace } from "@array/core/commands/workspace-submit"; import type { ArrContext } from "@array/core/engine"; import { checkPrerequisites } from "@array/core/init"; +import { listWorkspaces } from "@array/core/jj/workspace"; import { blank, cyan, dim, + formatSuccess, green, indent, message, @@ -13,10 +16,11 @@ import { status, yellow, } from "../utils/output"; -import { confirm } from "../utils/prompt"; +import { confirm, textInput } from "../utils/prompt"; import { unwrap } from "../utils/run"; export async function submit( + args: string[], flags: Record, ctx: ArrContext, ): Promise { @@ -34,6 +38,50 @@ export async function submit( process.exit(1); } + // Check if first arg is a workspace name - if so, route to workspace submit + const workspaceName = args[0]; + if (workspaceName) { + const workspaces = await listWorkspaces(); + if (workspaces.ok) { + const ws = workspaces.value.find((w) => w.name === workspaceName); + if (ws) { + // Route to workspace submit + const draft = Boolean(flags.draft || flags.d); + let msg = (flags.message ?? flags.m) as string | undefined; + + let result = await submitWorkspace(workspaceName, { + draft, + message: msg, + }); + + // If missing message, prompt for it + if (!result.ok && result.error.code === "MISSING_MESSAGE") { + const prompted = await textInput("Commit message"); + if (!prompted) { + message(dim("Cancelled")); + return; + } + msg = prompted; + result = await submitWorkspace(workspaceName, { + draft, + message: msg, + }); + } + + const value = unwrap(result); + + if (value.status === "created") { + message(formatSuccess(`Created PR for ${cyan(value.workspace)}`)); + } else { + message(formatSuccess(`Updated PR for ${cyan(value.workspace)}`)); + } + message(` ${dim("PR:")} ${value.prUrl}`); + message(` ${dim("Branch:")} ${value.bookmark}`); + return; + } + } + } + const skipConfirm = Boolean(flags.yes || flags.y || flags["no-dry-run"]); const dryRunOnly = Boolean(flags["dry-run"]); const isTTY = process.stdin.isTTY; diff --git a/apps/cli/src/commands/workspace.ts b/apps/cli/src/commands/workspace.ts new file mode 100644 index 00000000..a0eb6808 --- /dev/null +++ b/apps/cli/src/commands/workspace.ts @@ -0,0 +1,155 @@ +import { workspaceAdd } from "@array/core/commands/workspace-add"; +import { workspaceList } from "@array/core/commands/workspace-list"; +import { workspaceRemove } from "@array/core/commands/workspace-remove"; +import { workspaceStatus } from "@array/core/commands/workspace-status"; +import { submitWorkspace } from "@array/core/commands/workspace-submit"; +import { + cyan, + dim, + formatSuccess, + green, + message, + red, + yellow, +} from "../utils/output"; +import { textInput } from "../utils/prompt"; +import { requireArg, unwrap } from "../utils/run"; + +function formatStatusChar(status: "M" | "A" | "D" | "R"): string { + switch (status) { + case "M": + return yellow("M"); + case "A": + return green("A"); + case "D": + return red("D"); + case "R": + return cyan("R"); + } +} + +export async function workspace( + subcommand: string, + args: string[], +): Promise { + switch (subcommand) { + case "add": { + requireArg(args[0], "Usage: arr workspace add "); + const result = unwrap(await workspaceAdd(args[0])); + message(formatSuccess(`Created workspace ${cyan(result.name)}`)); + message(dim(` Path: ${result.path}`)); + message(dim(` Change: ${result.changeId.slice(0, 8)}`)); + message(""); + message(`To use this workspace:`); + message(dim(` cd ${result.path}`)); + break; + } + + case "remove": + case "rm": { + requireArg(args[0], "Usage: arr workspace remove "); + unwrap(await workspaceRemove(args[0])); + message(formatSuccess(`Removed workspace ${args[0]}`)); + break; + } + + case "list": + case "ls": { + const workspaces = unwrap(await workspaceList()); + + if (workspaces.length === 0) { + message(dim("No workspaces found")); + message(""); + message(`Create one with: ${cyan("arr workspace add ")}`); + return; + } + + message( + `${workspaces.length} workspace${workspaces.length === 1 ? "" : "s"}:`, + ); + message(""); + + for (const ws of workspaces) { + const staleIndicator = ws.isStale ? yellow(" (stale)") : ""; + message(` ${green(ws.name)}${staleIndicator}`); + message(dim(` ${ws.changeId.slice(0, 8)} · ${ws.path}`)); + } + break; + } + + case "status": + case "st": { + const statuses = unwrap(await workspaceStatus(args[0])); + + if (statuses.length === 0) { + message(dim("No workspaces found")); + return; + } + + for (const ws of statuses) { + message(`${green(ws.name)} changes:`); + + if (ws.changes.length === 0) { + message(dim(" (no changes)")); + } else { + for (const change of ws.changes) { + message(` ${formatStatusChar(change.status)} ${change.path}`); + } + + const { added, removed, files } = ws.stats; + if (files > 0) { + message( + ` ${green(`+${added}`)} ${red(`-${removed}`)} ${dim(`${files} file${files === 1 ? "" : "s"} changed`)}`, + ); + } + } + message(""); + } + break; + } + + case "submit": { + requireArg(args[0], "Usage: arr workspace submit [-m ]"); + + const draft = args.includes("--draft") || args.includes("-d"); + const msgIdx = args.indexOf("--message"); + const mIdx = args.indexOf("-m"); + const msgFlagIdx = msgIdx !== -1 ? msgIdx : mIdx; + let msg = msgFlagIdx !== -1 ? args[msgFlagIdx + 1] : undefined; + + // Try to submit - if missing message, prompt for it + let result = await submitWorkspace(args[0], { draft, message: msg }); + + if (!result.ok && result.error.code === "MISSING_MESSAGE") { + const prompted = await textInput("Commit message"); + if (!prompted) { + message(dim("Cancelled")); + return; + } + msg = prompted; + result = await submitWorkspace(args[0], { draft, message: msg }); + } + + const value = unwrap(result); + + if (value.status === "created") { + message(formatSuccess(`Created PR for ${cyan(value.workspace)}`)); + } else { + message(formatSuccess(`Updated PR for ${cyan(value.workspace)}`)); + } + message(` ${dim("PR:")} ${value.prUrl}`); + message(` ${dim("Branch:")} ${value.bookmark}`); + break; + } + + default: + message("Usage: arr workspace [name]"); + message(""); + message("Subcommands:"); + message(" add Create a new workspace"); + message(" remove Remove a workspace"); + message(" list List all workspaces"); + message(" status [name] Show changes in workspace(s)"); + message(" submit Submit workspace as a GitHub PR"); + } +} diff --git a/apps/cli/src/registry.ts b/apps/cli/src/registry.ts index 15bf2bb6..d2a222eb 100644 --- a/apps/cli/src/registry.ts +++ b/apps/cli/src/registry.ts @@ -22,15 +22,19 @@ import { untrackCommand } from "@array/core/commands/untrack"; import { upCommand } from "@array/core/commands/up"; import type { ContextLevel } from "@array/core/context"; import type { ArrContext } from "@array/core/engine"; +import { assign, unassigned } from "./commands/assign"; import { auth, meta as authMeta } from "./commands/auth"; import { bottom } from "./commands/bottom"; import { checkout } from "./commands/checkout"; import { ci, meta as ciMeta } from "./commands/ci"; import { config, meta as configMeta } from "./commands/config"; import { create } from "./commands/create"; +import { daemon } from "./commands/daemon"; import { deleteChange } from "./commands/delete"; import { down } from "./commands/down"; -import { exit, meta as exitMeta } from "./commands/exit"; +import { run as enter, meta as enterMeta } from "./commands/enter"; +import { run as exit, meta as exitMeta } from "./commands/exit"; +import { focus } from "./commands/focus"; import { get } from "./commands/get"; import { init, meta as initMeta } from "./commands/init"; import { log } from "./commands/log"; @@ -49,6 +53,7 @@ import { trunk } from "./commands/trunk"; import { undo } from "./commands/undo"; import { untrack } from "./commands/untrack"; import { up } from "./commands/up"; +import { workspace } from "./commands/workspace"; import type { ParsedCommand } from "./utils/args"; export type { CommandMeta, CommandMeta as CommandInfo, CommandCategory }; @@ -86,6 +91,44 @@ const logMeta: CommandMeta = { core: true, }; +const workspaceMeta: CommandMeta = { + name: "workspace", + args: " [name]", + description: "Manage agent workspaces", + aliases: ["ws"], + category: "management", + core: true, +}; + +const focusMeta: CommandMeta = { + name: "focus", + args: "[add|remove|only|all|none|resolve] [workspace...]", + description: "Manage live focus of workspace changes", + category: "workflow", + core: true, +}; + +const daemonMeta: CommandMeta = { + name: "daemon", + args: "", + description: "Manage workspace sync daemon", + category: "management", +}; + +const assignMeta: CommandMeta = { + name: "assign", + args: " | --new ", + description: "Move unassigned files to a workspace", + category: "workflow", +}; + +const unassignedMeta: CommandMeta = { + name: "unassigned", + args: "", + description: "Manage unassigned user edits", + category: "info", +}; + export const COMMANDS = { auth: authMeta, init: initMeta, @@ -111,11 +154,17 @@ export const COMMANDS = { squash: squashCommand.meta, merge: mergeCommand.meta, undo: undoCommand.meta, + enter: enterMeta, exit: exitMeta, ci: ciMeta, config: configMeta, help: helpMeta, version: versionMeta, + workspace: workspaceMeta, + focus: focusMeta, + daemon: daemonMeta, + assign: assignMeta, + unassigned: unassignedMeta, } as const; export const HANDLERS: Record = { @@ -124,7 +173,7 @@ export const HANDLERS: Record = { config: () => config(), status: (p) => status({ debug: !!p.flags.debug }), create: (p, ctx) => create(p.args.join(" "), ctx!), - submit: (p, ctx) => submit(p.flags, ctx!), + submit: (p, ctx) => submit(p.args, p.flags, ctx!), get: (p, ctx) => get(ctx!, p.args[0]), track: (p, ctx) => track(p.args[0], ctx!), untrack: (p, ctx) => @@ -151,8 +200,14 @@ export const HANDLERS: Record = { squash: (p, ctx) => squash(p.flags, ctx!), merge: (p, ctx) => merge(p.flags, ctx!), undo: () => undo(), + enter: () => enter(), exit: () => exit(), ci: () => ci(), + workspace: (p) => workspace(p.args[0], p.args.slice(1)), + focus: (p) => focus(p.args[0], p.args.slice(1)), + daemon: (p) => daemon(p.args[0]), + assign: (p) => assign(p.args), + unassigned: (p) => unassigned(p.args[0], p.args.slice(1)), }; type CommandName = keyof typeof COMMANDS; diff --git a/packages/core/package.json b/packages/core/package.json index 5a0fb744..c5afa586 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,7 @@ "@octokit/graphql": "^9.0.3", "@octokit/graphql-schema": "^15.26.1", "@octokit/rest": "^22.0.1", + "@parcel/watcher": "^2.5.1", "zod": "^3.24.1" }, "files": [ diff --git a/packages/core/src/commands/assign.ts b/packages/core/src/commands/assign.ts new file mode 100644 index 00000000..5c7f2678 --- /dev/null +++ b/packages/core/src/commands/assign.ts @@ -0,0 +1,215 @@ +import { runJJ } from "../jj/runner"; +import { + addWorkspace, + getUnassignedFiles, + UNASSIGNED_WORKSPACE, + workspaceRef, +} from "../jj/workspace"; +import { createError, err, ok, type Result } from "../result"; +import type { Command } from "./types"; + +export interface AssignResult { + files: string[]; + from: string; + to: string; +} + +/** + * Escape all regex metacharacters in a string. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Match files against pathspecs. + * Supports glob patterns like *.txt, src/**, etc. + */ +function matchFiles(patterns: string[], availableFiles: string[]): string[] { + const matched = new Set(); + + for (const pattern of patterns) { + // Use placeholders for glob tokens before escaping + const withPlaceholders = pattern + .replace(/\*\*/g, "\0GLOBSTAR\0") + .replace(/\*/g, "\0GLOB\0"); + + // Escape all regex metacharacters + const escaped = escapeRegex(withPlaceholders); + + // Replace placeholders with regex equivalents + const regexPattern = escaped + .replace(/\0GLOBSTAR\0/g, ".*") + .replace(/\0GLOB\0/g, "[^/]*"); + + const regex = new RegExp(`^${regexPattern}$`); + + for (const file of availableFiles) { + if (regex.test(file) || file === pattern) { + matched.add(file); + } + } + } + + return [...matched]; +} + +/** + * Move files from unassigned workspace to a specific workspace. + * Supports multiple files and glob patterns. + * Uses jj squash to atomically move the changes. + */ +export async function assignFiles( + patterns: string[], + targetWorkspace: string, + cwd = process.cwd(), +): Promise> { + if (patterns.length === 0) { + return err(createError("INVALID_INPUT", "No files specified")); + } + + // Get files in unassigned + const unassignedFiles = await getUnassignedFiles(cwd); + if (!unassignedFiles.ok) return unassignedFiles; + + if (unassignedFiles.value.length === 0) { + return err(createError("NOT_FOUND", "No files in unassigned workspace")); + } + + // Match patterns against available files + const filesToAssign = matchFiles(patterns, unassignedFiles.value); + + if (filesToAssign.length === 0) { + return err( + createError( + "NOT_FOUND", + `No matching files in unassigned workspace for: ${patterns.join(", ")}`, + ), + ); + } + + // Squash files from unassigned to target workspace + // jj squash --from unassigned@ --into @ ... + const result = await runJJ( + [ + "squash", + "--from", + workspaceRef(UNASSIGNED_WORKSPACE), + "--into", + workspaceRef(targetWorkspace), + ...filesToAssign, + ], + cwd, + ); + + if (!result.ok) return result; + + return ok({ + files: filesToAssign, + from: UNASSIGNED_WORKSPACE, + to: targetWorkspace, + }); +} + +/** + * Create a new workspace from unassigned files. + * Supports multiple files and glob patterns. + */ +export async function assignFilesToNewWorkspace( + patterns: string[], + newWorkspaceName: string, + cwd = process.cwd(), +): Promise> { + if (patterns.length === 0) { + return err(createError("INVALID_INPUT", "No files specified")); + } + + // Get files in unassigned + const unassignedFiles = await getUnassignedFiles(cwd); + if (!unassignedFiles.ok) return unassignedFiles; + + if (unassignedFiles.value.length === 0) { + return err(createError("NOT_FOUND", "No files in unassigned workspace")); + } + + // Match patterns against available files + const filesToAssign = matchFiles(patterns, unassignedFiles.value); + + if (filesToAssign.length === 0) { + return err( + createError( + "NOT_FOUND", + `No matching files in unassigned workspace for: ${patterns.join(", ")}`, + ), + ); + } + + // Create the new workspace + const createResult = await addWorkspace(newWorkspaceName, cwd); + if (!createResult.ok) return createResult; + + // Squash files from unassigned to new workspace + const squashResult = await runJJ( + [ + "squash", + "--from", + workspaceRef(UNASSIGNED_WORKSPACE), + "--into", + workspaceRef(newWorkspaceName), + ...filesToAssign, + ], + cwd, + ); + + if (!squashResult.ok) return squashResult; + + return ok({ + files: filesToAssign, + from: UNASSIGNED_WORKSPACE, + to: newWorkspaceName, + }); +} + +export interface UnassignedListResult { + files: string[]; +} + +/** + * List files in the unassigned workspace. + */ +export async function listUnassigned( + cwd = process.cwd(), +): Promise> { + const result = await getUnassignedFiles(cwd); + if (!result.ok) return result; + + return ok({ files: result.value }); +} + +// Command exports +export const assignCommand: Command = + { + meta: { + name: "assign", + args: " ", + description: "Move unassigned files to a workspace", + category: "workflow", + flags: [ + { + name: "new", + short: "n", + description: "Create new workspace with this name", + }, + ], + }, + run: assignFiles, + }; + +export const unassignedListCommand: Command = { + meta: { + name: "unassigned list", + description: "List files in unassigned workspace", + category: "info", + }, + run: listUnassigned, +}; diff --git a/packages/core/src/commands/daemon.ts b/packages/core/src/commands/daemon.ts new file mode 100644 index 00000000..2091f487 --- /dev/null +++ b/packages/core/src/commands/daemon.ts @@ -0,0 +1,148 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + cleanup, + getLogPath, + getWorkspacePath, + isRunning, + type RepoEntry, + readPid, + readRepos, +} from "../daemon/pid"; +import { createError, err, ok, type Result } from "../result"; +import type { Command } from "./types"; + +// Get the path to the daemon process script +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DAEMON_PROCESS_PATH = join(__dirname, "../daemon/daemon-process.ts"); + +interface DaemonStatus { + running: boolean; + pid?: number; + repos: Array<{ path: string; workspaces: string[] }>; + logPath: string; +} + +/** + * Start the global daemon process + */ +export async function daemonStart(): Promise> { + if (isRunning()) { + return err(createError("DAEMON_RUNNING", "Daemon is already running")); + } + + // Spawn detached daemon process + const proc = spawn("bun", [DAEMON_PROCESS_PATH], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); + + proc.unref(); + + // Give it a moment to start and write PID + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify it started + if (!isRunning()) { + return err(createError("COMMAND_FAILED", "Failed to start daemon")); + } + + return ok(undefined); +} + +/** + * Stop the global daemon process + */ +export async function daemonStop(): Promise> { + const pid = readPid(); + + if (!pid || !isRunning()) { + return err(createError("DAEMON_NOT_RUNNING", "Daemon is not running")); + } + + try { + process.kill(pid, "SIGTERM"); + cleanup(); + return ok(undefined); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to stop daemon: ${e}`)); + } +} + +/** + * Get daemon status + */ +export async function daemonStatus(): Promise> { + const running = isRunning(); + const pid = running ? (readPid() ?? undefined) : undefined; + const logPath = getLogPath(); + + // Filter repos to only include workspaces that actually exist + const rawRepos = readRepos(); + const repos: RepoEntry[] = []; + for (const repo of rawRepos) { + const validWorkspaces = repo.workspaces.filter((ws) => { + const wsPath = getWorkspacePath(repo.path, ws); + return existsSync(wsPath); + }); + if (validWorkspaces.length > 0) { + repos.push({ path: repo.path, workspaces: validWorkspaces }); + } + } + + return ok({ running, pid, repos, logPath }); +} + +/** + * Restart the daemon (stop if running, then start) + */ +export async function daemonRestart(): Promise> { + // Stop if running (ignore errors if not running) + if (isRunning()) { + const stopResult = await daemonStop(); + if (!stopResult.ok) return stopResult; + + // Brief pause to ensure cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + return daemonStart(); +} + +export const daemonStartCommand: Command = { + meta: { + name: "daemon start", + description: "Start the global workspace sync daemon", + category: "management", + }, + run: daemonStart, +}; + +export const daemonStopCommand: Command = { + meta: { + name: "daemon stop", + description: "Stop the global workspace sync daemon", + category: "management", + }, + run: daemonStop, +}; + +export const daemonStatusCommand: Command = { + meta: { + name: "daemon status", + description: "Check daemon status and watched repos", + category: "management", + }, + run: daemonStatus, +}; + +export const daemonRestartCommand: Command = { + meta: { + name: "daemon restart", + description: "Restart the daemon", + category: "management", + }, + run: daemonRestart, +}; diff --git a/packages/core/src/commands/enter.ts b/packages/core/src/commands/enter.ts new file mode 100644 index 00000000..6d1797f5 --- /dev/null +++ b/packages/core/src/commands/enter.ts @@ -0,0 +1,45 @@ +import { disableGitMode } from "../daemon/pid"; +import { getCurrentBranch, isDetachedHead } from "../git/head"; +import { status } from "../jj/status"; +import { getRepoRoot } from "../jj/workspace"; +import { ok, type Result } from "../result"; + +export interface EnterResult { + bookmark: string; + alreadyInJjMode: boolean; + workingCopyChangeId: string; +} + +/** + * Enter jj mode from Git. + * + * This is mostly a no-op since jj auto-syncs with git. The main purpose is to: + * 1. Trigger jj's auto-sync (by running a jj command) + * 2. Report the current state to the user + * + * Working tree files are always preserved - jj snapshots them automatically. + */ +export async function enter(cwd = process.cwd()): Promise> { + const detached = await isDetachedHead(cwd); + const branch = await getCurrentBranch(cwd); + + // Running jj status triggers auto-sync with git + const statusResult = await status(cwd); + if (!statusResult.ok) { + return statusResult; + } + + const workingCopy = statusResult.value.workingCopy; + + // Disable git mode - no longer need daemon to watch git→unassigned + const rootResult = await getRepoRoot(cwd); + if (rootResult.ok) { + disableGitMode(rootResult.value); + } + + return ok({ + bookmark: branch || "", + alreadyInJjMode: detached, + workingCopyChangeId: workingCopy.changeId, + }); +} diff --git a/packages/core/src/commands/exit.ts b/packages/core/src/commands/exit.ts new file mode 100644 index 00000000..c2df3a95 --- /dev/null +++ b/packages/core/src/commands/exit.ts @@ -0,0 +1,167 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { enableGitMode } from "../daemon/pid"; +import { getCurrentBranch, isDetachedHead, setHeadToBranch } from "../git/head"; +import { parseDiffPaths } from "../jj/diff"; +import { list } from "../jj/list"; +import { getTrunk, runJJ } from "../jj/runner"; +import { + getRepoRoot, + getWorkspacePath, + UNASSIGNED_WORKSPACE, + workspaceRef, +} from "../jj/workspace"; +import { createError, err, ok, type Result } from "../result"; + +export interface ExitResult { + branch: string; + alreadyInGitMode: boolean; + usedFallback: boolean; + syncedFiles: number; +} + +/** + * Copy files from unassigned workspace to main repo working tree. + * This makes uncommitted work visible in git mode. + */ +async function syncUnassignedToRepo(cwd: string): Promise { + const rootResult = await getRepoRoot(cwd); + if (!rootResult.ok) return 0; + const repoPath = rootResult.value; + + const unassignedPath = getWorkspacePath(UNASSIGNED_WORKSPACE, repoPath); + if (!existsSync(unassignedPath)) return 0; + + // Get files modified in unassigned workspace + const diffResult = await runJJ( + ["diff", "-r", workspaceRef(UNASSIGNED_WORKSPACE), "--summary"], + cwd, + ); + if (!diffResult.ok) return 0; + + const files = parseDiffPaths(diffResult.value.stdout); + if (files.length === 0) return 0; + + let copied = 0; + for (const file of files) { + const srcPath = join(unassignedPath, file); + const destPath = join(repoPath, file); + + try { + if (existsSync(srcPath)) { + // Ensure destination directory exists + const destDir = dirname(destPath); + if (!existsSync(destDir)) { + await mkdir(destDir, { recursive: true }); + } + // Copy file content + const content = readFileSync(srcPath); + writeFileSync(destPath, content); + copied++; + } else { + // File was deleted in unassigned - delete in repo too + if (existsSync(destPath)) { + await rm(destPath, { force: true }); + copied++; + } + } + } catch { + // Ignore copy errors for individual files + } + } + + return copied; +} + +/** + * Exit jj mode to Git. + * + * Finds the nearest bookmark by walking up ancestors from @, + * then moves Git HEAD to that branch without touching working tree. + * + * If no bookmark found, falls back to trunk. + */ +export async function exit(cwd = process.cwd()): Promise> { + const detached = await isDetachedHead(cwd); + + if (!detached) { + // Already in Git mode - still enable gitMode for daemon sync + const rootResult = await getRepoRoot(cwd); + if (rootResult.ok) { + enableGitMode(rootResult.value); + } + const branch = await getCurrentBranch(cwd); + return ok({ + branch: branch || "unknown", + alreadyInGitMode: true, + usedFallback: false, + syncedFiles: 0, + }); + } + + // Find the nearest ancestor with a bookmark (up to 10 levels) + // Uses revset: @, @-, @--, etc. until we find one with bookmarks + const changesResult = await list( + { revset: "ancestors(@, 10) & ~immutable()" }, + cwd, + ); + + if (!changesResult.ok) { + return err( + createError( + "COMMAND_FAILED", + `Failed to get ancestors: ${changesResult.error.message}`, + ), + ); + } + + // Find the first change with a bookmark + let targetBookmark: string | null = null; + let usedFallback = false; + + for (const change of changesResult.value) { + if (change.bookmarks.length > 0) { + targetBookmark = change.bookmarks[0]; + break; + } + } + + // Fall back to trunk if no bookmark found + if (!targetBookmark) { + try { + targetBookmark = await getTrunk(cwd); + usedFallback = true; + } catch { + return err( + createError( + "INVALID_STATE", + "No bookmark on current change and trunk not configured. Run `arr create` first.", + ), + ); + } + } + + // Sync unassigned workspace files to repo (so they appear as uncommitted in git) + const syncedFiles = await syncUnassignedToRepo(cwd); + + // Move Git HEAD to the branch without touching working tree + const setHeadResult = await setHeadToBranch(cwd, targetBookmark); + + // Enable git mode so daemon watches for git→unassigned sync + const rootResult = await getRepoRoot(cwd); + if (rootResult.ok) { + enableGitMode(rootResult.value); + } + + if (!setHeadResult.ok) { + return err(setHeadResult.error); + } + + return ok({ + branch: targetBookmark, + alreadyInGitMode: false, + usedFallback, + syncedFiles, + }); +} diff --git a/packages/core/src/commands/focus-resolve.ts b/packages/core/src/commands/focus-resolve.ts new file mode 100644 index 00000000..ddd34309 --- /dev/null +++ b/packages/core/src/commands/focus-resolve.ts @@ -0,0 +1,211 @@ +import { getWorkspacesForFile } from "../jj/file-ownership"; +import { runJJ } from "../jj/runner"; +import { workspaceRef } from "../jj/workspace"; +import { createError, err, ok, type Result } from "../result"; +import { focusRemove, focusStatus } from "./focus"; +import type { Command } from "./types"; + +export interface FileConflict { + file: string; + workspaces: string[]; +} + +export interface ResolveResult { + file: string; + kept: string; + removed: string[]; +} + +/** + * List all file conflicts in the current preview. + */ +export async function listConflicts( + cwd = process.cwd(), +): Promise> { + const status = await focusStatus(cwd); + if (!status.ok) return status; + + if (!status.value.isFocused) { + return err(createError("INVALID_STATE", "Not in focus mode")); + } + + if (status.value.workspaces.length < 2) { + return ok([]); // No conflicts possible with single workspace + } + + // Build map of file -> workspaces + const fileWorkspaces = new Map(); + + for (const ws of status.value.workspaces) { + const diff = await runJJ( + ["diff", "-r", workspaceRef(ws), "--summary"], + cwd, + ); + if (!diff.ok) continue; + + for (const line of diff.value.stdout.split("\n")) { + const match = line.trim().match(/^[MADR]\s+(.+)$/); + if (match) { + const file = match[1].trim(); + const existing = fileWorkspaces.get(file) || []; + existing.push(ws); + fileWorkspaces.set(file, existing); + } + } + } + + // Filter to only files with multiple workspaces + const conflicts: FileConflict[] = []; + for (const [file, workspaces] of fileWorkspaces) { + if (workspaces.length > 1) { + conflicts.push({ file, workspaces }); + } + } + + return ok(conflicts); +} + +/** + * Get conflict info for a specific file. + */ +export async function getFileConflict( + file: string, + cwd = process.cwd(), +): Promise> { + const status = await focusStatus(cwd); + if (!status.ok) return status; + + if (!status.value.isFocused) { + return err(createError("INVALID_STATE", "Not in focus mode")); + } + + const workspaces = await getWorkspacesForFile( + file, + status.value.workspaces, + cwd, + ); + + if (workspaces.length < 2) { + return ok(null); // No conflict + } + + return ok({ file, workspaces }); +} + +/** + * Resolve a file conflict by keeping one workspace and removing others from focus. + */ +export async function resolveConflict( + file: string, + keepWorkspace: string, + cwd = process.cwd(), +): Promise> { + const conflict = await getFileConflict(file, cwd); + if (!conflict.ok) return conflict; + + if (!conflict.value) { + return err( + createError("NOT_FOUND", `No conflict found for file '${file}'`), + ); + } + + if (!conflict.value.workspaces.includes(keepWorkspace)) { + return err( + createError( + "INVALID_INPUT", + `Workspace '${keepWorkspace}' has not modified '${file}'`, + ), + ); + } + + // Remove all other workspaces from focus + const toRemove = conflict.value.workspaces.filter( + (ws) => ws !== keepWorkspace, + ); + + const removeResult = await focusRemove(toRemove, cwd); + if (!removeResult.ok) return removeResult; + + return ok({ + file, + kept: keepWorkspace, + removed: toRemove, + }); +} + +/** + * Batch resolve conflicts by computing which workspaces to remove. + * + * Takes a map of file -> chosen workspace, and returns the set of workspaces + * that should be removed from focus to resolve all conflicts. + * + * Returns workspaces to remove (not the ones to keep). + */ +export async function resolveConflictsBatch( + choices: Map, + cwd = process.cwd(), +): Promise> { + const conflicts = await listConflicts(cwd); + if (!conflicts.ok) return conflicts; + + const workspacesToRemove = new Set(); + const results: ResolveResult[] = []; + + for (const conflict of conflicts.value) { + const choice = choices.get(conflict.file); + if (!choice) continue; + + // Filter out workspaces already marked for removal + const remainingWorkspaces = conflict.workspaces.filter( + (ws) => !workspacesToRemove.has(ws), + ); + + // If only one workspace remains, no conflict to resolve + if (remainingWorkspaces.length < 2) continue; + + // Validate the choice is valid for this conflict + if (!remainingWorkspaces.includes(choice)) continue; + + // Mark non-chosen workspaces for removal + const toRemove = remainingWorkspaces.filter((ws) => ws !== choice); + for (const ws of toRemove) { + workspacesToRemove.add(ws); + } + + results.push({ + file: conflict.file, + kept: choice, + removed: toRemove, + }); + } + + // Actually remove the workspaces from focus + if (workspacesToRemove.size > 0) { + const removeResult = await focusRemove([...workspacesToRemove], cwd); + if (!removeResult.ok) return removeResult; + } + + return ok(results); +} + +export const listConflictsCommand: Command = { + meta: { + name: "focus conflicts", + description: "List file conflicts in focus", + category: "info", + }, + run: listConflicts, +}; + +export const resolveConflictCommand: Command< + ResolveResult, + [string, string, string?] +> = { + meta: { + name: "focus resolve", + args: " ", + description: "Resolve a file conflict by keeping one workspace", + category: "workflow", + }, + run: resolveConflict, +}; diff --git a/packages/core/src/commands/focus.ts b/packages/core/src/commands/focus.ts new file mode 100644 index 00000000..be0fddb5 --- /dev/null +++ b/packages/core/src/commands/focus.ts @@ -0,0 +1,454 @@ +import { existsSync } from "node:fs"; +import { readRepos, setRepoWorkspaces, unregisterRepo } from "../daemon/pid"; +import { + getConflictingFiles, + getWorkspacesForFile, +} from "../jj/file-ownership"; +import { getTrunk, runJJ } from "../jj/runner"; +import { + ensureUnassignedWorkspace, + FOCUS_COMMIT_DESCRIPTION, + getRepoRoot, + getWorkspacePath, + getWorkspaceTip, + listWorkspaces, + setupWorkspaceLinks, + snapshotWorkspace, + UNASSIGNED_WORKSPACE, + type WorkspaceInfo, +} from "../jj/workspace"; +import { createError, err, ok, type Result } from "../result"; +import type { Command } from "./types"; + +export interface ConflictInfo { + file: string; + workspaces: string[]; +} + +export interface FocusStatus { + isFocused: boolean; + workspaces: string[]; + allWorkspaces: WorkspaceInfo[]; + conflicts: ConflictInfo[]; +} + +/** + * Get focused workspaces from repos.json (single source of truth). + * Only returns workspaces that actually exist on disk. + */ +async function getFocusWorkspaces( + cwd = process.cwd(), +): Promise> { + const rootResult = await getRepoRoot(cwd); + if (!rootResult.ok) return rootResult; + const repoPath = rootResult.value; + + const repos = readRepos(); + const repo = repos.find((r) => r.path === repoPath); + + if (!repo) { + return ok([]); + } + + // Filter to only workspaces that actually exist + const existingWorkspaces = repo.workspaces.filter((ws) => { + const wsPath = getWorkspacePath(ws, repoPath); + return existsSync(wsPath); + }); + + return ok(existingWorkspaces); +} + +/** + * Update the preview merge commit based on the given workspaces. + * + * Graph structure: + * trunk ← agent-a ←─────┐ + * ↖ agent-b ←─────├─ preview (merge) + * ↖ unassigned ←──┘ + * + * All workspaces are siblings on trunk, only merged at preview time. + * This keeps PRs clean - landing agent-a only lands agent-a's changes. + */ +async function updateFocus( + workspaces: string[], + cwd = process.cwd(), +): Promise> { + // Get repo root for workspace paths + const rootResult = await getRepoRoot(cwd); + if (!rootResult.ok) return rootResult; + const repoPath = rootResult.value; + + // Get current commit ID to abandon later (if it's a preview commit) + const currentResult = await runJJ( + ["log", "-r", "@", "--no-graph", "-T", "commit_id"], + cwd, + ); + const oldCommitId = currentResult.ok + ? currentResult.value.stdout.trim() + : null; + + // Check if currently in focus mode + const currentWorkspaces = await getFocusWorkspaces(cwd); + const isCurrentFocus = + currentWorkspaces.ok && currentWorkspaces.value.length > 0; + + if (workspaces.length === 0) { + // Exit focus mode - go back to trunk + const trunk = await getTrunk(cwd); + const result = await runJJ(["new", trunk], cwd); + if (!result.ok) return result; + + // Abandon old focus commit + if (isCurrentFocus && oldCommitId) { + await runJJ(["abandon", oldCommitId], cwd); + } + + // Unregister repo from daemon + unregisterRepo(repoPath); + + return ok(""); + } + + // Filter to only workspaces that actually exist on disk + const validWorkspaces = workspaces.filter((ws) => { + const wsPath = getWorkspacePath(ws, repoPath); + return existsSync(wsPath); + }); + + if (validWorkspaces.length === 0) { + return err(createError("WORKSPACE_NOT_FOUND", "No valid workspaces found")); + } + + // Ensure unassigned workspace exists (creates on trunk if needed) + const unassignedResult = await ensureUnassignedWorkspace(cwd); + if (!unassignedResult.ok) return unassignedResult; + + // Snapshot each workspace to pick up existing changes, then get tip + const changeIds: string[] = []; + + // First, add unassigned workspace tip to merge parents + const unassignedTipResult = await getWorkspaceTip(UNASSIGNED_WORKSPACE, cwd); + if (unassignedTipResult.ok) { + changeIds.push(unassignedTipResult.value); + } + + // Then add each agent workspace tip + for (const ws of validWorkspaces) { + const wsPath = getWorkspacePath(ws, repoPath); + + // Ensure editor integration links exist + setupWorkspaceLinks(wsPath, repoPath); + + await snapshotWorkspace(wsPath); + + const tipResult = await getWorkspaceTip(ws, cwd); + if (!tipResult.ok) { + return err( + createError( + "WORKSPACE_NOT_FOUND", + `Workspace '${ws}' not found or has no tip`, + ), + ); + } + changeIds.push(tipResult.value); + } + + // Create the merge commit with simple description + const description = FOCUS_COMMIT_DESCRIPTION; + const newArgs = ["new", ...changeIds, "-m", description]; + const result = await runJJ(newArgs, cwd); + + if (!result.ok) return result; + + // Abandon old focus commit (now that we've moved away from it) + if (isCurrentFocus && oldCommitId) { + await runJJ(["abandon", oldCommitId], cwd); + } + + // Get the new change-id + const idResult = await runJJ( + ["log", "-r", "@", "--no-graph", "-T", "change_id"], + cwd, + ); + if (!idResult.ok) return idResult; + + // Set exact workspace list in repos.json (single source of truth) + setRepoWorkspaces(repoPath, validWorkspaces); + + return ok(idResult.value.stdout.trim()); +} + +/** + * Get list of files with merge conflicts in current commit (via jj resolve --list). + * Different from getConflictingFiles in file-ownership.ts which checks ownership conflicts. + */ +async function getMergeConflictFiles(cwd: string): Promise { + const result = await runJJ(["resolve", "--list"], cwd); + if (!result.ok) return []; + + return result.value.stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + // Output format: "filename 2-sided conflict" - extract just filename + const parts = line.trim().split(/\s{2,}/); + return parts[0]; + }); +} + +/** + * Show current preview state + */ +export async function focusStatus( + cwd = process.cwd(), +): Promise> { + const [previewWorkspaces, allWorkspaces] = await Promise.all([ + getFocusWorkspaces(cwd), + listWorkspaces(cwd), + ]); + + if (!previewWorkspaces.ok) return previewWorkspaces; + if (!allWorkspaces.ok) return allWorkspaces; + + // Check for merge conflicts in the preview commit + const conflicts: ConflictInfo[] = []; + if (previewWorkspaces.value.length > 0) { + const mergeConflictFiles = await getMergeConflictFiles(cwd); + for (const file of mergeConflictFiles) { + const wsForFile = await getWorkspacesForFile( + file, + previewWorkspaces.value, + cwd, + ); + conflicts.push({ file, workspaces: wsForFile }); + } + } + + return ok({ + isFocused: previewWorkspaces.value.length > 0, + workspaces: previewWorkspaces.value, + allWorkspaces: allWorkspaces.value, + conflicts, + }); +} + +/** + * Add workspaces to preview. + * + * Checks for file conflicts before adding - if the combined set of workspaces + * would have files modified by multiple agents, the operation is blocked. + */ +export async function focusAdd( + workspaces: string[], + cwd = process.cwd(), +): Promise> { + // Get current preview workspaces + const currentResult = await getFocusWorkspaces(cwd); + if (!currentResult.ok) return currentResult; + + // Add new workspaces (avoiding duplicates) + const current = new Set(currentResult.value); + for (const ws of workspaces) { + current.add(ws); + } + + const allWorkspaces = [...current]; + + // Check for file conflicts before adding + if (allWorkspaces.length > 1) { + const conflictsResult = await getConflictingFiles(allWorkspaces, cwd); + if (conflictsResult.ok && conflictsResult.value.length > 0) { + const conflictList = conflictsResult.value + .map((c) => ` ${c.file} (${c.workspaces.join(", ")})`) + .join("\n"); + return err( + createError( + "CONFLICT", + `Cannot add: file conflicts between workspaces:\n${conflictList}`, + ), + ); + } + } + + // Update the preview + const updateResult = await updateFocus(allWorkspaces, cwd); + if (!updateResult.ok) return updateResult; + + return focusStatus(cwd); +} + +/** + * Remove workspaces from preview + */ +export async function focusRemove( + workspaces: string[], + cwd = process.cwd(), +): Promise> { + // Get current preview workspaces + const currentResult = await getFocusWorkspaces(cwd); + if (!currentResult.ok) return currentResult; + + // Remove specified workspaces + const toRemove = new Set(workspaces); + const remaining = currentResult.value.filter((ws) => !toRemove.has(ws)); + + // Update the preview + const updateResult = await updateFocus(remaining, cwd); + if (!updateResult.ok) return updateResult; + + return focusStatus(cwd); +} + +/** + * Preview only the specified workspace (exclude all others) + */ +export async function focusOnly( + workspace: string, + cwd = process.cwd(), +): Promise> { + const updateResult = await updateFocus([workspace], cwd); + if (!updateResult.ok) return updateResult; + + return focusStatus(cwd); +} + +/** + * Include all workspaces in preview. + * + * Checks for file conflicts before adding - if any workspaces have files + * modified by multiple agents, the operation is blocked. + */ +export async function focusAll( + cwd = process.cwd(), +): Promise> { + // Get all workspaces + const allResult = await listWorkspaces(cwd); + if (!allResult.ok) return allResult; + + // Filter out "unassigned" - it's handled separately by updateFocus via ensureUnassignedWorkspace + const workspaceNames = allResult.value + .map((ws) => ws.name) + .filter((name) => name !== UNASSIGNED_WORKSPACE); + + if (workspaceNames.length === 0) { + return err(createError("WORKSPACE_NOT_FOUND", "No workspaces found")); + } + + // Check for file conflicts before adding + if (workspaceNames.length > 1) { + const conflictsResult = await getConflictingFiles(workspaceNames, cwd); + if (conflictsResult.ok && conflictsResult.value.length > 0) { + const conflictList = conflictsResult.value + .map((c) => ` ${c.file} (${c.workspaces.join(", ")})`) + .join("\n"); + return err( + createError( + "CONFLICT", + `Cannot preview all: file conflicts between workspaces:\n${conflictList}`, + ), + ); + } + } + + const updateResult = await updateFocus(workspaceNames, cwd); + if (!updateResult.ok) return updateResult; + + return focusStatus(cwd); +} + +/** + * Exit preview mode (back to trunk) + */ +export async function focusNone(cwd = process.cwd()): Promise> { + const updateResult = await updateFocus([], cwd); + if (!updateResult.ok) return updateResult; + return ok(undefined); +} + +/** + * Enter edit mode for a single workspace. + * + * With intelligent edit routing, this is equivalent to `focusOnly` - + * files are always writable, and edits are routed to the single workspace. + */ +export async function focusEdit( + workspace: string, + cwd = process.cwd(), +): Promise> { + // Single-workspace preview = edit mode (all edits go to this workspace) + const updateResult = await updateFocus([workspace], cwd); + if (!updateResult.ok) return updateResult; + + return focusStatus(cwd); +} + +// Command exports +export const focusStatusCommand: Command = { + meta: { + name: "focus", + description: "Show current focus state", + category: "workflow", + core: true, + }, + run: focusStatus, +}; + +export const focusAddCommand: Command = { + meta: { + name: "focus add", + args: "", + description: "Add workspaces to focus", + category: "workflow", + }, + run: focusAdd, +}; + +export const focusRemoveCommand: Command = { + meta: { + name: "focus remove", + args: "", + description: "Remove workspaces from focus", + category: "workflow", + }, + run: focusRemove, +}; + +export const focusOnlyCommand: Command = { + meta: { + name: "focus only", + args: "", + description: "Focus only this workspace", + category: "workflow", + }, + run: focusOnly, +}; + +export const focusAllCommand: Command = { + meta: { + name: "focus all", + description: "Include all workspaces in focus", + category: "workflow", + }, + run: focusAll, +}; + +export const focusNoneCommand: Command = { + meta: { + name: "focus none", + description: "Exit focus mode", + category: "workflow", + }, + run: focusNone, +}; + +export const focusEditCommand: Command = { + meta: { + name: "focus edit", + args: "", + description: "Enter edit mode for a workspace (single-focus, writable)", + category: "workflow", + }, + run: focusEdit, +}; diff --git a/packages/core/src/commands/split.ts b/packages/core/src/commands/split.ts index 42daf4a4..96dee766 100644 --- a/packages/core/src/commands/split.ts +++ b/packages/core/src/commands/split.ts @@ -1,6 +1,7 @@ import { resolveBookmarkConflict } from "../bookmark-utils"; import type { Engine } from "../engine"; import { ensureBookmark, list, runJJ, status } from "../jj"; +import { parseDiffSummary } from "../jj/diff"; import { createError, err, ok, type Result } from "../result"; import { datePrefixedLabel } from "../slugify"; import type { Command } from "./types"; @@ -27,7 +28,7 @@ interface SplitOptions { engine: Engine; } -interface FileInfo { +export interface FileInfo { path: string; status: string; } @@ -39,21 +40,6 @@ const STATUS_MAP: Record = { R: "renamed", }; -/** - * Parse diff summary output into file info array. - */ -function parseDiffSummary(stdout: string): FileInfo[] { - return stdout - .trim() - .split("\n") - .filter(Boolean) - .map((line) => { - const statusChar = line[0]; - const path = line.slice(2).trim(); - return { path, status: STATUS_MAP[statusChar] ?? statusChar }; - }); -} - /** * Get the list of files in the parent change that can be split. * Returns the parent's files (since split targets @-). @@ -61,7 +47,12 @@ function parseDiffSummary(stdout: string): FileInfo[] { export async function getSplittableFiles(): Promise> { const parentDiffResult = await runJJ(["diff", "-r", "@-", "--summary"]); if (!parentDiffResult.ok) return parentDiffResult; - return ok(parseDiffSummary(parentDiffResult.value.stdout)); + return ok( + parseDiffSummary(parentDiffResult.value.stdout).map((entry) => ({ + path: entry.path, + status: STATUS_MAP[entry.status] ?? entry.status, + })), + ); } /** diff --git a/packages/core/src/commands/submit.ts b/packages/core/src/commands/submit.ts index 4461810f..d2203ead 100644 --- a/packages/core/src/commands/submit.ts +++ b/packages/core/src/commands/submit.ts @@ -74,12 +74,19 @@ export async function submit( export const submitCommand: Command = { meta: { name: "submit", - description: "Create or update GitHub PRs for the current stack", + args: "[workspace]", + description: + "Create or update GitHub PRs for a workspace or the current stack", aliases: ["s"], flags: [ { name: "yes", short: "y", description: "Skip confirmation prompt" }, { name: "dry-run", description: "Show plan only, don't execute" }, - { name: "draft", description: "Create PRs as drafts" }, + { name: "draft", short: "d", description: "Create PRs as drafts" }, + { + name: "message", + short: "m", + description: "Commit message / PR title (workspace only)", + }, ], category: "workflow", core: true, diff --git a/packages/core/src/commands/workspace-add.ts b/packages/core/src/commands/workspace-add.ts new file mode 100644 index 00000000..e56f1cb3 --- /dev/null +++ b/packages/core/src/commands/workspace-add.ts @@ -0,0 +1,27 @@ +import { addWorkspace, type WorkspaceInfo } from "../jj/workspace"; +import type { Result } from "../result"; +import { focusAdd } from "./focus"; +import type { Command } from "./types"; + +export async function workspaceAdd( + name: string, + cwd = process.cwd(), +): Promise> { + const result = await addWorkspace(name, cwd); + if (!result.ok) return result; + + // Automatically add new workspace to focus + await focusAdd([name], cwd); + + return result; +} + +export const workspaceAddCommand: Command = { + meta: { + name: "workspace add", + args: "", + description: "Create a new agent workspace", + category: "management", + }, + run: workspaceAdd, +}; diff --git a/packages/core/src/commands/workspace-list.ts b/packages/core/src/commands/workspace-list.ts new file mode 100644 index 00000000..41f9babc --- /dev/null +++ b/packages/core/src/commands/workspace-list.ts @@ -0,0 +1,16 @@ +import { listWorkspaces, type WorkspaceInfo } from "../jj/workspace"; +import type { Result } from "../result"; +import type { Command } from "./types"; + +export async function workspaceList(): Promise> { + return listWorkspaces(); +} + +export const workspaceListCommand: Command = { + meta: { + name: "workspace list", + description: "List all agent workspaces", + category: "management", + }, + run: workspaceList, +}; diff --git a/packages/core/src/commands/workspace-remove.ts b/packages/core/src/commands/workspace-remove.ts new file mode 100644 index 00000000..afb87aab --- /dev/null +++ b/packages/core/src/commands/workspace-remove.ts @@ -0,0 +1,31 @@ +import { removeWorkspace } from "../jj/workspace"; +import type { Result } from "../result"; +import { focusRemove, focusStatus } from "./focus"; +import type { Command } from "./types"; + +export async function workspaceRemove( + name: string, + cwd = process.cwd(), +): Promise> { + // If workspace is in focus, remove it from focus first + const status = await focusStatus(cwd); + if ( + status.ok && + status.value.isFocused && + status.value.workspaces.includes(name) + ) { + await focusRemove([name], cwd); + } + + return removeWorkspace(name, cwd); +} + +export const workspaceRemoveCommand: Command = { + meta: { + name: "workspace remove", + args: "", + description: "Remove an agent workspace", + category: "management", + }, + run: workspaceRemove, +}; diff --git a/packages/core/src/commands/workspace-status.ts b/packages/core/src/commands/workspace-status.ts new file mode 100644 index 00000000..53a60cc8 --- /dev/null +++ b/packages/core/src/commands/workspace-status.ts @@ -0,0 +1,143 @@ +import { type DiffEntry, parseDiffSummary } from "../jj/diff"; +import { runJJ } from "../jj/runner"; +import { listWorkspaces, workspaceRef } from "../jj/workspace"; +import { ok, type Result } from "../result"; +import type { Command } from "./types"; + +export interface FileChange { + status: "M" | "A" | "D" | "R"; + path: string; +} + +export interface DiffStats { + added: number; + removed: number; + files: number; +} + +export interface WorkspaceStatus { + name: string; + changes: FileChange[]; + stats: DiffStats; +} + +function diffEntryToFileChange(entry: DiffEntry): FileChange { + return { status: entry.status, path: entry.path }; +} + +/** + * Parse jj diff --stat output to get line stats. + */ +function parseDiffStats(output: string): DiffStats { + let added = 0; + let removed = 0; + let files = 0; + + for (const line of output.split("\n")) { + // Match lines like: "file.txt | 5 ++--" + const match = line.match(/\|\s*(\d+)\s*([+-]*)/); + if (match) { + files++; + const changes = match[2]; + added += (changes.match(/\+/g) || []).length; + removed += (changes.match(/-/g) || []).length; + } + + // Match summary line: "2 files changed, 10 insertions(+), 5 deletions(-)" + const summaryMatch = line.match( + /(\d+)\s+files?\s+changed(?:,\s*(\d+)\s+insertions?\(\+\))?(?:,\s*(\d+)\s+deletions?\(-\))?/, + ); + if (summaryMatch) { + files = parseInt(summaryMatch[1], 10); + added = summaryMatch[2] ? parseInt(summaryMatch[2], 10) : 0; + removed = summaryMatch[3] ? parseInt(summaryMatch[3], 10) : 0; + } + } + + return { added, removed, files }; +} + +/** + * Get status for a single workspace. + */ +export async function getWorkspaceStatus( + workspaceName: string, + cwd = process.cwd(), +): Promise> { + // Get diff summary + const summaryResult = await runJJ( + ["diff", "-r", workspaceRef(workspaceName), "--summary"], + cwd, + ); + if (!summaryResult.ok) return summaryResult; + + const changes = parseDiffSummary(summaryResult.value.stdout).map( + diffEntryToFileChange, + ); + + // Get diff stats + const statResult = await runJJ( + ["diff", "-r", workspaceRef(workspaceName), "--stat"], + cwd, + ); + + const stats = statResult.ok + ? parseDiffStats(statResult.value.stdout) + : { added: 0, removed: 0, files: changes.length }; + + return ok({ + name: workspaceName, + changes, + stats, + }); +} + +/** + * Get status for all workspaces. + */ +export async function getAllWorkspaceStatus( + cwd = process.cwd(), +): Promise> { + const workspacesResult = await listWorkspaces(cwd); + if (!workspacesResult.ok) return workspacesResult; + + const statuses: WorkspaceStatus[] = []; + + for (const ws of workspacesResult.value) { + const statusResult = await getWorkspaceStatus(ws.name, cwd); + if (statusResult.ok) { + statuses.push(statusResult.value); + } + } + + return ok(statuses); +} + +/** + * Get workspace status - single workspace or all. + */ +export async function workspaceStatus( + workspaceName?: string, + cwd = process.cwd(), +): Promise> { + if (workspaceName) { + const result = await getWorkspaceStatus(workspaceName, cwd); + if (!result.ok) return result; + return ok([result.value]); + } + + return getAllWorkspaceStatus(cwd); +} + +export const workspaceStatusCommand: Command< + WorkspaceStatus[], + [string?, string?] +> = { + meta: { + name: "workspace status", + args: "[workspace]", + description: "Show changes in workspace(s)", + category: "info", + }, + run: workspaceStatus, +}; diff --git a/packages/core/src/commands/workspace-submit.ts b/packages/core/src/commands/workspace-submit.ts new file mode 100644 index 00000000..36f60b63 --- /dev/null +++ b/packages/core/src/commands/workspace-submit.ts @@ -0,0 +1,231 @@ +import { createPR, updatePR } from "../github/pr-actions"; +import { getPRForBranch } from "../github/pr-status"; +import { getTrunk, push } from "../jj"; +import { runJJ } from "../jj/runner"; +import { + getWorkspaceTip, + listWorkspaces, + UNASSIGNED_WORKSPACE, + workspaceRef, +} from "../jj/workspace"; +import { createError, err, ok, type Result } from "../result"; +import { datePrefixedLabel } from "../slugify"; +import type { Command } from "./types"; + +export interface WorkspaceSubmitResult { + workspace: string; + bookmark: string; + prNumber: number; + prUrl: string; + status: "created" | "updated"; +} + +interface SubmitOptions { + draft?: boolean; + message?: string; +} + +/** + * Get the description of a workspace's commit. + * Returns empty string if no description. + */ +async function getWorkspaceDescription( + workspace: string, + cwd = process.cwd(), +): Promise { + const result = await runJJ( + ["log", "-r", workspaceRef(workspace), "--no-graph", "-T", "description"], + cwd, + ); + if (!result.ok) return ""; + + return result.value.stdout.trim(); +} + +/** + * Submit a workspace as a PR. + * Creates a bookmark, pushes it, and creates/updates the PR. + */ +export async function submitWorkspace( + workspace: string, + options: SubmitOptions = {}, + cwd = process.cwd(), +): Promise> { + // Prevent submitting unassigned workspace + if (workspace === UNASSIGNED_WORKSPACE) { + return err( + createError( + "INVALID_INPUT", + "Cannot submit unassigned workspace. Assign files to a workspace first with 'arr assign'.", + ), + ); + } + + // Verify workspace exists + const workspacesResult = await listWorkspaces(cwd); + if (!workspacesResult.ok) return workspacesResult; + + const ws = workspacesResult.value.find((w) => w.name === workspace); + if (!ws) { + return err( + createError("WORKSPACE_NOT_FOUND", `Workspace '${workspace}' not found`), + ); + } + + // Get the workspace tip + const tipResult = await getWorkspaceTip(workspace, cwd); + if (!tipResult.ok) return tipResult; + const _changeId = tipResult.value; + + // Check if workspace has changes + const diffResult = await runJJ( + ["diff", "-r", workspaceRef(workspace), "--summary"], + cwd, + ); + if (!diffResult.ok) return diffResult; + + if (!diffResult.value.stdout.trim()) { + return err( + createError( + "EMPTY_CHANGE", + `Workspace '${workspace}' has no changes to submit`, + ), + ); + } + + // Get workspace description - require message if none + let description = await getWorkspaceDescription(workspace, cwd); + const message = options.message || description; + + if (!message) { + return err( + createError( + "MISSING_MESSAGE", + `Workspace '${workspace}' has no description`, + ), + ); + } + + // If message provided but no description, set it on the commit + if (options.message && !description) { + const describeResult = await runJJ( + ["describe", "-r", workspaceRef(workspace), "-m", options.message], + cwd, + ); + if (!describeResult.ok) return describeResult; + description = options.message; + } + + // Get or generate bookmark name + // First check if there's already a bookmark on this change + const bookmarkResult = await runJJ( + ["log", "-r", workspaceRef(workspace), "--no-graph", "-T", "bookmarks"], + cwd, + ); + + let bookmark: string; + const existingBookmarks = bookmarkResult.ok + ? bookmarkResult.value.stdout.trim().split(/\s+/).filter(Boolean) + : []; + + if (existingBookmarks.length > 0) { + bookmark = existingBookmarks[0]; + } else { + // Generate a new bookmark name from description + bookmark = datePrefixedLabel(description, new Date()); + + // Create the bookmark + const createResult = await runJJ( + ["bookmark", "create", bookmark, "-r", workspaceRef(workspace)], + cwd, + ); + if (!createResult.ok) return createResult; + } + + // Push the bookmark + const pushResult = await push({ bookmark }); + if (!pushResult.ok) { + return err( + createError( + "COMMAND_FAILED", + `Failed to push: ${pushResult.error.message}`, + ), + ); + } + + // Check if PR already exists + const existingPR = await getPRForBranch(bookmark, cwd); + const trunk = await getTrunk(); + + if (existingPR.ok && existingPR.value) { + // Update existing PR + const updateResult = await updatePR(existingPR.value.number, { + base: trunk, + }); + if (!updateResult.ok) { + return err( + createError( + "COMMAND_FAILED", + `Failed to update PR: ${updateResult.error.message}`, + ), + ); + } + + return ok({ + workspace, + bookmark, + prNumber: existingPR.value.number, + prUrl: existingPR.value.url, + status: "updated", + }); + } + + // Create new PR + const prResult = await createPR({ + head: bookmark, + title: message, + base: trunk, + draft: options.draft, + }); + + if (!prResult.ok) { + return err( + createError( + "COMMAND_FAILED", + `Failed to create PR: ${prResult.error.message}`, + ), + ); + } + + // Fetch to update tracking + await runJJ(["git", "fetch"], cwd); + + return ok({ + workspace, + bookmark, + prNumber: prResult.value.number, + prUrl: prResult.value.url, + status: "created", + }); +} + +export const workspaceSubmitCommand: Command< + WorkspaceSubmitResult, + [string, SubmitOptions?, string?] +> = { + meta: { + name: "workspace submit", + args: "", + description: "Submit a workspace as a GitHub PR", + category: "workflow", + flags: [ + { name: "draft", short: "d", description: "Create PR as draft" }, + { + name: "message", + short: "m", + description: "Commit message / PR title", + }, + ], + }, + run: submitWorkspace, +}; diff --git a/packages/core/src/daemon/daemon-process.ts b/packages/core/src/daemon/daemon-process.ts new file mode 100644 index 00000000..900e61c8 --- /dev/null +++ b/packages/core/src/daemon/daemon-process.ts @@ -0,0 +1,935 @@ +#!/usr/bin/env bun + +/** + * Global daemon process that watches workspaces across all registered repos. + * + * Architecture: + * 1. Reads ~/.array/repos.json for list of repos to watch + * 2. Watches repos.json for changes (repos added/removed) + * 3. For each repo, watches its workspaces for file changes + * 4. On file change: snapshot workspace → update focus + * + * All jj operations use retry logic for lock contention. + */ + +import { spawn } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import * as watcher from "@parcel/watcher"; +import { parseDiffPaths } from "../jj/diff"; +import { UNASSIGNED_WORKSPACE, workspaceRef } from "../jj/workspace"; +import { + cleanup, + getReposPath, + getWorkspacePath, + log, + type RepoEntry, + readRepos, + writePid, + writeRepos, +} from "./pid"; + +const JJ_TIMEOUT_MS = 10000; +const MAX_RETRIES = 10; +const RETRY_DELAY_MS = 20; +const DEBOUNCE_MS = 100; + +interface JJResult { + stdout: string | null; + isLockError: boolean; +} + +/** + * Run jj command with timeout. + */ +function runJJOnce(args: string[], cwd: string): Promise { + return new Promise((resolve) => { + const _tSpawn = performance.now(); + const proc = spawn("jj", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let killed = false; + let tFirstData = 0; + + const timeout = setTimeout(() => { + killed = true; + proc.kill("SIGKILL"); + log(`jj ${args[0]} timed out after ${JJ_TIMEOUT_MS}ms`); + resolve({ stdout: null, isLockError: false }); + }, JJ_TIMEOUT_MS); + + proc.stdout.on("data", (data) => { + if (!tFirstData) tFirstData = performance.now(); + stdout += data.toString(); + }); + + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + const _tClose = performance.now(); + clearTimeout(timeout); + if (killed) return; + // log(`jj ${args[0]}: spawn→firstData=${tFirstData ? (tFirstData - tSpawn).toFixed(0) : 'n/a'}ms, spawn→close=${(tClose - tSpawn).toFixed(0)}ms`); + if (code !== 0) { + const isLockError = + stderr.includes("locked") || + stderr.includes("lock") || + stderr.includes("packed-refs"); + if (!isLockError) { + log(`jj ${args.join(" ")} failed (code ${code}): ${stderr.trim()}`); + } + resolve({ stdout: null, isLockError }); + } else { + resolve({ stdout, isLockError: false }); + } + }); + + proc.on("error", (err) => { + clearTimeout(timeout); + log(`jj ${args[0]} error: ${err.message}`); + resolve({ stdout: null, isLockError: false }); + }); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Run jj command with retry logic for lock contention. + */ +async function runJJ(args: string[], cwd: string): Promise { + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const result = await runJJOnce(args, cwd); + + if (result.stdout !== null) { + if (attempt > 0) { + log(`jj ${args[0]} succeeded after ${attempt} retries`); + } + return result.stdout; + } + + if (!result.isLockError) { + return null; + } + + if (attempt < MAX_RETRIES - 1) { + await sleep(RETRY_DELAY_MS); + } + } + + log( + `jj ${args.join(" ")} failed after ${MAX_RETRIES} retries (lock contention)`, + ); + return null; +} + +/** Parse .gitignore into a set of ignored names */ +async function loadGitignore(workspacePath: string): Promise> { + const ignored = new Set([".jj", ".git", "node_modules", ".DS_Store"]); + try { + const content = await readFile(join(workspacePath, ".gitignore"), "utf-8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith("#")) { + ignored.add(trimmed.replace(/^\//, "").replace(/\/$/, "")); + } + } + } catch { + // .gitignore doesn't exist or isn't readable - use defaults only + } + return ignored; +} + +function shouldIgnore(filename: string, ignored: Set): boolean { + if (!filename) return true; + for (const part of filename.split("/")) { + if (ignored.has(part)) return true; + } + return false; +} + +/** + * Get list of tracked (non-gitignored) files via jj file list + */ +async function getTrackedFiles(cwd: string): Promise { + const result = await runJJ(["file", "list"], cwd); + if (result === null) return []; + return result.trim().split("\n").filter(Boolean); +} + +/** + * Rewrite files in-place to trigger file watchers. + * jj rebase replaces files (new inode), but VSCode watches the old inode. + * Rewriting the file content triggers watchers on the new inode. + */ +async function rewriteFilesInPlace(cwd: string): Promise { + const files = await getTrackedFiles(cwd); + let errorCount = 0; + + for (const file of files) { + const filePath = join(cwd, file); + if (existsSync(filePath)) { + try { + const stats = statSync(filePath); + if (stats.isFile()) { + const content = readFileSync(filePath); + writeFileSync(filePath, content); + } + } catch (err) { + errorCount++; + // Log first few errors to avoid spam + if (errorCount <= 3) { + log(`Failed to rewrite ${file}: ${err}`); + } + } + } + } + + if (errorCount > 3) { + log(`...and ${errorCount - 3} more file rewrite errors`); + } +} + +/** Active subscriptions: "repoPath:wsName" → subscription */ +const subscriptions: Map = new Map(); + +/** Preview subscriptions: repoPath → subscription (watches main repo for edits) */ +const focusSubscriptions: Map = new Map(); + +/** Workspaces currently syncing */ +const syncingWorkspaces: Set = new Set(); + +/** Workspaces that changed during sync (need re-sync) */ +const dirtyWorkspaces: Set = new Set(); + +/** Queue of pending syncs per repo (to serialize syncs to same focus) */ +const repoSyncQueue: Map< + string, + Array<{ wsName: string; wsPath: string }> +> = new Map(); + +/** Repos currently processing their sync queue */ +const repoSyncing: Set = new Set(); + +/** Preview repos with dirty edits that need routing */ +const dirtyPreviews: Set = new Set(); + +/** Debounce timers for workspace syncs */ +const wsDebounceTimers: Map> = new Map(); + +function wsKey(repoPath: string, wsName: string): string { + return `${repoPath}:${wsName}`; +} + +/** + * Mark workspace sync as complete and re-queue if dirty. + */ +function finishWorkspaceSync( + key: string, + repoPath: string, + wsName: string, + wsPath: string, +): void { + syncingWorkspaces.delete(key); + + if (dirtyWorkspaces.has(key)) { + log(`[${key}] Changes during sync, re-queuing`); + dirtyWorkspaces.delete(key); + const queue = repoSyncQueue.get(repoPath) || []; + if (!queue.some((item) => item.wsName === wsName)) { + queue.push({ wsName, wsPath }); + repoSyncQueue.set(repoPath, queue); + } + } +} + +/** + * Rebase focus commit onto all workspace tips. + */ +async function rebaseFocusCommit( + repoPath: string, + workspaces: string[], +): Promise { + const destinations = [ + "-d", + workspaceRef(UNASSIGNED_WORKSPACE), + ...workspaces.flatMap((ws) => ["-d", workspaceRef(ws)]), + ]; + const result = await runJJ(["rebase", "-r", "@", ...destinations], repoPath); + return result !== null; +} + +/** + * Snapshot a workspace and update focus. + */ +async function snapshotAndSync( + repoPath: string, + wsName: string, + wsPath: string, +): Promise { + const key = wsKey(repoPath, wsName); + const t0 = performance.now(); + + syncingWorkspaces.add(key); + dirtyWorkspaces.delete(key); + log(`[${key}] Starting sync`); + + const repo = currentRepos.find((r) => r.path === repoPath); + if (!repo || repo.workspaces.length === 0) { + log(`[${key}] No registered workspaces, skipping`); + finishWorkspaceSync(key, repoPath, wsName, wsPath); + return; + } + + // Step 1: Snapshot the workspace + const t1 = performance.now(); + const snapResult = await runJJ(["status", "--quiet"], wsPath); + if (snapResult === null) { + log(`[${key}] Snapshot failed, aborting sync`); + finishWorkspaceSync(key, repoPath, wsName, wsPath); + return; + } + log(`[${key}] Snapshot complete (${(performance.now() - t1).toFixed(0)}ms)`); + + // Step 2: Rebase focus commit onto all workspace tips + const t2 = performance.now(); + const rebaseOk = await rebaseFocusCommit(repoPath, repo.workspaces); + if (!rebaseOk) { + log(`[${key}] Rebase failed`); + finishWorkspaceSync(key, repoPath, wsName, wsPath); + return; + } + log(`[${key}] Rebase complete (${(performance.now() - t2).toFixed(0)}ms)`); + + // Step 3: Rewrite files to trigger VSCode's file watcher + const t3 = performance.now(); + await rewriteFilesInPlace(repoPath); + log(`[${key}] Rewrote files (${(performance.now() - t3).toFixed(0)}ms)`); + + log( + `[${key}] Sync complete (total: ${(performance.now() - t0).toFixed(0)}ms)`, + ); + finishWorkspaceSync(key, repoPath, wsName, wsPath); +} + +function triggerSync(repoPath: string, wsName: string, wsPath: string): void { + const key = wsKey(repoPath, wsName); + + // If this specific workspace is already syncing, mark dirty for re-sync + if (syncingWorkspaces.has(key)) { + dirtyWorkspaces.add(key); + return; + } + + // Debounce: reset timer on each trigger + const existing = wsDebounceTimers.get(key); + if (existing) { + clearTimeout(existing); + } + + const timer = setTimeout(() => { + wsDebounceTimers.delete(key); + + // Add to repo's sync queue + const queue = repoSyncQueue.get(repoPath) || []; + if (!queue.some((item) => item.wsName === wsName)) { + queue.push({ wsName, wsPath }); + repoSyncQueue.set(repoPath, queue); + } + + // Process queue if not already processing + processRepoSyncQueue(repoPath); + }, DEBOUNCE_MS); + + wsDebounceTimers.set(key, timer); +} + +/** + * Process sync queue for a repo serially. + * Only one workspace syncs to focus at a time to prevent overwrites. + */ +async function processRepoSyncQueue(repoPath: string): Promise { + // If already processing this repo's queue, let it continue + if (repoSyncing.has(repoPath)) { + return; + } + + repoSyncing.add(repoPath); + + while (true) { + const queue = repoSyncQueue.get(repoPath) || []; + if (queue.length === 0) { + break; + } + + // Take the first item from queue + const { wsName, wsPath } = queue.shift()!; + repoSyncQueue.set(repoPath, queue); + + // Sync this workspace (await completion before next) + await snapshotAndSync(repoPath, wsName, wsPath); + } + + repoSyncing.delete(repoPath); +} + +async function watchWorkspace( + repoPath: string, + wsName: string, + wsPath: string, +): Promise { + const key = wsKey(repoPath, wsName); + + if (subscriptions.has(key)) { + return; + } + + if (!existsSync(wsPath)) { + log(`[${key}] Workspace path does not exist, skipping`); + return; + } + + const ignored = await loadGitignore(wsPath); + + try { + const subscription = await watcher.subscribe(wsPath, (err, events) => { + const tEvent = Date.now(); + if (err) { + log(`[${key}] Watcher error: ${err.message}`); + return; + } + + const relevantEvents = events.filter((event) => { + const relativePath = event.path.slice(wsPath.length + 1); + return !shouldIgnore(relativePath, ignored); + }); + + if (relevantEvents.length === 0) return; + + // Check watcher latency by comparing file mtime to now + // File may be deleted between event and stat - safe to ignore + let maxLatency = 0; + for (const event of relevantEvents) { + try { + const mtime = statSync(event.path).mtimeMs; + const latency = tEvent - mtime; + if (latency > maxLatency) maxLatency = latency; + } catch { + // File was deleted or inaccessible - skip latency calculation + } + } + + log( + `[${key}] ${relevantEvents.length} file change(s) (watcher latency: ${maxLatency.toFixed(0)}ms)`, + ); + triggerSync(repoPath, wsName, wsPath); + }); + + subscriptions.set(key, subscription); + log(`[${key}] Watching started`); + } catch (err) { + log(`[${key}] Failed to start watcher: ${err}`); + } +} + +async function unwatchWorkspace( + repoPath: string, + wsName: string, +): Promise { + const key = wsKey(repoPath, wsName); + const subscription = subscriptions.get(key); + if (subscription) { + await subscription.unsubscribe(); + subscriptions.delete(key); + log(`[${key}] Watcher stopped`); + } +} + +// ============================================================================ +// Preview Watcher: Routes edits from main repo to appropriate workspace +// ============================================================================ + +/** + * Build ownership map: file → workspaces that modified it. + */ +async function buildOwnershipMap( + workspaces: string[], + cwd: string, +): Promise> { + const ownership = new Map(); + + for (const ws of workspaces) { + const result = await runJJ( + ["diff", "-r", workspaceRef(ws), "--summary"], + cwd, + ); + if (result === null) continue; + + const files = parseDiffPaths(result); + for (const file of files) { + const owners = ownership.get(file) || []; + if (!owners.includes(ws)) { + owners.push(ws); + } + ownership.set(file, owners); + } + } + + return ownership; +} + +/** + * Copy files from focus to target workspace directory. + * Instead of using jj squash (which creates divergent commits), + * we copy the file content directly and let the workspace watcher + * pick up the changes naturally. + * + * NOTE: We intentionally do NOT run `jj restore` here. The workspace watcher + * will trigger snapshotAndSync, which runs `jj restore` right before rebase. + * This eliminates the visible flash where content disappears then reappears. + */ +function copyFilesToWorkspace( + files: string[], + targetWorkspace: string, + repoPath: string, +): boolean { + if (files.length === 0) return true; + + const wsPath = getWorkspacePath(repoPath, targetWorkspace); + if (!existsSync(wsPath)) return false; + + for (const file of files) { + const srcPath = join(repoPath, file); + const destPath = join(wsPath, file); + + try { + if (existsSync(srcPath)) { + // Ensure destination directory exists + const destDir = join(destPath, ".."); + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }); + } + // Copy file content + const content = readFileSync(srcPath); + writeFileSync(destPath, content); + } + } catch (err) { + log(`[focus:${repoPath}] Failed to copy ${file}: ${err}`); + } + } + + return true; +} + +/** + * Group changed files by their target workspace based on ownership. + */ +function groupFilesByOwner( + changedFiles: string[], + ownership: Map, + repoPath: string, +): Map { + const toRoute = new Map(); + + for (const file of changedFiles) { + const owners = ownership.get(file) || []; + + if (owners.length === 0) { + const files = toRoute.get(UNASSIGNED_WORKSPACE) || []; + files.push(file); + toRoute.set(UNASSIGNED_WORKSPACE, files); + } else if (owners.length === 1) { + const files = toRoute.get(owners[0]) || []; + files.push(file); + toRoute.set(owners[0], files); + } else { + log( + `[focus:${repoPath}] WARNING: ${file} has multiple owners: ${owners.join(", ")}`, + ); + } + } + + return toRoute; +} + +/** + * Route files to workspaces and log results. + */ +async function routeFilesToWorkspaces( + toRoute: Map, + repoPath: string, +): Promise { + for (const [target, files] of toRoute) { + const success = await copyFilesToWorkspace(files, target, repoPath); + if (success) { + log(`[focus:${repoPath}] Routed ${files.length} file(s) to ${target}`); + } else { + log(`[focus:${repoPath}] Failed to route files to ${target}`); + } + } +} + +/** + * Route focus edits to appropriate workspaces. + * + * Routing rules: + * - File modified by exactly 1 agent → route to that agent + * - File not modified by any agent → route to unassigned + * - File modified by 2+ agents → BLOCKED (shouldn't happen) + */ +async function routePreviewEdits(repoPath: string): Promise { + const t0 = performance.now(); + log(`[focus:${repoPath}] Starting edit routing`); + + const repo = currentRepos.find((r) => r.path === repoPath); + if (!repo) { + log(`[focus:${repoPath}] Repo not registered, skipping`); + return; + } + + const diffResult = await runJJ(["diff", "--summary"], repoPath); + if (!diffResult) { + log(`[focus:${repoPath}] No changes to route`); + return; + } + + const changedFiles = parseDiffPaths(diffResult); + if (changedFiles.length === 0) { + log(`[focus:${repoPath}] No tracked files changed`); + return; + } + + // Git mode (no focused workspaces): all edits go to unassigned + if (repo.workspaces.length === 0) { + await copyFilesToWorkspace(changedFiles, UNASSIGNED_WORKSPACE, repoPath); + log( + `[focus:${repoPath}] Routed ${changedFiles.length} file(s) to ${UNASSIGNED_WORKSPACE} (git mode)`, + ); + } else if (repo.workspaces.length === 1) { + // Single workspace mode: all edits go directly to that workspace + await copyFilesToWorkspace(changedFiles, repo.workspaces[0], repoPath); + log( + `[focus:${repoPath}] Routed ${changedFiles.length} file(s) to ${repo.workspaces[0]}`, + ); + } else { + // Multi-workspace mode: route based on ownership + const ownership = await buildOwnershipMap(repo.workspaces, repoPath); + const toRoute = groupFilesByOwner(changedFiles, ownership, repoPath); + await routeFilesToWorkspaces(toRoute, repoPath); + } + + log( + `[focus:${repoPath}] Edit routing complete (${(performance.now() - t0).toFixed(0)}ms)`, + ); +} + +/** + * Trigger preview edit routing (with dirty flag handling). + */ +function triggerPreviewRoute(repoPath: string): void { + // Debounce handles batching - just mark dirty and schedule + if (dirtyPreviews.has(repoPath)) { + // Already scheduled, debounce will reset timer + routePreviewEditsDebounced(repoPath); + return; + } + + dirtyPreviews.add(repoPath); + routePreviewEditsDebounced(repoPath); +} + +/** + * Debounced version of routePreviewEdits. + * Waits for file activity to settle before routing. + */ +const focusDebounceTimers: Map< + string, + ReturnType +> = new Map(); + +function routePreviewEditsDebounced(repoPath: string): void { + const existing = focusDebounceTimers.get(repoPath); + if (existing) { + clearTimeout(existing); + } + + const timer = setTimeout(async () => { + focusDebounceTimers.delete(repoPath); + dirtyPreviews.delete(repoPath); + await routePreviewEdits(repoPath); + }, DEBOUNCE_MS); + + focusDebounceTimers.set(repoPath, timer); +} + +/** + * Watch the main repo for user edits (bidirectional sync). + */ +async function watchPreview(repoPath: string): Promise { + if (focusSubscriptions.has(repoPath)) { + return; + } + + if (!existsSync(repoPath)) { + log(`[focus:${repoPath}] Repo path does not exist, skipping`); + return; + } + + const ignored = await loadGitignore(repoPath); + + try { + const subscription = await watcher.subscribe(repoPath, (err, events) => { + if (err) { + log(`[focus:${repoPath}] Watcher error: ${err.message}`); + return; + } + + const relevantEvents = events.filter((event) => { + const relativePath = event.path.slice(repoPath.length + 1); + return !shouldIgnore(relativePath, ignored); + }); + + if (relevantEvents.length === 0) return; + + log( + `[focus:${repoPath}] ${relevantEvents.length} file change(s) detected`, + ); + triggerPreviewRoute(repoPath); + }); + + focusSubscriptions.set(repoPath, subscription); + log(`[focus:${repoPath}] Focus watcher started`); + } catch (err) { + log(`[focus:${repoPath}] Failed to start focus watcher: ${err}`); + } +} + +/** + * Stop watching the main repo for edits. + */ +async function unwatchPreview(repoPath: string): Promise { + const subscription = focusSubscriptions.get(repoPath); + if (subscription) { + await subscription.unsubscribe(); + focusSubscriptions.delete(repoPath); + log(`[focus:${repoPath}] Focus watcher stopped`); + } + + // Clear any pending debounce timers + const timer = focusDebounceTimers.get(repoPath); + if (timer) { + clearTimeout(timer); + focusDebounceTimers.delete(repoPath); + } +} + +async function watchRepo(repo: RepoEntry): Promise { + log(`Watching repo: ${repo.path}`); + + // Watch agent workspaces for changes + for (const wsName of repo.workspaces) { + const wsPath = getWorkspacePath(repo.path, wsName); + await watchWorkspace(repo.path, wsName, wsPath); + } + + // Watch main repo for user edits (bidirectional sync) + // Either in focus mode (workspaces.length > 0) or git mode + if (repo.workspaces.length > 0 || repo.gitMode) { + await watchPreview(repo.path); + } +} + +async function unwatchRepo(repoPath: string): Promise { + log(`Unwatching repo: ${repoPath}`); + + // Find all subscriptions for this repo and unwatch them + for (const key of subscriptions.keys()) { + if (key.startsWith(`${repoPath}:`)) { + const wsName = key.slice(repoPath.length + 1); + await unwatchWorkspace(repoPath, wsName); + } + } + + // Stop focus watcher + await unwatchPreview(repoPath); +} + +/** Currently watched repos (for diffing on reload) */ +let currentRepos: RepoEntry[] = []; + +async function reloadRepos(): Promise { + const rawRepos = readRepos(); + + // Filter to only workspaces that actually exist on disk + // This cleans up stale entries from manual deletions or crashes + const newRepos: RepoEntry[] = []; + let needsWrite = false; + + for (const repo of rawRepos) { + const validWorkspaces = repo.workspaces.filter((ws) => { + const wsPath = getWorkspacePath(repo.path, ws); + return existsSync(wsPath); + }); + + if (validWorkspaces.length !== repo.workspaces.length) { + needsWrite = true; + const removed = repo.workspaces.filter( + (ws) => !validWorkspaces.includes(ws), + ); + log( + `Cleaning stale workspaces from ${repo.path}: [${removed.join(", ")}]`, + ); + } + + if (validWorkspaces.length > 0 || repo.gitMode) { + newRepos.push({ + path: repo.path, + workspaces: validWorkspaces, + gitMode: repo.gitMode, + }); + } else { + needsWrite = true; + log(`Removing repo with no valid workspaces: ${repo.path}`); + } + } + + // Update repos.json if we cleaned anything + if (needsWrite) { + writeRepos(newRepos); + } + + // Find repos to remove + for (const oldRepo of currentRepos) { + const stillExists = newRepos.find((r) => r.path === oldRepo.path); + if (!stillExists) { + await unwatchRepo(oldRepo.path); + } + } + + // Find repos to add or update + for (const newRepo of newRepos) { + const oldRepo = currentRepos.find((r) => r.path === newRepo.path); + if (!oldRepo) { + // New repo + await watchRepo(newRepo); + } else { + // Check for workspace changes + const oldWs = new Set(oldRepo.workspaces); + const newWs = new Set(newRepo.workspaces); + + // Removed workspaces + for (const ws of oldWs) { + if (!newWs.has(ws)) { + await unwatchWorkspace(newRepo.path, ws); + } + } + + // Added workspaces + for (const ws of newWs) { + if (!oldWs.has(ws)) { + const wsPath = getWorkspacePath(newRepo.path, ws); + await watchWorkspace(newRepo.path, ws, wsPath); + } + } + } + } + + currentRepos = newRepos; +} + +let reposWatcher: watcher.AsyncSubscription | null = null; + +async function watchReposFile(): Promise { + const reposPath = getReposPath(); + const reposDir = join(reposPath, ".."); + + if (!existsSync(reposDir)) { + log("~/.array/ does not exist yet, will check on file changes"); + return; + } + + try { + reposWatcher = await watcher.subscribe(reposDir, async (err, events) => { + if (err) { + log(`repos.json watcher error: ${err.message}`); + return; + } + + const reposChanged = events.some((e) => e.path.endsWith("repos.json")); + if (reposChanged) { + log("repos.json changed, reloading"); + await reloadRepos(); + } + }); + + log("Watching ~/.array/repos.json for changes"); + } catch (err) { + log(`Failed to watch repos.json: ${err}`); + } +} + +async function main(): Promise { + writePid(process.pid); + log(`Daemon started (PID: ${process.pid})`); + + const shutdown = async () => { + log("Daemon shutting down"); + + if (reposWatcher) { + await reposWatcher.unsubscribe(); + } + + for (const [key, subscription] of subscriptions) { + await subscription.unsubscribe(); + log(`[${key}] Watcher stopped`); + } + subscriptions.clear(); + + // Clean up preview subscriptions + for (const [repoPath, subscription] of focusSubscriptions) { + await subscription.unsubscribe(); + log(`[focus:${repoPath}] Focus watcher stopped`); + } + focusSubscriptions.clear(); + + // Clear any pending debounce timers + for (const timer of focusDebounceTimers.values()) { + clearTimeout(timer); + } + focusDebounceTimers.clear(); + + cleanup(); + process.exit(0); + }; + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + + // Initial load + await reloadRepos(); + + // Watch for repo changes + await watchReposFile(); + + log("Daemon initialization complete"); + + // Keep process alive + await new Promise(() => {}); +} + +main().catch((e) => { + log(`Daemon crashed: ${e}`); + console.error("Daemon crashed:", e); + process.exit(1); +}); diff --git a/packages/core/src/daemon/pid.ts b/packages/core/src/daemon/pid.ts new file mode 100644 index 00000000..38753d78 --- /dev/null +++ b/packages/core/src/daemon/pid.ts @@ -0,0 +1,304 @@ +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; + +const ARRAY_DIR = ".array"; +const PID_FILE = "daemon.pid"; +const LOG_FILE = "daemon.log"; +const REPOS_FILE = "repos.json"; + +export interface RepoEntry { + path: string; + workspaces: string[]; + /** When true, daemon watches main repo for git→unassigned sync even without focus */ + gitMode?: boolean; +} + +/** + * Get the path to the global ~/.array directory + */ +export function getArrayDir(): string { + return join(homedir(), ARRAY_DIR); +} + +/** + * Get the path to the PID file + */ +export function getPidPath(): string { + return join(getArrayDir(), PID_FILE); +} + +/** + * Get the path to the log file + */ +export function getLogPath(): string { + return join(getArrayDir(), LOG_FILE); +} + +/** + * Get the path to the repos file + */ +export function getReposPath(): string { + return join(getArrayDir(), REPOS_FILE); +} + +/** + * Ensure the ~/.array directory exists + */ +export function ensureArrayDir(): void { + const arrayDir = getArrayDir(); + if (!existsSync(arrayDir)) { + mkdirSync(arrayDir, { recursive: true }); + } +} + +/** + * Write the daemon PID to the PID file + */ +export function writePid(pid: number): void { + ensureArrayDir(); + writeFileSync(getPidPath(), pid.toString(), "utf-8"); +} + +/** + * Read the daemon PID from the PID file + */ +export function readPid(): number | null { + const pidPath = getPidPath(); + if (!existsSync(pidPath)) return null; + + try { + const pidStr = readFileSync(pidPath, "utf-8").trim(); + const pid = parseInt(pidStr, 10); + return Number.isNaN(pid) ? null : pid; + } catch { + return null; + } +} + +/** + * Check if the daemon is running by checking if the PID exists and the process is alive + */ +export function isRunning(): boolean { + const pid = readPid(); + if (!pid) return false; + + try { + // Signal 0 checks if process exists without killing it + process.kill(pid, 0); + return true; + } catch { + // Process doesn't exist - clean up stale PID file + cleanup(); + return false; + } +} + +/** + * Clean up the PID file + */ +export function cleanup(): void { + const pidPath = getPidPath(); + if (existsSync(pidPath)) { + try { + unlinkSync(pidPath); + } catch { + // Ignore errors + } + } +} + +/** + * Append a log message to the daemon log file + */ +export function log(message: string): void { + ensureArrayDir(); + const timestamp = new Date().toISOString(); + const logLine = `${timestamp}: ${message}\n`; + try { + writeFileSync(getLogPath(), logLine, { flag: "a" }); + } catch { + // Ignore log errors + } +} + +/** + * Read the registered repos from repos.json + */ +export function readRepos(): RepoEntry[] { + const reposPath = getReposPath(); + if (!existsSync(reposPath)) return []; + + try { + const content = readFileSync(reposPath, "utf-8"); + return JSON.parse(content); + } catch { + return []; + } +} + +/** + * Write the registered repos to repos.json + */ +export function writeRepos(repos: RepoEntry[]): void { + ensureArrayDir(); + writeFileSync(getReposPath(), JSON.stringify(repos, null, 2), "utf-8"); +} + +/** + * Register a repo with workspaces for the daemon to watch. + * Merges with existing workspaces. + */ +export function registerRepo(repoPath: string, workspaces: string[]): void { + const repos = readRepos(); + const existing = repos.find((r) => r.path === repoPath); + + if (existing) { + // Merge workspaces (avoid duplicates) + const allWorkspaces = new Set([...existing.workspaces, ...workspaces]); + existing.workspaces = [...allWorkspaces]; + } else { + repos.push({ path: repoPath, workspaces }); + } + + writeRepos(repos); + log( + `Registered repo: ${repoPath} with workspaces: [${workspaces.join(", ")}]`, + ); +} + +/** + * Set the exact list of workspaces for a repo (replaces existing). + * Use this when updating focus to ensure repos.json matches exactly. + */ +export function setRepoWorkspaces( + repoPath: string, + workspaces: string[], +): void { + const repos = readRepos(); + const existing = repos.find((r) => r.path === repoPath); + + if (existing) { + existing.workspaces = workspaces; + } else { + repos.push({ path: repoPath, workspaces }); + } + + writeRepos(repos); + log(`Set repo workspaces: ${repoPath} -> [${workspaces.join(", ")}]`); +} + +/** + * Unregister a repo from the daemon + */ +export function unregisterRepo(repoPath: string): void { + const repos = readRepos(); + const filtered = repos.filter((r) => r.path !== repoPath); + writeRepos(filtered); + log(`Unregistered repo: ${repoPath}`); +} + +/** + * Enable git mode for a repo (daemon watches main repo for git→unassigned sync) + */ +export function enableGitMode(repoPath: string): void { + const repos = readRepos(); + const existing = repos.find((r) => r.path === repoPath); + + if (existing) { + existing.gitMode = true; + } else { + repos.push({ path: repoPath, workspaces: [], gitMode: true }); + } + + writeRepos(repos); + log(`Enabled git mode for: ${repoPath}`); +} + +/** + * Disable git mode for a repo + */ +export function disableGitMode(repoPath: string): void { + const repos = readRepos(); + const existing = repos.find((r) => r.path === repoPath); + + if (existing) { + existing.gitMode = false; + // If no workspaces and no git mode, remove the repo + if (existing.workspaces.length === 0) { + writeRepos(repos.filter((r) => r.path !== repoPath)); + log(`Disabled git mode and removed repo: ${repoPath}`); + return; + } + } + + writeRepos(repos); + log(`Disabled git mode for: ${repoPath}`); +} + +/** + * Remove specific workspaces from a repo (unregister repo if no workspaces left) + */ +export function unregisterWorkspaces( + repoPath: string, + workspaces: string[], +): void { + const repos = readRepos(); + const existing = repos.find((r) => r.path === repoPath); + + if (!existing) return; + + existing.workspaces = existing.workspaces.filter( + (ws) => !workspaces.includes(ws), + ); + + if (existing.workspaces.length === 0) { + // No workspaces left, remove the repo entirely + writeRepos(repos.filter((r) => r.path !== repoPath)); + log(`Unregistered repo (no workspaces left): ${repoPath}`); + } else { + writeRepos(repos); + log(`Unregistered workspaces from ${repoPath}: [${workspaces.join(", ")}]`); + } +} + +/** + * Get a filesystem-safe slug for a repo path. + * Uses basename for readability (e.g., "/Users/jonathan/dev/posthog" -> "posthog") + */ +export function getRepoSlug(repoPath: string): string { + return basename(repoPath); +} + +/** + * Get the path to the workspaces directory for a repo + */ +export function getRepoWorkspacesDir(repoPath: string): string { + return join(getArrayDir(), "workspaces", getRepoSlug(repoPath)); +} + +/** + * Get the path to a specific workspace + */ +export function getWorkspacePath( + repoPath: string, + workspaceName: string, +): string { + return join(getRepoWorkspacesDir(repoPath), workspaceName); +} + +/** + * Ensure the workspaces directory for a repo exists + */ +export function ensureRepoWorkspacesDir(repoPath: string): void { + const dir = getRepoWorkspacesDir(repoPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} diff --git a/packages/core/src/git/head.ts b/packages/core/src/git/head.ts new file mode 100644 index 00000000..136e372d --- /dev/null +++ b/packages/core/src/git/head.ts @@ -0,0 +1,87 @@ +import { type CommandExecutor, shellExecutor } from "../executor"; +import { createError, err, ok, type Result } from "../result"; +import { gitCheck, gitOutput } from "./runner"; + +/** + * Check if Git HEAD is detached (not on a branch). + */ +export async function isDetachedHead( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + // symbolic-ref fails if HEAD is detached + const isOnBranch = await gitCheck( + ["symbolic-ref", "--quiet", "HEAD"], + cwd, + executor, + ); + return !isOnBranch; +} + +/** + * Get current Git branch name (null if detached). + */ +export async function getCurrentBranch( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + const output = await gitOutput( + ["symbolic-ref", "--short", "HEAD"], + cwd, + executor, + ); + return output?.trim() || null; +} + +/** + * Move Git HEAD to a branch without touching the working tree. + * This is the key to seamless enter/exit - files stay exactly as they are. + * + * jj will auto-sync and create a new working copy commit on top of the branch, + * preserving any uncommitted changes. + */ +export async function setHeadToBranch( + cwd: string, + branch: string, + executor: CommandExecutor = shellExecutor, +): Promise> { + const result = await executor.execute( + "git", + ["symbolic-ref", "HEAD", `refs/heads/${branch}`], + { cwd }, + ); + + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + `Failed to set HEAD to branch '${branch}': ${result.stderr}`, + ), + ); + } + + return ok(undefined); +} + +/** + * Detach Git HEAD at current commit. + * Used when entering jj mode. + */ +export async function detachHead( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise> { + const result = await executor.execute( + "git", + ["checkout", "--detach", "HEAD"], + { cwd }, + ); + + if (result.exitCode !== 0) { + return err( + createError("COMMAND_FAILED", `Failed to detach HEAD: ${result.stderr}`), + ); + } + + return ok(undefined); +} diff --git a/packages/core/src/jj/diff.ts b/packages/core/src/jj/diff.ts index 04c97336..f8ce4256 100644 --- a/packages/core/src/jj/diff.ts +++ b/packages/core/src/jj/diff.ts @@ -2,6 +2,81 @@ import { ok, type Result } from "../result"; import type { DiffStats } from "../types"; import { runJJ } from "./runner"; +// ============================================================================= +// Diff Summary Parsing +// ============================================================================= + +export type DiffStatus = "M" | "A" | "D" | "R"; + +export interface DiffEntry { + status: DiffStatus; + path: string; + /** For renames, the original path */ + oldPath?: string; +} + +/** + * Parse jj diff --summary output into structured entries. + * + * Handles: + * - M path (modified) + * - A path (added) + * - D path (deleted) + * - R {old => new} (renamed) + */ +export function parseDiffSummary(output: string): DiffEntry[] { + const entries: DiffEntry[] = []; + + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Match: M path, A path, D path + const simpleMatch = trimmed.match(/^([MAD])\s+(.+)$/); + if (simpleMatch) { + entries.push({ + status: simpleMatch[1] as DiffStatus, + path: simpleMatch[2].trim(), + }); + continue; + } + + // Match: R {old => new} + const renameMatch = trimmed.match(/^R\s+\{(.+)\s+=>\s+(.+)\}$/); + if (renameMatch) { + entries.push({ + status: "R", + path: renameMatch[2].trim(), + oldPath: renameMatch[1].trim(), + }); + } + } + + return entries; +} + +/** + * Extract just the file paths from diff summary output. + * For renames, includes both old and new paths. + */ +export function parseDiffPaths(output: string): string[] { + const entries = parseDiffSummary(output); + const paths: string[] = []; + + for (const entry of entries) { + paths.push(entry.path); + if (entry.oldPath) { + paths.push(entry.oldPath); + } + } + + return paths; +} + +// ============================================================================= +// Diff Stats Parsing +// ============================================================================= + function parseDiffStats(stdout: string): DiffStats { // Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)" // or just "X file changed, ..." for single file diff --git a/packages/core/src/jj/file-ownership.ts b/packages/core/src/jj/file-ownership.ts new file mode 100644 index 00000000..e9689bab --- /dev/null +++ b/packages/core/src/jj/file-ownership.ts @@ -0,0 +1,89 @@ +import { ok, type Result } from "../result"; +import { parseDiffPaths } from "./diff"; +import { runJJ } from "./runner"; +import { workspaceRef } from "./workspace"; + +export interface FileOwnershipMap { + ownership: Map; + getOwners(file: string): string[]; + hasConflict(file: string): boolean; +} + +/** + * Build a map of file -> workspaces that have modified that file. + * Uses `jj diff -r @ --summary` for each workspace. + */ +export async function buildFileOwnershipMap( + workspaces: string[], + cwd = process.cwd(), +): Promise> { + const ownership = new Map(); + + for (const ws of workspaces) { + // Get files modified by this workspace (vs trunk) + const result = await runJJ( + ["diff", "-r", workspaceRef(ws), "--summary"], + cwd, + ); + if (!result.ok) continue; + + const files = parseDiffPaths(result.value.stdout); + + for (const file of files) { + const owners = ownership.get(file) || []; + if (!owners.includes(ws)) { + owners.push(ws); + } + ownership.set(file, owners); + } + } + + return ok({ + ownership, + getOwners: (file: string) => ownership.get(file) || [], + hasConflict: (file: string) => (ownership.get(file) || []).length > 1, + }); +} + +/** + * Get list of files that would conflict if these workspaces were combined. + * Returns files that are modified by more than one workspace. + */ +export async function getConflictingFiles( + workspaces: string[], + cwd = process.cwd(), +): Promise>> { + const ownershipResult = await buildFileOwnershipMap(workspaces, cwd); + if (!ownershipResult.ok) return ownershipResult; + + const conflicts: Array<{ file: string; workspaces: string[] }> = []; + + for (const [file, owners] of ownershipResult.value.ownership) { + if (owners.length > 1) { + conflicts.push({ file, workspaces: owners }); + } + } + + return ok(conflicts); +} + +/** + * Get workspaces that have modified a specific file. + */ +export async function getWorkspacesForFile( + file: string, + workspaces: string[], + cwd = process.cwd(), +): Promise { + const result: string[] = []; + for (const ws of workspaces) { + const diff = await runJJ( + ["diff", "-r", workspaceRef(ws), "--summary"], + cwd, + ); + if (diff.ok && diff.value.stdout.includes(file)) { + result.push(ws); + } + } + return result; +} diff --git a/packages/core/src/jj/index.ts b/packages/core/src/jj/index.ts index 9ac5b5f2..57804b2e 100644 --- a/packages/core/src/jj/index.ts +++ b/packages/core/src/jj/index.ts @@ -20,3 +20,15 @@ export { export { getStack } from "./stack"; export { status } from "./status"; export { sync } from "./sync"; +export { + addWorkspace, + getRepoRoot, + getWorkspaceInfo, + getWorkspacePath, + getWorkspacesDir, + getWorkspaceTip, + listWorkspaces, + removeWorkspace, + snapshotWorkspace, + type WorkspaceInfo, +} from "./workspace"; diff --git a/packages/core/src/jj/push.ts b/packages/core/src/jj/push.ts index 19afd0f5..16bb0fcd 100644 --- a/packages/core/src/jj/push.ts +++ b/packages/core/src/jj/push.ts @@ -8,13 +8,7 @@ export async function push( ): 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"]; + const args = ["git", "push", "--allow-new"]; if (options?.remote) { args.push("--remote", options.remote); } @@ -22,5 +16,13 @@ export async function push( args.push("--bookmark", options.bookmark); } - return runJJVoid(args, cwd); + const result = await runJJVoid(args, cwd); + if (!result.ok) return result; + + // After pushing, set up tracking for the bookmark + if (options?.bookmark) { + await runJJ(["bookmark", "track", `${options.bookmark}@${remote}`], cwd); + } + + return result; } diff --git a/packages/core/src/jj/workspace.ts b/packages/core/src/jj/workspace.ts new file mode 100644 index 00000000..9f202a09 --- /dev/null +++ b/packages/core/src/jj/workspace.ts @@ -0,0 +1,404 @@ +import { existsSync, symlinkSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { + ensureRepoWorkspacesDir, + getWorkspacePath as getGlobalWorkspacePath, + getRepoWorkspacesDir, +} from "../daemon/pid"; +import { createError, err, ok, type Result } from "../result"; +import { ensureBookmark } from "./bookmark-create"; +import { parseDiffPaths } from "./diff"; +import { runJJ } from "./runner"; + +/** Special workspace for user edits not yet assigned to an agent */ +export const UNASSIGNED_WORKSPACE = "unassigned"; + +/** Description used for focus merge commits */ +export const FOCUS_COMMIT_DESCRIPTION = "focus"; + +/** Suffix for workspace working copy references (e.g., "agent-a@") */ +export function workspaceRef(name: string): string { + return `${name}@`; +} + +export interface WorkspaceInfo { + name: string; + path: string; + changeId: string; + isStale: boolean; +} + +/** + * Get the path to the workspaces directory for a repo + */ +export function getWorkspacesDir(repoPath: string): string { + return getRepoWorkspacesDir(repoPath); +} + +/** + * Get the path to a specific workspace + */ +export function getWorkspacePath(name: string, repoPath: string): string { + return getGlobalWorkspacePath(repoPath, name); +} + +/** + * Get the trunk change ID for workspace creation. + */ +async function getTrunkChangeId(cwd: string): Promise> { + const result = await runJJ( + ["log", "-r", "trunk()", "--no-graph", "-T", "change_id", "--limit", "1"], + cwd, + ); + if (!result.ok) return result; + return ok(result.value.stdout.trim()); +} + +/** + * Setup workspace links for editor integration: + * - Symlink .git to enable git diffs/gutters + * - Create .jj/.gitignore to ignore jj internals from git + */ +export function setupWorkspaceLinks( + workspacePath: string, + repoPath: string, +): void { + const gitPath = join(repoPath, ".git"); + const workspaceGitPath = join(workspacePath, ".git"); + if (existsSync(gitPath) && !existsSync(workspaceGitPath)) { + symlinkSync(gitPath, workspaceGitPath); + } + + const workspaceJjGitignorePath = join(workspacePath, ".jj", ".gitignore"); + if (!existsSync(workspaceJjGitignorePath)) { + writeFileSync(workspaceJjGitignorePath, "/*\n"); + } +} + +/** + * Create a new jj workspace in ~/.array/workspaces// + */ +export async function addWorkspace( + name: string, + cwd = process.cwd(), +): Promise> { + // Get repo root to calculate paths correctly + const rootResult = await getRepoRoot(cwd); + if (!rootResult.ok) return rootResult; + const repoPath = rootResult.value; + + const workspacePath = getWorkspacePath(name, repoPath); + + // Check if workspace already exists + if (existsSync(workspacePath)) { + return err( + createError("WORKSPACE_EXISTS", `Workspace '${name}' already exists`), + ); + } + + // Ensure the workspaces directory exists + ensureRepoWorkspacesDir(repoPath); + + // Get trunk to create workspace at + const trunkResult = await getTrunkChangeId(cwd); + if (!trunkResult.ok) return trunkResult; + + // Create the workspace at trunk (not current working copy) + const result = await runJJ( + [ + "workspace", + "add", + workspacePath, + "--name", + name, + "-r", + trunkResult.value, + ], + cwd, + ); + if (!result.ok) return result; + + // Setup editor integration links + setupWorkspaceLinks(workspacePath, repoPath); + + // Get the workspace info + const infoResult = await getWorkspaceInfo(name, cwd); + if (!infoResult.ok) return infoResult; + + // Create a bookmark for the workspace so arr exit can find it + await ensureBookmark(name, infoResult.value.changeId, cwd); + + return ok(infoResult.value); +} + +/** + * Remove a workspace (jj workspace forget + rm -rf) + */ +export async function removeWorkspace( + name: string, + cwd = process.cwd(), +): Promise> { + // Get repo root to calculate paths correctly + const rootResult = await getRepoRoot(cwd); + if (!rootResult.ok) return rootResult; + const repoPath = rootResult.value; + + const workspacePath = getWorkspacePath(name, repoPath); + + // Check if workspace exists + if (!existsSync(workspacePath)) { + return err( + createError("WORKSPACE_NOT_FOUND", `Workspace '${name}' not found`), + ); + } + + // Get the workspace's commit before forgetting (so we can abandon it) + const tipResult = await getWorkspaceTip(name, cwd); + const commitToAbandon = tipResult.ok ? tipResult.value : null; + + // Clean up any bookmarks on this workspace's commit BEFORE abandoning + // This prevents "Tracked remote bookmarks exist for deleted bookmark" errors + if (commitToAbandon) { + // Get bookmarks on this commit + const bookmarksResult = await runJJ( + ["log", "-r", workspaceRef(name), "--no-graph", "-T", "bookmarks"], + cwd, + ); + if (bookmarksResult.ok) { + const bookmarks = bookmarksResult.value.stdout + .trim() + .split(/\s+/) + .filter(Boolean); + for (const bookmark of bookmarks) { + // Untrack remote bookmark first (if it exists) + await runJJ(["bookmark", "untrack", `${bookmark}@origin`], cwd); + // Delete the local bookmark + await runJJ(["bookmark", "delete", bookmark], cwd); + } + } + } + + // Forget the workspace in jj + const forgetResult = await runJJ(["workspace", "forget", name], cwd); + if (!forgetResult.ok) return forgetResult; + + // Abandon the workspace's commit (clean up orphaned commits) + if (commitToAbandon) { + await runJJ(["abandon", commitToAbandon], cwd); + } + + // Remove the directory + try { + await rm(workspacePath, { recursive: true, force: true }); + } catch (e) { + return err( + createError( + "COMMAND_FAILED", + `Failed to remove workspace directory: ${e}`, + ), + ); + } + + return ok(undefined); +} + +/** + * List all workspaces managed by arr (in ~/.array/workspaces//) + */ +export async function listWorkspaces( + cwd = process.cwd(), +): Promise> { + // Get repo root to calculate paths correctly + const rootResult = await getRepoRoot(cwd); + if (!rootResult.ok) return rootResult; + const repoPath = rootResult.value; + + // Get list of all jj workspaces + const result = await runJJ(["workspace", "list"], cwd); + if (!result.ok) return result; + + const _workspacesDir = getWorkspacesDir(repoPath); + const workspaces: WorkspaceInfo[] = []; + + // Parse jj workspace list output + // Format: "name: change_id (stale)" or "name: change_id" + const lines = result.value.stdout.trim().split("\n").filter(Boolean); + + for (const line of lines) { + const match = line.match(/^(\S+):\s+(\S+)(?:\s+\(stale\))?/); + if (!match) continue; + + const [, name, changeId] = match; + const isStale = line.includes("(stale)"); + + // Only include workspaces in our managed directory + // The default workspace won't have a path in ~/.array/workspaces// + const expectedPath = getWorkspacePath(name, repoPath); + if (existsSync(expectedPath)) { + workspaces.push({ + name, + path: expectedPath, + changeId, + isStale, + }); + } + } + + return ok(workspaces); +} + +/** + * Get info for a specific workspace + */ +export async function getWorkspaceInfo( + name: string, + cwd = process.cwd(), +): Promise> { + // Get repo root to calculate paths correctly + const rootResult = await getRepoRoot(cwd); + if (!rootResult.ok) return rootResult; + const repoPath = rootResult.value; + + const workspacePath = getWorkspacePath(name, repoPath); + + if (!existsSync(workspacePath)) { + return err( + createError("WORKSPACE_NOT_FOUND", `Workspace '${name}' not found`), + ); + } + + // Get workspace list to find this workspace's info + const listResult = await listWorkspaces(cwd); + if (!listResult.ok) return listResult; + + const workspace = listResult.value.find((ws) => ws.name === name); + if (!workspace) { + return err( + createError("WORKSPACE_NOT_FOUND", `Workspace '${name}' not found in jj`), + ); + } + + return ok(workspace); +} + +/** + * Get the tip change-id for a workspace + */ +export async function getWorkspaceTip( + name: string, + cwd = process.cwd(), +): Promise> { + // Use the workspace@ syntax to get the working copy of that workspace + const result = await runJJ( + ["log", "-r", workspaceRef(name), "--no-graph", "-T", "change_id"], + cwd, + ); + + if (!result.ok) return result; + + const changeId = result.value.stdout.trim(); + if (!changeId) { + return err( + createError( + "WORKSPACE_NOT_FOUND", + `Could not get tip for workspace '${name}'`, + ), + ); + } + + return ok(changeId); +} + +/** + * Trigger a snapshot in a workspace by running jj status + */ +export async function snapshotWorkspace( + workspacePath: string, +): Promise> { + const result = await runJJ(["status", "--quiet"], workspacePath); + if (!result.ok) return result; + return ok(undefined); +} + +/** + * Get the repo root directory from any path within the repo + */ +export async function getRepoRoot( + cwd = process.cwd(), +): Promise> { + const result = await runJJ(["root"], cwd); + if (!result.ok) return result; + return ok(result.value.stdout.trim()); +} + +/** + * Ensure the unassigned workspace exists, creating it on trunk if needed. + * The unassigned workspace holds user edits not yet assigned to any agent. + */ +export async function ensureUnassignedWorkspace( + cwd = process.cwd(), +): Promise> { + const rootResult = await getRepoRoot(cwd); + if (!rootResult.ok) return rootResult; + const repoPath = rootResult.value; + + const workspacePath = getWorkspacePath(UNASSIGNED_WORKSPACE, repoPath); + + // If workspace already exists in jj, return its info + if (existsSync(workspacePath)) { + const info = await getWorkspaceInfo(UNASSIGNED_WORKSPACE, cwd); + if (info.ok) return info; + // Directory exists but jj doesn't know about it - clean up and recreate + await rm(workspacePath, { recursive: true, force: true }); + } + + // Ensure the workspaces directory exists + ensureRepoWorkspacesDir(repoPath); + + // Get trunk revision to create workspace at + const trunkResult = await getTrunkChangeId(cwd); + if (!trunkResult.ok) return trunkResult; + + // Create workspace at trunk + const createResult = await runJJ( + [ + "workspace", + "add", + workspacePath, + "--name", + UNASSIGNED_WORKSPACE, + "-r", + trunkResult.value, + ], + cwd, + ); + if (!createResult.ok) return createResult; + + // Setup editor integration links + setupWorkspaceLinks(workspacePath, repoPath); + + // Get info and create bookmark + const infoResult = await getWorkspaceInfo(UNASSIGNED_WORKSPACE, cwd); + if (!infoResult.ok) return infoResult; + + // Create a bookmark for the workspace so arr exit can find it + await ensureBookmark(UNASSIGNED_WORKSPACE, infoResult.value.changeId, cwd); + + return infoResult; +} + +/** + * Get files modified in the unassigned workspace (vs trunk). + */ +export async function getUnassignedFiles( + cwd = process.cwd(), +): Promise> { + const result = await runJJ( + ["diff", "-r", workspaceRef(UNASSIGNED_WORKSPACE), "--summary"], + cwd, + ); + if (!result.ok) return result; + + return ok(parseDiffPaths(result.value.stdout)); +} diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts index 8c3eb32c..af90cbc2 100644 --- a/packages/core/src/result.ts +++ b/packages/core/src/result.ts @@ -25,7 +25,11 @@ export type JJErrorCode = | "INVALID_REVISION" | "AMBIGUOUS_REVISION" | "INVALID_STATE" + | "INVALID_INPUT" | "WORKSPACE_NOT_FOUND" + | "WORKSPACE_EXISTS" + | "DAEMON_RUNNING" + | "DAEMON_NOT_RUNNING" | "PARSE_ERROR" | "DEPENDENCY_MISSING" | "NAVIGATION_FAILED" @@ -33,6 +37,7 @@ export type JJErrorCode = | "ALREADY_MERGED" | "NOT_FOUND" | "EMPTY_CHANGE" + | "MISSING_MESSAGE" | "CI_FAILED" | "UNKNOWN"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5677bb40..9edfa0dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -461,6 +461,9 @@ importers: '@octokit/rest': specifier: ^22.0.1 version: 22.0.1 + '@parcel/watcher': + specifier: ^2.5.1 + version: 2.5.1 zod: specifier: ^3.24.1 version: 3.25.76