From 8d8aa90bb3844e120cd369c925a83d0bde608d46 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 13 Jan 2026 16:49:02 +0100 Subject: [PATCH 1/8] messy start for workspaces --- apps/cli/src/commands/assign.ts | 125 +++ apps/cli/src/commands/daemon.ts | 72 ++ apps/cli/src/commands/preview.ts | 245 +++++ apps/cli/src/commands/workspace.ts | 141 +++ apps/cli/src/registry.ts | 52 ++ packages/core/package.json | 1 + packages/core/src/commands/assign.ts | 201 +++++ packages/core/src/commands/daemon.ts | 133 +++ packages/core/src/commands/preview-resolve.ts | 169 ++++ packages/core/src/commands/preview.ts | 486 ++++++++++ packages/core/src/commands/workspace-add.ts | 19 + packages/core/src/commands/workspace-list.ts | 16 + .../core/src/commands/workspace-remove.ts | 17 + .../core/src/commands/workspace-status.ts | 158 ++++ .../core/src/commands/workspace-submit.ts | 210 +++++ packages/core/src/daemon/daemon-process.ts | 842 ++++++++++++++++++ packages/core/src/daemon/pid.ts | 242 +++++ packages/core/src/jj/file-ownership.ts | 93 ++ packages/core/src/jj/index.ts | 12 + packages/core/src/jj/workspace.ts | 351 ++++++++ packages/core/src/result.ts | 4 + pnpm-lock.yaml | 3 + 22 files changed, 3592 insertions(+) create mode 100644 apps/cli/src/commands/assign.ts create mode 100644 apps/cli/src/commands/daemon.ts create mode 100644 apps/cli/src/commands/preview.ts create mode 100644 apps/cli/src/commands/workspace.ts create mode 100644 packages/core/src/commands/assign.ts create mode 100644 packages/core/src/commands/daemon.ts create mode 100644 packages/core/src/commands/preview-resolve.ts create mode 100644 packages/core/src/commands/preview.ts create mode 100644 packages/core/src/commands/workspace-add.ts create mode 100644 packages/core/src/commands/workspace-list.ts create mode 100644 packages/core/src/commands/workspace-remove.ts create mode 100644 packages/core/src/commands/workspace-status.ts create mode 100644 packages/core/src/commands/workspace-submit.ts create mode 100644 packages/core/src/daemon/daemon-process.ts create mode 100644 packages/core/src/daemon/pid.ts create mode 100644 packages/core/src/jj/file-ownership.ts create mode 100644 packages/core/src/jj/workspace.ts diff --git a/apps/cli/src/commands/assign.ts b/apps/cli/src/commands/assign.ts new file mode 100644 index 000000000..dd1a1106f --- /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 000000000..b890e6605 --- /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/preview.ts b/apps/cli/src/commands/preview.ts new file mode 100644 index 000000000..46bddd95c --- /dev/null +++ b/apps/cli/src/commands/preview.ts @@ -0,0 +1,245 @@ +import { + type PreviewStatus, + previewAdd, + previewAll, + previewEdit, + previewNone, + previewOnly, + previewRemove, + previewStatus, +} from "@array/core/commands/preview"; +import { listConflicts } from "@array/core/commands/preview-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 displayPreviewStatus(status: PreviewStatus): void { + if (!status.isPreview) { + message(dim("Not in preview mode")); + message(""); + if (status.allWorkspaces.length > 0) { + message( + `Available workspaces: ${status.allWorkspaces.map((ws) => cyan(ws.name)).join(", ")}`, + ); + message(""); + message(`Start preview with: ${cmd("arr preview add ")}`); + message(`Or preview all: ${cmd("arr preview all")}`); + } else { + message(dim("No workspaces available")); + message(`Create one with: ${cmd("arr workspace add ")}`); + } + return; + } + + message(`${green("Preview mode")}`); + message(""); + message(`Previewing: ${status.workspaces.map((ws) => cyan(ws)).join(", ")}`); + + // Show workspaces not in preview + const notInPreview = status.allWorkspaces.filter( + (ws) => !status.workspaces.includes(ws.name), + ); + if (notInPreview.length > 0) { + message( + dim(`Not in preview: ${notInPreview.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 preview resolve")}`); + } +} + +export async function preview( + subcommand: string | undefined, + args: string[], +): Promise { + // No subcommand = show status + if (!subcommand || subcommand === "status") { + const status = unwrap(await previewStatus()); + displayPreviewStatus(status); + return; + } + + switch (subcommand) { + case "add": { + requireArg(args[0], "Usage: arr preview add "); + const result = unwrap(await previewAdd(args)); + message(formatSuccess(`Added ${args.join(", ")} to preview`)); + message(""); + displayPreviewStatus(result); + break; + } + + case "remove": + case "rm": { + requireArg(args[0], "Usage: arr preview remove "); + const result = unwrap(await previewRemove(args)); + message(formatSuccess(`Removed ${args.join(", ")} from preview`)); + message(""); + displayPreviewStatus(result); + break; + } + + case "only": { + requireArg(args[0], "Usage: arr preview only "); + const result = unwrap(await previewOnly(args[0])); + message(formatSuccess(`Now previewing only ${cyan(args[0])}`)); + message(""); + displayPreviewStatus(result); + break; + } + + case "all": { + const result = unwrap(await previewAll()); + message(formatSuccess("Now previewing all workspaces")); + message(""); + displayPreviewStatus(result); + break; + } + + case "edit": { + requireArg(args[0], "Usage: arr preview edit "); + const result = unwrap(await previewEdit(args[0])); + message(formatSuccess(`Editing ${cyan(args[0])} (files are writable)`)); + message(""); + displayPreviewStatus(result); + break; + } + + case "none": + case "exit": { + unwrap(await previewNone()); + message(formatSuccess("Exited preview 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 preview resolve")}`); + break; + } + + case "resolve": { + const conflicts = unwrap(await listConflicts()); + + if (conflicts.length === 0) { + message(green("No conflicts to resolve")); + return; + } + + const removedWorkspaces = new Set(); + let resolved = 0; + let skipped = 0; + + for (const conflict of conflicts) { + // Filter out workspaces that have already been removed + const remainingWorkspaces = conflict.workspaces.filter( + (ws) => !removedWorkspaces.has(ws), + ); + + // If only one workspace remains, no conflict to resolve + if (remainingWorkspaces.length < 2) { + skipped++; + continue; + } + + message(`${yellow("Conflict:")} ${cyan(conflict.file)}`); + message(dim(` Modified by: ${remainingWorkspaces.join(", ")}`)); + message(""); + + const choice = await select( + "Which version do you want to keep in focus?", + remainingWorkspaces.map((ws) => ({ label: ws, value: ws })), + ); + + if (!choice) { + message(dim("Cancelled")); + return; + } + + // Mark non-chosen workspaces as removed + for (const ws of remainingWorkspaces) { + if (ws !== choice) { + removedWorkspaces.add(ws); + } + } + + resolved++; + message(""); + } + + // Actually remove the workspaces from preview + if (removedWorkspaces.size > 0) { + unwrap(await previewRemove([...removedWorkspaces])); + } + + message( + formatSuccess( + `Resolved ${resolved} conflict${resolved === 1 ? "" : "s"}, removed ${[...removedWorkspaces].join(", ")} from preview`, + ), + ); + if (skipped > 0) { + message( + dim( + `Skipped ${skipped} conflict${skipped === 1 ? "" : "s"} (already resolved)`, + ), + ); + } + break; + } + + default: + message( + "Usage: arr preview [add|remove|only|all|edit|none|resolve] [workspace...]", + ); + message(""); + message("Subcommands:"); + message(" (none) Show current preview state"); + message(" add Add workspaces to preview"); + message(" remove Remove workspaces from preview"); + message(" only Preview only this workspace"); + message(" all Preview all workspaces"); + message( + " edit Edit mode (single workspace, files writable)", + ); + message(" none Exit preview mode"); + message(" conflicts List file conflicts"); + message(" resolve Resolve a file conflict interactively"); + } +} diff --git a/apps/cli/src/commands/workspace.ts b/apps/cli/src/commands/workspace.ts new file mode 100644 index 000000000..86afaad8b --- /dev/null +++ b/apps/cli/src/commands/workspace.ts @@ -0,0 +1,141 @@ +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 { 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 "); + + const draft = args.includes("--draft") || args.includes("-d"); + const titleIdx = args.indexOf("--title"); + const tIdx = args.indexOf("-t"); + const titleFlagIdx = titleIdx !== -1 ? titleIdx : tIdx; + const title = titleFlagIdx !== -1 ? args[titleFlagIdx + 1] : undefined; + + const result = unwrap(await submitWorkspace(args[0], { draft, title })); + + if (result.status === "created") { + message(formatSuccess(`Created PR for ${cyan(result.workspace)}`)); + } else { + message(formatSuccess(`Updated PR for ${cyan(result.workspace)}`)); + } + message(` ${dim("PR:")} ${result.prUrl}`); + message(` ${dim("Branch:")} ${result.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 15bf2bb6c..7b4c7af45 100644 --- a/apps/cli/src/registry.ts +++ b/apps/cli/src/registry.ts @@ -22,12 +22,14 @@ 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"; @@ -36,6 +38,7 @@ import { init, meta as initMeta } from "./commands/init"; import { log } from "./commands/log"; import { merge } from "./commands/merge"; import { modify } from "./commands/modify"; +import { preview } from "./commands/preview"; import { resolve } from "./commands/resolve"; import { restack } from "./commands/restack"; import { split } from "./commands/split"; @@ -49,6 +52,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 +90,44 @@ const logMeta: CommandMeta = { core: true, }; +const workspaceMeta: CommandMeta = { + name: "workspace", + args: " [name]", + description: "Manage agent workspaces", + aliases: ["ws"], + category: "management", + core: true, +}; + +const previewMeta: CommandMeta = { + name: "preview", + args: "[add|remove|only|all|none|resolve] [workspace...]", + description: "Manage live preview 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, @@ -116,6 +158,11 @@ export const COMMANDS = { config: configMeta, help: helpMeta, version: versionMeta, + workspace: workspaceMeta, + preview: previewMeta, + daemon: daemonMeta, + assign: assignMeta, + unassigned: unassignedMeta, } as const; export const HANDLERS: Record = { @@ -153,6 +200,11 @@ export const HANDLERS: Record = { undo: () => undo(), exit: () => exit(), ci: () => ci(), + workspace: (p) => workspace(p.args[0], p.args.slice(1)), + preview: (p) => preview(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 5a0fb7444..c5afa586b 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 000000000..07ecbe7eb --- /dev/null +++ b/packages/core/src/commands/assign.ts @@ -0,0 +1,201 @@ +import { runJJ } from "../jj/runner"; +import { + addWorkspace, + getUnassignedFiles, + UNASSIGNED_WORKSPACE, +} 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; +} + +/** + * 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) { + // Convert glob pattern to regex + const regexPattern = pattern + .replace(/\./g, "\\.") + .replace(/\*\*/g, "{{GLOBSTAR}}") + .replace(/\*/g, "[^/]*") + .replace(/{{GLOBSTAR}}/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", + `${UNASSIGNED_WORKSPACE}@`, + "--into", + `${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", + `${UNASSIGNED_WORKSPACE}@`, + "--into", + `${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 000000000..aac64ed9e --- /dev/null +++ b/packages/core/src/commands/daemon.ts @@ -0,0 +1,133 @@ +import { spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + cleanup, + getLogPath, + isRunning, + 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 repos = readRepos(); + const logPath = getLogPath(); + + 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/preview-resolve.ts b/packages/core/src/commands/preview-resolve.ts new file mode 100644 index 000000000..51fb5aa71 --- /dev/null +++ b/packages/core/src/commands/preview-resolve.ts @@ -0,0 +1,169 @@ +import { runJJ } from "../jj/runner"; +import { createError, err, ok, type Result } from "../result"; +import { previewRemove, previewStatus } from "./preview"; +import type { Command } from "./types"; + +export interface FileConflict { + file: string; + workspaces: string[]; +} + +export interface ResolveResult { + file: string; + kept: string; + removed: string[]; +} + +/** + * Get workspaces that have modified a specific file. + */ +async function getWorkspacesForFile( + file: string, + workspaces: string[], + cwd: string, +): Promise { + const result: string[] = []; + for (const ws of workspaces) { + const diff = await runJJ(["diff", "-r", `${ws}@`, "--summary"], cwd); + if (diff.ok && diff.value.stdout.includes(file)) { + result.push(ws); + } + } + return result; +} + +/** + * List all file conflicts in the current preview. + */ +export async function listConflicts( + cwd = process.cwd(), +): Promise> { + const status = await previewStatus(cwd); + if (!status.ok) return status; + + if (!status.value.isPreview) { + return err(createError("INVALID_STATE", "Not in preview 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", `${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 previewStatus(cwd); + if (!status.ok) return status; + + if (!status.value.isPreview) { + return err(createError("INVALID_STATE", "Not in preview 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 preview. + */ +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 preview + const toRemove = conflict.value.workspaces.filter( + (ws) => ws !== keepWorkspace, + ); + + const removeResult = await previewRemove(toRemove, cwd); + if (!removeResult.ok) return removeResult; + + return ok({ + file, + kept: keepWorkspace, + removed: toRemove, + }); +} + +export const listConflictsCommand: Command = { + meta: { + name: "preview conflicts", + description: "List file conflicts in preview", + category: "info", + }, + run: listConflicts, +}; + +export const resolveConflictCommand: Command< + ResolveResult, + [string, string, string?] +> = { + meta: { + name: "preview resolve", + args: " ", + description: "Resolve a file conflict by keeping one workspace", + category: "workflow", + }, + run: resolveConflict, +}; diff --git a/packages/core/src/commands/preview.ts b/packages/core/src/commands/preview.ts new file mode 100644 index 000000000..bb5101f6f --- /dev/null +++ b/packages/core/src/commands/preview.ts @@ -0,0 +1,486 @@ +import { existsSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { registerRepo, unregisterRepo } from "../daemon/pid"; +import { getConflictingFiles } from "../jj/file-ownership"; +import { getTrunk, runJJ } from "../jj/runner"; +import { + ensureUnassignedWorkspace, + getRepoRoot, + getWorkspacePath, + getWorkspaceTip, + listWorkspaces, + snapshotWorkspace, + UNASSIGNED_WORKSPACE, + type WorkspaceInfo, +} from "../jj/workspace"; +import { createError, err, ok, type Result } from "../result"; +import type { Command } from "./types"; + +const PREVIEW_TRAILER_KEY = "Preview-Workspace"; + +export interface ConflictInfo { + file: string; + workspaces: string[]; +} + +export interface PreviewStatus { + isPreview: boolean; + workspaces: string[]; + allWorkspaces: WorkspaceInfo[]; + conflicts: ConflictInfo[]; +} + +/** + * Parse Preview-Workspace trailers from the current commit description + */ +async function getPreviewWorkspaces( + cwd = process.cwd(), +): Promise> { + // Get the description of the current commit + const result = await runJJ( + ["log", "-r", "@", "--no-graph", "-T", "description"], + cwd, + ); + + if (!result.ok) return result; + + const description = result.value.stdout; + const workspaces: string[] = []; + + // Parse trailers (Key: Value format at end of description) + const lines = description.split("\n"); + for (const line of lines) { + const match = line.match(new RegExp(`^${PREVIEW_TRAILER_KEY}:\\s*(.+)$`)); + if (match) { + workspaces.push(match[1].trim()); + } + } + + return ok(workspaces); +} + +/** + * Build a description with preview trailers + */ +function buildPreviewDescription(workspaces: string[]): string { + if (workspaces.length === 0) return ""; + + const trailers = workspaces + .map((ws) => `${PREVIEW_TRAILER_KEY}: ${ws}`) + .join("\n"); + + return `preview\n\n${trailers}`; +} + +/** + * 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 updatePreview( + workspaces: string[], + cwd = process.cwd(), +): Promise> { + // 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 current commit is a preview (has trailers) + const currentWorkspaces = await getPreviewWorkspaces(cwd); + const isCurrentPreview = + currentWorkspaces.ok && currentWorkspaces.value.length > 0; + + if (workspaces.length === 0) { + // Exit preview mode - go back to trunk + const trunk = await getTrunk(cwd); + const result = await runJJ(["new", trunk], cwd); + if (!result.ok) return result; + + // Abandon old preview commit + if (isCurrentPreview && oldCommitId) { + await runJJ(["abandon", oldCommitId], cwd); + } + + // Unregister repo from daemon + unregisterRepo(cwd); + + return ok(""); + } + + // Get repo root for workspace paths + const rootResult = await getRepoRoot(cwd); + if (!rootResult.ok) return rootResult; + const repoPath = rootResult.value; + + // 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 gitPath = join(repoPath, ".git"); + 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 workspaces) { + const wsPath = getWorkspacePath(ws, repoPath); + + // Ensure .git symlink exists for editor integration + const workspaceGitPath = join(wsPath, ".git"); + if (existsSync(gitPath) && !existsSync(workspaceGitPath)) { + symlinkSync(gitPath, workspaceGitPath); + } + + // Create .jj/.gitignore to ignore jj internals + const workspaceJjGitignorePath = join(wsPath, ".jj", ".gitignore"); + if (!existsSync(workspaceJjGitignorePath)) { + writeFileSync(workspaceJjGitignorePath, "/*\n"); + } + + 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); + } + + // Build the description with trailers + const description = buildPreviewDescription(workspaces); + + // Create the merge commit + // jj new ... -m "" + const newArgs = ["new", ...changeIds, "-m", description]; + const result = await runJJ(newArgs, cwd); + + if (!result.ok) return result; + + // Abandon old preview commit (now that we've moved away from it) + if (isCurrentPreview && 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; + + // Register repo with daemon for file watching + registerRepo(cwd, workspaces); + + 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]; + }); +} + +/** + * Check which workspaces modified a given file + */ +async function getWorkspacesForFile( + file: string, + workspaces: string[], + cwd: string, +): Promise { + const result: string[] = []; + for (const ws of workspaces) { + const diff = await runJJ(["diff", "-r", `${ws}@`, "--summary"], cwd); + if (diff.ok && diff.value.stdout.includes(file)) { + result.push(ws); + } + } + return result; +} + +/** + * Show current preview state + */ +export async function previewStatus( + cwd = process.cwd(), +): Promise> { + const [previewWorkspaces, allWorkspaces] = await Promise.all([ + getPreviewWorkspaces(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({ + isPreview: 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 previewAdd( + workspaces: string[], + cwd = process.cwd(), +): Promise> { + // Get current preview workspaces + const currentResult = await getPreviewWorkspaces(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 updatePreview(allWorkspaces, cwd); + if (!updateResult.ok) return updateResult; + + return previewStatus(cwd); +} + +/** + * Remove workspaces from preview + */ +export async function previewRemove( + workspaces: string[], + cwd = process.cwd(), +): Promise> { + // Get current preview workspaces + const currentResult = await getPreviewWorkspaces(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 updatePreview(remaining, cwd); + if (!updateResult.ok) return updateResult; + + return previewStatus(cwd); +} + +/** + * Preview only the specified workspace (exclude all others) + */ +export async function previewOnly( + workspace: string, + cwd = process.cwd(), +): Promise> { + const updateResult = await updatePreview([workspace], cwd); + if (!updateResult.ok) return updateResult; + + return previewStatus(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 previewAll( + cwd = process.cwd(), +): Promise> { + // Get all workspaces + const allResult = await listWorkspaces(cwd); + if (!allResult.ok) return allResult; + + const workspaceNames = allResult.value.map((ws) => ws.name); + + 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 updatePreview(workspaceNames, cwd); + if (!updateResult.ok) return updateResult; + + return previewStatus(cwd); +} + +/** + * Exit preview mode (back to trunk) + */ +export async function previewNone(cwd = process.cwd()): Promise> { + const updateResult = await updatePreview([], cwd); + if (!updateResult.ok) return updateResult; + return ok(undefined); +} + +/** + * Enter edit mode for a single workspace. + * + * With intelligent edit routing, this is equivalent to `previewOnly` - + * files are always writable, and edits are routed to the single workspace. + */ +export async function previewEdit( + workspace: string, + cwd = process.cwd(), +): Promise> { + // Single-workspace preview = edit mode (all edits go to this workspace) + const updateResult = await updatePreview([workspace], cwd); + if (!updateResult.ok) return updateResult; + + return previewStatus(cwd); +} + +// Command exports +export const previewStatusCommand: Command = { + meta: { + name: "preview", + description: "Show current preview state", + category: "workflow", + core: true, + }, + run: previewStatus, +}; + +export const previewAddCommand: Command = { + meta: { + name: "preview add", + args: "", + description: "Add workspaces to preview", + category: "workflow", + }, + run: previewAdd, +}; + +export const previewRemoveCommand: Command = + { + meta: { + name: "preview remove", + args: "", + description: "Remove workspaces from preview", + category: "workflow", + }, + run: previewRemove, + }; + +export const previewOnlyCommand: Command = { + meta: { + name: "preview only", + args: "", + description: "Preview only this workspace", + category: "workflow", + }, + run: previewOnly, +}; + +export const previewAllCommand: Command = { + meta: { + name: "preview all", + description: "Include all workspaces in preview", + category: "workflow", + }, + run: previewAll, +}; + +export const previewNoneCommand: Command = { + meta: { + name: "preview none", + description: "Exit preview mode", + category: "workflow", + }, + run: previewNone, +}; + +export const previewEditCommand: Command = { + meta: { + name: "preview edit", + args: "", + description: "Enter edit mode for a workspace (single-preview, writable)", + category: "workflow", + }, + run: previewEdit, +}; diff --git a/packages/core/src/commands/workspace-add.ts b/packages/core/src/commands/workspace-add.ts new file mode 100644 index 000000000..cbf5de99a --- /dev/null +++ b/packages/core/src/commands/workspace-add.ts @@ -0,0 +1,19 @@ +import { addWorkspace, type WorkspaceInfo } from "../jj/workspace"; +import type { Result } from "../result"; +import type { Command } from "./types"; + +export async function workspaceAdd( + name: string, +): Promise> { + return addWorkspace(name); +} + +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 000000000..41f9babcd --- /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 000000000..8228405d2 --- /dev/null +++ b/packages/core/src/commands/workspace-remove.ts @@ -0,0 +1,17 @@ +import { removeWorkspace } from "../jj/workspace"; +import type { Result } from "../result"; +import type { Command } from "./types"; + +export async function workspaceRemove(name: string): Promise> { + return removeWorkspace(name); +} + +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 000000000..1c7e99067 --- /dev/null +++ b/packages/core/src/commands/workspace-status.ts @@ -0,0 +1,158 @@ +import { runJJ } from "../jj/runner"; +import { listWorkspaces } 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; +} + +/** + * Parse jj diff --summary output into FileChange array. + */ +function parseDiffSummary(output: string): FileChange[] { + const changes: FileChange[] = []; + + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const match = trimmed.match(/^([MADR])\s+(.+)$/); + if (match) { + changes.push({ + status: match[1] as FileChange["status"], + path: match[2].trim(), + }); + } + } + + return changes; +} + +/** + * 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", `${workspaceName}@`, "--summary"], + cwd, + ); + if (!summaryResult.ok) return summaryResult; + + const changes = parseDiffSummary(summaryResult.value.stdout); + + // Get diff stats + const statResult = await runJJ( + ["diff", "-r", `${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 000000000..582bb213a --- /dev/null +++ b/packages/core/src/commands/workspace-submit.ts @@ -0,0 +1,210 @@ +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, +} 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; + title?: string; +} + +/** + * Get the description of a workspace's commit for PR title. + */ +async function getWorkspaceDescription( + workspace: string, + cwd = process.cwd(), +): Promise { + const result = await runJJ( + ["log", "-r", `${workspace}@`, "--no-graph", "-T", "description"], + cwd, + ); + if (!result.ok) return workspace; + + const desc = result.value.stdout.trim(); + return desc || workspace; +} + +/** + * 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", `${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 or generate bookmark name + // First check if there's already a bookmark on this change + const bookmarkResult = await runJJ( + ["log", "-r", `${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 + const description = await getWorkspaceDescription(workspace, cwd); + bookmark = datePrefixedLabel(description, new Date()); + + // Create the bookmark + const createResult = await runJJ( + ["bookmark", "create", bookmark, "-r", `${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(); + const title = + options.title || (await getWorkspaceDescription(workspace, cwd)); + + 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, + 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: "title", + short: "t", + description: "PR title (defaults to commit description)", + }, + ], + }, + 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 000000000..4b0a4b2df --- /dev/null +++ b/packages/core/src/daemon/daemon-process.ts @@ -0,0 +1,842 @@ +#!/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 preview + * + * 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 { + cleanup, + getReposPath, + getWorkspacePath, + log, + type RepoEntry, + readRepos, + writePid, +} from "./pid"; + +const JJ_TIMEOUT_MS = 10000; +const MAX_RETRIES = 10; +const RETRY_DELAY_MS = 20; +const PREVIEW_DEBOUNCE_MS = 500; + +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 { + // No .gitignore + } + 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); + 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 { + // Ignore errors for individual files + } + } + } +} + +/** Active subscriptions: "repoPath:wsName" → subscription */ +const subscriptions: Map = new Map(); + +/** Preview subscriptions: repoPath → subscription (watches main repo for edits) */ +const previewSubscriptions: Map = new Map(); + +/** Workspaces currently syncing */ +const syncingWorkspaces: Set = new Set(); + +/** Repos currently syncing to preview (lock to prevent loops) */ +const syncingToPreview: Set = new Set(); + +/** Workspaces that changed during sync (need re-sync) */ +const dirtyWorkspaces: Set = new Set(); + +/** Preview repos with dirty edits that need routing */ +const dirtyPreviews: Set = new Set(); + +function wsKey(repoPath: string, wsName: string): string { + return `${repoPath}:${wsName}`; +} + +/** + * Snapshot a workspace and update preview. + */ +async function snapshotAndSync( + repoPath: string, + wsName: string, + wsPath: string, +): Promise { + const key = wsKey(repoPath, wsName); + const t0 = performance.now(); + + // Mark as syncing + syncingWorkspaces.add(key); + dirtyWorkspaces.delete(key); + + log(`[${key}] Starting sync`); + + const finishSync = () => { + syncingWorkspaces.delete(key); + + // If workspace was marked dirty during sync, sync again + if (dirtyWorkspaces.has(key)) { + log(`[${key}] Changes during sync, re-syncing`); + dirtyWorkspaces.delete(key); + snapshotAndSync(repoPath, wsName, wsPath); + } + }; + + // Get registered workspaces for this repo + const repo = currentRepos.find((r) => r.path === repoPath); + if (!repo || repo.workspaces.length === 0) { + log(`[${key}] No registered workspaces, skipping`); + finishSync(); + 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`); + finishSync(); + return; + } + const t2 = performance.now(); + log(`[${key}] Snapshot complete (${(t2 - t1).toFixed(0)}ms)`); + + // Step 2: Rebase preview commit onto updated workspace tips + // jj rebase -r @ -d ws1@ -d ws2@ ... + // Set syncingToPreview to prevent the preview watcher from routing these changes back + syncingToPreview.add(repoPath); + + // First, discard any uncommitted changes in preview working copy + // This prevents jj from auto-snapshotting them into a new commit during rebase + const t3a = performance.now(); + await runJJ(["restore"], repoPath); + const t3b = performance.now(); + log(`[${key}] Restored preview working copy (${(t3b - t3a).toFixed(0)}ms)`); + + const destinations = repo.workspaces.flatMap((ws) => ["-d", `${ws}@`]); + const t3 = performance.now(); + const rebaseResult = await runJJ( + ["rebase", "-r", "@", ...destinations], + repoPath, + ); + if (rebaseResult === null) { + log(`[${key}] Rebase failed`); + syncingToPreview.delete(repoPath); + finishSync(); + return; + } + const t4 = performance.now(); + log(`[${key}] Rebase complete (${(t4 - t3).toFixed(0)}ms)`); + + // Step 3: Rewrite files in-place to trigger VSCode's file watcher + // (jj rebase replaces files with new inodes, VSCode watches old inodes) + const t5 = performance.now(); + await rewriteFilesInPlace(repoPath); + const t6 = performance.now(); + log(`[${key}] Rewrote files in-place (${(t6 - t5).toFixed(0)}ms)`); + + // Clear syncingToPreview after a delay to let watchers settle + // Use a longer delay than the debounce timer to ensure preview watcher ignores our changes + setTimeout(() => { + syncingToPreview.delete(repoPath); + }, PREVIEW_DEBOUNCE_MS + 200); + + log( + `[${key}] Sync complete (total: ${(performance.now() - t0).toFixed(0)}ms)`, + ); + finishSync(); +} + +function triggerSync(repoPath: string, wsName: string, wsPath: string): void { + const key = wsKey(repoPath, wsName); + + // If already syncing, mark dirty for re-sync after + if (syncingWorkspaces.has(key)) { + dirtyWorkspaces.add(key); + log(`[${key}] Sync in progress, marked dirty`); + return; + } + + // Start sync immediately + snapshotAndSync(repoPath, wsName, wsPath); +} + +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 + 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 {} + } + + 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 +// ============================================================================ + +const UNASSIGNED_WORKSPACE = "unassigned"; + +/** + * Parse jj diff --summary output to extract file paths. + */ +function parseDiffSummary(output: string): string[] { + const files: string[] = []; + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const simpleMatch = trimmed.match(/^[MAD]\s+(.+)$/); + if (simpleMatch) { + files.push(simpleMatch[1].trim()); + continue; + } + + const renameMatch = trimmed.match(/^R\s+\{(.+)\s+=>\s+(.+)\}$/); + if (renameMatch) { + files.push(renameMatch[1].trim()); + files.push(renameMatch[2].trim()); + } + } + return files; +} + +/** + * 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", `${ws}@`, "--summary"], cwd); + if (result === null) continue; + + const files = parseDiffSummary(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 preview 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(`[preview:${repoPath}] Failed to copy ${file}: ${err}`); + } + } + + return true; +} + +/** + * Route preview 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, checked at preview add) + */ +async function routePreviewEdits(repoPath: string): Promise { + const t0 = performance.now(); + log(`[preview:${repoPath}] Starting edit routing`); + + const repo = currentRepos.find((r) => r.path === repoPath); + if (!repo || repo.workspaces.length === 0) { + log(`[preview:${repoPath}] No registered workspaces, skipping`); + return; + } + + // Single workspace mode: all edits go directly to that workspace + if (repo.workspaces.length === 1) { + const ws = repo.workspaces[0]; + // Get files changed in preview working copy (not committed) + const diffResult = await runJJ(["diff", "--summary"], repoPath); + if (diffResult) { + const files = parseDiffSummary(diffResult); + if (files.length > 0) { + const success = await copyFilesToWorkspace(files, ws, repoPath); + if (success) { + log(`[preview:${repoPath}] Routed ${files.length} file(s) to ${ws}`); + } + } + } + log( + `[preview:${repoPath}] Edit routing complete (${(performance.now() - t0).toFixed(0)}ms)`, + ); + return; + } + + // Multi-workspace mode: route based on ownership + // Get files changed in preview working copy (not committed) + const diffResult = await runJJ(["diff", "--summary"], repoPath); + if (!diffResult) { + log(`[preview:${repoPath}] No changes to route`); + return; + } + + const changedFiles = parseDiffSummary(diffResult); + if (changedFiles.length === 0) { + log(`[preview:${repoPath}] No tracked files changed`); + return; + } + + // Build ownership map for current workspaces + const ownership = await buildOwnershipMap(repo.workspaces, repoPath); + + // Group files by target workspace + const toRoute = new Map(); + + for (const file of changedFiles) { + const owners = ownership.get(file) || []; + + if (owners.length === 0) { + // Not owned by any agent → unassigned + const files = toRoute.get(UNASSIGNED_WORKSPACE) || []; + files.push(file); + toRoute.set(UNASSIGNED_WORKSPACE, files); + } else if (owners.length === 1) { + // Owned by exactly one agent → route to that agent + const files = toRoute.get(owners[0]) || []; + files.push(file); + toRoute.set(owners[0], files); + } else { + // Multiple owners → conflict, skip (shouldn't happen) + log( + `[preview:${repoPath}] WARNING: ${file} has multiple owners: ${owners.join(", ")}`, + ); + } + } + + // Copy files to their target workspaces + for (const [target, files] of toRoute) { + const success = await copyFilesToWorkspace(files, target, repoPath); + if (success) { + log(`[preview:${repoPath}] Routed ${files.length} file(s) to ${target}`); + } else { + log(`[preview:${repoPath}] Failed to route files to ${target}`); + } + } + + log( + `[preview:${repoPath}] Edit routing complete (${(performance.now() - t0).toFixed(0)}ms)`, + ); +} + +/** + * Trigger preview edit routing (with dirty flag handling). + */ +function triggerPreviewRoute(repoPath: string): void { + // If we're currently syncing TO preview, ignore these events (they're from us) + if (syncingToPreview.has(repoPath)) { + return; + } + + // If already routing, mark dirty + if (dirtyPreviews.has(repoPath)) { + return; + } + + dirtyPreviews.add(repoPath); + routePreviewEditsDebounced(repoPath); +} + +/** + * Debounced version of routePreviewEdits. + * Waits for file activity to settle before routing. + */ +const previewDebounceTimers: Map< + string, + ReturnType +> = new Map(); + +function routePreviewEditsDebounced(repoPath: string): void { + const existing = previewDebounceTimers.get(repoPath); + if (existing) { + clearTimeout(existing); + } + + const timer = setTimeout(async () => { + previewDebounceTimers.delete(repoPath); + dirtyPreviews.delete(repoPath); + await routePreviewEdits(repoPath); + }, PREVIEW_DEBOUNCE_MS); + + previewDebounceTimers.set(repoPath, timer); +} + +/** + * Watch the main repo for user edits (bidirectional sync). + */ +async function watchPreview(repoPath: string): Promise { + if (previewSubscriptions.has(repoPath)) { + return; + } + + if (!existsSync(repoPath)) { + log(`[preview:${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(`[preview:${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( + `[preview:${repoPath}] ${relevantEvents.length} file change(s) detected`, + ); + triggerPreviewRoute(repoPath); + }); + + previewSubscriptions.set(repoPath, subscription); + log(`[preview:${repoPath}] Preview watcher started`); + } catch (err) { + log(`[preview:${repoPath}] Failed to start preview watcher: ${err}`); + } +} + +/** + * Stop watching the main repo for edits. + */ +async function unwatchPreview(repoPath: string): Promise { + const subscription = previewSubscriptions.get(repoPath); + if (subscription) { + await subscription.unsubscribe(); + previewSubscriptions.delete(repoPath); + log(`[preview:${repoPath}] Preview watcher stopped`); + } + + // Clear any pending debounce timers + const timer = previewDebounceTimers.get(repoPath); + if (timer) { + clearTimeout(timer); + previewDebounceTimers.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) + 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 preview watcher + await unwatchPreview(repoPath); +} + +/** Currently watched repos (for diffing on reload) */ +let currentRepos: RepoEntry[] = []; + +async function reloadRepos(): Promise { + const newRepos = readRepos(); + + // 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 previewSubscriptions) { + await subscription.unsubscribe(); + log(`[preview:${repoPath}] Preview watcher stopped`); + } + previewSubscriptions.clear(); + + // Clear any pending debounce timers + for (const timer of previewDebounceTimers.values()) { + clearTimeout(timer); + } + previewDebounceTimers.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 000000000..d6cc9d55c --- /dev/null +++ b/packages/core/src/daemon/pid.ts @@ -0,0 +1,242 @@ +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[]; +} + +/** + * 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 + */ +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(", ")}]`, + ); +} + +/** + * 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}`); +} + +/** + * 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/jj/file-ownership.ts b/packages/core/src/jj/file-ownership.ts new file mode 100644 index 000000000..9ab46dee7 --- /dev/null +++ b/packages/core/src/jj/file-ownership.ts @@ -0,0 +1,93 @@ +import { ok, type Result } from "../result"; +import { runJJ } from "./runner"; + +export interface FileOwnershipMap { + ownership: Map; + getOwners(file: string): string[]; + hasConflict(file: string): boolean; +} + +/** + * Parse jj diff --summary output to extract file paths. + * Handles: M (modified), A (added), D (deleted), R (renamed) + * Rename format: R {old => new} + */ +function parseDiffSummary(output: string): string[] { + const files: string[] = []; + + 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) { + files.push(simpleMatch[1].trim()); + continue; + } + + // Match: R {old => new} + const renameMatch = trimmed.match(/^R\s+\{(.+)\s+=>\s+(.+)\}$/); + if (renameMatch) { + files.push(renameMatch[1].trim()); + files.push(renameMatch[2].trim()); + } + } + + return files; +} + +/** + * 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", `${ws}@`, "--summary"], cwd); + if (!result.ok) continue; + + const files = parseDiffSummary(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); +} diff --git a/packages/core/src/jj/index.ts b/packages/core/src/jj/index.ts index 9ac5b5f2f..57804b2ef 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/workspace.ts b/packages/core/src/jj/workspace.ts new file mode 100644 index 000000000..9cb53cd88 --- /dev/null +++ b/packages/core/src/jj/workspace.ts @@ -0,0 +1,351 @@ +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 { runJJ } from "./runner"; + +/** Special workspace for user edits not yet assigned to an agent */ +export const UNASSIGNED_WORKSPACE = "unassigned"; + +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); +} + +/** + * 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); + + // Create the workspace using jj + // jj workspace add --name + const result = await runJJ( + ["workspace", "add", workspacePath, "--name", name], + cwd, + ); + + if (!result.ok) return result; + + // Symlink .git to enable editor git integration (diffs, gutters) + const gitPath = join(repoPath, ".git"); + const workspaceGitPath = join(workspacePath, ".git"); + if (existsSync(gitPath) && !existsSync(workspaceGitPath)) { + symlinkSync(gitPath, workspaceGitPath); + } + + // Create .jj/.gitignore to ignore jj internals + const workspaceJjGitignorePath = join(workspacePath, ".jj", ".gitignore"); + if (!existsSync(workspaceJjGitignorePath)) { + writeFileSync(workspaceJjGitignorePath, "/*\n"); + } + + // Get the workspace info + const infoResult = await getWorkspaceInfo(name, cwd); + if (!infoResult.ok) return infoResult; + + 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`), + ); + } + + // Forget the workspace in jj + const forgetResult = await runJJ(["workspace", "forget", name], cwd); + if (!forgetResult.ok) return forgetResult; + + // 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", `${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, return its info + if (existsSync(workspacePath)) { + return getWorkspaceInfo(UNASSIGNED_WORKSPACE, cwd); + } + + // Ensure the workspaces directory exists + ensureRepoWorkspacesDir(repoPath); + + // Get trunk revision to create workspace at + const trunkResult = await runJJ( + ["log", "-r", "trunk()", "--no-graph", "-T", "change_id", "--limit", "1"], + cwd, + ); + if (!trunkResult.ok) return trunkResult; + const trunkChangeId = trunkResult.value.stdout.trim(); + + // Create workspace at trunk + // jj workspace add --name -r + const createResult = await runJJ( + [ + "workspace", + "add", + workspacePath, + "--name", + UNASSIGNED_WORKSPACE, + "-r", + trunkChangeId, + ], + cwd, + ); + if (!createResult.ok) return createResult; + + // Symlink .git for editor integration + const gitPath = join(repoPath, ".git"); + const workspaceGitPath = join(workspacePath, ".git"); + if (existsSync(gitPath) && !existsSync(workspaceGitPath)) { + symlinkSync(gitPath, workspaceGitPath); + } + + // Create .jj/.gitignore to ignore jj internals + const workspaceJjGitignorePath = join(workspacePath, ".jj", ".gitignore"); + if (!existsSync(workspaceJjGitignorePath)) { + writeFileSync(workspaceJjGitignorePath, "/*\n"); + } + + return getWorkspaceInfo(UNASSIGNED_WORKSPACE, cwd); +} + +/** + * Get files modified in the unassigned workspace (vs trunk). + */ +export async function getUnassignedFiles( + cwd = process.cwd(), +): Promise> { + const result = await runJJ( + ["diff", "-r", `${UNASSIGNED_WORKSPACE}@`, "--summary"], + cwd, + ); + if (!result.ok) return result; + + const files: string[] = []; + for (const line of result.value.stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Match: M path, A path, D path + const simpleMatch = trimmed.match(/^[MAD]\s+(.+)$/); + if (simpleMatch) { + files.push(simpleMatch[1].trim()); + continue; + } + + // Match: R {old => new} + const renameMatch = trimmed.match(/^R\s+\{(.+)\s+=>\s+(.+)\}$/); + if (renameMatch) { + files.push(renameMatch[2].trim()); // Only the new name matters for listing + } + } + + return ok(files); +} diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts index 8c3eb32ca..9e1d2d3e3 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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6358623db..3a99bca30 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 From 244572a183fd1cfdee522dbd10f09d25fe17309a Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 13 Jan 2026 17:12:07 +0100 Subject: [PATCH 2/8] submit bug fixes --- apps/cli/src/commands/exit.ts | 25 ++++++++-- apps/cli/src/commands/workspace.ts | 36 ++++++++++----- .../core/src/commands/workspace-submit.ts | 46 +++++++++++++------ packages/core/src/jj/push.ts | 18 ++++---- packages/core/src/result.ts | 1 + 5 files changed, 90 insertions(+), 36 deletions(-) diff --git a/apps/cli/src/commands/exit.ts b/apps/cli/src/commands/exit.ts index 8b27fa6b9..54ba1de24 100644 --- a/apps/cli/src/commands/exit.ts +++ b/apps/cli/src/commands/exit.ts @@ -1,23 +1,40 @@ +import { previewNone, previewStatus } from "@array/core/commands/preview"; 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 { + blank, + cyan, + formatSuccess, + green, + hint, + message, +} from "../utils/output"; export const meta: CommandMeta = { name: "exit", - description: "Exit to plain git on trunk (escape hatch if you need git)", + description: "Exit preview mode, or exit to plain git if not previewing", context: "jj", category: "management", }; export async function exit(): Promise { + // Check if we're in preview mode + const status = await previewStatus(); + if (status.ok && status.value.isPreview) { + // Exit preview mode + coreUnwrap(await previewNone()); + message(formatSuccess("Exited preview mode")); + return; + } + + // Not in preview - exit to git const trunk = await getTrunk(); const result = coreUnwrap(await exitToGit(process.cwd(), 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 any arr command to return to jj."); } diff --git a/apps/cli/src/commands/workspace.ts b/apps/cli/src/commands/workspace.ts index 86afaad8b..a0eb68084 100644 --- a/apps/cli/src/commands/workspace.ts +++ b/apps/cli/src/commands/workspace.ts @@ -12,6 +12,7 @@ import { red, yellow, } from "../utils/output"; +import { textInput } from "../utils/prompt"; import { requireArg, unwrap } from "../utils/run"; function formatStatusChar(status: "M" | "A" | "D" | "R"): string { @@ -108,23 +109,36 @@ export async function workspace( } case "submit": { - requireArg(args[0], "Usage: arr workspace submit "); + requireArg(args[0], "Usage: arr workspace submit [-m ]"); const draft = args.includes("--draft") || args.includes("-d"); - const titleIdx = args.indexOf("--title"); - const tIdx = args.indexOf("-t"); - const titleFlagIdx = titleIdx !== -1 ? titleIdx : tIdx; - const title = titleFlagIdx !== -1 ? args[titleFlagIdx + 1] : undefined; + 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 result = unwrap(await submitWorkspace(args[0], { draft, title })); + const value = unwrap(result); - if (result.status === "created") { - message(formatSuccess(`Created PR for ${cyan(result.workspace)}`)); + if (value.status === "created") { + message(formatSuccess(`Created PR for ${cyan(value.workspace)}`)); } else { - message(formatSuccess(`Updated PR for ${cyan(result.workspace)}`)); + message(formatSuccess(`Updated PR for ${cyan(value.workspace)}`)); } - message(` ${dim("PR:")} ${result.prUrl}`); - message(` ${dim("Branch:")} ${result.bookmark}`); + message(` ${dim("PR:")} ${value.prUrl}`); + message(` ${dim("Branch:")} ${value.bookmark}`); break; } diff --git a/packages/core/src/commands/workspace-submit.ts b/packages/core/src/commands/workspace-submit.ts index 582bb213a..a9c0a6476 100644 --- a/packages/core/src/commands/workspace-submit.ts +++ b/packages/core/src/commands/workspace-submit.ts @@ -21,11 +21,12 @@ export interface WorkspaceSubmitResult { interface SubmitOptions { draft?: boolean; - title?: string; + message?: string; } /** - * Get the description of a workspace's commit for PR title. + * Get the description of a workspace's commit. + * Returns empty string if no description. */ async function getWorkspaceDescription( workspace: string, @@ -35,10 +36,9 @@ async function getWorkspaceDescription( ["log", "-r", `${workspace}@`, "--no-graph", "-T", "description"], cwd, ); - if (!result.ok) return workspace; + if (!result.ok) return ""; - const desc = result.value.stdout.trim(); - return desc || workspace; + return result.value.stdout.trim(); } /** @@ -92,6 +92,29 @@ export async function submitWorkspace( ); } + // 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", `${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( @@ -107,8 +130,7 @@ export async function submitWorkspace( if (existingBookmarks.length > 0) { bookmark = existingBookmarks[0]; } else { - // Generate a new bookmark name - const description = await getWorkspaceDescription(workspace, cwd); + // Generate a new bookmark name from description bookmark = datePrefixedLabel(description, new Date()); // Create the bookmark @@ -133,8 +155,6 @@ export async function submitWorkspace( // Check if PR already exists const existingPR = await getPRForBranch(bookmark, cwd); const trunk = await getTrunk(); - const title = - options.title || (await getWorkspaceDescription(workspace, cwd)); if (existingPR.ok && existingPR.value) { // Update existing PR @@ -162,7 +182,7 @@ export async function submitWorkspace( // Create new PR const prResult = await createPR({ head: bookmark, - title, + title: message, base: trunk, draft: options.draft, }); @@ -200,9 +220,9 @@ export const workspaceSubmitCommand: Command< flags: [ { name: "draft", short: "d", description: "Create PR as draft" }, { - name: "title", - short: "t", - description: "PR title (defaults to commit description)", + name: "message", + short: "m", + description: "Commit message / PR title", }, ], }, diff --git a/packages/core/src/jj/push.ts b/packages/core/src/jj/push.ts index 19afd0f52..16bb0fcd4 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/result.ts b/packages/core/src/result.ts index 9e1d2d3e3..af90cbc25 100644 --- a/packages/core/src/result.ts +++ b/packages/core/src/result.ts @@ -37,6 +37,7 @@ export type JJErrorCode = | "ALREADY_MERGED" | "NOT_FOUND" | "EMPTY_CHANGE" + | "MISSING_MESSAGE" | "CI_FAILED" | "UNKNOWN"; From 85e744179c4256a55be5770b92a59676700f7a0c Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 13 Jan 2026 18:25:37 +0100 Subject: [PATCH 3/8] more fixes --- apps/cli/src/commands/exit.ts | 16 +- .../cli/src/commands/{preview.ts => focus.ts} | 114 +++++++------ apps/cli/src/registry.ts | 12 +- .../{preview-resolve.ts => focus-resolve.ts} | 26 +-- .../src/commands/{preview.ts => focus.ts} | 154 +++++++++--------- packages/core/src/jj/workspace.ts | 22 ++- 6 files changed, 181 insertions(+), 163 deletions(-) rename apps/cli/src/commands/{preview.ts => focus.ts} (60%) rename packages/core/src/commands/{preview-resolve.ts => focus-resolve.ts} (85%) rename packages/core/src/commands/{preview.ts => focus.ts} (76%) diff --git a/apps/cli/src/commands/exit.ts b/apps/cli/src/commands/exit.ts index 54ba1de24..a816c2ba3 100644 --- a/apps/cli/src/commands/exit.ts +++ b/apps/cli/src/commands/exit.ts @@ -1,4 +1,4 @@ -import { previewNone, previewStatus } from "@array/core/commands/preview"; +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"; @@ -14,18 +14,18 @@ import { export const meta: CommandMeta = { name: "exit", - description: "Exit preview mode, or exit to plain git if not previewing", + description: "Exit focus mode, or exit to plain git if not previewing", context: "jj", category: "management", }; export async function exit(): Promise { - // Check if we're in preview mode - const status = await previewStatus(); - if (status.ok && status.value.isPreview) { - // Exit preview mode - coreUnwrap(await previewNone()); - message(formatSuccess("Exited preview mode")); + // Check if we're in focus mode + const status = await focusStatus(); + if (status.ok && status.value.isFocused) { + // Exit focus mode + coreUnwrap(await focusNone()); + message(formatSuccess("Exited focus mode")); return; } diff --git a/apps/cli/src/commands/preview.ts b/apps/cli/src/commands/focus.ts similarity index 60% rename from apps/cli/src/commands/preview.ts rename to apps/cli/src/commands/focus.ts index 46bddd95c..e0b6e3d88 100644 --- a/apps/cli/src/commands/preview.ts +++ b/apps/cli/src/commands/focus.ts @@ -1,14 +1,14 @@ import { - type PreviewStatus, - previewAdd, - previewAll, - previewEdit, - previewNone, - previewOnly, - previewRemove, - previewStatus, -} from "@array/core/commands/preview"; -import { listConflicts } from "@array/core/commands/preview-resolve"; + type FocusStatus, + focusAdd, + focusAll, + focusEdit, + focusNone, + focusOnly, + focusRemove, + focusStatus, +} from "@array/core/commands/focus"; +import { listConflicts } from "@array/core/commands/focus-resolve"; import { cmd, cyan, @@ -22,17 +22,17 @@ import { import { select } from "../utils/prompt"; import { requireArg, unwrap } from "../utils/run"; -function displayPreviewStatus(status: PreviewStatus): void { - if (!status.isPreview) { - message(dim("Not in preview mode")); +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 preview with: ${cmd("arr preview add ")}`); - message(`Or preview all: ${cmd("arr preview all")}`); + 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 ")}`); @@ -40,18 +40,16 @@ function displayPreviewStatus(status: PreviewStatus): void { return; } - message(`${green("Preview mode")}`); + message(`${green("Focus mode")}`); message(""); - message(`Previewing: ${status.workspaces.map((ws) => cyan(ws)).join(", ")}`); + message(`Focusing: ${status.workspaces.map((ws) => cyan(ws)).join(", ")}`); - // Show workspaces not in preview - const notInPreview = status.allWorkspaces.filter( + // Show workspaces not in focus + const notInFocus = status.allWorkspaces.filter( (ws) => !status.workspaces.includes(ws.name), ); - if (notInPreview.length > 0) { - message( - dim(`Not in preview: ${notInPreview.map((ws) => ws.name).join(", ")}`), - ); + if (notInFocus.length > 0) { + message(dim(`Not in focus: ${notInFocus.map((ws) => ws.name).join(", ")}`)); } // Show conflicts @@ -66,71 +64,71 @@ function displayPreviewStatus(status: PreviewStatus): void { message(` ${conflict.file} ${dim("←")} ${wsNames}`); } message(""); - message(`${dim("Resolve with:")} ${cmd("arr preview resolve")}`); + message(`${dim("Resolve with:")} ${cmd("arr focus resolve")}`); } } -export async function preview( +export async function focus( subcommand: string | undefined, args: string[], ): Promise { // No subcommand = show status if (!subcommand || subcommand === "status") { - const status = unwrap(await previewStatus()); - displayPreviewStatus(status); + const status = unwrap(await focusStatus()); + displayFocusStatus(status); return; } switch (subcommand) { case "add": { - requireArg(args[0], "Usage: arr preview add "); - const result = unwrap(await previewAdd(args)); - message(formatSuccess(`Added ${args.join(", ")} to preview`)); + requireArg(args[0], "Usage: arr focus add "); + const result = unwrap(await focusAdd(args)); + message(formatSuccess(`Added ${args.join(", ")} to focus`)); message(""); - displayPreviewStatus(result); + displayFocusStatus(result); break; } case "remove": case "rm": { - requireArg(args[0], "Usage: arr preview remove "); - const result = unwrap(await previewRemove(args)); - message(formatSuccess(`Removed ${args.join(", ")} from preview`)); + requireArg(args[0], "Usage: arr focus remove "); + const result = unwrap(await focusRemove(args)); + message(formatSuccess(`Removed ${args.join(", ")} from focus`)); message(""); - displayPreviewStatus(result); + displayFocusStatus(result); break; } case "only": { - requireArg(args[0], "Usage: arr preview only "); - const result = unwrap(await previewOnly(args[0])); - message(formatSuccess(`Now previewing only ${cyan(args[0])}`)); + requireArg(args[0], "Usage: arr focus only "); + const result = unwrap(await focusOnly(args[0])); + message(formatSuccess(`Now focusing only ${cyan(args[0])}`)); message(""); - displayPreviewStatus(result); + displayFocusStatus(result); break; } case "all": { - const result = unwrap(await previewAll()); - message(formatSuccess("Now previewing all workspaces")); + const result = unwrap(await focusAll()); + message(formatSuccess("Now focusing all workspaces")); message(""); - displayPreviewStatus(result); + displayFocusStatus(result); break; } case "edit": { - requireArg(args[0], "Usage: arr preview edit "); - const result = unwrap(await previewEdit(args[0])); + requireArg(args[0], "Usage: arr focus edit "); + const result = unwrap(await focusEdit(args[0])); message(formatSuccess(`Editing ${cyan(args[0])} (files are writable)`)); message(""); - displayPreviewStatus(result); + displayFocusStatus(result); break; } case "none": case "exit": { - unwrap(await previewNone()); - message(formatSuccess("Exited preview mode")); + unwrap(await focusNone()); + message(formatSuccess("Exited focus mode")); break; } @@ -151,7 +149,7 @@ export async function preview( message(dim(` Modified by: ${conflict.workspaces.join(", ")}`)); } message(""); - message(`${dim("Resolve with:")} ${cmd("arr preview resolve")}`); + message(`${dim("Resolve with:")} ${cmd("arr focus resolve")}`); break; } @@ -204,14 +202,14 @@ export async function preview( message(""); } - // Actually remove the workspaces from preview + // Actually remove the workspaces from focus if (removedWorkspaces.size > 0) { - unwrap(await previewRemove([...removedWorkspaces])); + unwrap(await focusRemove([...removedWorkspaces])); } message( formatSuccess( - `Resolved ${resolved} conflict${resolved === 1 ? "" : "s"}, removed ${[...removedWorkspaces].join(", ")} from preview`, + `Resolved ${resolved} conflict${resolved === 1 ? "" : "s"}, removed ${[...removedWorkspaces].join(", ")} from focus`, ), ); if (skipped > 0) { @@ -226,19 +224,19 @@ export async function preview( default: message( - "Usage: arr preview [add|remove|only|all|edit|none|resolve] [workspace...]", + "Usage: arr focus [add|remove|only|all|edit|none|resolve] [workspace...]", ); message(""); message("Subcommands:"); - message(" (none) Show current preview state"); - message(" add Add workspaces to preview"); - message(" remove Remove workspaces from preview"); - message(" only Preview only this workspace"); - message(" all Preview all workspaces"); + 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 preview mode"); + message(" none Exit focus mode"); message(" conflicts List file conflicts"); message(" resolve Resolve a file conflict interactively"); } diff --git a/apps/cli/src/registry.ts b/apps/cli/src/registry.ts index 7b4c7af45..25515a61e 100644 --- a/apps/cli/src/registry.ts +++ b/apps/cli/src/registry.ts @@ -33,12 +33,12 @@ import { daemon } from "./commands/daemon"; import { deleteChange } from "./commands/delete"; import { down } from "./commands/down"; import { 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"; import { merge } from "./commands/merge"; import { modify } from "./commands/modify"; -import { preview } from "./commands/preview"; import { resolve } from "./commands/resolve"; import { restack } from "./commands/restack"; import { split } from "./commands/split"; @@ -99,10 +99,10 @@ const workspaceMeta: CommandMeta = { core: true, }; -const previewMeta: CommandMeta = { - name: "preview", +const focusMeta: CommandMeta = { + name: "focus", args: "[add|remove|only|all|none|resolve] [workspace...]", - description: "Manage live preview of workspace changes", + description: "Manage live focus of workspace changes", category: "workflow", core: true, }; @@ -159,7 +159,7 @@ export const COMMANDS = { help: helpMeta, version: versionMeta, workspace: workspaceMeta, - preview: previewMeta, + focus: focusMeta, daemon: daemonMeta, assign: assignMeta, unassigned: unassignedMeta, @@ -201,7 +201,7 @@ export const HANDLERS: Record = { exit: () => exit(), ci: () => ci(), workspace: (p) => workspace(p.args[0], p.args.slice(1)), - preview: (p) => preview(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)), diff --git a/packages/core/src/commands/preview-resolve.ts b/packages/core/src/commands/focus-resolve.ts similarity index 85% rename from packages/core/src/commands/preview-resolve.ts rename to packages/core/src/commands/focus-resolve.ts index 51fb5aa71..e69f03ca7 100644 --- a/packages/core/src/commands/preview-resolve.ts +++ b/packages/core/src/commands/focus-resolve.ts @@ -1,6 +1,6 @@ import { runJJ } from "../jj/runner"; import { createError, err, ok, type Result } from "../result"; -import { previewRemove, previewStatus } from "./preview"; +import { focusRemove, focusStatus } from "./focus"; import type { Command } from "./types"; export interface FileConflict { @@ -38,11 +38,11 @@ async function getWorkspacesForFile( export async function listConflicts( cwd = process.cwd(), ): Promise> { - const status = await previewStatus(cwd); + const status = await focusStatus(cwd); if (!status.ok) return status; - if (!status.value.isPreview) { - return err(createError("INVALID_STATE", "Not in preview mode")); + if (!status.value.isFocused) { + return err(createError("INVALID_STATE", "Not in focus mode")); } if (status.value.workspaces.length < 2) { @@ -85,11 +85,11 @@ export async function getFileConflict( file: string, cwd = process.cwd(), ): Promise> { - const status = await previewStatus(cwd); + const status = await focusStatus(cwd); if (!status.ok) return status; - if (!status.value.isPreview) { - return err(createError("INVALID_STATE", "Not in preview mode")); + if (!status.value.isFocused) { + return err(createError("INVALID_STATE", "Not in focus mode")); } const workspaces = await getWorkspacesForFile( @@ -106,7 +106,7 @@ export async function getFileConflict( } /** - * Resolve a file conflict by keeping one workspace and removing others from preview. + * Resolve a file conflict by keeping one workspace and removing others from focus. */ export async function resolveConflict( file: string, @@ -131,12 +131,12 @@ export async function resolveConflict( ); } - // Remove all other workspaces from preview + // Remove all other workspaces from focus const toRemove = conflict.value.workspaces.filter( (ws) => ws !== keepWorkspace, ); - const removeResult = await previewRemove(toRemove, cwd); + const removeResult = await focusRemove(toRemove, cwd); if (!removeResult.ok) return removeResult; return ok({ @@ -148,8 +148,8 @@ export async function resolveConflict( export const listConflictsCommand: Command = { meta: { - name: "preview conflicts", - description: "List file conflicts in preview", + name: "focus conflicts", + description: "List file conflicts in focus", category: "info", }, run: listConflicts, @@ -160,7 +160,7 @@ export const resolveConflictCommand: Command< [string, string, string?] > = { meta: { - name: "preview resolve", + name: "focus resolve", args: " ", description: "Resolve a file conflict by keeping one workspace", category: "workflow", diff --git a/packages/core/src/commands/preview.ts b/packages/core/src/commands/focus.ts similarity index 76% rename from packages/core/src/commands/preview.ts rename to packages/core/src/commands/focus.ts index bb5101f6f..f68d7aa02 100644 --- a/packages/core/src/commands/preview.ts +++ b/packages/core/src/commands/focus.ts @@ -16,24 +16,24 @@ import { import { createError, err, ok, type Result } from "../result"; import type { Command } from "./types"; -const PREVIEW_TRAILER_KEY = "Preview-Workspace"; +const FOCUS_TRAILER_KEY = "Focus-Workspace"; export interface ConflictInfo { file: string; workspaces: string[]; } -export interface PreviewStatus { - isPreview: boolean; +export interface FocusStatus { + isFocused: boolean; workspaces: string[]; allWorkspaces: WorkspaceInfo[]; conflicts: ConflictInfo[]; } /** - * Parse Preview-Workspace trailers from the current commit description + * Parse Focus-Workspace trailers from the current commit description */ -async function getPreviewWorkspaces( +async function getFocusWorkspaces( cwd = process.cwd(), ): Promise> { // Get the description of the current commit @@ -50,7 +50,7 @@ async function getPreviewWorkspaces( // Parse trailers (Key: Value format at end of description) const lines = description.split("\n"); for (const line of lines) { - const match = line.match(new RegExp(`^${PREVIEW_TRAILER_KEY}:\\s*(.+)$`)); + const match = line.match(new RegExp(`^${FOCUS_TRAILER_KEY}:\\s*(.+)$`)); if (match) { workspaces.push(match[1].trim()); } @@ -62,11 +62,11 @@ async function getPreviewWorkspaces( /** * Build a description with preview trailers */ -function buildPreviewDescription(workspaces: string[]): string { +function buildFocusDescription(workspaces: string[]): string { if (workspaces.length === 0) return ""; const trailers = workspaces - .map((ws) => `${PREVIEW_TRAILER_KEY}: ${ws}`) + .map((ws) => `${FOCUS_TRAILER_KEY}: ${ws}`) .join("\n"); return `preview\n\n${trailers}`; @@ -83,7 +83,7 @@ function buildPreviewDescription(workspaces: string[]): string { * 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 updatePreview( +async function updateFocus( workspaces: string[], cwd = process.cwd(), ): Promise> { @@ -97,7 +97,7 @@ async function updatePreview( : null; // Check if current commit is a preview (has trailers) - const currentWorkspaces = await getPreviewWorkspaces(cwd); + const currentWorkspaces = await getFocusWorkspaces(cwd); const isCurrentPreview = currentWorkspaces.ok && currentWorkspaces.value.length > 0; @@ -168,7 +168,7 @@ async function updatePreview( } // Build the description with trailers - const description = buildPreviewDescription(workspaces); + const description = buildFocusDescription(workspaces); // Create the merge commit // jj new ... -m "" @@ -235,11 +235,11 @@ async function getWorkspacesForFile( /** * Show current preview state */ -export async function previewStatus( +export async function focusStatus( cwd = process.cwd(), -): Promise> { +): Promise> { const [previewWorkspaces, allWorkspaces] = await Promise.all([ - getPreviewWorkspaces(cwd), + getFocusWorkspaces(cwd), listWorkspaces(cwd), ]); @@ -261,7 +261,7 @@ export async function previewStatus( } return ok({ - isPreview: previewWorkspaces.value.length > 0, + isFocused: previewWorkspaces.value.length > 0, workspaces: previewWorkspaces.value, allWorkspaces: allWorkspaces.value, conflicts, @@ -274,12 +274,12 @@ export async function previewStatus( * 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 previewAdd( +export async function focusAdd( workspaces: string[], cwd = process.cwd(), -): Promise> { +): Promise> { // Get current preview workspaces - const currentResult = await getPreviewWorkspaces(cwd); + const currentResult = await getFocusWorkspaces(cwd); if (!currentResult.ok) return currentResult; // Add new workspaces (avoiding duplicates) @@ -307,21 +307,21 @@ export async function previewAdd( } // Update the preview - const updateResult = await updatePreview(allWorkspaces, cwd); + const updateResult = await updateFocus(allWorkspaces, cwd); if (!updateResult.ok) return updateResult; - return previewStatus(cwd); + return focusStatus(cwd); } /** * Remove workspaces from preview */ -export async function previewRemove( +export async function focusRemove( workspaces: string[], cwd = process.cwd(), -): Promise> { +): Promise> { // Get current preview workspaces - const currentResult = await getPreviewWorkspaces(cwd); + const currentResult = await getFocusWorkspaces(cwd); if (!currentResult.ok) return currentResult; // Remove specified workspaces @@ -329,23 +329,23 @@ export async function previewRemove( const remaining = currentResult.value.filter((ws) => !toRemove.has(ws)); // Update the preview - const updateResult = await updatePreview(remaining, cwd); + const updateResult = await updateFocus(remaining, cwd); if (!updateResult.ok) return updateResult; - return previewStatus(cwd); + return focusStatus(cwd); } /** * Preview only the specified workspace (exclude all others) */ -export async function previewOnly( +export async function focusOnly( workspace: string, cwd = process.cwd(), -): Promise> { - const updateResult = await updatePreview([workspace], cwd); +): Promise> { + const updateResult = await updateFocus([workspace], cwd); if (!updateResult.ok) return updateResult; - return previewStatus(cwd); + return focusStatus(cwd); } /** @@ -354,14 +354,17 @@ export async function previewOnly( * Checks for file conflicts before adding - if any workspaces have files * modified by multiple agents, the operation is blocked. */ -export async function previewAll( +export async function focusAll( cwd = process.cwd(), -): Promise> { +): Promise> { // Get all workspaces const allResult = await listWorkspaces(cwd); if (!allResult.ok) return allResult; - const workspaceNames = allResult.value.map((ws) => ws.name); + // 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")); @@ -383,17 +386,17 @@ export async function previewAll( } } - const updateResult = await updatePreview(workspaceNames, cwd); + const updateResult = await updateFocus(workspaceNames, cwd); if (!updateResult.ok) return updateResult; - return previewStatus(cwd); + return focusStatus(cwd); } /** * Exit preview mode (back to trunk) */ -export async function previewNone(cwd = process.cwd()): Promise> { - const updateResult = await updatePreview([], cwd); +export async function focusNone(cwd = process.cwd()): Promise> { + const updateResult = await updateFocus([], cwd); if (!updateResult.ok) return updateResult; return ok(undefined); } @@ -401,86 +404,85 @@ export async function previewNone(cwd = process.cwd()): Promise> { /** * Enter edit mode for a single workspace. * - * With intelligent edit routing, this is equivalent to `previewOnly` - + * With intelligent edit routing, this is equivalent to `focusOnly` - * files are always writable, and edits are routed to the single workspace. */ -export async function previewEdit( +export async function focusEdit( workspace: string, cwd = process.cwd(), -): Promise> { +): Promise> { // Single-workspace preview = edit mode (all edits go to this workspace) - const updateResult = await updatePreview([workspace], cwd); + const updateResult = await updateFocus([workspace], cwd); if (!updateResult.ok) return updateResult; - return previewStatus(cwd); + return focusStatus(cwd); } // Command exports -export const previewStatusCommand: Command = { +export const focusStatusCommand: Command = { meta: { - name: "preview", - description: "Show current preview state", + name: "focus", + description: "Show current focus state", category: "workflow", core: true, }, - run: previewStatus, + run: focusStatus, }; -export const previewAddCommand: Command = { +export const focusAddCommand: Command = { meta: { - name: "preview add", + name: "focus add", args: "", - description: "Add workspaces to preview", + description: "Add workspaces to focus", category: "workflow", }, - run: previewAdd, + run: focusAdd, }; -export const previewRemoveCommand: Command = - { - meta: { - name: "preview remove", - args: "", - description: "Remove workspaces from preview", - category: "workflow", - }, - run: previewRemove, - }; - -export const previewOnlyCommand: Command = { +export const focusRemoveCommand: Command = { meta: { - name: "preview only", + name: "focus remove", + args: "", + description: "Remove workspaces from focus", + category: "workflow", + }, + run: focusRemove, +}; + +export const focusOnlyCommand: Command = { + meta: { + name: "focus only", args: "", - description: "Preview only this workspace", + description: "Focus only this workspace", category: "workflow", }, - run: previewOnly, + run: focusOnly, }; -export const previewAllCommand: Command = { +export const focusAllCommand: Command = { meta: { - name: "preview all", - description: "Include all workspaces in preview", + name: "focus all", + description: "Include all workspaces in focus", category: "workflow", }, - run: previewAll, + run: focusAll, }; -export const previewNoneCommand: Command = { +export const focusNoneCommand: Command = { meta: { - name: "preview none", - description: "Exit preview mode", + name: "focus none", + description: "Exit focus mode", category: "workflow", }, - run: previewNone, + run: focusNone, }; -export const previewEditCommand: Command = { +export const focusEditCommand: Command = { meta: { - name: "preview edit", + name: "focus edit", args: "", - description: "Enter edit mode for a workspace (single-preview, writable)", + description: "Enter edit mode for a workspace (single-focus, writable)", category: "workflow", }, - run: previewEdit, + run: focusEdit, }; diff --git a/packages/core/src/jj/workspace.ts b/packages/core/src/jj/workspace.ts index 9cb53cd88..5e44d951f 100644 --- a/packages/core/src/jj/workspace.ts +++ b/packages/core/src/jj/workspace.ts @@ -83,6 +83,10 @@ export async function addWorkspace( const infoResult = await getWorkspaceInfo(name, cwd); if (!infoResult.ok) return infoResult; + // Automatically add to focus (dynamic import to avoid circular dependency) + const { focusAdd } = await import("../commands/focus"); + await focusAdd([name], cwd); + return ok(infoResult.value); } @@ -107,6 +111,17 @@ export async function removeWorkspace( ); } + // If workspace is in focus, remove it from focus first (dynamic import to avoid circular dependency) + const { focusStatus, focusRemove } = await import("../commands/focus"); + const status = await focusStatus(cwd); + if ( + status.ok && + status.value.isFocused && + status.value.workspaces.includes(name) + ) { + await focusRemove([name], cwd); + } + // Forget the workspace in jj const forgetResult = await runJJ(["workspace", "forget", name], cwd); if (!forgetResult.ok) return forgetResult; @@ -268,9 +283,12 @@ export async function ensureUnassignedWorkspace( const workspacePath = getWorkspacePath(UNASSIGNED_WORKSPACE, repoPath); - // If workspace already exists, return its info + // If workspace already exists in jj, return its info if (existsSync(workspacePath)) { - return getWorkspaceInfo(UNASSIGNED_WORKSPACE, cwd); + 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 From eb0123eec1cadb7e1f10e21758386c7515cf615d Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 13 Jan 2026 19:51:32 +0100 Subject: [PATCH 4/8] clean up of horrible code --- apps/cli/src/commands/submit.ts | 50 ++++- apps/cli/src/registry.ts | 2 +- packages/core/src/commands/daemon.ts | 17 +- packages/core/src/commands/focus.ts | 96 ++++----- packages/core/src/commands/submit.ts | 11 +- packages/core/src/daemon/daemon-process.ts | 237 ++++++++++++++------- packages/core/src/daemon/pid.ts | 24 ++- packages/core/src/jj/workspace.ts | 45 +++- 8 files changed, 341 insertions(+), 141 deletions(-) diff --git a/apps/cli/src/commands/submit.ts b/apps/cli/src/commands/submit.ts index c004b293e..ad35f96bf 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/registry.ts b/apps/cli/src/registry.ts index 25515a61e..9f9fbf181 100644 --- a/apps/cli/src/registry.ts +++ b/apps/cli/src/registry.ts @@ -171,7 +171,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) => diff --git a/packages/core/src/commands/daemon.ts b/packages/core/src/commands/daemon.ts index aac64ed9e..2091f487a 100644 --- a/packages/core/src/commands/daemon.ts +++ b/packages/core/src/commands/daemon.ts @@ -1,10 +1,13 @@ 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"; @@ -74,9 +77,21 @@ export async function daemonStop(): Promise> { export async function daemonStatus(): Promise> { const running = isRunning(); const pid = running ? (readPid() ?? undefined) : undefined; - const repos = readRepos(); 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 }); } diff --git a/packages/core/src/commands/focus.ts b/packages/core/src/commands/focus.ts index f68d7aa02..b6ac6e4c4 100644 --- a/packages/core/src/commands/focus.ts +++ b/packages/core/src/commands/focus.ts @@ -1,6 +1,6 @@ import { existsSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { registerRepo, unregisterRepo } from "../daemon/pid"; +import { readRepos, setRepoWorkspaces, unregisterRepo } from "../daemon/pid"; import { getConflictingFiles } from "../jj/file-ownership"; import { getTrunk, runJJ } from "../jj/runner"; import { @@ -16,8 +16,6 @@ import { import { createError, err, ok, type Result } from "../result"; import type { Command } from "./types"; -const FOCUS_TRAILER_KEY = "Focus-Workspace"; - export interface ConflictInfo { file: string; workspaces: string[]; @@ -31,45 +29,30 @@ export interface FocusStatus { } /** - * Parse Focus-Workspace trailers from the current commit description + * 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> { - // Get the description of the current commit - const result = await runJJ( - ["log", "-r", "@", "--no-graph", "-T", "description"], - cwd, - ); - - if (!result.ok) return result; + const rootResult = await getRepoRoot(cwd); + if (!rootResult.ok) return rootResult; + const repoPath = rootResult.value; - const description = result.value.stdout; - const workspaces: string[] = []; + const repos = readRepos(); + const repo = repos.find((r) => r.path === repoPath); - // Parse trailers (Key: Value format at end of description) - const lines = description.split("\n"); - for (const line of lines) { - const match = line.match(new RegExp(`^${FOCUS_TRAILER_KEY}:\\s*(.+)$`)); - if (match) { - workspaces.push(match[1].trim()); - } + if (!repo) { + return ok([]); } - return ok(workspaces); -} - -/** - * Build a description with preview trailers - */ -function buildFocusDescription(workspaces: string[]): string { - if (workspaces.length === 0) return ""; - - const trailers = workspaces - .map((ws) => `${FOCUS_TRAILER_KEY}: ${ws}`) - .join("\n"); + // Filter to only workspaces that actually exist + const existingWorkspaces = repo.workspaces.filter((ws) => { + const wsPath = getWorkspacePath(ws, repoPath); + return existsSync(wsPath); + }); - return `preview\n\n${trailers}`; + return ok(existingWorkspaces); } /** @@ -87,6 +70,11 @@ 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"], @@ -96,32 +84,37 @@ async function updateFocus( ? currentResult.value.stdout.trim() : null; - // Check if current commit is a preview (has trailers) + // Check if currently in focus mode const currentWorkspaces = await getFocusWorkspaces(cwd); - const isCurrentPreview = + const isCurrentFocus = currentWorkspaces.ok && currentWorkspaces.value.length > 0; if (workspaces.length === 0) { - // Exit preview mode - go back to trunk + // 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 preview commit - if (isCurrentPreview && oldCommitId) { + // Abandon old focus commit + if (isCurrentFocus && oldCommitId) { await runJJ(["abandon", oldCommitId], cwd); } // Unregister repo from daemon - unregisterRepo(cwd); + unregisterRepo(repoPath); return ok(""); } - // Get repo root for workspace paths - const rootResult = await getRepoRoot(cwd); - if (!rootResult.ok) return rootResult; - const repoPath = rootResult.value; + // 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); @@ -138,7 +131,7 @@ async function updateFocus( } // Then add each agent workspace tip - for (const ws of workspaces) { + for (const ws of validWorkspaces) { const wsPath = getWorkspacePath(ws, repoPath); // Ensure .git symlink exists for editor integration @@ -167,18 +160,15 @@ async function updateFocus( changeIds.push(tipResult.value); } - // Build the description with trailers - const description = buildFocusDescription(workspaces); - - // Create the merge commit - // jj new ... -m "" + // Create the merge commit with simple description + const description = "focus"; const newArgs = ["new", ...changeIds, "-m", description]; const result = await runJJ(newArgs, cwd); if (!result.ok) return result; - // Abandon old preview commit (now that we've moved away from it) - if (isCurrentPreview && oldCommitId) { + // Abandon old focus commit (now that we've moved away from it) + if (isCurrentFocus && oldCommitId) { await runJJ(["abandon", oldCommitId], cwd); } @@ -189,8 +179,8 @@ async function updateFocus( ); if (!idResult.ok) return idResult; - // Register repo with daemon for file watching - registerRepo(cwd, workspaces); + // Set exact workspace list in repos.json (single source of truth) + setRepoWorkspaces(repoPath, validWorkspaces); return ok(idResult.value.stdout.trim()); } diff --git a/packages/core/src/commands/submit.ts b/packages/core/src/commands/submit.ts index 4461810f3..d2203eadc 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/daemon/daemon-process.ts b/packages/core/src/daemon/daemon-process.ts index 4b0a4b2df..1cf7debee 100644 --- a/packages/core/src/daemon/daemon-process.ts +++ b/packages/core/src/daemon/daemon-process.ts @@ -7,7 +7,7 @@ * 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 preview + * 4. On file change: snapshot workspace → update focus * * All jj operations use retry logic for lock contention. */ @@ -31,12 +31,13 @@ import { type RepoEntry, readRepos, writePid, + writeRepos, } from "./pid"; const JJ_TIMEOUT_MS = 10000; const MAX_RETRIES = 10; const RETRY_DELAY_MS = 20; -const PREVIEW_DEBOUNCE_MS = 500; +const DEBOUNCE_MS = 100; interface JJResult { stdout: string | null; @@ -192,26 +193,35 @@ async function rewriteFilesInPlace(cwd: string): Promise { const subscriptions: Map = new Map(); /** Preview subscriptions: repoPath → subscription (watches main repo for edits) */ -const previewSubscriptions: Map = new Map(); +const focusSubscriptions: Map = new Map(); /** Workspaces currently syncing */ const syncingWorkspaces: Set = new Set(); -/** Repos currently syncing to preview (lock to prevent loops) */ -const syncingToPreview: 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}`; } /** - * Snapshot a workspace and update preview. + * Snapshot a workspace and update focus. */ async function snapshotAndSync( repoPath: string, @@ -230,11 +240,15 @@ async function snapshotAndSync( const finishSync = () => { syncingWorkspaces.delete(key); - // If workspace was marked dirty during sync, sync again + // If workspace was marked dirty during sync, re-queue it if (dirtyWorkspaces.has(key)) { - log(`[${key}] Changes during sync, re-syncing`); + log(`[${key}] Changes during sync, re-queuing`); dirtyWorkspaces.delete(key); - snapshotAndSync(repoPath, wsName, wsPath); + const queue = repoSyncQueue.get(repoPath) || []; + if (!queue.some((item) => item.wsName === wsName)) { + queue.push({ wsName, wsPath }); + repoSyncQueue.set(repoPath, queue); + } } }; @@ -257,19 +271,13 @@ async function snapshotAndSync( const t2 = performance.now(); log(`[${key}] Snapshot complete (${(t2 - t1).toFixed(0)}ms)`); - // Step 2: Rebase preview commit onto updated workspace tips - // jj rebase -r @ -d ws1@ -d ws2@ ... - // Set syncingToPreview to prevent the preview watcher from routing these changes back - syncingToPreview.add(repoPath); - - // First, discard any uncommitted changes in preview working copy - // This prevents jj from auto-snapshotting them into a new commit during rebase - const t3a = performance.now(); - await runJJ(["restore"], repoPath); - const t3b = performance.now(); - log(`[${key}] Restored preview working copy (${(t3b - t3a).toFixed(0)}ms)`); - - const destinations = repo.workspaces.flatMap((ws) => ["-d", `${ws}@`]); + // Step 2: Rebase focus commit onto all workspace tips + // jj rebase -r @ -d unassigned@ -d agent-a@ -d agent-b@ ... + const destinations = [ + "-d", + "unassigned@", + ...repo.workspaces.flatMap((ws) => ["-d", `${ws}@`]), + ]; const t3 = performance.now(); const rebaseResult = await runJJ( ["rebase", "-r", "@", ...destinations], @@ -277,7 +285,6 @@ async function snapshotAndSync( ); if (rebaseResult === null) { log(`[${key}] Rebase failed`); - syncingToPreview.delete(repoPath); finishSync(); return; } @@ -291,12 +298,6 @@ async function snapshotAndSync( const t6 = performance.now(); log(`[${key}] Rewrote files in-place (${(t6 - t5).toFixed(0)}ms)`); - // Clear syncingToPreview after a delay to let watchers settle - // Use a longer delay than the debounce timer to ensure preview watcher ignores our changes - setTimeout(() => { - syncingToPreview.delete(repoPath); - }, PREVIEW_DEBOUNCE_MS + 200); - log( `[${key}] Sync complete (total: ${(performance.now() - t0).toFixed(0)}ms)`, ); @@ -306,15 +307,62 @@ async function snapshotAndSync( function triggerSync(repoPath: string, wsName: string, wsPath: string): void { const key = wsKey(repoPath, wsName); - // If already syncing, mark dirty for re-sync after + // If this specific workspace is already syncing, mark dirty for re-sync if (syncingWorkspaces.has(key)) { dirtyWorkspaces.add(key); - log(`[${key}] Sync in progress, marked dirty`); return; } - // Start sync immediately - snapshotAndSync(repoPath, wsName, wsPath); + // 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( @@ -443,7 +491,7 @@ async function buildOwnershipMap( } /** - * Copy files from preview to target workspace directory. + * 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. @@ -478,7 +526,7 @@ function copyFilesToWorkspace( writeFileSync(destPath, content); } } catch (err) { - log(`[preview:${repoPath}] Failed to copy ${file}: ${err}`); + log(`[focus:${repoPath}] Failed to copy ${file}: ${err}`); } } @@ -486,7 +534,7 @@ function copyFilesToWorkspace( } /** - * Route preview edits to appropriate workspaces. + * Route focus edits to appropriate workspaces. * * Routing rules: * - File modified by exactly 1 agent → route to that agent @@ -495,45 +543,45 @@ function copyFilesToWorkspace( */ async function routePreviewEdits(repoPath: string): Promise { const t0 = performance.now(); - log(`[preview:${repoPath}] Starting edit routing`); + log(`[focus:${repoPath}] Starting edit routing`); const repo = currentRepos.find((r) => r.path === repoPath); if (!repo || repo.workspaces.length === 0) { - log(`[preview:${repoPath}] No registered workspaces, skipping`); + log(`[focus:${repoPath}] No registered workspaces, skipping`); return; } // Single workspace mode: all edits go directly to that workspace if (repo.workspaces.length === 1) { const ws = repo.workspaces[0]; - // Get files changed in preview working copy (not committed) + // Get files changed in focus working copy (not committed) const diffResult = await runJJ(["diff", "--summary"], repoPath); if (diffResult) { const files = parseDiffSummary(diffResult); if (files.length > 0) { const success = await copyFilesToWorkspace(files, ws, repoPath); if (success) { - log(`[preview:${repoPath}] Routed ${files.length} file(s) to ${ws}`); + log(`[focus:${repoPath}] Routed ${files.length} file(s) to ${ws}`); } } } log( - `[preview:${repoPath}] Edit routing complete (${(performance.now() - t0).toFixed(0)}ms)`, + `[focus:${repoPath}] Edit routing complete (${(performance.now() - t0).toFixed(0)}ms)`, ); return; } // Multi-workspace mode: route based on ownership - // Get files changed in preview working copy (not committed) + // Get files changed in focus working copy (not committed) const diffResult = await runJJ(["diff", "--summary"], repoPath); if (!diffResult) { - log(`[preview:${repoPath}] No changes to route`); + log(`[focus:${repoPath}] No changes to route`); return; } const changedFiles = parseDiffSummary(diffResult); if (changedFiles.length === 0) { - log(`[preview:${repoPath}] No tracked files changed`); + log(`[focus:${repoPath}] No tracked files changed`); return; } @@ -559,7 +607,7 @@ async function routePreviewEdits(repoPath: string): Promise { } else { // Multiple owners → conflict, skip (shouldn't happen) log( - `[preview:${repoPath}] WARNING: ${file} has multiple owners: ${owners.join(", ")}`, + `[focus:${repoPath}] WARNING: ${file} has multiple owners: ${owners.join(", ")}`, ); } } @@ -568,14 +616,14 @@ async function routePreviewEdits(repoPath: string): Promise { for (const [target, files] of toRoute) { const success = await copyFilesToWorkspace(files, target, repoPath); if (success) { - log(`[preview:${repoPath}] Routed ${files.length} file(s) to ${target}`); + log(`[focus:${repoPath}] Routed ${files.length} file(s) to ${target}`); } else { - log(`[preview:${repoPath}] Failed to route files to ${target}`); + log(`[focus:${repoPath}] Failed to route files to ${target}`); } } log( - `[preview:${repoPath}] Edit routing complete (${(performance.now() - t0).toFixed(0)}ms)`, + `[focus:${repoPath}] Edit routing complete (${(performance.now() - t0).toFixed(0)}ms)`, ); } @@ -583,13 +631,10 @@ async function routePreviewEdits(repoPath: string): Promise { * Trigger preview edit routing (with dirty flag handling). */ function triggerPreviewRoute(repoPath: string): void { - // If we're currently syncing TO preview, ignore these events (they're from us) - if (syncingToPreview.has(repoPath)) { - return; - } - - // If already routing, mark dirty + // Debounce handles batching - just mark dirty and schedule if (dirtyPreviews.has(repoPath)) { + // Already scheduled, debounce will reset timer + routePreviewEditsDebounced(repoPath); return; } @@ -601,36 +646,36 @@ function triggerPreviewRoute(repoPath: string): void { * Debounced version of routePreviewEdits. * Waits for file activity to settle before routing. */ -const previewDebounceTimers: Map< +const focusDebounceTimers: Map< string, ReturnType > = new Map(); function routePreviewEditsDebounced(repoPath: string): void { - const existing = previewDebounceTimers.get(repoPath); + const existing = focusDebounceTimers.get(repoPath); if (existing) { clearTimeout(existing); } const timer = setTimeout(async () => { - previewDebounceTimers.delete(repoPath); + focusDebounceTimers.delete(repoPath); dirtyPreviews.delete(repoPath); await routePreviewEdits(repoPath); - }, PREVIEW_DEBOUNCE_MS); + }, DEBOUNCE_MS); - previewDebounceTimers.set(repoPath, timer); + focusDebounceTimers.set(repoPath, timer); } /** * Watch the main repo for user edits (bidirectional sync). */ async function watchPreview(repoPath: string): Promise { - if (previewSubscriptions.has(repoPath)) { + if (focusSubscriptions.has(repoPath)) { return; } if (!existsSync(repoPath)) { - log(`[preview:${repoPath}] Repo path does not exist, skipping`); + log(`[focus:${repoPath}] Repo path does not exist, skipping`); return; } @@ -639,7 +684,7 @@ async function watchPreview(repoPath: string): Promise { try { const subscription = await watcher.subscribe(repoPath, (err, events) => { if (err) { - log(`[preview:${repoPath}] Watcher error: ${err.message}`); + log(`[focus:${repoPath}] Watcher error: ${err.message}`); return; } @@ -651,15 +696,15 @@ async function watchPreview(repoPath: string): Promise { if (relevantEvents.length === 0) return; log( - `[preview:${repoPath}] ${relevantEvents.length} file change(s) detected`, + `[focus:${repoPath}] ${relevantEvents.length} file change(s) detected`, ); triggerPreviewRoute(repoPath); }); - previewSubscriptions.set(repoPath, subscription); - log(`[preview:${repoPath}] Preview watcher started`); + focusSubscriptions.set(repoPath, subscription); + log(`[focus:${repoPath}] Focus watcher started`); } catch (err) { - log(`[preview:${repoPath}] Failed to start preview watcher: ${err}`); + log(`[focus:${repoPath}] Failed to start focus watcher: ${err}`); } } @@ -667,18 +712,18 @@ async function watchPreview(repoPath: string): Promise { * Stop watching the main repo for edits. */ async function unwatchPreview(repoPath: string): Promise { - const subscription = previewSubscriptions.get(repoPath); + const subscription = focusSubscriptions.get(repoPath); if (subscription) { await subscription.unsubscribe(); - previewSubscriptions.delete(repoPath); - log(`[preview:${repoPath}] Preview watcher stopped`); + focusSubscriptions.delete(repoPath); + log(`[focus:${repoPath}] Focus watcher stopped`); } // Clear any pending debounce timers - const timer = previewDebounceTimers.get(repoPath); + const timer = focusDebounceTimers.get(repoPath); if (timer) { clearTimeout(timer); - previewDebounceTimers.delete(repoPath); + focusDebounceTimers.delete(repoPath); } } @@ -706,7 +751,7 @@ async function unwatchRepo(repoPath: string): Promise { } } - // Stop preview watcher + // Stop focus watcher await unwatchPreview(repoPath); } @@ -714,7 +759,41 @@ async function unwatchRepo(repoPath: string): Promise { let currentRepos: RepoEntry[] = []; async function reloadRepos(): Promise { - const newRepos = readRepos(); + 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) { + newRepos.push({ path: repo.path, workspaces: validWorkspaces }); + } 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) { @@ -804,17 +883,17 @@ async function main(): Promise { subscriptions.clear(); // Clean up preview subscriptions - for (const [repoPath, subscription] of previewSubscriptions) { + for (const [repoPath, subscription] of focusSubscriptions) { await subscription.unsubscribe(); - log(`[preview:${repoPath}] Preview watcher stopped`); + log(`[focus:${repoPath}] Focus watcher stopped`); } - previewSubscriptions.clear(); + focusSubscriptions.clear(); // Clear any pending debounce timers - for (const timer of previewDebounceTimers.values()) { + for (const timer of focusDebounceTimers.values()) { clearTimeout(timer); } - previewDebounceTimers.clear(); + focusDebounceTimers.clear(); cleanup(); process.exit(0); diff --git a/packages/core/src/daemon/pid.ts b/packages/core/src/daemon/pid.ts index d6cc9d55c..c769ef450 100644 --- a/packages/core/src/daemon/pid.ts +++ b/packages/core/src/daemon/pid.ts @@ -150,7 +150,8 @@ export function writeRepos(repos: RepoEntry[]): void { } /** - * Register a repo with workspaces for the daemon to watch + * 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(); @@ -170,6 +171,27 @@ export function registerRepo(repoPath: string, workspaces: string[]): void { ); } +/** + * 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 */ diff --git a/packages/core/src/jj/workspace.ts b/packages/core/src/jj/workspace.ts index 5e44d951f..1e195f022 100644 --- a/packages/core/src/jj/workspace.ts +++ b/packages/core/src/jj/workspace.ts @@ -57,10 +57,18 @@ export async function addWorkspace( // Ensure the workspaces directory exists ensureRepoWorkspacesDir(repoPath); - // Create the workspace using jj - // jj workspace add --name + // Get trunk to create workspace at + const trunkResult = await runJJ( + ["log", "-r", "trunk()", "--no-graph", "-T", "change_id", "--limit", "1"], + cwd, + ); + if (!trunkResult.ok) return trunkResult; + const trunkChangeId = trunkResult.value.stdout.trim(); + + // Create the workspace at trunk (not current working copy) + // jj workspace add --name -r const result = await runJJ( - ["workspace", "add", workspacePath, "--name", name], + ["workspace", "add", workspacePath, "--name", name, "-r", trunkChangeId], cwd, ); @@ -122,10 +130,41 @@ export async function removeWorkspace( await focusRemove([name], cwd); } + // 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", `${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 }); From d2c55457fdcda8f38dcbe915af0fdd4dda54f0fa Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Thu, 15 Jan 2026 15:29:09 +0100 Subject: [PATCH 5/8] yuge cleanup --- apps/cli/src/commands/focus.ts | 50 +--- packages/core/src/commands/assign.ts | 9 +- packages/core/src/commands/focus-resolve.ts | 80 ++++-- packages/core/src/commands/focus.ts | 44 +-- packages/core/src/commands/split.ts | 25 +- packages/core/src/commands/workspace-add.ts | 10 +- .../core/src/commands/workspace-remove.ts | 18 +- .../core/src/commands/workspace-status.ts | 33 +-- .../core/src/commands/workspace-submit.ts | 11 +- packages/core/src/daemon/daemon-process.ts | 267 +++++++++--------- packages/core/src/jj/diff.ts | 75 +++++ packages/core/src/jj/file-ownership.ts | 60 ++-- packages/core/src/jj/workspace.ts | 139 ++++----- 13 files changed, 436 insertions(+), 385 deletions(-) diff --git a/apps/cli/src/commands/focus.ts b/apps/cli/src/commands/focus.ts index e0b6e3d88..8c57c59c0 100644 --- a/apps/cli/src/commands/focus.ts +++ b/apps/cli/src/commands/focus.ts @@ -8,7 +8,10 @@ import { focusRemove, focusStatus, } from "@array/core/commands/focus"; -import { listConflicts } from "@array/core/commands/focus-resolve"; +import { + listConflicts, + resolveConflictsBatch, +} from "@array/core/commands/focus-resolve"; import { cmd, cyan, @@ -161,29 +164,17 @@ export async function focus( return; } - const removedWorkspaces = new Set(); - let resolved = 0; - let skipped = 0; + // Collect user choices for each conflict + const choices = new Map(); for (const conflict of conflicts) { - // Filter out workspaces that have already been removed - const remainingWorkspaces = conflict.workspaces.filter( - (ws) => !removedWorkspaces.has(ws), - ); - - // If only one workspace remains, no conflict to resolve - if (remainingWorkspaces.length < 2) { - skipped++; - continue; - } - message(`${yellow("Conflict:")} ${cyan(conflict.file)}`); - message(dim(` Modified by: ${remainingWorkspaces.join(", ")}`)); + message(dim(` Modified by: ${conflict.workspaces.join(", ")}`)); message(""); const choice = await select( "Which version do you want to keep in focus?", - remainingWorkspaces.map((ws) => ({ label: ws, value: ws })), + conflict.workspaces.map((ws) => ({ label: ws, value: ws })), ); if (!choice) { @@ -191,34 +182,19 @@ export async function focus( return; } - // Mark non-chosen workspaces as removed - for (const ws of remainingWorkspaces) { - if (ws !== choice) { - removedWorkspaces.add(ws); - } - } - - resolved++; + choices.set(conflict.file, choice); message(""); } - // Actually remove the workspaces from focus - if (removedWorkspaces.size > 0) { - unwrap(await focusRemove([...removedWorkspaces])); - } + // Resolve all conflicts in batch + const results = unwrap(await resolveConflictsBatch(choices)); + const removedWorkspaces = new Set(results.flatMap((r) => r.removed)); message( formatSuccess( - `Resolved ${resolved} conflict${resolved === 1 ? "" : "s"}, removed ${[...removedWorkspaces].join(", ")} from focus`, + `Resolved ${results.length} conflict${results.length === 1 ? "" : "s"}, removed ${[...removedWorkspaces].join(", ")} from focus`, ), ); - if (skipped > 0) { - message( - dim( - `Skipped ${skipped} conflict${skipped === 1 ? "" : "s"} (already resolved)`, - ), - ); - } break; } diff --git a/packages/core/src/commands/assign.ts b/packages/core/src/commands/assign.ts index 07ecbe7eb..df0abeb6d 100644 --- a/packages/core/src/commands/assign.ts +++ b/packages/core/src/commands/assign.ts @@ -3,6 +3,7 @@ import { addWorkspace, getUnassignedFiles, UNASSIGNED_WORKSPACE, + workspaceRef, } from "../jj/workspace"; import { createError, err, ok, type Result } from "../result"; import type { Command } from "./types"; @@ -80,9 +81,9 @@ export async function assignFiles( [ "squash", "--from", - `${UNASSIGNED_WORKSPACE}@`, + workspaceRef(UNASSIGNED_WORKSPACE), "--into", - `${targetWorkspace}@`, + workspaceRef(targetWorkspace), ...filesToAssign, ], cwd, @@ -139,9 +140,9 @@ export async function assignFilesToNewWorkspace( [ "squash", "--from", - `${UNASSIGNED_WORKSPACE}@`, + workspaceRef(UNASSIGNED_WORKSPACE), "--into", - `${newWorkspaceName}@`, + workspaceRef(newWorkspaceName), ...filesToAssign, ], cwd, diff --git a/packages/core/src/commands/focus-resolve.ts b/packages/core/src/commands/focus-resolve.ts index e69f03ca7..ddd34309d 100644 --- a/packages/core/src/commands/focus-resolve.ts +++ b/packages/core/src/commands/focus-resolve.ts @@ -1,4 +1,6 @@ +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"; @@ -14,24 +16,6 @@ export interface ResolveResult { removed: string[]; } -/** - * Get workspaces that have modified a specific file. - */ -async function getWorkspacesForFile( - file: string, - workspaces: string[], - cwd: string, -): Promise { - const result: string[] = []; - for (const ws of workspaces) { - const diff = await runJJ(["diff", "-r", `${ws}@`, "--summary"], cwd); - if (diff.ok && diff.value.stdout.includes(file)) { - result.push(ws); - } - } - return result; -} - /** * List all file conflicts in the current preview. */ @@ -53,7 +37,10 @@ export async function listConflicts( const fileWorkspaces = new Map(); for (const ws of status.value.workspaces) { - const diff = await runJJ(["diff", "-r", `${ws}@`, "--summary"], cwd); + const diff = await runJJ( + ["diff", "-r", workspaceRef(ws), "--summary"], + cwd, + ); if (!diff.ok) continue; for (const line of diff.value.stdout.split("\n")) { @@ -146,6 +133,61 @@ export async function resolveConflict( }); } +/** + * 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", diff --git a/packages/core/src/commands/focus.ts b/packages/core/src/commands/focus.ts index b6ac6e4c4..be0fddb50 100644 --- a/packages/core/src/commands/focus.ts +++ b/packages/core/src/commands/focus.ts @@ -1,14 +1,18 @@ -import { existsSync, symlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync } from "node:fs"; import { readRepos, setRepoWorkspaces, unregisterRepo } from "../daemon/pid"; -import { getConflictingFiles } from "../jj/file-ownership"; +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, @@ -121,7 +125,6 @@ async function updateFocus( if (!unassignedResult.ok) return unassignedResult; // Snapshot each workspace to pick up existing changes, then get tip - const gitPath = join(repoPath, ".git"); const changeIds: string[] = []; // First, add unassigned workspace tip to merge parents @@ -134,17 +137,8 @@ async function updateFocus( for (const ws of validWorkspaces) { const wsPath = getWorkspacePath(ws, repoPath); - // Ensure .git symlink exists for editor integration - const workspaceGitPath = join(wsPath, ".git"); - if (existsSync(gitPath) && !existsSync(workspaceGitPath)) { - symlinkSync(gitPath, workspaceGitPath); - } - - // Create .jj/.gitignore to ignore jj internals - const workspaceJjGitignorePath = join(wsPath, ".jj", ".gitignore"); - if (!existsSync(workspaceJjGitignorePath)) { - writeFileSync(workspaceJjGitignorePath, "/*\n"); - } + // Ensure editor integration links exist + setupWorkspaceLinks(wsPath, repoPath); await snapshotWorkspace(wsPath); @@ -161,7 +155,7 @@ async function updateFocus( } // Create the merge commit with simple description - const description = "focus"; + const description = FOCUS_COMMIT_DESCRIPTION; const newArgs = ["new", ...changeIds, "-m", description]; const result = await runJJ(newArgs, cwd); @@ -204,24 +198,6 @@ async function getMergeConflictFiles(cwd: string): Promise { }); } -/** - * Check which workspaces modified a given file - */ -async function getWorkspacesForFile( - file: string, - workspaces: string[], - cwd: string, -): Promise { - const result: string[] = []; - for (const ws of workspaces) { - const diff = await runJJ(["diff", "-r", `${ws}@`, "--summary"], cwd); - if (diff.ok && diff.value.stdout.includes(file)) { - result.push(ws); - } - } - return result; -} - /** * Show current preview state */ diff --git a/packages/core/src/commands/split.ts b/packages/core/src/commands/split.ts index 42daf4a46..96dee7660 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/workspace-add.ts b/packages/core/src/commands/workspace-add.ts index cbf5de99a..e56f1cb39 100644 --- a/packages/core/src/commands/workspace-add.ts +++ b/packages/core/src/commands/workspace-add.ts @@ -1,11 +1,19 @@ 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> { - return addWorkspace(name); + 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 = { diff --git a/packages/core/src/commands/workspace-remove.ts b/packages/core/src/commands/workspace-remove.ts index 8228405d2..afb87aabf 100644 --- a/packages/core/src/commands/workspace-remove.ts +++ b/packages/core/src/commands/workspace-remove.ts @@ -1,9 +1,23 @@ 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): Promise> { - return removeWorkspace(name); +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 = { diff --git a/packages/core/src/commands/workspace-status.ts b/packages/core/src/commands/workspace-status.ts index 1c7e99067..53a60cc89 100644 --- a/packages/core/src/commands/workspace-status.ts +++ b/packages/core/src/commands/workspace-status.ts @@ -1,5 +1,6 @@ +import { type DiffEntry, parseDiffSummary } from "../jj/diff"; import { runJJ } from "../jj/runner"; -import { listWorkspaces } from "../jj/workspace"; +import { listWorkspaces, workspaceRef } from "../jj/workspace"; import { ok, type Result } from "../result"; import type { Command } from "./types"; @@ -20,26 +21,8 @@ export interface WorkspaceStatus { stats: DiffStats; } -/** - * Parse jj diff --summary output into FileChange array. - */ -function parseDiffSummary(output: string): FileChange[] { - const changes: FileChange[] = []; - - for (const line of output.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - - const match = trimmed.match(/^([MADR])\s+(.+)$/); - if (match) { - changes.push({ - status: match[1] as FileChange["status"], - path: match[2].trim(), - }); - } - } - - return changes; +function diffEntryToFileChange(entry: DiffEntry): FileChange { + return { status: entry.status, path: entry.path }; } /** @@ -83,16 +66,18 @@ export async function getWorkspaceStatus( ): Promise> { // Get diff summary const summaryResult = await runJJ( - ["diff", "-r", `${workspaceName}@`, "--summary"], + ["diff", "-r", workspaceRef(workspaceName), "--summary"], cwd, ); if (!summaryResult.ok) return summaryResult; - const changes = parseDiffSummary(summaryResult.value.stdout); + const changes = parseDiffSummary(summaryResult.value.stdout).map( + diffEntryToFileChange, + ); // Get diff stats const statResult = await runJJ( - ["diff", "-r", `${workspaceName}@`, "--stat"], + ["diff", "-r", workspaceRef(workspaceName), "--stat"], cwd, ); diff --git a/packages/core/src/commands/workspace-submit.ts b/packages/core/src/commands/workspace-submit.ts index a9c0a6476..36f60b630 100644 --- a/packages/core/src/commands/workspace-submit.ts +++ b/packages/core/src/commands/workspace-submit.ts @@ -6,6 +6,7 @@ import { getWorkspaceTip, listWorkspaces, UNASSIGNED_WORKSPACE, + workspaceRef, } from "../jj/workspace"; import { createError, err, ok, type Result } from "../result"; import { datePrefixedLabel } from "../slugify"; @@ -33,7 +34,7 @@ async function getWorkspaceDescription( cwd = process.cwd(), ): Promise { const result = await runJJ( - ["log", "-r", `${workspace}@`, "--no-graph", "-T", "description"], + ["log", "-r", workspaceRef(workspace), "--no-graph", "-T", "description"], cwd, ); if (!result.ok) return ""; @@ -78,7 +79,7 @@ export async function submitWorkspace( // Check if workspace has changes const diffResult = await runJJ( - ["diff", "-r", `${workspace}@`, "--summary"], + ["diff", "-r", workspaceRef(workspace), "--summary"], cwd, ); if (!diffResult.ok) return diffResult; @@ -108,7 +109,7 @@ export async function submitWorkspace( // If message provided but no description, set it on the commit if (options.message && !description) { const describeResult = await runJJ( - ["describe", "-r", `${workspace}@`, "-m", options.message], + ["describe", "-r", workspaceRef(workspace), "-m", options.message], cwd, ); if (!describeResult.ok) return describeResult; @@ -118,7 +119,7 @@ export async function submitWorkspace( // Get or generate bookmark name // First check if there's already a bookmark on this change const bookmarkResult = await runJJ( - ["log", "-r", `${workspace}@`, "--no-graph", "-T", "bookmarks"], + ["log", "-r", workspaceRef(workspace), "--no-graph", "-T", "bookmarks"], cwd, ); @@ -135,7 +136,7 @@ export async function submitWorkspace( // Create the bookmark const createResult = await runJJ( - ["bookmark", "create", bookmark, "-r", `${workspace}@`], + ["bookmark", "create", bookmark, "-r", workspaceRef(workspace)], cwd, ); if (!createResult.ok) return createResult; diff --git a/packages/core/src/daemon/daemon-process.ts b/packages/core/src/daemon/daemon-process.ts index 1cf7debee..61e588175 100644 --- a/packages/core/src/daemon/daemon-process.ts +++ b/packages/core/src/daemon/daemon-process.ts @@ -23,6 +23,8 @@ import { 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, @@ -144,7 +146,7 @@ async function loadGitignore(workspacePath: string): Promise> { } } } catch { - // No .gitignore + // .gitignore doesn't exist or isn't readable - use defaults only } return ignored; } @@ -173,6 +175,8 @@ async function getTrackedFiles(cwd: string): Promise { */ 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)) { @@ -182,11 +186,19 @@ async function rewriteFilesInPlace(cwd: string): Promise { const content = readFileSync(filePath); writeFileSync(filePath, content); } - } catch { - // Ignore errors for individual files + } 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 */ @@ -220,6 +232,44 @@ 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. */ @@ -231,32 +281,14 @@ async function snapshotAndSync( const key = wsKey(repoPath, wsName); const t0 = performance.now(); - // Mark as syncing syncingWorkspaces.add(key); dirtyWorkspaces.delete(key); - log(`[${key}] Starting sync`); - const finishSync = () => { - syncingWorkspaces.delete(key); - - // If workspace was marked dirty during sync, re-queue it - 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); - } - } - }; - - // Get registered workspaces for this repo const repo = currentRepos.find((r) => r.path === repoPath); if (!repo || repo.workspaces.length === 0) { log(`[${key}] No registered workspaces, skipping`); - finishSync(); + finishWorkspaceSync(key, repoPath, wsName, wsPath); return; } @@ -265,43 +297,30 @@ async function snapshotAndSync( const snapResult = await runJJ(["status", "--quiet"], wsPath); if (snapResult === null) { log(`[${key}] Snapshot failed, aborting sync`); - finishSync(); + finishWorkspaceSync(key, repoPath, wsName, wsPath); return; } - const t2 = performance.now(); - log(`[${key}] Snapshot complete (${(t2 - t1).toFixed(0)}ms)`); + log(`[${key}] Snapshot complete (${(performance.now() - t1).toFixed(0)}ms)`); // Step 2: Rebase focus commit onto all workspace tips - // jj rebase -r @ -d unassigned@ -d agent-a@ -d agent-b@ ... - const destinations = [ - "-d", - "unassigned@", - ...repo.workspaces.flatMap((ws) => ["-d", `${ws}@`]), - ]; - const t3 = performance.now(); - const rebaseResult = await runJJ( - ["rebase", "-r", "@", ...destinations], - repoPath, - ); - if (rebaseResult === null) { + const t2 = performance.now(); + const rebaseOk = await rebaseFocusCommit(repoPath, repo.workspaces); + if (!rebaseOk) { log(`[${key}] Rebase failed`); - finishSync(); + finishWorkspaceSync(key, repoPath, wsName, wsPath); return; } - const t4 = performance.now(); - log(`[${key}] Rebase complete (${(t4 - t3).toFixed(0)}ms)`); + log(`[${key}] Rebase complete (${(performance.now() - t2).toFixed(0)}ms)`); - // Step 3: Rewrite files in-place to trigger VSCode's file watcher - // (jj rebase replaces files with new inodes, VSCode watches old inodes) - const t5 = performance.now(); + // Step 3: Rewrite files to trigger VSCode's file watcher + const t3 = performance.now(); await rewriteFilesInPlace(repoPath); - const t6 = performance.now(); - log(`[${key}] Rewrote files in-place (${(t6 - t5).toFixed(0)}ms)`); + log(`[${key}] Rewrote files (${(performance.now() - t3).toFixed(0)}ms)`); log( `[${key}] Sync complete (total: ${(performance.now() - t0).toFixed(0)}ms)`, ); - finishSync(); + finishWorkspaceSync(key, repoPath, wsName, wsPath); } function triggerSync(repoPath: string, wsName: string, wsPath: string): void { @@ -399,13 +418,16 @@ async function watchWorkspace( 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 {} + } catch { + // File was deleted or inaccessible - skip latency calculation + } } log( @@ -438,32 +460,6 @@ async function unwatchWorkspace( // Preview Watcher: Routes edits from main repo to appropriate workspace // ============================================================================ -const UNASSIGNED_WORKSPACE = "unassigned"; - -/** - * Parse jj diff --summary output to extract file paths. - */ -function parseDiffSummary(output: string): string[] { - const files: string[] = []; - for (const line of output.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - - const simpleMatch = trimmed.match(/^[MAD]\s+(.+)$/); - if (simpleMatch) { - files.push(simpleMatch[1].trim()); - continue; - } - - const renameMatch = trimmed.match(/^R\s+\{(.+)\s+=>\s+(.+)\}$/); - if (renameMatch) { - files.push(renameMatch[1].trim()); - files.push(renameMatch[2].trim()); - } - } - return files; -} - /** * Build ownership map: file → workspaces that modified it. */ @@ -474,10 +470,13 @@ async function buildOwnershipMap( const ownership = new Map(); for (const ws of workspaces) { - const result = await runJJ(["diff", "-r", `${ws}@`, "--summary"], cwd); + const result = await runJJ( + ["diff", "-r", workspaceRef(ws), "--summary"], + cwd, + ); if (result === null) continue; - const files = parseDiffSummary(result); + const files = parseDiffPaths(result); for (const file of files) { const owners = ownership.get(file) || []; if (!owners.includes(ws)) { @@ -534,85 +533,43 @@ function copyFilesToWorkspace( } /** - * 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, checked at preview add) + * Group changed files by their target workspace based on ownership. */ -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 || repo.workspaces.length === 0) { - log(`[focus:${repoPath}] No registered workspaces, skipping`); - return; - } - - // Single workspace mode: all edits go directly to that workspace - if (repo.workspaces.length === 1) { - const ws = repo.workspaces[0]; - // Get files changed in focus working copy (not committed) - const diffResult = await runJJ(["diff", "--summary"], repoPath); - if (diffResult) { - const files = parseDiffSummary(diffResult); - if (files.length > 0) { - const success = await copyFilesToWorkspace(files, ws, repoPath); - if (success) { - log(`[focus:${repoPath}] Routed ${files.length} file(s) to ${ws}`); - } - } - } - log( - `[focus:${repoPath}] Edit routing complete (${(performance.now() - t0).toFixed(0)}ms)`, - ); - return; - } - - // Multi-workspace mode: route based on ownership - // Get files changed in focus working copy (not committed) - const diffResult = await runJJ(["diff", "--summary"], repoPath); - if (!diffResult) { - log(`[focus:${repoPath}] No changes to route`); - return; - } - - const changedFiles = parseDiffSummary(diffResult); - if (changedFiles.length === 0) { - log(`[focus:${repoPath}] No tracked files changed`); - return; - } - - // Build ownership map for current workspaces - const ownership = await buildOwnershipMap(repo.workspaces, repoPath); - - // Group files by target workspace +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) { - // Not owned by any agent → unassigned const files = toRoute.get(UNASSIGNED_WORKSPACE) || []; files.push(file); toRoute.set(UNASSIGNED_WORKSPACE, files); } else if (owners.length === 1) { - // Owned by exactly one agent → route to that agent const files = toRoute.get(owners[0]) || []; files.push(file); toRoute.set(owners[0], files); } else { - // Multiple owners → conflict, skip (shouldn't happen) log( `[focus:${repoPath}] WARNING: ${file} has multiple owners: ${owners.join(", ")}`, ); } } - // Copy files to their target workspaces + 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) { @@ -621,6 +578,50 @@ async function routePreviewEdits(repoPath: string): Promise { 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 || repo.workspaces.length === 0) { + log(`[focus:${repoPath}] No registered workspaces, 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; + } + + // Single workspace mode: all edits go directly to that workspace + if (repo.workspaces.length === 1) { + 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)`, diff --git a/packages/core/src/jj/diff.ts b/packages/core/src/jj/diff.ts index 04c973362..f8ce4256d 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 index 9ab46dee7..e9689babb 100644 --- a/packages/core/src/jj/file-ownership.ts +++ b/packages/core/src/jj/file-ownership.ts @@ -1,5 +1,7 @@ import { ok, type Result } from "../result"; +import { parseDiffPaths } from "./diff"; import { runJJ } from "./runner"; +import { workspaceRef } from "./workspace"; export interface FileOwnershipMap { ownership: Map; @@ -7,36 +9,6 @@ export interface FileOwnershipMap { hasConflict(file: string): boolean; } -/** - * Parse jj diff --summary output to extract file paths. - * Handles: M (modified), A (added), D (deleted), R (renamed) - * Rename format: R {old => new} - */ -function parseDiffSummary(output: string): string[] { - const files: string[] = []; - - 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) { - files.push(simpleMatch[1].trim()); - continue; - } - - // Match: R {old => new} - const renameMatch = trimmed.match(/^R\s+\{(.+)\s+=>\s+(.+)\}$/); - if (renameMatch) { - files.push(renameMatch[1].trim()); - files.push(renameMatch[2].trim()); - } - } - - return files; -} - /** * Build a map of file -> workspaces that have modified that file. * Uses `jj diff -r @ --summary` for each workspace. @@ -49,10 +21,13 @@ export async function buildFileOwnershipMap( for (const ws of workspaces) { // Get files modified by this workspace (vs trunk) - const result = await runJJ(["diff", "-r", `${ws}@`, "--summary"], cwd); + const result = await runJJ( + ["diff", "-r", workspaceRef(ws), "--summary"], + cwd, + ); if (!result.ok) continue; - const files = parseDiffSummary(result.value.stdout); + const files = parseDiffPaths(result.value.stdout); for (const file of files) { const owners = ownership.get(file) || []; @@ -91,3 +66,24 @@ export async function getConflictingFiles( 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/workspace.ts b/packages/core/src/jj/workspace.ts index 1e195f022..8965ea594 100644 --- a/packages/core/src/jj/workspace.ts +++ b/packages/core/src/jj/workspace.ts @@ -7,11 +7,20 @@ import { getRepoWorkspacesDir, } from "../daemon/pid"; import { createError, err, ok, type Result } from "../result"; +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; @@ -33,6 +42,39 @@ 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// */ @@ -58,43 +100,31 @@ export async function addWorkspace( ensureRepoWorkspacesDir(repoPath); // Get trunk to create workspace at - const trunkResult = await runJJ( - ["log", "-r", "trunk()", "--no-graph", "-T", "change_id", "--limit", "1"], - cwd, - ); + const trunkResult = await getTrunkChangeId(cwd); if (!trunkResult.ok) return trunkResult; - const trunkChangeId = trunkResult.value.stdout.trim(); // Create the workspace at trunk (not current working copy) - // jj workspace add --name -r const result = await runJJ( - ["workspace", "add", workspacePath, "--name", name, "-r", trunkChangeId], + [ + "workspace", + "add", + workspacePath, + "--name", + name, + "-r", + trunkResult.value, + ], cwd, ); - if (!result.ok) return result; - // Symlink .git to enable editor git integration (diffs, gutters) - const gitPath = join(repoPath, ".git"); - const workspaceGitPath = join(workspacePath, ".git"); - if (existsSync(gitPath) && !existsSync(workspaceGitPath)) { - symlinkSync(gitPath, workspaceGitPath); - } - - // Create .jj/.gitignore to ignore jj internals - const workspaceJjGitignorePath = join(workspacePath, ".jj", ".gitignore"); - if (!existsSync(workspaceJjGitignorePath)) { - writeFileSync(workspaceJjGitignorePath, "/*\n"); - } + // Setup editor integration links + setupWorkspaceLinks(workspacePath, repoPath); // Get the workspace info const infoResult = await getWorkspaceInfo(name, cwd); if (!infoResult.ok) return infoResult; - // Automatically add to focus (dynamic import to avoid circular dependency) - const { focusAdd } = await import("../commands/focus"); - await focusAdd([name], cwd); - return ok(infoResult.value); } @@ -119,17 +149,6 @@ export async function removeWorkspace( ); } - // If workspace is in focus, remove it from focus first (dynamic import to avoid circular dependency) - const { focusStatus, focusRemove } = await import("../commands/focus"); - const status = await focusStatus(cwd); - if ( - status.ok && - status.value.isFocused && - status.value.workspaces.includes(name) - ) { - await focusRemove([name], cwd); - } - // 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; @@ -139,7 +158,7 @@ export async function removeWorkspace( if (commitToAbandon) { // Get bookmarks on this commit const bookmarksResult = await runJJ( - ["log", "-r", `${name}@`, "--no-graph", "-T", "bookmarks"], + ["log", "-r", workspaceRef(name), "--no-graph", "-T", "bookmarks"], cwd, ); if (bookmarksResult.ok) { @@ -268,7 +287,7 @@ export async function getWorkspaceTip( ): Promise> { // Use the workspace@ syntax to get the working copy of that workspace const result = await runJJ( - ["log", "-r", `${name}@`, "--no-graph", "-T", "change_id"], + ["log", "-r", workspaceRef(name), "--no-graph", "-T", "change_id"], cwd, ); @@ -334,15 +353,10 @@ export async function ensureUnassignedWorkspace( ensureRepoWorkspacesDir(repoPath); // Get trunk revision to create workspace at - const trunkResult = await runJJ( - ["log", "-r", "trunk()", "--no-graph", "-T", "change_id", "--limit", "1"], - cwd, - ); + const trunkResult = await getTrunkChangeId(cwd); if (!trunkResult.ok) return trunkResult; - const trunkChangeId = trunkResult.value.stdout.trim(); // Create workspace at trunk - // jj workspace add --name -r const createResult = await runJJ( [ "workspace", @@ -351,24 +365,14 @@ export async function ensureUnassignedWorkspace( "--name", UNASSIGNED_WORKSPACE, "-r", - trunkChangeId, + trunkResult.value, ], cwd, ); if (!createResult.ok) return createResult; - // Symlink .git for editor integration - const gitPath = join(repoPath, ".git"); - const workspaceGitPath = join(workspacePath, ".git"); - if (existsSync(gitPath) && !existsSync(workspaceGitPath)) { - symlinkSync(gitPath, workspaceGitPath); - } - - // Create .jj/.gitignore to ignore jj internals - const workspaceJjGitignorePath = join(workspacePath, ".jj", ".gitignore"); - if (!existsSync(workspaceJjGitignorePath)) { - writeFileSync(workspaceJjGitignorePath, "/*\n"); - } + // Setup editor integration links + setupWorkspaceLinks(workspacePath, repoPath); return getWorkspaceInfo(UNASSIGNED_WORKSPACE, cwd); } @@ -380,29 +384,10 @@ export async function getUnassignedFiles( cwd = process.cwd(), ): Promise> { const result = await runJJ( - ["diff", "-r", `${UNASSIGNED_WORKSPACE}@`, "--summary"], + ["diff", "-r", workspaceRef(UNASSIGNED_WORKSPACE), "--summary"], cwd, ); if (!result.ok) return result; - const files: string[] = []; - for (const line of result.value.stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - - // Match: M path, A path, D path - const simpleMatch = trimmed.match(/^[MAD]\s+(.+)$/); - if (simpleMatch) { - files.push(simpleMatch[1].trim()); - continue; - } - - // Match: R {old => new} - const renameMatch = trimmed.match(/^R\s+\{(.+)\s+=>\s+(.+)\}$/); - if (renameMatch) { - files.push(renameMatch[2].trim()); // Only the new name matters for listing - } - } - - return ok(files); + return ok(parseDiffPaths(result.value.stdout)); } From 73d4c25a7cb202549c6fd2f3df52c80759c9b393 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Thu, 15 Jan 2026 16:01:48 +0100 Subject: [PATCH 6/8] more reliable enter/exit behavior --- apps/cli/src/commands/enter.ts | 23 ++++++++ apps/cli/src/commands/exit.ts | 35 +++++++----- apps/cli/src/registry.ts | 5 +- packages/core/src/commands/enter.ts | 37 ++++++++++++ packages/core/src/commands/exit.ts | 87 +++++++++++++++++++++++++++++ packages/core/src/git/head.ts | 87 +++++++++++++++++++++++++++++ 6 files changed, 260 insertions(+), 14 deletions(-) create mode 100644 apps/cli/src/commands/enter.ts create mode 100644 packages/core/src/commands/enter.ts create mode 100644 packages/core/src/commands/exit.ts create mode 100644 packages/core/src/git/head.ts diff --git a/apps/cli/src/commands/enter.ts b/apps/cli/src/commands/enter.ts new file mode 100644 index 000000000..7a591cda0 --- /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 a816c2ba3..1eb4e0b93 100644 --- a/apps/cli/src/commands/exit.ts +++ b/apps/cli/src/commands/exit.ts @@ -1,40 +1,49 @@ +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 { 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 focus mode, or exit to plain git if not previewing", + description: "Exit focus mode, or exit to plain git if not in focus", context: "jj", category: "management", }; -export async function exit(): Promise { - // Check if we're in focus mode +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) { - // Exit focus mode - coreUnwrap(await focusNone()); + 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; } - // Not in preview - exit to git - const trunk = await getTrunk(); - const result = coreUnwrap(await exitToGit(process.cwd(), trunk)); + message(`${green(">")} Switched to git branch ${cyan(result.branch)}`); + + 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("Run any arr command to return to jj."); + hint("Run `arr enter` to return to jj."); } diff --git a/apps/cli/src/registry.ts b/apps/cli/src/registry.ts index 9f9fbf181..d2a222eb5 100644 --- a/apps/cli/src/registry.ts +++ b/apps/cli/src/registry.ts @@ -32,7 +32,8 @@ 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"; @@ -153,6 +154,7 @@ export const COMMANDS = { squash: squashCommand.meta, merge: mergeCommand.meta, undo: undoCommand.meta, + enter: enterMeta, exit: exitMeta, ci: ciMeta, config: configMeta, @@ -198,6 +200,7 @@ 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)), diff --git a/packages/core/src/commands/enter.ts b/packages/core/src/commands/enter.ts new file mode 100644 index 000000000..e6908db59 --- /dev/null +++ b/packages/core/src/commands/enter.ts @@ -0,0 +1,37 @@ +import { getCurrentBranch, isDetachedHead } from "../git/head"; +import { status } from "../jj/status"; +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; + + 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 000000000..670133386 --- /dev/null +++ b/packages/core/src/commands/exit.ts @@ -0,0 +1,87 @@ +import { getCurrentBranch, isDetachedHead, setHeadToBranch } from "../git/head"; +import { list } from "../jj/list"; +import { getTrunk } from "../jj/runner"; +import { createError, err, ok, type Result } from "../result"; + +export interface ExitResult { + branch: string; + alreadyInGitMode: boolean; + usedFallback: boolean; +} + +/** + * 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 - nothing to do + const branch = await getCurrentBranch(cwd); + return ok({ + branch: branch || "unknown", + alreadyInGitMode: true, + usedFallback: false, + }); + } + + // 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.", + ), + ); + } + } + + // Move Git HEAD to the branch without touching working tree + const setHeadResult = await setHeadToBranch(cwd, targetBookmark); + + if (!setHeadResult.ok) { + return err(setHeadResult.error); + } + + return ok({ + branch: targetBookmark, + alreadyInGitMode: false, + usedFallback, + }); +} diff --git a/packages/core/src/git/head.ts b/packages/core/src/git/head.ts new file mode 100644 index 000000000..136e372dd --- /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); +} From 76396dea7cbc4c741f307c8e61f3ae204e6a003e Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Thu, 15 Jan 2026 16:27:15 +0100 Subject: [PATCH 7/8] sync unassigned to git one xit, and sync uncomittted git to unassigned --- apps/cli/src/commands/exit.ts | 6 ++ packages/core/src/commands/enter.ts | 8 +++ packages/core/src/commands/exit.ts | 84 +++++++++++++++++++++- packages/core/src/daemon/daemon-process.ts | 27 +++++-- packages/core/src/daemon/pid.ts | 40 +++++++++++ packages/core/src/jj/workspace.ts | 13 +++- 6 files changed, 168 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/exit.ts b/apps/cli/src/commands/exit.ts index 1eb4e0b93..c5e695c82 100644 --- a/apps/cli/src/commands/exit.ts +++ b/apps/cli/src/commands/exit.ts @@ -38,6 +38,12 @@ export async function run(): Promise { 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."); diff --git a/packages/core/src/commands/enter.ts b/packages/core/src/commands/enter.ts index e6908db59..6d1797f5c 100644 --- a/packages/core/src/commands/enter.ts +++ b/packages/core/src/commands/enter.ts @@ -1,5 +1,7 @@ +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 { @@ -29,6 +31,12 @@ export async function enter(cwd = process.cwd()): Promise> { 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, diff --git a/packages/core/src/commands/exit.ts b/packages/core/src/commands/exit.ts index 670133386..c2df3a95a 100644 --- a/packages/core/src/commands/exit.ts +++ b/packages/core/src/commands/exit.ts @@ -1,12 +1,77 @@ +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 } from "../jj/runner"; +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; } /** @@ -21,12 +86,17 @@ export async function exit(cwd = process.cwd()): Promise> { const detached = await isDetachedHead(cwd); if (!detached) { - // Already in Git mode - nothing to do + // 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, }); } @@ -72,9 +142,18 @@ export async function exit(cwd = process.cwd()): Promise> { } } + // 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); } @@ -83,5 +162,6 @@ export async function exit(cwd = process.cwd()): Promise> { branch: targetBookmark, alreadyInGitMode: false, usedFallback, + syncedFiles, }); } diff --git a/packages/core/src/daemon/daemon-process.ts b/packages/core/src/daemon/daemon-process.ts index 61e588175..900e61c8b 100644 --- a/packages/core/src/daemon/daemon-process.ts +++ b/packages/core/src/daemon/daemon-process.ts @@ -593,8 +593,8 @@ async function routePreviewEdits(repoPath: string): Promise { log(`[focus:${repoPath}] Starting edit routing`); const repo = currentRepos.find((r) => r.path === repoPath); - if (!repo || repo.workspaces.length === 0) { - log(`[focus:${repoPath}] No registered workspaces, skipping`); + if (!repo) { + log(`[focus:${repoPath}] Repo not registered, skipping`); return; } @@ -610,8 +610,14 @@ async function routePreviewEdits(repoPath: string): Promise { return; } - // Single workspace mode: all edits go directly to that workspace - if (repo.workspaces.length === 1) { + // 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]}`, @@ -738,7 +744,10 @@ async function watchRepo(repo: RepoEntry): Promise { } // Watch main repo for user edits (bidirectional sync) - await watchPreview(repo.path); + // 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 { @@ -783,8 +792,12 @@ async function reloadRepos(): Promise { ); } - if (validWorkspaces.length > 0) { - newRepos.push({ path: repo.path, workspaces: validWorkspaces }); + 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}`); diff --git a/packages/core/src/daemon/pid.ts b/packages/core/src/daemon/pid.ts index c769ef450..38753d786 100644 --- a/packages/core/src/daemon/pid.ts +++ b/packages/core/src/daemon/pid.ts @@ -16,6 +16,8 @@ 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; } /** @@ -202,6 +204,44 @@ export function unregisterRepo(repoPath: string): void { 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) */ diff --git a/packages/core/src/jj/workspace.ts b/packages/core/src/jj/workspace.ts index 8965ea594..9f202a09a 100644 --- a/packages/core/src/jj/workspace.ts +++ b/packages/core/src/jj/workspace.ts @@ -7,6 +7,7 @@ import { 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"; @@ -125,6 +126,9 @@ export async function addWorkspace( 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); } @@ -374,7 +378,14 @@ export async function ensureUnassignedWorkspace( // Setup editor integration links setupWorkspaceLinks(workspacePath, repoPath); - return getWorkspaceInfo(UNASSIGNED_WORKSPACE, cwd); + // 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; } /** From 57ea2c6cd234b47d52e62fb2fdd619802a1c2f86 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Thu, 15 Jan 2026 16:28:49 +0100 Subject: [PATCH 8/8] regex handling --- packages/core/src/commands/assign.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/core/src/commands/assign.ts b/packages/core/src/commands/assign.ts index df0abeb6d..5c7f2678c 100644 --- a/packages/core/src/commands/assign.ts +++ b/packages/core/src/commands/assign.ts @@ -14,6 +14,13 @@ export interface AssignResult { 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. @@ -22,12 +29,18 @@ function matchFiles(patterns: string[], availableFiles: string[]): string[] { const matched = new Set(); for (const pattern of patterns) { - // Convert glob pattern to regex - const regexPattern = pattern - .replace(/\./g, "\\.") - .replace(/\*\*/g, "{{GLOBSTAR}}") - .replace(/\*/g, "[^/]*") - .replace(/{{GLOBSTAR}}/g, ".*"); + // 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}$`);