diff --git a/CLAUDE.md b/CLAUDE.md index f1447c16..44f44b60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,9 +46,12 @@ ### Avoid Barrel Files +- Do not make use of index.ts + Barrel files: + - Break tree-shaking -- Create circular dependency risks +- Create circular dependency risks - Hide the true source of imports - Make refactoring harder @@ -74,6 +77,17 @@ See [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed patterns (DI, services, tR - PostHog API integration in `posthog-api.ts` - Task execution and session management +### CLI Package (packages/cli) + +- **Dumb shell, imperative core**: CLI commands should be thin wrappers that call `@array/core` +- All business logic belongs in `@array/core`, not in CLI command files +- CLI only handles: argument parsing, calling core, formatting output +- No data transformation, tree building, or complex logic in CLI + +### Core Package (packages/core) + +- Shared business logic for jj/GitHub operations + ## Key Libraries - React 18, Radix UI Themes, Tailwind CSS @@ -91,6 +105,5 @@ TODO: Update me ## Testing -- Tests use vitest with jsdom environment -- Test helpers in `src/test/` -- Run specific test: `pnpm --filter array test -- path/to/test` +- `pnpm test` - Run tests across all packages +- Array app: Vitest with jsdom, helpers in `apps/array/src/test/` diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 00000000..c059a4e1 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,93 @@ +> [!IMPORTANT] > `arr` is still in development and not production-ready. Interested? Email jonathan@posthog.com + +# arr + +arr is CLI for stacked PR management using Jujutsu (`jj`). + +Split your work into small changes, push them as a PR stack, and keep everything in sync. + +## Install + +Requires [Bun](https://bun.sh). + +``` +git clone https://github.com/posthog/array +cd array +pnpm install +pnpm --filter @array/core build +``` + +Then install the `arr` command (symlinked to `~/bin/arr`): + +``` +./apps/cli/arr.sh install +``` + +## Usage + +``` +arr init # set up arr in a git repo +arr create "message" # new change on stack +arr submit # push stack, create PRs +arr merge # merge stack of PRs +arr sync # fetch, rebase, cleanup merged +arr up / arr down # navigate stack +arr log # show stack +arr exit # back to git +arr help --all # show all commands +``` + +## Example + +``` +$ echo "user model" >> user_model.ts +$ arr create "Add user model" +✓ Created add-user-model-qtrsqm + +$ echo "user api" >> user_api.ts +$ arr create "Add user API" +✓ Created add-user-api-nnmzrt + +$ arr log +◉ (working copy) +│ Empty +○ 12-23-add-user-api nnmzrtzz (+1, 1 file) +│ Not submitted +○ 12-23-add-user-model qtrsqmmy (+1, 1 file) +│ Not submitted +○ main + +$ arr submit +Created PR #8: 12-23-add-user-model + https://github.com/username/your-repo/pull/8 +Created PR #9: 12-23-add-user-api + https://github.com/username/your-repo/pull/9 + +$ arr merge +... + +$ arr sync +``` + +Each change becomes a PR. +Stacked PRs are explained through a generated comments so reviewers see the dependency. + +## FAQ + +**Can I use this with an existing `git` repo?** + +Yes, do so by using `arr init` in any `git` repo. `jj` works alongside `git`. + +**Do my teammates need to use `arr` or `jj`?** + +No, your PRs are normal GitHub PRs. Teammates review and merge them as usual. `jj` has full support for `git`. + +**What if I want to stop using `arr`?** + +Run `arr exit` to switch back to `git`. Your repo, branches, and PRs stay exactly as they are. + +## Learn more + +- [`jj` documentation](https://jj-vcs.github.io/jj/latest/) - full `jj` reference +- [`jj` tutorial](https://jj-vcs.github.io/jj/latest/tutorial/) - getting started with `jj` +- `arr help` diff --git a/apps/cli/arr.sh b/apps/cli/arr.sh new file mode 100755 index 00000000..69d9dbc6 --- /dev/null +++ b/apps/cli/arr.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Wrapper script to run arr CLI via bun. +SOURCE="${BASH_SOURCE[0]}" +while [ -L "$SOURCE" ]; do + DIR="$(cd "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" +done +SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)" + +# Self-install: ./arr.sh install +if [ "$1" = "install" ]; then + mkdir -p ~/bin + ln -sf "$SCRIPT_DIR/arr.sh" ~/bin/arr + echo "Installed: ~/bin/arr -> $SCRIPT_DIR/arr.sh" + echo "Make sure ~/bin is in your PATH" + exit 0 +fi + +exec bun run "$SCRIPT_DIR/bin/arr.ts" "$@" diff --git a/apps/cli/bin/arr.ts b/apps/cli/bin/arr.ts new file mode 100755 index 00000000..c455ca43 --- /dev/null +++ b/apps/cli/bin/arr.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env bun + +import { main } from "../src/cli"; + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 00000000..8ae86b4c --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,25 @@ +{ + "name": "@array/cli", + "version": "0.0.1", + "description": "CLI for changeset management with jj", + "bin": { + "arr": "./bin/arr.ts" + }, + "type": "module", + "scripts": { + "build": "bun build ./src/index.ts --outdir ./dist --target bun", + "dev": "bun run ./bin/arr.ts", + "typecheck": "tsc --noEmit", + "test": "bun test --concurrent tests/unit tests/e2e/cli.test.ts", + "test:pty": "vitest run tests/e2e/pty.test.ts" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^25.0.3", + "typescript": "^5.5.0", + "vitest": "^4.0.16" + }, + "dependencies": { + "@array/core": "workspace:*" + } +} diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts new file mode 100644 index 00000000..62052b1d --- /dev/null +++ b/apps/cli/src/cli.ts @@ -0,0 +1,234 @@ +import { triggerBackgroundRefresh } from "@array/core/background-refresh"; +import { type ArrContext, initContext } from "@array/core/engine"; +import { dumpRefs } from "./commands/hidden/dump-refs"; +import { refreshPRInfo } from "./commands/hidden/refresh-pr-info"; +import { + CATEGORY_LABELS, + CATEGORY_ORDER, + COMMANDS as COMMAND_INFO, + type CommandInfo, + getCommandsByCategory, + getCoreCommands, + getRequiredContext, + HANDLERS, + resolveCommandAlias, +} from "./registry"; +import { parseArgs } from "./utils/args"; +import { + checkContext, + isContextValid, + printContextError, +} from "./utils/context"; +import { + arr, + bold, + cyan, + dim, + formatError, + hint, + message, +} from "./utils/output"; + +const CLI_NAME = "arr"; +const CLI_VERSION = "0.0.1"; +const CMD_WIDTH = 22; + +const TAGLINE = `arr is a CLI for stacked PRs using jj. +It enables stacking changes on top of each other to keep you unblocked +and your changes small, focused, and reviewable.`; + +const USAGE = `${bold("USAGE")} + $ arr [flags]`; + +const TERMS = `${bold("TERMS")} + stack: A sequence of changes, each building off of its parent. + ex: main <- "add API" <- "update frontend" <- "docs" + trunk: The branch that stacks are merged into (e.g., main). + change: A jj commit/revision. Unlike git, jj tracks the working + copy as a change automatically.`; + +const GLOBAL_OPTIONS = `${bold("GLOBAL OPTIONS")} + --help Show help for a command. + --help --all Show full command reference. + --version Show arr version number.`; + +const DOCS = `${bold("DOCS")} + Get started: https://github.com/posthog/array`; + +function formatCommand( + c: CommandInfo, + showAliases = true, + showFlags = false, +): string { + const full = c.args ? `${c.name} ${c.args}` : c.name; + const aliasStr = + showAliases && c.aliases?.length + ? ` ${dim(`[aliases: ${c.aliases.join(", ")}]`)}` + : ""; + let result = ` ${cyan(full.padEnd(CMD_WIDTH))}${c.description}.${aliasStr}`; + + if (showFlags && c.flags?.length) { + for (const flag of c.flags) { + const flagName = flag.short + ? `-${flag.short}, --${flag.name}` + : `--${flag.name}`; + result += `\n ${dim(flagName.padEnd(CMD_WIDTH - 2))}${dim(flag.description)}`; + } + } + + return result; +} + +function printHelp(): void { + const coreCommands = getCoreCommands(); + + console.log(`${TAGLINE} + +${USAGE} + +${TERMS} + +${bold("CORE COMMANDS")} +${coreCommands.map((c) => formatCommand(c, false)).join("\n")} + + Run ${arr(COMMAND_INFO.help, "--all")} for a full command reference. + +${bold("CORE WORKFLOW")} + 1. ${dim("(make edits)")}\t\t\tno need to stage, jj tracks automatically + 2. ${arr(COMMAND_INFO.create, '"add user model"')}\tSave as a change + 3. ${dim("(make more edits)")}\t\t\tStack more work + 4. ${arr(COMMAND_INFO.create, '"add user api"')}\t\tSave as another change + 5. ${arr(COMMAND_INFO.submit)}\t\t\t\tCreate PRs for the stack + 6. ${arr(COMMAND_INFO.merge)}\t\t\t\tMerge PRs from the CLI + 7. ${arr(COMMAND_INFO.sync)}\t\t\t\tFetch & rebase after reviews + +${bold("ESCAPE HATCH")} + ${arr(COMMAND_INFO.exit)}\t\t\t\tSwitch back to plain git if you need it. + \t\t\t\t\tYour jj changes are preserved and you can return anytime. + +${bold("LEARN MORE")} + Documentation\t\t\thttps://github.com/posthog/array + jj documentation\t\thttps://www.jj-vcs.dev/latest/ +`); +} + +function printHelpAll(): void { + const hidden = new Set(["help", "version", "config"]); + const sections = CATEGORY_ORDER.map((category) => { + const commands = getCommandsByCategory(category).filter( + (c) => !hidden.has(c.name), + ); + if (commands.length === 0) return ""; + return `${bold(CATEGORY_LABELS[category])}\n${commands.map((c) => formatCommand(c, true, true)).join("\n")}`; + }).filter(Boolean); + + console.log(`${TAGLINE} + +${USAGE} + +${TERMS} + +${sections.join("\n\n")} + +${GLOBAL_OPTIONS} + +${DOCS} +`); +} + +function printVersion(): void { + console.log(`${CLI_NAME} ${CLI_VERSION}`); +} + +export async function main(): Promise { + const parsed = parseArgs(Bun.argv); + const command = resolveCommandAlias(parsed.name); + + if (parsed.name && parsed.name !== command) { + message(dim(`(${parsed.name} → ${command})`)); + } + + if (parsed.flags.help || parsed.flags.h) { + if (parsed.flags.all) { + printHelpAll(); + } else { + printHelp(); + } + return; + } + + if (parsed.flags.version || parsed.flags.v) { + printVersion(); + return; + } + + // No command provided - show help + if (command === "__guided") { + printHelp(); + return; + } + + // Built-in commands + if (command === "help") { + parsed.flags.all ? printHelpAll() : printHelp(); + return; + } + if (command === "version") { + printVersion(); + return; + } + + // Hidden commands + if (command === "__refresh-pr-info") { + await refreshPRInfo(); + return; + } + if (command === "__dump-refs") { + await dumpRefs(); + return; + } + + const handler = HANDLERS[command]; + if (handler) { + const requiredLevel = getRequiredContext(command); + + // Commands that don't need context (auth, help, etc.) + if (requiredLevel === "none") { + await handler(parsed, null); + return; + } + + // Check prerequisites (git, jj, arr initialized) + const debug = !!parsed.flags.debug; + let t0 = Date.now(); + const prereqs = await checkContext(); + if (debug) console.log(` checkContext: ${Date.now() - t0}ms`); + if (!isContextValid(prereqs, requiredLevel)) { + printContextError(prereqs, requiredLevel); + process.exit(1); + } + + // Initialize context with engine + let context: ArrContext | null = null; + try { + t0 = Date.now(); + context = await initContext(); + if (debug) console.log(` initContext: ${Date.now() - t0}ms`); + + // Trigger background PR refresh (rate-limited) + triggerBackgroundRefresh(context.cwd); + + t0 = Date.now(); + await handler(parsed, context); + if (debug) console.log(` handler: ${Date.now() - t0}ms`); + } finally { + // Auto-persist engine changes + context?.engine.persist(); + } + return; + } + + console.error(formatError(`Unknown command: ${command}`)); + hint(`Run '${arr(COMMAND_INFO.help)}' to see available commands.`); + process.exit(1); +} diff --git a/apps/cli/src/commands/auth.ts b/apps/cli/src/commands/auth.ts new file mode 100644 index 00000000..a9f6d9b8 --- /dev/null +++ b/apps/cli/src/commands/auth.ts @@ -0,0 +1,96 @@ +import { + checkGhAuth, + ghAuthLogin, + isGhInstalled, + saveAuthState, +} from "@array/core/auth"; +import type { CommandMeta } from "@array/core/commands/types"; +import { COMMANDS } from "../registry"; +import { + arr, + blank, + bold, + cmd, + cyan, + dim, + formatError, + formatSuccess, + heading, + hint, + indent, + message, + status, + steps, +} from "../utils/output"; +import { select } from "../utils/prompt"; + +export const meta: CommandMeta = { + name: "auth", + description: "Authenticate with GitHub for PR management", + context: "none", + category: "setup", +}; + +export async function auth(): Promise { + heading("GitHub Authentication"); + + const ghInstalled = await isGhInstalled(); + if (!ghInstalled) { + message(formatError("GitHub CLI (gh) is required but not installed.")); + steps("Install via Homebrew:", ["brew install gh"], COMMANDS.auth); + process.exit(1); + } + + const authStatus = await checkGhAuth(); + + if (authStatus.authenticated) { + message( + formatSuccess( + `Already authenticated as ${cyan(`@${authStatus.username}`)}`, + ), + ); + blank(); + hint(`To re-authenticate, run: ${cmd("gh auth login")}`); + return; + } + + message("To submit PRs, Array needs access to GitHub."); + blank(); + + const method = await select("Authenticate via:", [ + { label: "Browser (recommended)", value: "browser" as const }, + { label: "Token", value: "token" as const }, + ]); + + if (!method) { + message(dim("Cancelled.")); + return; + } + + blank(); + + if (method === "browser") { + status("Opening browser..."); + const result = await ghAuthLogin(); + + if (!result.ok) { + console.error(formatError(result.error.message)); + process.exit(1); + } + + blank(); + message(formatSuccess(`Authenticated as ${cyan(`@${result.value}`)}`)); + + await saveAuthState({ + version: 1, + ghAuthenticated: true, + username: result.value, + }); + } else { + indent(`1. Go to ${cyan("https://github.com/settings/tokens")}`); + indent(`2. Create a token with ${bold("repo")} scope`); + indent(`3. Run: ${cmd("gh auth login --with-token")}`); + blank(); + hint(`Then run ${arr(COMMANDS.auth)} again to verify.`); + } +} diff --git a/apps/cli/src/commands/bottom.ts b/apps/cli/src/commands/bottom.ts new file mode 100644 index 00000000..6022091a --- /dev/null +++ b/apps/cli/src/commands/bottom.ts @@ -0,0 +1,7 @@ +import { bottom as coreBottom } from "@array/core/commands/bottom"; +import { printNavResult } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function bottom(): Promise { + printNavResult(unwrap(await coreBottom())); +} diff --git a/apps/cli/src/commands/checkout.ts b/apps/cli/src/commands/checkout.ts new file mode 100644 index 00000000..342c35be --- /dev/null +++ b/apps/cli/src/commands/checkout.ts @@ -0,0 +1,8 @@ +import { checkout as checkoutCmd } from "@array/core/commands/checkout"; +import { printNavResult } from "../utils/output"; +import { requireArg, unwrap } from "../utils/run"; + +export async function checkout(id: string): Promise { + requireArg(id, "Usage: arr checkout "); + printNavResult(unwrap(await checkoutCmd(id))); +} diff --git a/apps/cli/src/commands/ci.ts b/apps/cli/src/commands/ci.ts new file mode 100644 index 00000000..04c38fd7 --- /dev/null +++ b/apps/cli/src/commands/ci.ts @@ -0,0 +1,111 @@ +import { + checkRulesetExists, + enableStackCheckProtection, + getBranchProtectionUrl, + getRepoInfo, + setupCI, +} from "@array/core/ci"; +import type { CommandMeta } from "@array/core/commands/types"; +import { shellExecutor } from "@array/core/executor"; +import { getTrunk } from "@array/core/jj"; +import { + blank, + cyan, + formatError, + formatSuccess, + hint, + indent, + message, + status, + warning, +} from "../utils/output"; +import { confirm } from "../utils/prompt"; + +export const meta: CommandMeta = { + name: "ci", + description: "Set up GitHub CI for stack checks", + context: "jj", + category: "setup", +}; + +export async function ci(): Promise { + const cwd = process.cwd(); + + // Always write workflow file (create or update) + const result = setupCI(cwd); + if (result.created) { + message(formatSuccess("Created .github/workflows/array-stack-check.yml")); + } else if (result.updated) { + message(formatSuccess("Updated .github/workflows/array-stack-check.yml")); + } + + const repoInfo = await getRepoInfo(cwd, shellExecutor); + if (!repoInfo) { + blank(); + warning("Could not determine repository."); + hint( + "Manually add 'Stack Check' as a required status check in GitHub settings.", + ); + return; + } + + blank(); + + // Check if ruleset already exists to show appropriate prompt + const rulesetExists = await checkRulesetExists( + repoInfo.owner, + repoInfo.repo, + shellExecutor, + cwd, + ); + + const prompt = rulesetExists + ? "Update ruleset to latest? (needs admin access)" + : "Enable 'Stack Check' as required? (needs admin access)"; + + const shouldProceed = await confirm(prompt); + + if (shouldProceed) { + const trunk = await getTrunk(cwd); + status( + rulesetExists + ? "Updating ruleset..." + : `Creating ruleset for ${trunk}...`, + ); + + const protectionResult = await enableStackCheckProtection( + { owner: repoInfo.owner, repo: repoInfo.repo, trunk }, + shellExecutor, + cwd, + ); + + const rulesetsUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/rules`; + + if (protectionResult.success) { + if (protectionResult.updated) { + message(formatSuccess("Updated ruleset 'Array Stack Check'")); + } else if (protectionResult.alreadyEnabled) { + message(formatSuccess("Ruleset 'Array Stack Check' already exists")); + } else { + message(formatSuccess("Created ruleset 'Array Stack Check'")); + } + blank(); + message( + "PRs in a stack will now be blocked until their downstack PRs are merged.", + ); + blank(); + message("View or edit the ruleset:"); + indent(cyan(rulesetsUrl)); + return; + } + + message(formatError(protectionResult.error ?? "Failed to create ruleset")); + } + + // Show manual URL if they declined or API failed + blank(); + const url = getBranchProtectionUrl(repoInfo.owner, repoInfo.repo); + message("To enable manually, create a ruleset:"); + indent(cyan(url)); + hint("→ Add 'Require status checks' → Type 'Stack Check' → Create"); +} diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts new file mode 100644 index 00000000..fdd2dc73 --- /dev/null +++ b/apps/cli/src/commands/config.ts @@ -0,0 +1,117 @@ +import type { CommandMeta } from "@array/core/commands/types"; +import { + createDefaultUserConfig, + loadUserConfig, + saveUserConfig, +} from "@array/core/config"; +import { + blank, + bold, + dim, + green, + heading, + hint, + message, + warning, + yellow, +} from "../utils/output"; +import { confirm, select } from "../utils/prompt"; + +export const meta: CommandMeta = { + name: "config", + description: "Configure preferences", + context: "none", + category: "setup", +}; + +type ConfigSection = "tips" | "reset"; + +export async function config(): Promise { + heading("Array Configuration"); + + const section = await select( + "What would you like to configure?", + [ + { label: "Tips & hints", value: "tips" }, + { label: "Reset all settings", value: "reset" }, + ], + ); + + if (!section) { + message(dim("Cancelled.")); + return; + } + + blank(); + + switch (section) { + case "tips": + await configureTips(); + break; + case "reset": + await resetConfig(); + break; + } +} + +async function configureTips(): Promise { + const userConfig = await loadUserConfig(); + + message(bold("Tips & Hints")); + blank(); + hint( + `Current setting: ${userConfig.tipsEnabled ? green("enabled") : yellow("disabled")}`, + ); + hint(`Tips seen: ${userConfig.tipsSeen.length}`); + blank(); + + const action = await select("What would you like to do?", [ + { + label: userConfig.tipsEnabled ? "Disable tips" : "Enable tips", + value: "toggle", + }, + { label: "Reset tips (show them again)", value: "reset" }, + { label: "Back", value: "back" }, + ]); + + if (!action || action === "back") { + return; + } + + if (action === "toggle") { + userConfig.tipsEnabled = !userConfig.tipsEnabled; + await saveUserConfig(userConfig); + blank(); + hint( + `Tips ${userConfig.tipsEnabled ? green("enabled") : yellow("disabled")}.`, + ); + } else if (action === "reset") { + userConfig.tipsSeen = []; + await saveUserConfig(userConfig); + blank(); + hint("Tips reset. You'll see them again."); + } +} + +async function resetConfig(): Promise { + message(bold("Reset Configuration")); + blank(); + warning("This will reset all user preferences to defaults."); + blank(); + + const confirmed = await confirm("Are you sure?"); + + if (confirmed === null) { + message(dim("Cancelled.")); + return; + } + + if (confirmed) { + await saveUserConfig(createDefaultUserConfig()); + blank(); + hint("Configuration reset to defaults."); + } else { + blank(); + hint("Cancelled."); + } +} diff --git a/apps/cli/src/commands/create.ts b/apps/cli/src/commands/create.ts new file mode 100644 index 00000000..e63fe37d --- /dev/null +++ b/apps/cli/src/commands/create.ts @@ -0,0 +1,38 @@ +import { create as createCmd } from "@array/core/commands/create"; +import type { ArrContext } from "@array/core/engine"; +import { COMMANDS } from "../registry"; +import { + arr, + cyan, + dim, + formatSuccess, + indent, + message, +} from "../utils/output"; +import { requireArg, unwrap } from "../utils/run"; +import { showTip } from "../utils/tips"; + +export async function create(msg: string, ctx: ArrContext): Promise { + requireArg( + msg, + "Usage: arr create \n Creates a change with current file modifications", + ); + + const result = unwrap( + await createCmd({ + message: msg, + engine: ctx.engine, + }), + ); + + message(formatSuccess(`Created ${cyan(result.bookmarkName)}`)); + message(""); + indent(dim("Now on empty working copy")); + indent(`${arr(COMMANDS.submit)} ${dim("to create a PR")}`); + indent( + `${arr(COMMANDS.down)} ${dim(`to add more changes to ${result.bookmarkName}`)}`, + ); + message(""); + + await showTip("create"); +} diff --git a/apps/cli/src/commands/delete.ts b/apps/cli/src/commands/delete.ts new file mode 100644 index 00000000..82093c2a --- /dev/null +++ b/apps/cli/src/commands/delete.ts @@ -0,0 +1,44 @@ +import { deleteChange as deleteCmd } from "@array/core/commands/delete"; +import type { ArrContext } from "@array/core/engine"; +import { changeLabel } from "@array/core/slugify"; +import { + cyan, + dim, + formatSuccess, + hint, + message, + red, + yellow, +} from "../utils/output"; +import { confirm } from "../utils/prompt"; +import { requireArg, unwrap } from "../utils/run"; + +export async function deleteChange( + id: string, + ctx: ArrContext, + options?: { yes?: boolean }, +): Promise { + requireArg(id, "Usage: arr delete "); + + // Note: We call deleteCmd which resolves the change internally. + // For confirmation, we show the raw id since we can't resolve beforehand without duplicating logic. + // The actual label will be shown in the success message. + const confirmed = await confirm( + `Delete ${cyan(id)}? ${red("Work will be permanently lost.")}`, + { autoYes: options?.yes, default: false }, + ); + + if (!confirmed) { + message(dim("Cancelled")); + return; + } + + const result = unwrap(await deleteCmd({ id, engine: ctx.engine })); + + const label = changeLabel(result.change.description, result.change.changeId); + message(formatSuccess(`Deleted change ${cyan(label)}`)); + + if (result.movedTo) { + hint(`Moved to parent: ${yellow(result.movedTo)}`); + } +} diff --git a/apps/cli/src/commands/down.ts b/apps/cli/src/commands/down.ts new file mode 100644 index 00000000..a23dc0f4 --- /dev/null +++ b/apps/cli/src/commands/down.ts @@ -0,0 +1,7 @@ +import { down as coreDown } from "@array/core/commands/down"; +import { printNavResult } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function down(): Promise { + printNavResult(unwrap(await coreDown())); +} diff --git a/apps/cli/src/commands/exit.ts b/apps/cli/src/commands/exit.ts new file mode 100644 index 00000000..8b27fa6b --- /dev/null +++ b/apps/cli/src/commands/exit.ts @@ -0,0 +1,23 @@ +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"; + +export const meta: CommandMeta = { + name: "exit", + description: "Exit to plain git on trunk (escape hatch if you need git)", + context: "jj", + category: "management", +}; + +export async function exit(): Promise { + 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)}`); +} diff --git a/apps/cli/src/commands/get.ts b/apps/cli/src/commands/get.ts new file mode 100644 index 00000000..b6142524 --- /dev/null +++ b/apps/cli/src/commands/get.ts @@ -0,0 +1,58 @@ +import { get as coreGet } from "@array/core/commands/get"; +import type { CommandMeta } from "@array/core/commands/types"; +import type { ArrContext } from "@array/core/engine"; +import { + cyan, + dim, + formatError, + formatSuccess, + hint, + magenta, + message, + status, +} from "../utils/output"; + +export const meta: CommandMeta = { + name: "get", + args: "", + description: + "Restore a branch and its stack from remote by name or PR number", + category: "workflow", +}; + +export async function get(ctx: ArrContext, target?: string): Promise { + if (!target) { + console.error(formatError("Missing argument: branch name or PR number")); + hint("Usage: arr get or arr get "); + process.exit(1); + } + + status(`Getting ${target} from remote...`); + + const result = await coreGet({ + target, + engine: ctx.engine, + cwd: ctx.cwd, + }); + + if (!result.ok) { + console.error(formatError(result.error.message)); + process.exit(1); + } + + const { stack, targetBranch } = result.value; + + message(formatSuccess(`Restored and switched to ${cyan(targetBranch)}`)); + + if (stack.length > 1) { + message(dim("Stack:")); + for (const branch of stack) { + const isTarget = branch.branchName === targetBranch; + const prLabel = magenta(`PR #${branch.prNumber}`); + const branchLabel = cyan(branch.branchName); + const marker = isTarget ? " ← you are here" : ""; + + message(` ${branchLabel} ${dim(`(${prLabel})`)}${marker}`); + } + } +} diff --git a/apps/cli/src/commands/hidden/dump-refs.ts b/apps/cli/src/commands/hidden/dump-refs.ts new file mode 100644 index 00000000..a495e994 --- /dev/null +++ b/apps/cli/src/commands/hidden/dump-refs.ts @@ -0,0 +1,27 @@ +import { getAllBranchMetadata } from "@array/core/git/metadata"; + +/** + * Hidden debug command to dump all arr refs metadata. + * Usage: arr __dump-refs + * + * Shows the contents of all refs/arr/* blobs, which store + * metadata about changes (PR info, etc.). + */ +export async function dumpRefs(): Promise { + const branchMetadata = getAllBranchMetadata(); + + if (branchMetadata.size === 0) { + console.log("No arr refs found."); + return; + } + + for (const [branchName, meta] of branchMetadata) { + console.log(`=== arr/${branchName} ===`); + if (meta) { + console.log(JSON.stringify(meta, null, 2)); + } else { + console.log("(failed to read metadata)"); + } + console.log(); + } +} diff --git a/apps/cli/src/commands/hidden/refresh-pr-info.ts b/apps/cli/src/commands/hidden/refresh-pr-info.ts new file mode 100644 index 00000000..83dd6ec3 --- /dev/null +++ b/apps/cli/src/commands/hidden/refresh-pr-info.ts @@ -0,0 +1,17 @@ +import { syncPRInfo } from "@array/core/commands/sync-pr-info"; +import { initContext } from "@array/core/engine"; + +/** + * Background PR info refresh command. + * Called by triggerBackgroundRefresh() as a detached process. + * Silently syncs PR info and exits. + */ +export async function refreshPRInfo(): Promise { + try { + const context = await initContext(); + await syncPRInfo({ engine: context.engine }); + context.engine.persist(); + } catch { + // Silent failure - background task shouldn't crash + } +} diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts new file mode 100644 index 00000000..c89002e9 --- /dev/null +++ b/apps/cli/src/commands/init.ts @@ -0,0 +1,230 @@ +import type { CommandMeta } from "@array/core/commands/types"; +import { isRepoInitialized } from "@array/core/config"; +import { hasBranch } from "@array/core/git/branch"; +import { hasRemote, isBranchPushed, pushBranch } from "@array/core/git/remote"; +import { hasGitCommits, initGit, isInGitRepo } from "@array/core/git/repo"; +import { detectTrunkBranches } from "@array/core/git/trunk"; +import { + checkPrerequisites, + configureTrunk, + initJj, + installJj, + isJjInitialized, +} from "@array/core/init"; +import { COMMANDS } from "../registry"; +import { + arr, + blank, + bold, + cyan, + dim, + formatError, + hint, + message, + status, + steps, + success, + warning, +} from "../utils/output"; +import { confirm, select } from "../utils/prompt"; + +export const meta: CommandMeta = { + name: "init", + description: + "Initialize Array in this repository by selecting a trunk branch", + context: "none", + category: "setup", +}; + +function printBanner(): void { + blank(); + message(`${bold("Array")} ${dim("- Stacked PRs for jj")}`); + blank(); +} + +function printQuickStart(): void { + const commands: [cmd: string, desc: string, args?: string][] = [ + [`arr ${COMMANDS.create.name}`, "Create a change", '"my first change"'], + [`arr ${COMMANDS.log.name}`, "See your stack"], + [`arr ${COMMANDS.submit.name}`, "Create a PR"], + ]; + + // Calculate max width for alignment + const maxWidth = Math.max( + ...commands.map(([cmd, , args]) => { + const full = args ? `${cmd} ${args}` : cmd; + return full.length; + }), + ); + + blank(); + message(cyan("You're ready to go!")); + blank(); + message(dim("Quick start:")); + for (const [cmd, desc, args] of commands) { + const full = args ? `${cmd} ${dim(args)}` : cmd; + const displayLen = args ? `${cmd} ${args}`.length : cmd.length; + const padding = " ".repeat(maxWidth - displayLen + 2); + message(` ${cyan(full)}${padding}${desc}`); + } + blank(); +} + +export async function init( + flags: Record = {}, +): Promise { + const cwd = process.cwd(); + const autoYes = Boolean(flags.y || flags.yes); + + printBanner(); + + const alreadyInitialized = await isRepoInitialized(cwd); + if (alreadyInitialized) { + warning("Array is already initialized in this repo."); + hint(`Run \`${arr(COMMANDS.status)}\` to see your current state.`); + return; + } + + const prereqs = await checkPrerequisites(); + + if (!prereqs.git.found) { + console.error(formatError("git not found")); + steps("Please install git first:", ["brew install git"]); + process.exit(1); + } + + if (!prereqs.jj.found) { + const installChoice = await select("jj is required. Install now?", [ + { label: "Yes, install via Homebrew", value: "brew" as const }, + { label: "Yes, install via Cargo", value: "cargo" as const }, + { label: "No, I'll install it myself", value: "skip" as const }, + ]); + + if (installChoice === "skip" || installChoice === null) { + steps( + "Install jj manually:", + ["brew install jj (Homebrew)", "cargo install jj-cli (Cargo)"], + COMMANDS.init, + ); + process.exit(1); + } + + status("Installing jj..."); + const installResult = await installJj(installChoice); + if (!installResult.ok) { + console.error(formatError(installResult.error.message)); + process.exit(1); + } + success("jj installed"); + blank(); + } + + let inGitRepo = await isInGitRepo(cwd); + if (!inGitRepo) { + const shouldInitGit = await confirm("Initialize git here?", { autoYes }); + if (shouldInitGit === null) { + message(dim("Cancelled.")); + process.exit(1); + } + if (!shouldInitGit) { + steps("Initialize git manually:", ["git init"], COMMANDS.init); + process.exit(1); + } + + const gitResult = await initGit(cwd); + if (!gitResult.ok) { + console.error(formatError(gitResult.error.message)); + process.exit(1); + } + success("Initialized git repository"); + inGitRepo = true; + } + + const hasCommits = await hasGitCommits(cwd); + if (!hasCommits) { + console.error(formatError("No commits found in this repository.")); + steps( + "Create your first commit before initializing Array:", + ["git add .", 'git commit -m "Initial commit"'], + COMMANDS.init, + ); + process.exit(1); + } + + const trunkCandidates = await detectTrunkBranches(cwd); + let trunk: string; + + if (trunkCandidates.length === 1) { + trunk = trunkCandidates[0]; + // Verify the branch actually exists + const exists = await hasBranch(cwd, trunk); + if (!exists) { + console.error(formatError(`Branch '${trunk}' not found.`)); + steps( + "Create your first commit on a branch:", + ["git checkout -b main", "git add .", 'git commit -m "Initial commit"'], + COMMANDS.init, + ); + process.exit(1); + } + } else { + const selected = await select( + "Select your trunk branch:", + trunkCandidates.map((b) => ({ label: b, value: b })), + ); + if (!selected) { + message(dim("Cancelled.")); + process.exit(1); + } + trunk = selected; + } + + const jjInitialized = await isJjInitialized(cwd); + if (!jjInitialized) { + const shouldInit = await confirm("Initialize jj in this repo?", { + autoYes, + }); + if (shouldInit === null) { + message(dim("Cancelled.")); + process.exit(1); + } + if (!shouldInit) { + steps( + "Initialize jj manually:", + ["jj git init --colocate"], + COMMANDS.init, + ); + process.exit(1); + } + + const initResult = await initJj(cwd); + if (!initResult.ok) { + console.error(formatError(initResult.error.message)); + process.exit(1); + } + success("Initialized jj"); + } + + // Configure jj's trunk() alias to point to the selected trunk branch + const trunkResult = await configureTrunk(cwd, trunk); + if (!trunkResult.ok) { + console.error(formatError(trunkResult.error.message)); + process.exit(1); + } + + // Ensure trunk is pushed to remote (required for PR creation) + const remoteExists = await hasRemote(cwd); + if (remoteExists) { + const trunkPushed = await isBranchPushed(cwd, trunk); + if (!trunkPushed) { + const pushResult = await pushBranch(cwd, trunk); + if (!pushResult.ok) { + warning(`Could not push ${trunk} to remote.`); + hint(`PRs require ${trunk} to exist on the remote.`); + hint(`Run: git push -u origin ${trunk}`); + } + } + } + + printQuickStart(); +} diff --git a/apps/cli/src/commands/log.ts b/apps/cli/src/commands/log.ts new file mode 100644 index 00000000..fd0d69b5 --- /dev/null +++ b/apps/cli/src/commands/log.ts @@ -0,0 +1,404 @@ +import { + log as coreLog, + formatRelativeTime, + type LogPRInfo, + type NormalLogResult, + parseChangeLine, +} from "@array/core/commands/log"; +import type { ArrContext } from "@array/core/engine"; +import { COMMANDS } from "../registry"; +import { + arr, + blank, + blue, + cyan, + dim, + formatChangeId, + formatCommitId, + green, + hint, + magenta, + message, + red, + yellow, +} from "../utils/output"; + +interface LogFlags { + debug?: boolean; +} + +export async function log( + ctx: ArrContext, + flags: LogFlags = {}, +): Promise { + const result = await coreLog({ + engine: ctx.engine, + cwd: ctx.cwd, + debug: flags.debug, + }); + + if (result.type === "unmanaged") { + renderUnmanagedBranch(result.branch, result.trunk); + return; + } + + if (result.type === "empty") { + message(`${green("◉")} ${blue(result.trunk)} ${dim("(current)")}`); + hint(`${cyan("arr create")} to start a new stack`); + return; + } + + // Normal log output + const { data } = result; + const output = renderEnhancedOutput(data); + message(output); + message("│"); + + if (data.timings) { + console.log("\n=== TIMINGS (ms) ==="); + for (const [key, value] of Object.entries(data.timings)) { + console.log(` ${key}: ${value}`); + } + console.log( + ` TOTAL: ${Object.values(data.timings).reduce((a, b) => a + b, 0)}`, + ); + console.log("=== END TIMINGS ==="); + } +} + +/** + * Process log data and render with colors, PR info, etc. + */ +function renderEnhancedOutput(data: NormalLogResult["data"]): string { + const { + lines, + prInfoMap, + unsyncedBookmarks, + trackedBookmarks, + behindTrunkChanges, + wcParentBookmark, + hasResolvedConflict, + unsyncedDiffStats, + trunk: trunkName, + } = data; + + const output: string[] = []; + + // Track current change context for multi-line enhancement + let currentBookmark: string | null = null; + let currentIsTracked = false; + let currentIsModified = false; + let currentIsTrunk = false; + let currentIsForkPoint = false; + let currentIsBehindTrunk = false; + let currentIsWorkingCopy = false; + let pendingHints: string[] = []; + + for (const line of lines) { + const { graphPrefix, tag, data: lineData } = line; + + if (tag === "BLANK") { + if (graphPrefix.trim() !== "") { + output.push(graphPrefix); + } + continue; + } + + switch (tag) { + case "CHANGE": { + const change = parseChangeLine(lineData); + const isWorkingCopy = graphPrefix.includes("@"); + + // Update context + currentBookmark = + change.bookmarks.find((b) => trackedBookmarks.includes(b)) || + change.bookmarks[0] || + null; + currentIsTracked = change.bookmarks.some((b) => + trackedBookmarks.includes(b), + ); + currentIsModified = change.bookmarks.some((b) => + unsyncedBookmarks.has(b), + ); + currentIsTrunk = change.bookmarks.includes(trunkName); + currentIsForkPoint = change.isImmutable && !currentIsTrunk; + currentIsBehindTrunk = behindTrunkChanges.has(change.changeId); + currentIsWorkingCopy = isWorkingCopy; + + // Check PR state + const prInfo = currentBookmark ? prInfoMap.get(currentBookmark) : null; + const isMerged = prInfo?.state === "MERGED"; + const isClosed = prInfo?.state === "CLOSED"; + + // Skip fork points + if (currentIsForkPoint) { + const connectorOnly = graphPrefix.replace(/[◆○@]/g, "│"); + if (connectorOnly.trim()) { + output.push(connectorOnly); + } + break; + } + + // Style the marker + let styledPrefix = graphPrefix; + if (isWorkingCopy) { + styledPrefix = graphPrefix.replace("@", green("◉")); + } else if (isMerged) { + styledPrefix = graphPrefix.replace(/[◆○]/g, magenta("◆")); + } else if (isClosed) { + styledPrefix = graphPrefix.replace(/[◆○]/g, red("×")); + } else if (graphPrefix.includes("◆") || graphPrefix.includes("○")) { + styledPrefix = graphPrefix.replace(/[◆○]/g, "◯"); + } + + // Build diff stats for WC + const wcDiffStats = + isWorkingCopy && + (change.linesAdded > 0 || + change.linesRemoved > 0 || + change.fileCount > 0) + ? formatDiffStats( + change.linesAdded, + change.linesRemoved, + change.fileCount, + ) + : ""; + + // Build the label + if (isWorkingCopy && !currentBookmark) { + output.push(`${styledPrefix}${blue("(working copy)")}${wcDiffStats}`); + } else if (currentIsTrunk) { + output.push(`${styledPrefix}${blue(trunkName)}`); + } else { + const label = currentBookmark + ? blue(currentBookmark) + : change.description || dim("(no description)"); + const shortId = formatChangeId( + change.changeId, + change.changeIdPrefix, + ); + + const badges: string[] = []; + if (isMerged) badges.push(magenta("merged")); + else if (isClosed) badges.push(red("closed")); + else if (currentIsBehindTrunk) badges.push(yellow("behind trunk")); + if (currentIsModified && !isMerged && !isClosed) + badges.push(yellow("local changes")); + if (change.hasConflict) badges.push(yellow("conflicts")); + const badgeStr = + badges.length > 0 + ? ` ${dim("(")}${badges.join(", ")}${dim(")")}` + : ""; + + // Diff stats for unsynced bookmarks + let localDiffStats = ""; + if (currentIsModified && currentBookmark) { + const stats = unsyncedDiffStats.get(currentBookmark); + if (stats && (stats.added > 0 || stats.removed > 0)) { + localDiffStats = ` ${formatDiffStats(stats.added, stats.removed, 0)}`; + } + } + + output.push( + `${styledPrefix}${label} ${shortId}${badgeStr}${localDiffStats}`, + ); + } + break; + } + + case "TIME": { + if (currentIsForkPoint) break; + + const timestamp = new Date(lineData); + const timeStr = formatRelativeTime(timestamp); + output.push(`${graphPrefix}${dim(timeStr)}`); + if (currentIsTrunk) { + output.push("│"); + } + break; + } + + case "HINT": { + // Hints handled in COMMIT case + break; + } + + case "PR": { + if (currentIsForkPoint) break; + + const [bookmarksStr] = lineData.split("|"); + const bookmark = parseBookmark(bookmarksStr, trunkName); + + if ( + bookmark && + bookmark !== trunkName && + currentIsTracked && + !currentIsTrunk + ) { + // Ensure we have a proper prefix for PR lines + let prefix = graphPrefix; + if ( + !prefix.includes("│") && + !prefix.includes("├") && + !prefix.includes("╯") + ) { + prefix = "│ "; + } + + const prInfo = prInfoMap.get(bookmark); + if (prInfo) { + if (prInfo.state === "OPEN") { + output.push(`${prefix}${formatPRLine(prInfo)}`); + output.push(`${prefix}${cyan(prInfo.url)}`); + if (currentIsBehindTrunk) { + pendingHints.push( + `${prefix}${arr(COMMANDS.restack)} ${dim("to rebase onto trunk")}`, + ); + } + if (currentIsModified) { + pendingHints.push( + `${prefix}${arr(COMMANDS.submit)} ${dim("to push local changes")}`, + ); + } + } else if (prInfo.state === "MERGED") { + output.push(`${prefix}${formatPRLine(prInfo)}`); + output.push(`${prefix}${cyan(prInfo.url)}`); + pendingHints.push( + `${prefix}${arr(COMMANDS.sync)} ${dim("to clean up merged changes")}`, + ); + } else if (prInfo.state === "CLOSED") { + output.push(`${prefix}${formatPRLine(prInfo)}`); + output.push(`${prefix}${cyan(prInfo.url)}`); + pendingHints.push( + `${prefix}${arr(COMMANDS.sync)} ${dim("to clean up closed PR")}`, + ); + } + } else { + output.push(`${prefix}${dim("Not submitted")}`); + pendingHints.push( + `${prefix}${arr(COMMANDS.submit)} ${dim("to create a PR")}`, + ); + } + } + break; + } + + case "COMMIT": { + if (currentIsForkPoint) break; + + const [commitId, commitIdPrefix, description] = lineData.split("|"); + const commitIdFormatted = formatCommitId(commitId, commitIdPrefix); + let prefix = graphPrefix; + if ( + !prefix.includes("│") && + !prefix.includes("├") && + !prefix.includes("╯") + ) { + prefix = "│ "; + } + output.push( + `${prefix}${commitIdFormatted} ${dim(`- ${description || "(no description)"}`)}`, + ); + + // Add hints for WC + if (currentIsWorkingCopy && !currentBookmark) { + const hintPrefix = "│ "; + if (hasResolvedConflict) { + pendingHints.push( + `${hintPrefix}${arr(COMMANDS.resolve)} ${dim("to apply conflict resolution")}`, + ); + } else { + pendingHints.push( + `${hintPrefix}${arr(COMMANDS.create)} ${dim('"message"')} ${dim("to save as new change")}`, + ); + if (wcParentBookmark) { + pendingHints.push( + `${hintPrefix}${arr(COMMANDS.modify)} ${dim(`to update ${wcParentBookmark}`)}`, + ); + } + } + } + + // Output pending hints + if (pendingHints.length > 0) { + for (const h of pendingHints) { + output.push(h); + } + pendingHints = []; + } + // Blank line after commit + const blankLinePrefix = prefix.replace(/[^\s│├─╯╮╭]/g, " ").trimEnd(); + output.push(blankLinePrefix || "│"); + break; + } + } + } + + // Remove trailing empty/graph-only lines + while ( + output.length > 0 && + output[output.length - 1].match(/^[│├─╯╮╭\s]*$/) + ) { + output.pop(); + } + + return output.join("\n"); +} + +function parseBookmarks(bookmarksStr: string): string[] { + if (!bookmarksStr) return []; + return bookmarksStr + .split(",") + .map((b) => b.replace(/\*$/, "").replace(/@\w+$/, "")) + .filter((b) => b.length > 0); +} + +function parseBookmark(bookmarksStr: string, trunkName: string): string | null { + const bookmarks = parseBookmarks(bookmarksStr); + const nonTrunk = bookmarks.find((b) => b !== trunkName); + return nonTrunk || bookmarks[0] || null; +} + +function formatPRLine(prInfo: LogPRInfo): string { + const stateColor = + prInfo.state === "MERGED" ? magenta : prInfo.state === "OPEN" ? green : red; + const stateLabel = + prInfo.state.charAt(0) + prInfo.state.slice(1).toLowerCase(); + return `${stateColor(`PR #${prInfo.number}`)} ${dim(`(${stateLabel})`)} ${prInfo.title}`; +} + +function formatDiffStats( + added: number, + removed: number, + fileCount: number, +): string { + if (added === 0 && removed === 0 && fileCount === 0) return ""; + + const parts: string[] = []; + if (added > 0) parts.push(green(`+${added}`)); + if (removed > 0) parts.push(red(`-${removed}`)); + + const fileStr = + fileCount > 0 ? dim(`${fileCount} file${fileCount === 1 ? "" : "s"}`) : ""; + + if (parts.length === 0 && fileCount > 0) { + return ` ${dim("(")}${fileStr}${dim(")")}`; + } + if (parts.length > 0) { + const statsStr = parts.join(dim(", ")); + const suffix = fileStr ? dim(", ") + fileStr : ""; + return ` ${dim("(")}${statsStr}${suffix}${dim(")")}`; + } + return ""; +} + +function renderUnmanagedBranch(branch: string, trunkName: string): void { + message(`${green("◉")} ${blue(trunkName)} ${dim("(current)")}`); + blank(); + message(yellow(`⚠ You're on git branch '${branch}'.`)); + blank(); + hint( + `To use arr, run ${arr(COMMANDS.checkout, trunkName)} or ${arr(COMMANDS.checkout, "")}.`, + ); + hint("To continue with git, use git commands."); +} diff --git a/apps/cli/src/commands/merge.ts b/apps/cli/src/commands/merge.ts new file mode 100644 index 00000000..0d0bb574 --- /dev/null +++ b/apps/cli/src/commands/merge.ts @@ -0,0 +1,95 @@ +import { getMergeablePrs, merge as mergeCmd } from "@array/core/commands/merge"; +import { sync as syncCmd } from "@array/core/commands/sync"; +import type { ArrContext } from "@array/core/engine"; +import type { PRToMerge } from "@array/core/types"; +import { COMMANDS } from "../registry"; +import { + arr, + blank, + cyan, + dim, + formatError, + formatSuccess, + hint, + message, + status, + warning, +} from "../utils/output"; +import { unwrap } from "../utils/run"; + +interface MergeFlags { + squash?: boolean; + rebase?: boolean; + merge?: boolean; +} + +export async function merge(flags: MergeFlags, ctx: ArrContext): Promise { + const trunk = ctx.trunk; + + const prsResult = await getMergeablePrs(); + + if (!prsResult.ok) { + if (prsResult.error.code === "INVALID_STATE") { + if (prsResult.error.message.includes("No bookmark")) { + console.error( + formatError( + `No bookmark on current change. Submit first with ${arr(COMMANDS.submit)}`, + ), + ); + } else if (prsResult.error.message.includes("No PR found")) { + console.error(formatError(prsResult.error.message)); + hint(`Submit first with ${arr(COMMANDS.submit)}`); + } else { + console.error(formatError(prsResult.error.message)); + } + process.exit(1); + } + console.error(formatError(prsResult.error.message)); + process.exit(1); + } + + const prs = prsResult.value; + + if (prs.length === 0) { + warning("No open PRs to merge"); + hint("Running sync to update local state..."); + unwrap(await syncCmd({ engine: ctx.engine })); + message(formatSuccess("Synced")); + return; + } + + let method: "merge" | "squash" | "rebase" = "squash"; + if (flags.merge) method = "merge"; + if (flags.rebase) method = "rebase"; + + message(`Merging ${prs.length} PR${prs.length > 1 ? "s" : ""} from stack...`); + blank(); + + const result = await mergeCmd(prs, { + method, + engine: ctx.engine, + onMerging: (pr: PRToMerge, nextPr?: PRToMerge) => { + message(`Merging PR #${cyan(String(pr.prNumber))}: ${pr.prTitle}`); + hint(`Branch: ${pr.bookmarkName} → ${pr.baseRefName}`); + if (nextPr) { + hint(`Rebasing PR #${nextPr.prNumber} onto ${trunk}...`); + } + }, + onWaiting: () => { + process.stdout.write(dim(" Waiting for GitHub...")); + }, + onMerged: (pr: PRToMerge) => { + process.stdout.write(`\r${" ".repeat(30)}\r`); + message(formatSuccess(`Merged PR #${pr.prNumber}`)); + }, + }); + + if (!result.ok) { + console.error(formatError(result.error.message)); + process.exit(1); + } + + blank(); + status("Syncing to update local state..."); + message(formatSuccess("Done! All PRs merged and synced.")); +} diff --git a/apps/cli/src/commands/modify.ts b/apps/cli/src/commands/modify.ts new file mode 100644 index 00000000..8f602bca --- /dev/null +++ b/apps/cli/src/commands/modify.ts @@ -0,0 +1,10 @@ +import { modify as coreModify } from "@array/core/commands/modify"; +import { green, message } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function modify(): Promise { + const result = unwrap(await coreModify()); + const label = + result.bookmark || result.description || result.changeId.slice(0, 8); + message(`Modified ${green(label)}`); +} diff --git a/apps/cli/src/commands/resolve.ts b/apps/cli/src/commands/resolve.ts new file mode 100644 index 00000000..426eb508 --- /dev/null +++ b/apps/cli/src/commands/resolve.ts @@ -0,0 +1,47 @@ +import { resolve as coreResolve } from "@array/core/commands/resolve"; +import { COMMANDS } from "../registry"; +import { + arr, + blank, + cyan, + formatSuccess, + hint, + indent, + message, + red, + warning, +} from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function resolve(): Promise { + const result = unwrap(await coreResolve()); + + // Show what was resolved + const resolvedLabel = result.resolved.description || result.resolved.changeId; + message(formatSuccess(`Applied resolution to "${resolvedLabel}"`)); + + // Check if there are more conflicts + if (result.nextConflict) { + blank(); + warning(`More conflicts at "${result.nextConflict.description}"`); + blank(); + for (const file of result.nextConflict.conflictedFiles) { + indent(`${red("C")} ${file}`); + } + blank(); + hint( + `Resolve the conflicts, then run ${arr(COMMANDS.resolve)} to continue`, + ); + return; + } + + // All done + if (result.returnedTo) { + blank(); + message(formatSuccess("All conflicts resolved")); + blank(); + message(`Returned to ${cyan(result.returnedTo)}`); + } else { + message(formatSuccess("Conflict resolved")); + } +} diff --git a/apps/cli/src/commands/restack.ts b/apps/cli/src/commands/restack.ts new file mode 100644 index 00000000..07378056 --- /dev/null +++ b/apps/cli/src/commands/restack.ts @@ -0,0 +1,58 @@ +import { restack as coreRestack } from "@array/core/commands/restack"; +import type { ArrContext } from "@array/core/engine"; +import { COMMANDS } from "../registry"; +import { + arr, + blank, + formatSuccess, + hint, + indent, + message, + red, + status, + warning, +} from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function restack(ctx: ArrContext): Promise { + status("Restacking all changes onto trunk..."); + + const result = unwrap(await coreRestack({ engine: ctx.engine }, ctx.cwd)); + + if (result.restacked === 0 && !result.conflict) { + message("All stacks already up to date with trunk"); + return; + } + + if (result.restacked > 0) { + message( + formatSuccess( + `Restacked ${result.restacked} stack${result.restacked === 1 ? "" : "s"} onto trunk`, + ), + ); + } + + // Handle conflicts + if (result.conflict) { + blank(); + warning(`Conflicts detected at "${result.conflict.description}"`); + blank(); + for (const file of result.conflict.conflictedFiles) { + indent(`${red("C")} ${file}`); + } + blank(); + hint( + `Resolve the conflicts in your editor, then run ${arr(COMMANDS.resolve)}`, + ); + return; + } + + // No conflicts - show push results + if (result.pushed.length > 0) { + message( + formatSuccess( + `Pushed ${result.pushed.length} bookmark${result.pushed.length === 1 ? "" : "s"}`, + ), + ); + } +} diff --git a/apps/cli/src/commands/split.ts b/apps/cli/src/commands/split.ts new file mode 100644 index 00000000..7d81e5a5 --- /dev/null +++ b/apps/cli/src/commands/split.ts @@ -0,0 +1,76 @@ +import { previewSplit, split as splitCmd } from "@array/core/commands/split"; +import type { ArrContext } from "@array/core/engine"; +import { + cyan, + dim, + formatError, + formatSuccess, + hint, + message, +} from "../utils/output"; +import { textInput } from "../utils/prompt"; +import { unwrap } from "../utils/run"; + +export async function split( + paths: string[], + options: { message?: string }, + ctx: ArrContext, +): Promise { + // Get preview from core + const previewResult = await previewSplit(paths); + + if (!previewResult.ok) { + console.error(formatError(previewResult.error.message)); + if ( + previewResult.error.code === "INVALID_STATE" && + previewResult.error.message.includes("No paths provided") + ) { + // Fetch files to show hint + const { getSplittableFiles } = await import("@array/core/commands/split"); + const filesResult = await getSplittableFiles(); + if (filesResult.ok && filesResult.value.length > 0) { + hint( + `Files in parent: ${filesResult.value.map((f) => f.path).join(", ")}`, + ); + } + } + process.exit(1); + } + + const { matchingFiles, availableFiles } = previewResult.value; + + // Show preview + message( + `Splitting ${cyan(String(matchingFiles.length))} file${matchingFiles.length === 1 ? "" : "s"} into new change:`, + ); + for (const file of matchingFiles) { + console.log(` ${dim(file.status)} ${file.path}`); + } + console.log(); + + // Get description - from option or prompt + let description = options.message; + if (!description) { + const input = await textInput("Description for new change"); + if (!input) { + console.error(formatError("Description is required")); + process.exit(1); + } + description = input; + } + + const result = unwrap( + await splitCmd({ + paths, + description, + engine: ctx.engine, + }), + ); + + message( + formatSuccess( + `Split ${cyan(String(result.fileCount))} file${result.fileCount === 1 ? "" : "s"} into "${result.description}"`, + ), + ); + hint(`Tracking: ${cyan(result.bookmarkName)}`); +} diff --git a/apps/cli/src/commands/squash.ts b/apps/cli/src/commands/squash.ts new file mode 100644 index 00000000..61bb1089 --- /dev/null +++ b/apps/cli/src/commands/squash.ts @@ -0,0 +1,26 @@ +import { squash as squashCmd } from "@array/core/commands/squash"; +import type { ArrContext } from "@array/core/engine"; +import { cyan, dim, formatSuccess, message } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function squash( + flags: { message?: string; m?: string }, + ctx: ArrContext, +): Promise { + const commitMessage = (flags.message ?? flags.m) as string | undefined; + const result = unwrap( + await squashCmd({ engine: ctx.engine, message: commitMessage }), + ); + + if (result.squashedCount === 0) { + message(dim(`Nothing to squash - ${result.bookmark} already has 1 commit`)); + return; + } + + message( + formatSuccess( + `Squashed ${cyan(result.squashedCount.toString())} commits into ${cyan(result.bookmark)}`, + ), + ); + message(dim(`Base: ${result.base}`)); +} diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts new file mode 100644 index 00000000..24383145 --- /dev/null +++ b/apps/cli/src/commands/status.ts @@ -0,0 +1,126 @@ +import { status as statusCmd } from "@array/core/commands/status"; +import { COMMANDS } from "../registry"; +import { + arr, + blank, + cyan, + dim, + formatChangeId, + formatDiffStats, + green, + hint, + indent, + message, + red, + warning, + yellow, +} from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function status(options: { debug?: boolean } = {}): Promise { + const debug = options.debug ?? false; + const { + info, + stats, + hasResolvedConflict: hasResolved, + } = unwrap(await statusCmd({ debug })); + const statsStr = stats ? ` ${formatDiffStats(stats)}` : ""; + + // Check if on main with no stack above (fresh start) + const isOnMainFresh = + info.isUndescribed && + info.stackPath.length === 1 && + info.stackPath[0] === "main"; + + // Line 1: Current position with change ID (prefix highlighted) + const changeId = formatChangeId( + info.changeId.slice(0, 8), + info.changeIdPrefix, + ); + if (isOnMainFresh) { + message(`${green("◉")} On ${cyan("main")} ${changeId}${statsStr}`); + } else if (info.isUndescribed) { + const label = info.hasChanges ? "(unsaved)" : "(empty)"; + message(`${green(label)} ${changeId}${statsStr}`); + message(dim(` ↳ ${info.stackPath.join(" → ")}`)); + } else { + message(`${green(info.name)} ${changeId}${statsStr}`); + // Only skip first element if it matches the name (i.e., current change has a bookmark) + // Otherwise show full path (current change has no bookmark, just a description) + const hasCurrentBookmark = info.stackPath[0] === info.name; + const parentPath = hasCurrentBookmark + ? info.stackPath.slice(1) + : info.stackPath; + if (parentPath.length > 0) { + message(dim(` ↳ ${parentPath.join(" → ")}`)); + } + } + + // Conflicts + if (info.conflicts.length > 0) { + blank(); + warning("Conflicts:"); + for (const conflict of info.conflicts) { + indent(`${red("C")} ${conflict.path}`); + } + } + + // Modified files + if (info.modifiedFiles.length > 0) { + blank(); + message(dim("Modified:")); + for (const file of info.modifiedFiles) { + const color = + file.status === "added" + ? green + : file.status === "deleted" + ? red + : yellow; + const statusPrefix = + file.status === "added" ? "A" : file.status === "deleted" ? "D" : "M"; + indent(`${color(statusPrefix)} ${file.path}`); + } + } + + // Behind trunk warning + if (info.isBehindTrunk && !info.hasConflicts) { + blank(); + warning("Stack is behind trunk"); + hint(`Run ${arr(COMMANDS.restack)} to rebase onto latest trunk`); + return; + } + + // Guidance + blank(); + const { action, reason } = info.nextAction; + const parentBookmark = info.stackPath.length > 1 ? info.stackPath[0] : null; + const hasTrackedParent = parentBookmark && parentBookmark !== "main"; + + if (isOnMainFresh && !info.hasChanges) { + message( + `Edit files, then run ${arr(COMMANDS.create)} to start a new stack`, + ); + hint(`Or ${arr(COMMANDS.top)} to return to your previous stack`); + } else if (action === "continue") { + if (hasResolved) { + message(`${arr(COMMANDS.resolve)} to apply conflict resolution`); + } else { + message(`Fix conflicts, then run ${arr(COMMANDS.resolve)}`); + } + } else if (info.hasChanges) { + // WC has uncommitted changes - show create/modify hints (matching arr log) + message( + `${arr(COMMANDS.create)} ${dim('"message"')} ${dim("to save as new change")}`, + ); + if (hasTrackedParent) { + message(`${arr(COMMANDS.modify)} ${dim(`to update ${parentBookmark}`)}`); + } + } else if (action === "submit") { + message( + `${arr(COMMANDS.submit)} to ${reason === "update_pr" ? "update PR" : "create PR"}`, + ); + } else { + // No changes yet - prompt to edit files + message(`${dim("Edit files to get started")}`); + } +} diff --git a/apps/cli/src/commands/submit.ts b/apps/cli/src/commands/submit.ts new file mode 100644 index 00000000..c004b293 --- /dev/null +++ b/apps/cli/src/commands/submit.ts @@ -0,0 +1,141 @@ +import { isGhInstalled } from "@array/core/auth"; +import { submit as submitCmd } from "@array/core/commands/submit"; +import type { ArrContext } from "@array/core/engine"; +import { checkPrerequisites } from "@array/core/init"; +import { + blank, + cyan, + dim, + green, + indent, + message, + printInstallInstructions, + status, + yellow, +} from "../utils/output"; +import { confirm } from "../utils/prompt"; +import { unwrap } from "../utils/run"; + +export async function submit( + flags: Record, + ctx: ArrContext, +): Promise { + const [prereqs, ghInstalled] = await Promise.all([ + checkPrerequisites(), + isGhInstalled(), + ]); + + const missing: ("jj" | "gh")[] = []; + if (!prereqs.jj.found) missing.push("jj"); + if (!ghInstalled) missing.push("gh"); + + if (missing.length > 0) { + printInstallInstructions(missing); + process.exit(1); + } + + const skipConfirm = Boolean(flags.yes || flags.y || flags["no-dry-run"]); + const dryRunOnly = Boolean(flags["dry-run"]); + const isTTY = process.stdin.isTTY; + + // First, do a dry run to show what would happen + status("Planning submit..."); + blank(); + + const plan = unwrap( + await submitCmd({ + draft: Boolean(flags.draft), + engine: ctx.engine, + dryRun: true, + }), + ); + + // Only show PRs that would be created or updated + const actionablePrs = plan.prs.filter( + (pr) => pr.status !== "synced" && pr.status !== "untracked", + ); + + if (actionablePrs.length === 0) { + message(dim("Nothing to submit")); + return; + } + + // Show the plan + for (const pr of actionablePrs) { + const action = pr.status === "created" ? green("Create") : yellow("Update"); + message(`${action} PR: ${cyan(pr.bookmarkName)}`); + indent(`base: ${dim(pr.base)}`); + } + + // Summary + const parts: string[] = []; + if (plan.created > 0) parts.push(`${green("Create:")} ${plan.created}`); + if (plan.updated > 0) parts.push(`${yellow("Update:")} ${plan.updated}`); + if (plan.synced > 0) parts.push(`${dim(`(${plan.synced} unchanged)`)}`); + + if (parts.length > 0) { + blank(); + message(parts.join(" ")); + } + + // Dry run only - exit without executing + if (dryRunOnly) { + return; + } + + // Non-TTY without confirmation flag - exit with hint + if (!isTTY && !skipConfirm) { + blank(); + message( + dim("Run with -y or --no-dry-run to execute in non-interactive mode"), + ); + return; + } + + // Ask for confirmation (unless skipping) + if (!skipConfirm) { + blank(); + const confirmed = await confirm("Proceed?", { autoYes: false }); + if (!confirmed) { + message(dim("Cancelled")); + return; + } + } + + // Execute the actual submit + blank(); + status("Submitting..."); + blank(); + + const result = unwrap( + await submitCmd({ + draft: Boolean(flags.draft), + engine: ctx.engine, + dryRun: false, + }), + ); + + // Show results + const completedPrs = result.prs.filter( + (pr) => pr.status !== "synced" && pr.status !== "untracked", + ); + + for (const pr of completedPrs) { + const label = + pr.status === "created" ? green("Created") : yellow("Updated"); + message(`${label} PR #${pr.prNumber}: ${cyan(pr.bookmarkName)}`); + indent(cyan(pr.prUrl)); + } + + // Final summary + const finalParts: string[] = []; + if (result.created > 0) + finalParts.push(`${green("Created:")} ${result.created}`); + if (result.updated > 0) + finalParts.push(`${yellow("Updated:")} ${result.updated}`); + + if (finalParts.length > 0) { + blank(); + message(finalParts.join(" ")); + } +} diff --git a/apps/cli/src/commands/sync.ts b/apps/cli/src/commands/sync.ts new file mode 100644 index 00000000..b22d16ac --- /dev/null +++ b/apps/cli/src/commands/sync.ts @@ -0,0 +1,121 @@ +import { restack as coreRestack } from "@array/core/commands/restack"; +import { + cleanupMergedChange, + sync as coreSync, +} from "@array/core/commands/sync"; +import type { ArrContext, Engine } from "@array/core/engine"; +import type { MergedChange } from "@array/core/stacks"; +import { COMMANDS } from "../registry"; +import { + arr, + dim, + formatSuccess, + hint, + magenta, + message, + status, + warning, +} from "../utils/output"; +import { confirm } from "../utils/prompt"; +import { unwrap } from "../utils/run"; + +/** + * Prompt user to clean up merged/closed changes. + */ +async function promptAndCleanupMerged( + pending: MergedChange[], + engine: Engine, +): Promise { + if (pending.length === 0) return 0; + + let cleanedUp = 0; + + for (const item of pending) { + const prLabel = magenta(`PR #${item.prNumber}`); + const branchLabel = dim(`(${item.bookmark})`); + const stateLabel = item.reason === "merged" ? "merged" : "closed"; + + const confirmed = await confirm( + `Clean up ${stateLabel} ${prLabel} ${branchLabel}: ${item.description}?`, + { default: true }, + ); + + if (confirmed) { + const result = await cleanupMergedChange(item, engine); + if (result.ok) { + cleanedUp++; + } + } + } + + return cleanedUp; +} + +export async function sync(ctx: ArrContext): Promise { + status("Syncing with remote..."); + + const result = unwrap(await coreSync({ engine: ctx.engine })); + + // Check if anything actually happened + const hadChanges = result.fetched || result.rebased || result.hasConflicts; + + if (!hadChanges) { + message(formatSuccess("Already up to date")); + } else { + if (result.fetched && !result.hasConflicts) { + message(formatSuccess("Synced with remote")); + } + if (result.hasConflicts) { + warning("Rebase resulted in conflicts"); + hint(`Resolve conflicts and run ${arr(COMMANDS.sync)} again`); + } + } + + // Find and prompt to clean up merged/closed changes + const cleanedUpCount = await promptAndCleanupMerged( + result.pendingCleanup, + ctx.engine, + ); + + if (cleanedUpCount > 0) { + message( + formatSuccess( + `Cleaned up ${cleanedUpCount} ${cleanedUpCount === 1 ? "branch" : "branches"}`, + ), + ); + } + + if (result.updatedComments > 0) { + message(formatSuccess("Updated stack comments")); + } + + // Check if there are other stacks behind trunk + if (result.stacksBehind > 0) { + const count = result.stacksBehind; + const confirmed = await confirm( + `${count} stack${count === 1 ? "" : "s"} behind trunk. Restack onto latest?`, + { default: true }, + ); + if (confirmed) { + status("Restacking and pushing..."); + const trackedBookmarks = ctx.engine.getTrackedBookmarks(); + const restackResult = unwrap(await coreRestack({ trackedBookmarks })); + + if (restackResult.restacked > 0) { + message( + formatSuccess( + `Restacked ${restackResult.restacked} stack${restackResult.restacked === 1 ? "" : "s"} onto trunk`, + ), + ); + } + + if (restackResult.pushed.length > 0) { + message( + formatSuccess( + `Pushed ${restackResult.pushed.length} bookmark${restackResult.pushed.length === 1 ? "" : "s"}`, + ), + ); + } + } + } +} diff --git a/apps/cli/src/commands/top.ts b/apps/cli/src/commands/top.ts new file mode 100644 index 00000000..f4d23ca7 --- /dev/null +++ b/apps/cli/src/commands/top.ts @@ -0,0 +1,7 @@ +import { top as coreTop } from "@array/core/commands/top"; +import { printNavResult } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function top(): Promise { + printNavResult(unwrap(await coreTop())); +} diff --git a/apps/cli/src/commands/track.ts b/apps/cli/src/commands/track.ts new file mode 100644 index 00000000..d2e38a93 --- /dev/null +++ b/apps/cli/src/commands/track.ts @@ -0,0 +1,19 @@ +import { track as trackCmd } from "@array/core/commands/track"; +import type { ArrContext } from "@array/core/engine"; +import { cyan, dim, formatSuccess, indent, message } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function track( + target: string | undefined, + ctx: ArrContext, +): Promise { + const result = unwrap( + await trackCmd({ + engine: ctx.engine, + target, + }), + ); + + message(formatSuccess(`Now tracking ${cyan(result.bookmark)}`)); + indent(`${dim("Parent:")} ${result.parent}`); +} diff --git a/apps/cli/src/commands/trunk.ts b/apps/cli/src/commands/trunk.ts new file mode 100644 index 00000000..f7d55b1b --- /dev/null +++ b/apps/cli/src/commands/trunk.ts @@ -0,0 +1,7 @@ +import { trunk as coreTrunk } from "@array/core/commands/trunk"; +import { printNavResult } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function trunk(): Promise { + printNavResult(unwrap(await coreTrunk())); +} diff --git a/apps/cli/src/commands/undo.ts b/apps/cli/src/commands/undo.ts new file mode 100644 index 00000000..16df5b6a --- /dev/null +++ b/apps/cli/src/commands/undo.ts @@ -0,0 +1,8 @@ +import { undo as coreUndo } from "@array/core/commands/undo"; +import { formatSuccess, message } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function undo(): Promise { + unwrap(await coreUndo()); + message(formatSuccess("Undone last operation")); +} diff --git a/apps/cli/src/commands/untrack.ts b/apps/cli/src/commands/untrack.ts new file mode 100644 index 00000000..7e888b6b --- /dev/null +++ b/apps/cli/src/commands/untrack.ts @@ -0,0 +1,82 @@ +import type { CommandMeta } from "@array/core/commands/types"; +import { + untrack as coreUntrack, + previewUntrack, +} from "@array/core/commands/untrack"; +import type { ArrContext } from "@array/core/engine"; +import { + cyan, + dim, + formatError, + formatSuccess, + message, + yellow, +} from "../utils/output"; +import { confirm } from "../utils/prompt"; + +export const meta: CommandMeta = { + name: "untrack", + args: "[branch]", + description: "Stop tracking a branch (and its upstack) with arr", + category: "workflow", +}; + +interface UntrackFlags { + force?: boolean; + f?: boolean; +} + +export async function untrack( + ctx: ArrContext, + target?: string, + flags: UntrackFlags = {}, +): Promise { + const { engine } = ctx; + const force = flags.force || flags.f; + + // Get preview of what will be untracked + const previewResult = await previewUntrack({ engine, target }); + + if (!previewResult.ok) { + console.error(formatError(previewResult.error.message)); + process.exit(1); + } + + const { bookmark, toUntrack, hasChildren } = previewResult.value; + + // Confirm if has children (unless --force) + if (hasChildren && !force) { + message( + `Will untrack ${cyan(bookmark)} and ${yellow(`${toUntrack.length - 1} upstack branches`)}:`, + ); + for (const b of toUntrack) { + message(` ${b === bookmark ? cyan(b) : dim(b)}`); + } + message(""); + + const confirmed = await confirm("Continue?"); + if (!confirmed) { + message(dim("Cancelled.")); + process.exit(0); + } + } + + // Actually untrack + const result = await coreUntrack({ engine, target: bookmark }); + + if (!result.ok) { + console.error(formatError(result.error.message)); + process.exit(1); + } + + const { untracked } = result.value; + + if (untracked.length === 1) { + message(formatSuccess(`Untracked ${cyan(untracked[0])}`)); + } else { + message(formatSuccess(`Untracked ${untracked.length} branches`)); + for (const b of untracked) { + message(` ${dim(b)}`); + } + } +} diff --git a/apps/cli/src/commands/up.ts b/apps/cli/src/commands/up.ts new file mode 100644 index 00000000..cbd3ebbb --- /dev/null +++ b/apps/cli/src/commands/up.ts @@ -0,0 +1,7 @@ +import { up as coreUp } from "@array/core/commands/up"; +import { printNavResult } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function up(): Promise { + printNavResult(unwrap(await coreUp())); +} diff --git a/apps/cli/src/registry.ts b/apps/cli/src/registry.ts new file mode 100644 index 00000000..15bf2bb6 --- /dev/null +++ b/apps/cli/src/registry.ts @@ -0,0 +1,201 @@ +import { bottomCommand } from "@array/core/commands/bottom"; +import { checkoutCommand } from "@array/core/commands/checkout"; +import { createCommand } from "@array/core/commands/create"; +import { deleteCommand } from "@array/core/commands/delete"; +import { downCommand } from "@array/core/commands/down"; +import { getCommand } from "@array/core/commands/get"; +import { mergeCommand } from "@array/core/commands/merge"; +import { modifyCommand } from "@array/core/commands/modify"; +import { resolveCommand } from "@array/core/commands/resolve"; +import { restackCommand } from "@array/core/commands/restack"; +import { splitCommand } from "@array/core/commands/split"; +import { squashCommand } from "@array/core/commands/squash"; +import { statusCommand } from "@array/core/commands/status"; +import { submitCommand } from "@array/core/commands/submit"; +import { syncCommand } from "@array/core/commands/sync"; +import { topCommand } from "@array/core/commands/top"; +import { trackCommand } from "@array/core/commands/track"; +import { trunkCommand } from "@array/core/commands/trunk"; +import type { CommandCategory, CommandMeta } from "@array/core/commands/types"; +import { undoCommand } from "@array/core/commands/undo"; +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 { 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 { deleteChange } from "./commands/delete"; +import { down } from "./commands/down"; +import { exit, meta as exitMeta } from "./commands/exit"; +import { get } from "./commands/get"; +import { init, meta as initMeta } from "./commands/init"; +import { log } from "./commands/log"; +import { merge } from "./commands/merge"; +import { modify } from "./commands/modify"; +import { resolve } from "./commands/resolve"; +import { restack } from "./commands/restack"; +import { split } from "./commands/split"; +import { squash } from "./commands/squash"; +import { status } from "./commands/status"; +import { submit } from "./commands/submit"; +import { sync } from "./commands/sync"; +import { top } from "./commands/top"; +import { track } from "./commands/track"; +import { trunk } from "./commands/trunk"; +import { undo } from "./commands/undo"; +import { untrack } from "./commands/untrack"; +import { up } from "./commands/up"; +import type { ParsedCommand } from "./utils/args"; + +export type { CommandMeta, CommandMeta as CommandInfo, CommandCategory }; + +/** + * Command handler function. + * Context is passed for commands that need jj/arr context. + * Handlers that don't need context can ignore the second parameter. + */ +type CommandHandler = ( + parsed: ParsedCommand, + context: ArrContext | null, +) => Promise; + +// Help and version don't have implementations, just define inline +const helpMeta: CommandMeta = { + name: "help", + description: "Show help", + context: "none", + category: "setup", +}; + +const versionMeta: CommandMeta = { + name: "version", + description: "Show version", + context: "none", + category: "setup", +}; + +const logMeta: CommandMeta = { + name: "log", + description: "Show a visual overview of the current stack with PR status", + aliases: ["l"], + category: "info", + core: true, +}; + +export const COMMANDS = { + auth: authMeta, + init: initMeta, + create: createCommand.meta, + submit: submitCommand.meta, + sync: syncCommand.meta, + restack: restackCommand.meta, + get: getCommand.meta, + track: trackCommand.meta, + untrack: untrackCommand.meta, + bottom: bottomCommand.meta, + checkout: checkoutCommand.meta, + down: downCommand.meta, + top: topCommand.meta, + trunk: trunkCommand.meta, + up: upCommand.meta, + log: logMeta, + status: statusCommand.meta, + delete: deleteCommand.meta, + modify: modifyCommand.meta, + resolve: resolveCommand.meta, + split: splitCommand.meta, + squash: squashCommand.meta, + merge: mergeCommand.meta, + undo: undoCommand.meta, + exit: exitMeta, + ci: ciMeta, + config: configMeta, + help: helpMeta, + version: versionMeta, +} as const; + +export const HANDLERS: Record = { + init: (p) => init(p.flags), + auth: () => auth(), + config: () => config(), + status: (p) => status({ debug: !!p.flags.debug }), + create: (p, ctx) => create(p.args.join(" "), ctx!), + submit: (p, ctx) => submit(p.flags, ctx!), + get: (p, ctx) => get(ctx!, p.args[0]), + track: (p, ctx) => track(p.args[0], ctx!), + untrack: (p, ctx) => + untrack(ctx!, p.args[0], { force: !!p.flags.force || !!p.flags.f }), + up: () => up(), + down: () => down(), + top: () => top(), + trunk: () => trunk(), + bottom: () => bottom(), + log: (p, ctx) => log(ctx!, { debug: !!p.flags.debug }), + sync: (_p, ctx) => sync(ctx!), + restack: (_p, ctx) => restack(ctx!), + checkout: (p) => checkout(p.args[0]), + delete: (p, ctx) => + deleteChange(p.args[0], ctx!, { yes: !!p.flags.yes || !!p.flags.y }), + modify: () => modify(), + resolve: () => resolve(), + split: (p, ctx) => + split( + p.args, + { message: (p.flags.message ?? p.flags.m) as string | undefined }, + ctx!, + ), + squash: (p, ctx) => squash(p.flags, ctx!), + merge: (p, ctx) => merge(p.flags, ctx!), + undo: () => undo(), + exit: () => exit(), + ci: () => ci(), +}; + +type CommandName = keyof typeof COMMANDS; + +export const CATEGORY_LABELS: Record = { + setup: "SETUP COMMANDS", + workflow: "CORE WORKFLOW COMMANDS", + navigation: "STACK NAVIGATION", + info: "STACK INFO", + management: "STACK MANAGEMENT", +}; + +export const CATEGORY_ORDER: CommandCategory[] = [ + "setup", + "workflow", + "navigation", + "info", + "management", +]; + +const COMMAND_ALIASES: Record = Object.fromEntries( + Object.values(COMMANDS).flatMap((cmd) => + (cmd.aliases ?? []).map((alias) => [alias, cmd.name]), + ), +); + +export function getRequiredContext(command: string): ContextLevel { + const cmd = COMMANDS[command as CommandName]; + return cmd?.context ?? "jj"; +} + +export function resolveCommandAlias(alias: string): string { + return COMMAND_ALIASES[alias] ?? alias; +} + +export function getCommandsByCategory( + category: CommandCategory, +): CommandMeta[] { + return Object.values(COMMANDS).filter( + (cmd) => cmd.category === category && !cmd.disabled, + ); +} + +export function getCoreCommands(): CommandMeta[] { + return Object.values(COMMANDS).filter((cmd) => cmd.core && !cmd.disabled); +} diff --git a/apps/cli/src/utils/args.ts b/apps/cli/src/utils/args.ts new file mode 100644 index 00000000..a1742c0a --- /dev/null +++ b/apps/cli/src/utils/args.ts @@ -0,0 +1,42 @@ +export interface ParsedCommand { + name: string; + args: string[]; + flags: Record; +} + +export function parseArgs(argv: string[]): ParsedCommand { + const allArgs = argv.slice(2); + + const flags: Record = {}; + const args: string[] = []; + let command = "__guided"; + + for (let i = 0; i < allArgs.length; i++) { + const arg = allArgs[i]; + if (arg.startsWith("--")) { + const key = arg.slice(2); + const nextArg = allArgs[i + 1]; + if (nextArg && !nextArg.startsWith("-")) { + flags[key] = nextArg; + i++; + } else { + flags[key] = true; + } + } else if (arg.startsWith("-") && arg.length === 2) { + const key = arg.slice(1); + const nextArg = allArgs[i + 1]; + if (nextArg && !nextArg.startsWith("-")) { + flags[key] = nextArg; + i++; + } else { + flags[key] = true; + } + } else if (command === "__guided") { + command = arg; + } else { + args.push(arg); + } + } + + return { name: command, args, flags }; +} diff --git a/apps/cli/src/utils/context.ts b/apps/cli/src/utils/context.ts new file mode 100644 index 00000000..01b88073 --- /dev/null +++ b/apps/cli/src/utils/context.ts @@ -0,0 +1,32 @@ +import { + type Context, + type ContextLevel, + checkContext as checkContextCore, + isContextValid, +} from "@array/core/context"; +import { COMMANDS } from "../registry"; +import { arr, blank, hint } from "./output"; + +export { isContextValid }; + +export function checkContext(): Promise { + return checkContextCore(process.cwd()); +} + +export function printContextError( + context: Context, + _level: ContextLevel, +): void { + blank(); + if (!context.jjInstalled) { + hint("jj is required but not installed."); + } else if (!context.inGitRepo) { + hint("Not in a git repository."); + } else if (!context.jjInitialized) { + hint("This repo is not using jj yet."); + } else { + hint("Array is not initialized."); + } + blank(); + hint(`Run ${arr(COMMANDS.init)} to get started.`); +} diff --git a/apps/cli/src/utils/output.ts b/apps/cli/src/utils/output.ts new file mode 100644 index 00000000..fa013692 --- /dev/null +++ b/apps/cli/src/utils/output.ts @@ -0,0 +1,225 @@ +import type { CommandMeta } from "@array/core/commands/types"; +import type { NavigationResult } from "@array/core/types"; + +const colors = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + white: "\x1b[37m", + pink: "\x1b[1;95m", + brightBlue: "\x1b[1;94m", + // jj-style 256 colors + jjChangeId: "\x1b[38;5;5m", // magenta (256-color) + jjChangeIdBright: "\x1b[1m\x1b[38;5;13m", // bright magenta (for WC) + jjCommitId: "\x1b[38;5;4m", // blue (256-color) + jjCommitIdBright: "\x1b[1m\x1b[38;5;12m", // bright blue (for WC) + jjGray: "\x1b[38;5;8m", // gray for suffixes +}; + +export function red(text: string): string { + return `${colors.red}${text}${colors.reset}`; +} + +export function green(text: string): string { + return `${colors.green}${text}${colors.reset}`; +} + +export function yellow(text: string): string { + return `${colors.yellow}${text}${colors.reset}`; +} + +export function cyan(text: string): string { + return `${colors.cyan}${text}${colors.reset}`; +} + +export function blue(text: string): string { + return `${colors.brightBlue}${text}${colors.reset}`; +} + +export function magenta(text: string): string { + return `${colors.magenta}${text}${colors.reset}`; +} + +export function pink(text: string): string { + return `${colors.pink}${text}${colors.reset}`; +} + +export function white(text: string): string { + return `${colors.white}${text}${colors.reset}`; +} + +export function bold(text: string): string { + return `${colors.bold}${text}${colors.reset}`; +} + +export function dim(text: string): string { + return `${colors.dim}${text}${colors.reset}`; +} + +export function formatChangeId(fullId: string, prefix: string): string { + const rest = fullId.slice(prefix.length); + return `${colors.jjChangeId}${prefix}${colors.jjGray}${rest}${colors.reset}`; +} + +export function formatCommitId(fullId: string, prefix: string): string { + const rest = fullId.slice(prefix.length); + return `${colors.brightBlue}${prefix}${colors.reset}${colors.jjGray}${rest}${colors.reset}`; +} + +export function formatError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return `${red("error:")} ${message}`; +} + +export function formatSuccess(message: string): string { + return `${green("✓")} ${message}`; +} + +/** + * Print navigation result with consistent formatting. + * + * Messages: + * - editing: "Editing branch-name" + * - on-top: "Now working on branch-name" + * - on-trunk: "Starting fresh on main" + */ +export function printNavResult(nav: NavigationResult): void { + const label = nav.bookmark || nav.description || nav.changeId.slice(0, 8); + + switch (nav.position) { + case "editing": + console.log(`Editing ${green(label)}`); + break; + case "on-top": + console.log(`Now working on ${green(label)}`); + break; + case "on-trunk": + console.log(`Starting fresh on ${cyan(label)}`); + break; + } +} + +export function blank(): void { + console.log(); +} + +export function heading(text: string): void { + console.log(); + console.log(bold(text)); + console.log(); +} + +export function status(text: string): void { + console.log(dim(text)); +} + +export function message(text: string): void { + console.log(text); +} + +export function indent(text: string): void { + console.log(` ${text}`); +} + +export function indent2(text: string): void { + console.log(` ${text}`); +} + +export function success(msg: string, detail?: string): void { + const suffix = detail ? ` ${dim(`(${detail})`)}` : ""; + console.log(` ${green("✓")} ${msg}${suffix}`); +} + +export function warning(msg: string, detail?: string): void { + const suffix = detail ? ` ${dim(`(${detail})`)}` : ""; + console.log(` ${yellow("⚠")} ${msg}${suffix}`); +} + +export function hint(text: string): void { + console.log(` ${dim(text)}`); +} + +export function cmd(command: string): string { + return cyan(command); +} + +export function arr(cmd: CommandMeta, args?: string): string { + return args ? cyan(`arr ${cmd.name} ${args}`) : cyan(`arr ${cmd.name}`); +} + +export function steps( + intro: string, + commands: string[], + retry?: CommandMeta, +): void { + console.log(); + console.log(` ${dim(intro)}`); + for (const command of commands) { + console.log(` ${cyan(command)}`); + } + if (retry) { + console.log(); + console.log(` ${dim("Then run")} ${arr(retry)} ${dim("again.")}`); + } +} + +export function printInstallInstructions(missing: ("jj" | "gh")[]): void { + console.log(`\n${red("Missing dependencies:")}\n`); + + if (missing.includes("jj")) { + console.log(`${bold("jj")} (Jujutsu) is not installed. Install with:`); + console.log(` ${cyan("macOS:")} brew install jj`); + console.log(` ${cyan("cargo:")} cargo install --locked jj-cli`); + console.log(` ${cyan("Windows:")} scoop install jujutsu`); + console.log(""); + } + + if (missing.includes("gh")) { + console.log(`${bold("gh")} (GitHub CLI) is not installed. Install with:`); + console.log(` ${cyan("macOS:")} brew install gh`); + console.log(` ${cyan("Linux:")} apt install gh`); + console.log(` ${cyan("Windows:")} scoop install gh`); + console.log(""); + } +} + +interface DiffStats { + filesChanged: number; + insertions: number; + deletions: number; +} + +export function formatDiffStats(stats: DiffStats): string { + if (stats.filesChanged === 0) return ""; + const parts: string[] = []; + if (stats.insertions > 0) parts.push(green(`+${stats.insertions}`)); + if (stats.deletions > 0) parts.push(red(`-${stats.deletions}`)); + const filesLabel = stats.filesChanged === 1 ? "file" : "files"; + parts.push(white(`${stats.filesChanged} ${filesLabel}`)); + return white("(") + parts.join(white(", ")) + white(")"); +} + +export function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return `${text.slice(0, maxLen - 1)}…`; +} + +// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes require control characters +const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g; + +export function visualWidth(text: string): number { + // Strip ANSI escape codes for width calculation + return text.replace(ANSI_ESCAPE_REGEX, "").length; +} + +export function padToWidth(text: string, width: number): string { + const currentWidth = visualWidth(text); + if (currentWidth >= width) return text; + return text + " ".repeat(width - currentWidth); +} diff --git a/apps/cli/src/utils/prompt.ts b/apps/cli/src/utils/prompt.ts new file mode 100644 index 00000000..675c208d --- /dev/null +++ b/apps/cli/src/utils/prompt.ts @@ -0,0 +1,151 @@ +import { bold, cyan, dim, green } from "./output"; + +async function readKey(): Promise { + const { stdin } = process; + + if (!stdin.isTTY) { + return ""; + } + + return new Promise((resolve) => { + stdin.setRawMode(true); + stdin.resume(); + + stdin.once("data", (data: Buffer) => { + stdin.setRawMode(false); + stdin.pause(); + resolve(data.toString("utf-8")); + }); + }); +} + +function shouldQuit(key: string): boolean { + const char = key[0]; + return char === "q" || char === "Q" || key === "\x03" || key === "\x1b"; +} + +function hideCursor(): void { + process.stdout.write("\x1b[?25l"); +} + +function showCursor(): void { + process.stdout.write("\x1b[?25h"); +} + +export async function confirm( + message: string, + options?: { autoYes?: boolean; default?: boolean }, +): Promise { + if (options?.autoYes) { + console.log(`${green("✓")} ${bold(message)} ${dim("›")} Yes`); + return true; + } + + const defaultValue = options?.default ?? true; + const selectOptions = defaultValue + ? [ + { label: "Yes", value: "yes" as const }, + { label: "No", value: "no" as const }, + ] + : [ + { label: "No", value: "no" as const }, + { label: "Yes", value: "yes" as const }, + ]; + + const result = await select(message, selectOptions); + if (result === null) return null; + return result === "yes"; +} + +export async function textInput(message: string): Promise { + const { stdin, stdout } = process; + const readline = await import("node:readline"); + + if (!stdin.isTTY) { + return null; + } + + const rl = readline.createInterface({ + input: stdin, + output: stdout, + }); + + return new Promise((resolve) => { + rl.question(`${cyan("?")} ${bold(message)} ${dim("›")} `, (answer) => { + rl.close(); + if (answer.trim() === "") { + resolve(null); + } else { + resolve(answer.trim()); + } + }); + }); +} + +export async function select( + message: string, + options: { label: string; value: T; hint?: string }[], +): Promise { + let selected = 0; + + const render = (initial = false) => { + // Move cursor up to redraw (except first render) + if (!initial) { + const lines = options.length + 1; // +1 for the message line + process.stdout.write(`\x1b[${lines}A`); + } + + console.log( + `${cyan("?")} ${bold(message)} ${dim("› Use arrow keys. Return to submit.")}`, + ); + for (const [i, opt] of options.entries()) { + const isSelected = i === selected; + const prefix = isSelected ? `${cyan("❯")}` : " "; + const label = isSelected ? cyan(opt.label) : dim(opt.label); + const hint = opt.hint ? ` ${dim(`(${opt.hint})`)}` : ""; + console.log(`${prefix} ${label}${hint}`); + } + }; + + hideCursor(); + + // Initial render + render(true); + + try { + while (true) { + const key = await readKey(); + + if (shouldQuit(key)) { + showCursor(); + return null; + } + + // Enter confirms current selection + if (key === "\r" || key === "\n") { + // Clear and show final selection + const lines = options.length + 1; + process.stdout.write(`\x1b[${lines}A\x1b[J`); + console.log( + `${green("✓")} ${bold(message)} ${dim("›")} ${options[selected].label}`, + ); + showCursor(); + return options[selected].value; + } + + // Arrow up or k + if (key === "\x1b[A" || key === "k") { + selected = selected > 0 ? selected - 1 : options.length - 1; + render(); + } + + // Arrow down or j + if (key === "\x1b[B" || key === "j") { + selected = selected < options.length - 1 ? selected + 1 : 0; + render(); + } + } + } finally { + showCursor(); + } +} diff --git a/apps/cli/src/utils/run.ts b/apps/cli/src/utils/run.ts new file mode 100644 index 00000000..6d567be6 --- /dev/null +++ b/apps/cli/src/utils/run.ts @@ -0,0 +1,65 @@ +import { findChange as jjFindChange } from "@array/core/jj"; +import type { Changeset } from "@array/core/parser"; +import type { Result } from "@array/core/result"; +import { + blank, + cyan, + dim, + formatChangeId, + formatError, + hint, + indent, + indent2, + message, +} from "./output"; + +export function unwrap(result: Result): T { + if (!result.ok) { + console.error(formatError(result.error.message)); + process.exit(1); + } + return result.value; +} + +export async function findChange( + query: string, + opts?: { includeBookmarks?: boolean }, +): Promise { + const result = unwrap(await jjFindChange(query, opts)); + + if (result.status === "none") { + console.error(formatError(`No changes matching: ${query}`)); + process.exit(1); + } + + if (result.status === "multiple") { + message(`Multiple matches for "${query}":`); + blank(); + for (const cs of result.matches) { + const bookmark = cs.bookmarks[0]; + const shortId = formatChangeId( + cs.changeId.slice(0, 8), + cs.changeIdPrefix, + ); + if (bookmark) { + indent(`${cyan(bookmark)} ${shortId}`); + indent2(cs.description || dim("(no description)")); + } else { + indent(`${shortId}: ${cs.description || dim("(no description)")}`); + } + } + blank(); + hint("Use a bookmark name or change ID to be more specific."); + process.exit(1); + } + + return result.change; +} + +export function requireArg(value: string | undefined, usage: string): string { + if (!value) { + console.error(formatError(usage)); + process.exit(1); + } + return value; +} diff --git a/apps/cli/src/utils/tips.ts b/apps/cli/src/utils/tips.ts new file mode 100644 index 00000000..b3661337 --- /dev/null +++ b/apps/cli/src/utils/tips.ts @@ -0,0 +1,19 @@ +import { getTip, markTipSeen, shouldShowTip } from "@array/core/config"; +import { blank, cmd, dim, hint } from "./output"; + +function formatTip(tip: string): string { + return tip.replace(/`([^`]+)`/g, (_, command) => cmd(command)); +} + +export async function showTip(command: string): Promise { + const tip = getTip(command); + if (!tip) return; + + const show = await shouldShowTip(command); + if (!show) return; + + blank(); + hint(`${dim("Tip:")} ${formatTip(tip)}`); + + await markTipSeen(command); +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 00000000..d7b071ee --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ES2022", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@array/core": ["../core/src/index.ts"] + } + }, + "include": ["src/**/*", "bin/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/knip.json b/knip.json index 3cceda00..0a938449 100644 --- a/knip.json +++ b/knip.json @@ -26,11 +26,22 @@ "node-addon-api" ] }, + "apps/cli": { + "entry": ["src/cli.ts"], + "project": ["src/**/*.ts", "bin/**/*.ts"], + "includeEntryExports": true + }, "packages/agent": { "project": ["src/**/*.ts"], "ignore": ["src/templates/**"], "ignoreDependencies": ["minimatch"], "includeEntryExports": true + }, + "packages/core": { + "entry": ["src/*.ts"], + "project": ["src/**/*.ts"], + "ignore": ["tests/**"], + "includeEntryExports": true } } } diff --git a/package.json b/package.json index bb64e2f6..cdc118e4 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "typecheck": "turbo typecheck", "lint": "biome check --write --unsafe", "format": "biome format --write", - "test": "pnpm -r test", + "test": "turbo test", + "test:bun": "turbo test --filter=@array/core --filter=@array/cli", + "test:vitest": "pnpm --filter array --filter @posthog/electron-trpc test", "clean": "pnpm -r clean", "knip": "knip", "prepare": "husky" diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..5a0fb744 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,34 @@ +{ + "name": "@array/core", + "version": "0.0.1", + "description": "Changeset management on top of jj (Jujutsu VCS)", + "type": "module", + "exports": { + "./commands/*": "./src/commands/*.ts", + "./engine": "./src/engine/index.ts", + "./git/*": "./src/git/*.ts", + "./jj": "./src/jj/index.ts", + "./jj/*": "./src/jj/*.ts", + "./stacks": "./src/stacks/index.ts", + "./stacks/*": "./src/stacks/*.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "build": "echo 'No build needed - using TypeScript sources directly'", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.5.0" + }, + "dependencies": { + "@octokit/graphql": "^9.0.3", + "@octokit/graphql-schema": "^15.26.1", + "@octokit/rest": "^22.0.1", + "zod": "^3.24.1" + }, + "files": [ + "dist/**/*", + "src/**/*" + ] +} diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts new file mode 100644 index 00000000..442bd752 --- /dev/null +++ b/packages/core/src/auth.ts @@ -0,0 +1,106 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import { type CommandExecutor, shellExecutor } from "./executor"; +import { createError, err, ok, type Result } from "./result"; + +const AuthStateSchema = z.object({ + version: z.literal(1), + ghAuthenticated: z.boolean(), + username: z.string().optional(), +}); + +type AuthState = z.infer; + +const AUTH_CONFIG_DIR = ".config/array"; +const AUTH_FILE = "auth.json"; + +function getAuthPath(): string { + return join(homedir(), AUTH_CONFIG_DIR, AUTH_FILE); +} + +export async function saveAuthState(state: AuthState): Promise { + const authDir = join(homedir(), AUTH_CONFIG_DIR); + const authPath = getAuthPath(); + + await ensureDir(authDir); + await Bun.write(authPath, JSON.stringify(state, null, 2)); +} + +interface GhAuthStatus { + authenticated: boolean; + username?: string; + error?: string; +} + +export async function checkGhAuth( + executor: CommandExecutor = shellExecutor, +): Promise { + try { + const result = await executor.execute("gh", ["auth", "status"], { + cwd: process.cwd(), + }); + + if (result.exitCode === 0) { + const usernameMatch = result.stdout.match( + /Logged in to github\.com account (\S+)/, + ); + const username = usernameMatch ? usernameMatch[1] : undefined; + return { authenticated: true, username }; + } + + return { authenticated: false, error: result.stderr }; + } catch (e) { + return { authenticated: false, error: `Failed to check gh auth: ${e}` }; + } +} + +export async function ghAuthLogin( + executor: CommandExecutor = shellExecutor, +): Promise> { + try { + const result = await executor.execute("gh", ["auth", "login", "--web"], { + cwd: process.cwd(), + }); + + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || "Failed to authenticate with GitHub", + ), + ); + } + + const status = await checkGhAuth(executor); + if (!status.authenticated) { + return err(createError("COMMAND_FAILED", "Authentication failed")); + } + + return ok(status.username || "unknown"); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to authenticate: ${e}`)); + } +} + +export async function isGhInstalled( + executor: CommandExecutor = shellExecutor, +): Promise { + try { + const result = await executor.execute("which", ["gh"], { + cwd: process.cwd(), + }); + return result.exitCode === 0; + } catch { + return false; + } +} + +async function ensureDir(dirPath: string): Promise { + try { + const { mkdir } = await import("node:fs/promises"); + await mkdir(dirPath, { recursive: true }); + } catch { + // Directory might already exist + } +} diff --git a/packages/core/src/background-refresh.ts b/packages/core/src/background-refresh.ts new file mode 100644 index 00000000..c28fbcb1 --- /dev/null +++ b/packages/core/src/background-refresh.ts @@ -0,0 +1,63 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const RATE_LIMIT_FILE = ".git/arr-last-pr-refresh"; +const RATE_LIMIT_MS = 60 * 1000; // 1 minute + +/** + * Get the path to the rate limit file. + */ +function getRateLimitPath(cwd: string): string { + return join(cwd, RATE_LIMIT_FILE); +} + +/** + * Check if we should refresh PR info (rate limited to once per minute). + */ +function shouldRefreshPRInfo(cwd: string): boolean { + const path = getRateLimitPath(cwd); + + if (!existsSync(path)) { + return true; + } + + try { + const content = readFileSync(path, "utf-8"); + const lastRefresh = parseInt(content, 10); + const now = Date.now(); + return now - lastRefresh > RATE_LIMIT_MS; + } catch { + return true; + } +} + +/** + * Mark that we're starting a PR refresh (update the timestamp). + */ +function markPRRefreshStarted(cwd: string): void { + const path = getRateLimitPath(cwd); + writeFileSync(path, String(Date.now())); +} + +/** + * Trigger background PR info refresh if rate limit allows. + * Spawns a detached process that runs `arr __refresh-pr-info`. + */ +export function triggerBackgroundRefresh(cwd: string): void { + if (!shouldRefreshPRInfo(cwd)) { + return; + } + + // Mark as started before spawning to prevent race conditions + markPRRefreshStarted(cwd); + + // Spawn detached process: arr __refresh-pr-info + const scriptPath = process.argv[1]; + const child = spawn(process.argv[0], [scriptPath, "__refresh-pr-info"], { + cwd, + detached: true, + stdio: "ignore", + }); + child.unref(); +} diff --git a/packages/core/src/bookmark-utils.ts b/packages/core/src/bookmark-utils.ts new file mode 100644 index 00000000..d9222b89 --- /dev/null +++ b/packages/core/src/bookmark-utils.ts @@ -0,0 +1,106 @@ +import { getPRForBranch, type PRInfo } from "./github/pr-status"; +import { createError, err, ok, type Result } from "./result"; + +/** Maximum number of suffix attempts before giving up on conflict resolution */ +const MAX_BOOKMARK_SUFFIX = 25; + +interface BookmarkConflictResult { + /** Original bookmark name before conflict resolution */ + originalName: string; + /** Final resolved bookmark name (may have -2, -3, etc. suffix) */ + resolvedName: string; + /** Whether the name was changed due to a conflict */ + hadConflict: boolean; +} + +/** + * Resolve bookmark name conflicts with existing closed/merged PRs on GitHub. + * + * When a bookmark name conflicts with a closed or merged PR, this function + * finds a unique name by appending -2, -3, etc. suffixes. + * + * @param bookmark - The bookmark name to check/resolve + * @param prCache - Optional pre-fetched PR cache to avoid redundant API calls + * @param assignedNames - Set of names already assigned in this batch (to avoid duplicates) + * @param cwd - Working directory (defaults to process.cwd()) + * @returns The resolved bookmark name, or error if too many conflicts + */ +export async function resolveBookmarkConflict( + bookmark: string, + prCache?: Map, + assignedNames?: Set, + cwd = process.cwd(), +): Promise> { + // Check cache first, otherwise fetch from GitHub + let existingPR: PRInfo | null = null; + if (prCache) { + existingPR = prCache.get(bookmark) ?? null; + } else { + const prResult = await getPRForBranch(bookmark, cwd); + if (!prResult.ok) return prResult; + existingPR = prResult.value; + } + + // No conflict if PR doesn't exist or is open + if (!existingPR || existingPR.state === "OPEN") { + return ok({ + originalName: bookmark, + resolvedName: bookmark, + hadConflict: false, + }); + } + + // PR exists and is closed/merged - find a unique suffix + const baseBookmark = bookmark; + let suffix = 2; + + while (suffix <= MAX_BOOKMARK_SUFFIX) { + const candidateName = `${baseBookmark}-${suffix}`; + + // Check if this candidate is already assigned in this batch + if (assignedNames?.has(candidateName)) { + suffix++; + continue; + } + + // Check if this candidate has an existing PR + let candidatePR: PRInfo | null = null; + if (prCache) { + candidatePR = prCache.get(candidateName) ?? null; + } else { + const checkResult = await getPRForBranch(candidateName, cwd); + if (checkResult.ok) { + candidatePR = checkResult.value; + } + } + + // Found an unused name + if (!candidatePR) { + return ok({ + originalName: bookmark, + resolvedName: candidateName, + hadConflict: true, + }); + } + + suffix++; + } + + // Exceeded max suffix attempts + return err( + createError( + "CONFLICT", + `Too many PR name conflicts for "${baseBookmark}". Clean up old PRs or use a different description.`, + ), + ); +} + +/** + * Check if a bookmark name is a remote-tracking bookmark (e.g., "feature@origin"). + * + * Remote-tracking bookmarks have a @remote suffix pattern and should be + * excluded from local operations. + */ +export function isTrackingBookmark(bookmark: string): boolean { + return /@[a-zA-Z0-9_-]+$/.test(bookmark); +} diff --git a/packages/core/src/ci.ts b/packages/core/src/ci.ts new file mode 100644 index 00000000..9cce11c9 --- /dev/null +++ b/packages/core/src/ci.ts @@ -0,0 +1,407 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createError, err, ok, type Result } from "./result"; + +const STACK_CHECK_WORKFLOW = `# Generated by Array CLI - https://github.com/posthog/array +# Blocks stacked PRs until their downstack dependencies are merged +# Only runs for PRs managed by Array (detected via stack comment marker) + +name: Stack Check + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + pull_request_target: + types: [closed] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + pull-requests: read + issues: read + steps: + - name: Check stack dependencies + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + // Check if this is an Array-managed PR by looking for stack comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number + }); + + const isArrayPR = comments.some(c => + c.body.includes('') + ); + + if (!isArrayPR) { + console.log('Not an Array PR, skipping'); + return; + } + + const baseBranch = pr.base.ref; + const trunk = ['main', 'master', 'develop']; + + if (trunk.includes(baseBranch)) { + console.log('Base is trunk, no dependencies'); + return; + } + + async function getBlockers(base, visited = new Set()) { + if (trunk.includes(base) || visited.has(base)) { + return []; + } + visited.add(base); + + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: \`\${context.repo.owner}:\${base}\` + }); + + if (prs.length === 0) { + return []; + } + + const blocker = prs[0]; + const upstream = await getBlockers(blocker.base.ref, visited); + return [{ number: blocker.number, title: blocker.title }, ...upstream]; + } + + const blockers = await getBlockers(baseBranch); + + if (blockers.length > 0) { + const list = blockers.map(b => \`#\${b.number} (\${b.title})\`).join('\\n - '); + core.setFailed(\`Blocked by:\\n - \${list}\\n\\nMerge these PRs first (bottom to top).\`); + } else { + console.log('All dependencies merged, ready to merge'); + } + + recheck-dependents: + runs-on: ubuntu-latest + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'closed' && + github.event.pull_request.merged == true + permissions: + pull-requests: write + issues: read + steps: + - name: Trigger recheck of dependent PRs + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + // Check if this is an Array-managed PR + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number + }); + + const isArrayPR = comments.some(c => + c.body.includes('') + ); + + if (!isArrayPR) { + console.log('Not an Array PR, skipping'); + return; + } + + const mergedBranch = pr.head.ref; + + const { data: dependentPRs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + base: mergedBranch, + state: 'open' + }); + + for (const dependentPR of dependentPRs) { + console.log(\`Retargeting PR #\${dependentPR.number} to \${pr.base.ref}\`); + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: dependentPR.number, + base: pr.base.ref + }); + } +`; + +export interface SetupCIResult { + created: boolean; + updated: boolean; + path: string; +} + +function getWorkflowPath(cwd: string): string { + return join(cwd, ".github", "workflows", "array-stack-check.yml"); +} + +export function setupCI(cwd: string): SetupCIResult { + const workflowPath = getWorkflowPath(cwd); + const existed = existsSync(workflowPath); + + const workflowDir = dirname(workflowPath); + mkdirSync(workflowDir, { recursive: true }); + writeFileSync(workflowPath, STACK_CHECK_WORKFLOW); + + return { + created: !existed, + updated: existed, + path: workflowPath, + }; +} + +export interface EnableProtectionResult { + success: boolean; + error?: string; + alreadyEnabled?: boolean; + updated?: boolean; +} + +/** + * Get repo info by reading git remote. + */ +export async function getRepoInfo( + cwd: string, + executor: { + execute: ( + cmd: string, + args: string[], + opts: { cwd: string }, + ) => Promise<{ exitCode: number; stdout: string; stderr: string }>; + }, +): Promise<{ owner: string; repo: string } | null> { + const remoteResult = await executor.execute( + "git", + ["config", "--get", "remote.origin.url"], + { cwd }, + ); + + if (remoteResult.exitCode !== 0) return null; + + const repoInfo = getRepoInfoFromRemote(remoteResult.stdout.trim()); + return repoInfo.ok ? repoInfo.value : null; +} + +export function getRepoInfoFromRemote( + remoteUrl: string, +): Result<{ owner: string; repo: string }> { + // Handle SSH format: git@github.com:owner/repo.git + // Use [\w-]+ to match valid GitHub usernames/org names (alphanumeric, underscore, hyphen) + const sshMatch = remoteUrl.match( + /^git@github\.com:([\w-]+)\/([\w.-]+?)(?:\.git)?$/, + ); + if (sshMatch) { + return ok({ owner: sshMatch[1], repo: sshMatch[2] }); + } + + // Handle HTTPS format: https://github.com/owner/repo.git + const httpsMatch = remoteUrl.match( + /^https:\/\/github\.com\/([\w-]+)\/([\w.-]+?)(?:\.git)?$/, + ); + if (httpsMatch) { + return ok({ owner: httpsMatch[1], repo: httpsMatch[2] }); + } + + return err( + createError("COMMAND_FAILED", "Could not parse GitHub remote URL"), + ); +} + +export function getBranchProtectionUrl(owner: string, repo: string): string { + // Use the newer rulesets UI with as many prefilled params as possible + const params = new URLSearchParams({ + target: "branch", + enforcement: "active", + name: "Array Stack Check", + // Try common param patterns for the check name + required_status_checks: "Stack Check", + }); + return `https://github.com/${owner}/${repo}/settings/rules/new?${params.toString()}`; +} + +export interface EnableProtectionOptions { + owner: string; + repo: string; + trunk: string; +} + +export async function checkRulesetExists( + owner: string, + repo: string, + executor: { + execute: ( + cmd: string, + args: string[], + opts: { cwd: string }, + ) => Promise<{ exitCode: number; stdout: string; stderr: string }>; + }, + cwd: string, +): Promise { + const listResult = await executor.execute( + "gh", + [ + "api", + `-H`, + `Accept: application/vnd.github+json`, + `/repos/${owner}/${repo}/rulesets`, + ], + { cwd }, + ); + + if (listResult.exitCode === 0) { + try { + const rulesets = JSON.parse(listResult.stdout); + return rulesets.some( + (r: { name: string }) => r.name === "Array Stack Check", + ); + } catch { + return false; + } + } + return false; +} + +export async function enableStackCheckProtection( + options: EnableProtectionOptions, + executor: { + execute: ( + cmd: string, + args: string[], + opts: { cwd: string }, + ) => Promise<{ exitCode: number; stdout: string; stderr: string }>; + }, + cwd: string, +): Promise { + const { owner, repo } = options; + + // Ruleset configuration + const rulesetBody = { + name: "Array Stack Check", + target: "branch", + enforcement: "active", + conditions: { + ref_name: { + include: ["~DEFAULT_BRANCH"], + exclude: [], + }, + }, + rules: [ + { + type: "required_status_checks", + parameters: { + required_status_checks: [ + { + context: "Stack Check", + integration_id: 15368, // GitHub Actions + }, + ], + strict_required_status_checks_policy: false, + }, + }, + ], + }; + + // Check if ruleset already exists + const listResult = await executor.execute( + "gh", + [ + "api", + `-H`, + `Accept: application/vnd.github+json`, + `/repos/${owner}/${repo}/rulesets`, + ], + { cwd }, + ); + + let existingId: number | null = null; + if (listResult.exitCode === 0) { + try { + const rulesets = JSON.parse(listResult.stdout); + const existing = rulesets.find( + (r: { name: string; id: number }) => r.name === "Array Stack Check", + ); + if (existing) { + existingId = existing.id; + } + } catch { + // Continue to create + } + } + + const body = JSON.stringify(rulesetBody); + + // Update existing or create new + if (existingId) { + const updateResult = await executor.execute( + "bash", + [ + "-c", + `echo '${body}' | gh api --method PUT -H "Accept: application/vnd.github+json" /repos/${owner}/${repo}/rulesets/${existingId} --input -`, + ], + { cwd }, + ); + + if (updateResult.exitCode === 0) { + return { success: true, updated: true }; + } + + const stderr = updateResult.stderr.toLowerCase(); + if ( + stderr.includes("403") || + stderr.includes("404") || + stderr.includes("must have admin") + ) { + return { + success: false, + error: "Admin access required. Ask a repo admin to run this command.", + }; + } + return { + success: false, + error: updateResult.stderr || "Failed to update ruleset", + }; + } + + const createResult = await executor.execute( + "bash", + [ + "-c", + `echo '${body}' | gh api --method POST -H "Accept: application/vnd.github+json" /repos/${owner}/${repo}/rulesets --input -`, + ], + { cwd }, + ); + + if (createResult.exitCode === 0) { + return { success: true }; + } + + const stderr = createResult.stderr.toLowerCase(); + if ( + stderr.includes("403") || + stderr.includes("404") || + stderr.includes("must have admin") + ) { + return { + success: false, + error: "Admin access required. Ask a repo admin to run this command.", + }; + } + + return { + success: false, + error: createResult.stderr || "Failed to create ruleset", + }; +} diff --git a/packages/core/src/commands/bottom.ts b/packages/core/src/commands/bottom.ts new file mode 100644 index 00000000..ce7446d1 --- /dev/null +++ b/packages/core/src/commands/bottom.ts @@ -0,0 +1,58 @@ +import { list, status } from "../jj"; +import { createError, err, type Result } from "../result"; +import type { NavigationResult } from "../types"; +import { navigateTo } from "./navigation"; +import type { Command } from "./types"; + +/** + * Navigate to the bottom of the current stack. + */ +export async function bottom(): Promise> { + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const hasChanges = statusResult.value.modifiedFiles.length > 0; + + if (hasChanges) { + return err( + createError( + "NAVIGATION_FAILED", + 'You have unsaved changes. Run `arr create "message"` to save them.', + ), + ); + } + + // Find roots of the current stack (changes between trunk and @-) + // Use @- since that's the current change (WC is on top) + const rootsResult = await list({ revset: "roots(trunk()..@-)" }); + if (!rootsResult.ok) return rootsResult; + + const roots = rootsResult.value.filter( + (c) => !c.changeId.startsWith("zzzzzzzz"), + ); + + if (roots.length === 0) { + return err(createError("NAVIGATION_FAILED", "Already at bottom of stack")); + } + + if (roots.length > 1) { + return err( + createError( + "NAVIGATION_FAILED", + "Stack has multiple roots - cannot determine bottom", + ), + ); + } + + return navigateTo(roots[0]); +} + +export const bottomCommand: Command = { + meta: { + name: "bottom", + description: "Switch to the change closest to trunk in the current stack", + aliases: ["b"], + category: "navigation", + }, + run: bottom, +}; diff --git a/packages/core/src/commands/checkout.ts b/packages/core/src/commands/checkout.ts new file mode 100644 index 00000000..5e8d7e7b --- /dev/null +++ b/packages/core/src/commands/checkout.ts @@ -0,0 +1,38 @@ +import { resolveChange } from "../jj"; +import type { Result } from "../result"; +import type { NavigationResult } from "../types"; +import { navigateTo, newOnTrunk } from "./navigation"; +import type { Command } from "./types"; + +/** + * Checkout a change by its ID, bookmark, or search query. + * If checking out trunk/main/master, creates a new empty change on top. + */ +export async function checkout( + target: string, +): Promise> { + // Handle trunk checkout - creates new empty change on main + if (target === "main" || target === "master" || target === "trunk") { + const trunkName = target === "trunk" ? "main" : target; + return newOnTrunk(trunkName); + } + + // Resolve the change + const changeResult = await resolveChange(target, { includeBookmarks: true }); + if (!changeResult.ok) return changeResult; + + // Navigate to the change (handles immutability correctly) + return navigateTo(changeResult.value); +} + +export const checkoutCommand: Command = { + meta: { + name: "checkout", + args: "[id]", + description: "Switch to a change by ID or description search", + aliases: ["co"], + category: "navigation", + core: true, + }, + run: checkout, +}; diff --git a/packages/core/src/commands/create.ts b/packages/core/src/commands/create.ts new file mode 100644 index 00000000..47c60462 --- /dev/null +++ b/packages/core/src/commands/create.ts @@ -0,0 +1,92 @@ +import { resolveBookmarkConflict } from "../bookmark-utils"; +import type { Engine } from "../engine"; +import { ensureBookmark, runJJ, status } from "../jj"; +import { createError, err, ok, type Result } from "../result"; +import { datePrefixedLabel } from "../slugify"; +import type { Command } from "./types"; + +interface CreateResult { + changeId: string; + bookmarkName: string; +} + +interface CreateOptions { + message: string; + engine: Engine; +} + +/** + * Create a new change with the current file modifications. + * Sets up bookmark and prepares for PR submission. + * Tracks the new bookmark in the engine. + */ +export async function create( + options: CreateOptions, +): Promise> { + const { message, engine } = options; + + const timestamp = new Date(); + const initialBookmarkName = datePrefixedLabel(message, timestamp); + + // Check GitHub for name conflicts + const conflictResult = await resolveBookmarkConflict(initialBookmarkName); + if (!conflictResult.ok) return conflictResult; + + const bookmarkName = conflictResult.value.resolvedName; + + // Get current working copy status + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const wc = statusResult.value.workingCopy; + const hasChanges = statusResult.value.modifiedFiles.length > 0; + + // Don't allow creating empty changes + if (!hasChanges) { + return err( + createError( + "EMPTY_CHANGE", + "No file changes to create. Make some changes first.", + ), + ); + } + + // Describe the WC with the message (converts it from scratch to real change) + const describeResult = await runJJ(["describe", "-m", message]); + if (!describeResult.ok) return describeResult; + + const createdChangeId = wc.changeId; + + // Create new empty WC on top + const newResult = await runJJ(["new"]); + if (!newResult.ok) return newResult; + + // Create bookmark pointing to the change + const bookmarkResult = await ensureBookmark(bookmarkName, createdChangeId); + if (!bookmarkResult.ok) return bookmarkResult; + + // Export to git + const exportResult = await runJJ(["git", "export"]); + if (!exportResult.ok) return exportResult; + + // Track the new bookmark in the engine by refreshing from jj + const refreshResult = await engine.refreshFromJJ(bookmarkName); + if (!refreshResult.ok) { + // This shouldn't happen since we just created the bookmark, but handle gracefully + return refreshResult; + } + + return ok({ changeId: createdChangeId, bookmarkName }); +} + +export const createCommand: Command = { + meta: { + name: "create", + args: "[message]", + description: "Create a new change stacked on the current change", + aliases: ["c"], + category: "workflow", + core: true, + }, + run: create, +}; diff --git a/packages/core/src/commands/delete.ts b/packages/core/src/commands/delete.ts new file mode 100644 index 00000000..cb5c2630 --- /dev/null +++ b/packages/core/src/commands/delete.ts @@ -0,0 +1,110 @@ +import type { Engine } from "../engine"; +import { + edit, + list, + resolveChange, + runJJWithMutableConfigVoid, + status, +} from "../jj"; +import type { Changeset } from "../parser"; +import { ok, type Result } from "../result"; +import type { Command } from "./types"; + +interface DeleteResult { + movedTo: string | null; + untrackedBookmarks: string[]; + /** The change that was deleted (for CLI display) */ + change: Changeset; +} + +interface DeleteOptions { + /** Change ID, bookmark name, or search query (required) */ + id: string; + engine: Engine; +} + +/** + * Delete a change, discarding its work. + * If the change has children, they are rebased onto the parent. + * If deleting the current change, moves to parent. + * Untracks any bookmarks on the deleted change from the engine. + */ +export async function deleteChange( + options: DeleteOptions, +): Promise> { + const { id, engine } = options; + + const statusBefore = await status(); + if (!statusBefore.ok) return statusBefore; + + // Resolve the change + const changeResult = await resolveChange(id, { includeBookmarks: true }); + if (!changeResult.ok) return changeResult; + const change = changeResult.value; + + const wasOnChange = + statusBefore.value.workingCopy.changeId === change.changeId; + const parentId = change.parents[0]; + + const childrenResult = await list({ + revset: `children(${change.changeId})`, + }); + const hasChildren = childrenResult.ok && childrenResult.value.length > 0; + + // Use mutable config for operations on potentially pushed commits + if (hasChildren) { + const rebaseResult = await runJJWithMutableConfigVoid([ + "rebase", + "-s", + `children(${change.changeId})`, + "-d", + parentId || "trunk()", + ]); + if (!rebaseResult.ok) return rebaseResult; + } + + // Discard work by restoring + const restoreResult = await runJJWithMutableConfigVoid([ + "restore", + "--changes-in", + change.changeId, + ]); + if (!restoreResult.ok) return restoreResult; + + const abandonResult = await runJJWithMutableConfigVoid([ + "abandon", + change.changeId, + ]); + if (!abandonResult.ok) return abandonResult; + + // Untrack any bookmarks on the deleted change + const untrackedBookmarks: string[] = []; + for (const bookmark of change.bookmarks) { + if (engine.isTracked(bookmark)) { + engine.untrack(bookmark); + untrackedBookmarks.push(bookmark); + } + } + + let movedTo: string | null = null; + if (wasOnChange && parentId) { + const editResult = await edit(parentId); + if (editResult.ok) { + movedTo = parentId; + } + } + + return ok({ movedTo, untrackedBookmarks, change }); +} + +export const deleteCommand: Command = { + meta: { + name: "delete", + args: "", + description: + "Delete a change, discarding its work. Children restack onto parent.", + aliases: ["dl"], + category: "management", + }, + run: deleteChange, +}; diff --git a/packages/core/src/commands/down.ts b/packages/core/src/commands/down.ts new file mode 100644 index 00000000..998078d4 --- /dev/null +++ b/packages/core/src/commands/down.ts @@ -0,0 +1,58 @@ +import { getTrunk, list, status } from "../jj"; +import { createError, err, type Result } from "../result"; +import type { NavigationResult } from "../types"; +import { navigateTo, newOnTrunk } from "./navigation"; +import type { Command } from "./types"; + +/** + * Navigate down in the stack (to the parent of the current change). + * Current change is always @- (the parent of WC). + */ +export async function down(): Promise> { + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const trunk = await getTrunk(); + const parents = statusResult.value.parents; + const hasChanges = statusResult.value.modifiedFiles.length > 0; + + if (hasChanges) { + return err( + createError( + "NAVIGATION_FAILED", + 'You have unsaved changes. Run `arr create "message"` to save them.', + ), + ); + } + + if (parents.length === 0) { + return newOnTrunk(trunk); + } + + const current = parents[0]; + + // Get current's parent + const parentsResult = await list({ revset: `${current.changeId}-` }); + if (!parentsResult.ok) return parentsResult; + + const grandparents = parentsResult.value.filter( + (c) => !c.changeId.startsWith("zzzzzzzz"), + ); + + if (grandparents.length === 0 || grandparents[0].bookmarks.includes(trunk)) { + return newOnTrunk(trunk); + } + + return navigateTo(grandparents[0]); +} + +export const downCommand: Command = { + meta: { + name: "down", + description: "Switch to the parent of the current change", + aliases: ["d"], + category: "navigation", + core: true, + }, + run: down, +}; diff --git a/packages/core/src/commands/get.ts b/packages/core/src/commands/get.ts new file mode 100644 index 00000000..ebab1290 --- /dev/null +++ b/packages/core/src/commands/get.ts @@ -0,0 +1,314 @@ +import type { Engine } from "../engine"; +import type { BranchMeta, PRInfo } from "../git/metadata"; +import { getMultiplePRInfos, getPRForBranch } from "../github/pr-status"; +import { edit, getTrunk, list, runJJ, runJJWithMutableConfigVoid } from "../jj"; +import { createError, err, ok, type Result } from "../result"; +import type { Command } from "./types"; + +export interface GetOptions { + /** Branch name or PR number to get */ + target: string; + engine: Engine; + cwd?: string; +} + +export interface GetResult { + /** All branches restored in the stack (trunk -> target order) */ + stack: RestoredBranch[]; + /** The target branch that was requested */ + targetBranch: string; +} + +export interface RestoredBranch { + branchName: string; + prNumber: number; + meta: BranchMeta; +} + +const MAX_STACK_DEPTH = 20; + +/** + * Walk up the PR base chain from a starting PR to trunk. + * Returns PRs in trunk->target order (parent first). + */ +async function walkBaseChain( + startPR: PRInfo, + trunk: string, + cwd: string, +): Promise> { + const stack: PRInfo[] = [startPR]; + const visited = new Set(); + visited.add(startPR.head ?? ""); + + let current = startPR; + + while (current.base !== trunk && stack.length < MAX_STACK_DEPTH) { + const baseBranch = current.base; + + // Cycle detection + if (visited.has(baseBranch)) { + return err( + createError( + "INVALID_STATE", + `Cycle detected in PR chain: ${baseBranch} already visited`, + ), + ); + } + visited.add(baseBranch); + + // Get PR for the parent branch + const parentResult = await getPRForBranch(baseBranch, cwd); + if (!parentResult.ok) return parentResult; + + const parentPR = parentResult.value; + if (!parentPR) { + // No PR for parent - assume it's trunk or an untracked branch + break; + } + + // Stop if parent PR is closed/merged - can't have valid stack with gap + if (parentPR.state !== "OPEN") { + // We still include this PR info for warning purposes + stack.unshift(parentPR); + break; + } + + stack.unshift(parentPR); + current = parentPR; + } + + if (stack.length >= MAX_STACK_DEPTH) { + return err( + createError( + "INVALID_STATE", + `Stack depth exceeded ${MAX_STACK_DEPTH} levels. Possible cycle or malformed stack.`, + ), + ); + } + + return ok(stack); +} + +/** + * Restore a single branch from remote and track it. + * Returns the metadata for the restored branch. + */ +async function restoreBranch( + branchName: string, + prInfo: PRInfo, + engine: Engine, + cwd: string, +): Promise> { + // Check if remote bookmark exists + const checkResult = await runJJ( + ["log", "-r", `${branchName}@origin`, "--no-graph", "-T", "change_id"], + cwd, + ); + + if (!checkResult.ok) { + return err( + createError( + "NOT_FOUND", + `Remote branch ${branchName}@origin not found. It may have been deleted.`, + ), + ); + } + + // Forget any existing local bookmark state (handles "deleted" bookmarks) + // This is a no-op if the bookmark doesn't exist locally + await runJJWithMutableConfigVoid(["bookmark", "forget", branchName], cwd); + + // Track the remote bookmark to create local bookmark + const trackResult = await runJJWithMutableConfigVoid( + ["bookmark", "track", `${branchName}@origin`], + cwd, + ); + + // If tracking fails, try setting the bookmark directly + if (!trackResult.ok) { + const setResult = await runJJWithMutableConfigVoid( + ["bookmark", "set", branchName, "-r", `${branchName}@origin`], + cwd, + ); + if (!setResult.ok) { + return err( + createError( + "COMMAND_FAILED", + `Failed to restore bookmark ${branchName}: ${setResult.error.message}`, + ), + ); + } + } + + // Get the change info from jj + const listResult = await list({ revset: branchName, limit: 1 }, cwd); + if (!listResult.ok) return listResult; + + if (listResult.value.length === 0) { + return err( + createError("NOT_FOUND", `Could not find changeset for ${branchName}`), + ); + } + + const change = listResult.value[0]; + + // Build metadata + const meta: BranchMeta = { + changeId: change.changeId, + commitId: change.commitId, + parentBranchName: prInfo.base, + prInfo, + }; + + // Store in engine + engine.setMeta(branchName, meta); + + return ok(meta); +} + +/** + * Get a branch (and its stack) from remote by name or PR number. + * Restores the entire downstack from trunk to target. + * + * Flow: + * 1. Resolve target to PR (by PR# or branch name) + * 2. Validate PR is OPEN + * 3. Walk base chain to trunk via GitHub API + * 4. Fetch from remote + * 5. Restore each branch in stack (trunk -> target order) + */ +export async function get(options: GetOptions): Promise> { + const { target, engine, cwd = process.cwd() } = options; + + // Get trunk name + const trunk = await getTrunk(cwd); + + // Resolve target to PRInfo + const isNumeric = /^\d+$/.test(target); + let targetPR: PRInfo; + + if (isNumeric) { + // Target is PR number + const prNumber = Number.parseInt(target, 10); + const prResult = await getMultiplePRInfos([prNumber], cwd); + if (!prResult.ok) return prResult; + + const info = prResult.value.get(prNumber); + if (!info) { + return err(createError("NOT_FOUND", `PR #${prNumber} not found`)); + } + targetPR = info; + } else { + // Target is branch name + const prResult = await getPRForBranch(target, cwd); + if (!prResult.ok) return prResult; + + if (!prResult.value) { + return err(createError("NOT_FOUND", `No PR found for branch: ${target}`)); + } + targetPR = prResult.value; + } + + // Validate PR is open + if (targetPR.state !== "OPEN") { + return err( + createError( + "INVALID_STATE", + `PR #${targetPR.number} is ${targetPR.state.toLowerCase()}. Cannot restore closed/merged PRs.`, + ), + ); + } + + // Walk base chain to build stack + const stackResult = await walkBaseChain(targetPR, trunk, cwd); + if (!stackResult.ok) return stackResult; + + const prStack = stackResult.value; + + // Check for closed/merged parent PRs + const closedParent = prStack.find( + (pr) => pr.state !== "OPEN" && pr.number !== targetPR.number, + ); + if (closedParent) { + // Filter out closed parent and warn + const openStack = prStack.filter((pr) => pr.state === "OPEN"); + if (openStack.length === 0) { + return err( + createError( + "INVALID_STATE", + `Parent PR #${closedParent.number} (${closedParent.head}) is ${closedParent.state.toLowerCase()}. Run arr sync to rebase your stack.`, + ), + ); + } + } + + // Fetch from git remote + const fetchResult = await runJJ(["git", "fetch"], cwd); + if (!fetchResult.ok) return fetchResult; + + // Restore each branch in stack (from trunk toward target) + const restoredStack: RestoredBranch[] = []; + + for (const pr of prStack) { + // Skip non-open PRs (they're in the chain for warning purposes) + if (pr.state !== "OPEN") continue; + + const branchName = pr.head; + if (!branchName) { + // Skip PRs without head (shouldn't happen with valid data) + continue; + } + + const restoreResult = await restoreBranch(branchName, pr, engine, cwd); + if (!restoreResult.ok) { + // Log warning but continue - partial stack is still useful + continue; + } + + restoredStack.push({ + branchName, + prNumber: pr.number, + meta: restoreResult.value, + }); + } + + if (restoredStack.length === 0) { + return err( + createError( + "COMMAND_FAILED", + "Failed to restore any branches from stack", + ), + ); + } + + // Find target branch name + const targetBranch = + targetPR.head ?? restoredStack[restoredStack.length - 1].branchName; + + // Switch to the target branch + const editResult = await edit(targetBranch, cwd); + if (!editResult.ok) { + return err( + createError( + "COMMAND_FAILED", + `Restored stack but failed to switch to ${targetBranch}: ${editResult.error.message}`, + ), + ); + } + + return ok({ + stack: restoredStack, + targetBranch, + }); +} + +export const getCommand: Command = { + meta: { + name: "get", + args: "", + description: + "Restore a branch and its stack from remote by name or PR number", + category: "workflow", + }, + run: get, +}; diff --git a/packages/core/src/commands/log.ts b/packages/core/src/commands/log.ts new file mode 100644 index 00000000..cac4ba03 --- /dev/null +++ b/packages/core/src/commands/log.ts @@ -0,0 +1,438 @@ +import type { Engine } from "../engine"; +import { getCurrentGitBranch } from "../git/status"; +import { runJJ } from "../jj"; +import { hasResolvedConflict } from "./resolve"; + +/** PR info for log display */ +export interface LogPRInfo { + number: number; + state: "OPEN" | "CLOSED" | "MERGED"; + url: string; + title: string; +} + +/** Parsed change from jj log output */ +export interface LogChange { + changeId: string; + changeIdPrefix: string; + commitId: string; + commitIdPrefix: string; + bookmarks: string[]; + description: string; + isEmpty: boolean; + isImmutable: boolean; + hasConflict: boolean; + timestamp: Date; + unsyncedBookmarks: string[]; + isBehindTrunk: boolean; + isWorkingCopy: boolean; + linesAdded: number; + linesRemoved: number; + fileCount: number; +} + +/** A parsed line from jj log with its graph prefix */ +export interface LogLine { + graphPrefix: string; + tag: "CHANGE" | "TIME" | "HINT" | "PR" | "COMMIT" | "BLANK"; + data: string; +} + +/** Result from core log command */ +export interface LogResult { + /** Raw jj log lines for graph rendering */ + lines: LogLine[]; + /** Map from bookmark to PR info */ + prInfoMap: Map; + /** Set of bookmarks with local changes not pushed */ + unsyncedBookmarks: Set; + /** Set of change IDs that are behind trunk */ + behindTrunkChanges: Set; + /** Bookmark of WC's parent (if any) */ + wcParentBookmark: string | null; + /** Whether WC has resolved conflict markers */ + hasResolvedConflict: boolean; + /** Diff stats for unsynced bookmarks (local vs origin) */ + unsyncedDiffStats: Map; + /** Tracked bookmarks list */ + trackedBookmarks: string[]; + /** Trunk branch name */ + trunk: string; + /** Debug timings if requested */ + timings?: Record; +} + +/** Unmanaged branch state */ +export interface UnmanagedBranchResult { + type: "unmanaged"; + branch: string; + trunk: string; +} + +/** Empty state (just on trunk) */ +export interface EmptyStateResult { + type: "empty"; + trunk: string; +} + +/** Normal log result */ +export interface NormalLogResult { + type: "normal"; + data: LogResult; +} + +export type LogCommandResult = + | UnmanagedBranchResult + | EmptyStateResult + | NormalLogResult; + +// Template for jj log - jj handles graph rendering and adds correct prefixes for each \n +const JJ_TEMPLATE = [ + // CHANGE line: changeId|prefix|commitId|prefix|bookmarks|description|empty|immutable|conflict|timestamp|unsyncedBookmarks|behindTrunk|added|removed|fileCount + '"CHANGE:" ++ change_id.short() ++ "|" ++ change_id.shortest().prefix() ++ "|" ++ commit_id.short() ++ "|" ++ commit_id.shortest().prefix() ++ "|" ++ bookmarks.join(",") ++ "|" ++ description.first_line() ++ "|" ++ if(empty, "1", "0") ++ "|" ++ if(immutable, "1", "0") ++ "|" ++ if(conflict, "1", "0") ++ "|" ++ committer.timestamp() ++ "|" ++ local_bookmarks.filter(|b| !b.synced()).map(|b| b.name()).join(",") ++ "|" ++ if(parents.all(|p| p.contained_in("trunk()::")), "0", "1") ++ "|" ++ self.diff().stat().total_added() ++ "|" ++ self.diff().stat().total_removed() ++ "|" ++ self.diff().stat().files().len() ++ "\\n"', + // TIME line + '"TIME:" ++ committer.timestamp() ++ "\\n"', + // HINT line for empty WC + 'if(current_working_copy && empty && !description, "HINT:empty\\n", "")', + // Blank line + '"\\n"', + // PR line (only if has bookmarks) + 'if(local_bookmarks, "PR:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "|" ++ if(description, description.first_line(), "") ++ "\\n", "")', + // COMMIT line + '"COMMIT:" ++ commit_id.short() ++ "|" ++ commit_id.shortest().prefix() ++ "|" ++ if(description, description.first_line(), "") ++ "\\n"', + // Trailing blank line + '"\\n"', +].join(" ++ "); + +export interface LogOptions { + engine: Engine; + cwd?: string; + debug?: boolean; +} + +/** + * Get log data for display. + * Returns structured data that CLI can format and render. + */ +export async function log(options: LogOptions): Promise { + const { engine, cwd = process.cwd(), debug = false } = options; + const timings: Record = {}; + + // Get trunk name from engine context + const trunkResult = await runJJ( + ["config", "get", "--repo", "revset-aliases.trunk()"], + cwd, + ); + const trunk = + trunkResult.ok && trunkResult.value.stdout.trim() + ? trunkResult.value.stdout.trim() + : "main"; + + // Check for unmanaged git branch first + let t0 = Date.now(); + const gitBranch = await getCurrentGitBranch(cwd); + timings.gitBranch = Date.now() - t0; + if ( + gitBranch !== null && + gitBranch !== trunk && + !engine.isTracked(gitBranch) + ) { + return { type: "unmanaged", branch: gitBranch, trunk }; + } + + // Get all tracked bookmarks (show all, not just OPEN PRs) + const trackedBookmarks = engine.getTrackedBookmarks(); + + // Build revset: trunk + mutable tracked bookmarks + fork points + WC + let revset: string; + if (trackedBookmarks.length === 0) { + revset = `${trunk} | @`; + } else { + const bookmarkRevsets = trackedBookmarks + .map((b) => `bookmarks(exact:"${b}")`) + .join(" | "); + // Show: trunk, mutable tracked bookmarks, their fork points (parents that are ancestors of trunk), and WC + const mutableBookmarks = `((${bookmarkRevsets}) & mutable())`; + const forkPoints = `((${mutableBookmarks})- & ::${trunk})`; + revset = `${trunk} | ${mutableBookmarks} | ${forkPoints} | @`; + } + + // Run jj log with our template + t0 = Date.now(); + const result = await runJJ( + ["log", "--color=never", "-r", revset, "-T", JJ_TEMPLATE], + cwd, + ); + timings.jjLog = Date.now() - t0; + + if (!result.ok) { + // Return empty state on error + return { type: "empty", trunk }; + } + + // Extract data directly from jj output + const { unsyncedBookmarks, behindTrunkChanges, wcParentBookmark, lines } = + parseJJOutput(result.value.stdout, trackedBookmarks); + + // Build PR info map from engine + t0 = Date.now(); + const prInfoMap = buildPRInfoMap(engine, trackedBookmarks); + timings.prInfoMap = Date.now() - t0; + + // Fetch diff stats for unsynced bookmarks in parallel + t0 = Date.now(); + const unsyncedDiffStats = await getUnsyncedDiffStats( + Array.from(unsyncedBookmarks), + cwd, + ); + timings.unsyncedDiffStats = Date.now() - t0; + + // Check for resolved conflicts + t0 = Date.now(); + const resolvedConflictResult = await hasResolvedConflict(cwd); + timings.parallelCalls = Date.now() - t0; + const hasResolved = resolvedConflictResult.ok && resolvedConflictResult.value; + + // Check if empty state (just on trunk with empty WC) + const changeLines = lines.filter((l) => l.tag === "CHANGE"); + if (changeLines.length <= 2 && trackedBookmarks.length === 0) { + // Only WC and trunk + const wcLine = changeLines.find((l) => l.data.includes("|1|0|0|")); // empty, not immutable + if (wcLine) { + return { type: "empty", trunk }; + } + } + + return { + type: "normal", + data: { + lines, + prInfoMap, + unsyncedBookmarks, + behindTrunkChanges, + wcParentBookmark, + hasResolvedConflict: hasResolved, + unsyncedDiffStats, + trackedBookmarks, + trunk, + timings: debug ? timings : undefined, + }, + }; +} + +function parseBookmarks(bookmarksStr: string): string[] { + if (!bookmarksStr) return []; + return bookmarksStr + .split(",") + .map((b) => b.replace(/\*$/, "").replace(/@\w+$/, "")) + .filter((b) => b.length > 0); +} + +/** + * Parse jj log output into structured lines and extract metadata. + */ +function parseJJOutput( + rawOutput: string, + trackedBookmarks: string[], +): { + lines: LogLine[]; + unsyncedBookmarks: Set; + behindTrunkChanges: Set; + wcParentBookmark: string | null; +} { + const unsyncedBookmarks = new Set(); + const behindTrunkChanges = new Set(); + const trackedSet = new Set(trackedBookmarks); + + // Parse all lines into structured format + const lines: LogLine[] = []; + + // Track changes for WC parent detection + const changes: { isWC: boolean; bookmarks: string[] }[] = []; + + for (const line of rawOutput.split("\n")) { + // Skip the ~ line at end and truly empty lines + if (line.trim() === "~" || line.trim() === "") continue; + + // Check if this line has a tag + const tagMatch = line.match(/(CHANGE:|TIME:|HINT:|PR:|COMMIT:)/); + if (!tagMatch) { + // Pure graph line or blank + lines.push({ graphPrefix: line, tag: "BLANK", data: "" }); + continue; + } + + const tagIndex = line.indexOf(tagMatch[1]); + const graphPrefix = line.substring(0, tagIndex); + const tag = tagMatch[1].replace(":", "") as LogLine["tag"]; + const data = line.substring(tagIndex + tagMatch[1].length); + + lines.push({ graphPrefix, tag, data }); + + // Extract metadata from CHANGE lines + if (tag === "CHANGE") { + const parts = data.split("|"); + const changeId = parts[0]; + const bookmarksStr = parts[4] || ""; + const bookmarks = parseBookmarks(bookmarksStr); + const isWC = graphPrefix.includes("@"); + + changes.push({ isWC, bookmarks }); + + // Index 10: unsynced bookmarks + if (parts[10]) { + const unsynced = parts[10].trim().split(",").filter(Boolean); + for (const b of unsynced) { + unsyncedBookmarks.add(b); + } + } + + // Index 11: behind trunk flag ("1" = behind) + if (parts[11]?.trim() === "1" && changeId) { + behindTrunkChanges.add(changeId); + } + } + } + + // Find WC parent's tracked bookmark + let wcParentBookmark: string | null = null; + for (let i = 0; i < changes.length; i++) { + if (changes[i].isWC && i + 1 < changes.length) { + const parentBookmarks = changes[i + 1].bookmarks; + wcParentBookmark = parentBookmarks.find((b) => trackedSet.has(b)) || null; + break; + } + } + + return { lines, unsyncedBookmarks, behindTrunkChanges, wcParentBookmark }; +} + +function buildPRInfoMap( + engine: Engine, + trackedBookmarks: string[], +): Map { + const prInfoMap = new Map(); + for (const bookmark of trackedBookmarks) { + const meta = engine.getMeta(bookmark); + if (meta?.prInfo) { + prInfoMap.set(bookmark, { + number: meta.prInfo.number, + state: meta.prInfo.state, + url: meta.prInfo.url, + title: meta.prInfo.title, + }); + } + } + return prInfoMap; +} + +/** + * Get diff stats for unsynced bookmarks (local vs origin). + */ +async function getUnsyncedDiffStats( + bookmarks: string[], + cwd: string, +): Promise> { + const result = new Map(); + if (bookmarks.length === 0) return result; + + // Run diff commands in parallel + const promises = bookmarks.map(async (bookmark) => { + const diffResult = await runJJ( + ["diff", "--from", `${bookmark}@origin`, "--to", bookmark, "--stat"], + cwd, + ); + if (!diffResult.ok) return { bookmark, added: 0, removed: 0 }; + + // Parse diff stat output + const stdout = diffResult.value.stdout; + const match = stdout.match( + /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/, + ); + if (match) { + return { + bookmark, + added: match[2] ? parseInt(match[2], 10) : 0, + removed: match[3] ? parseInt(match[3], 10) : 0, + }; + } + return { bookmark, added: 0, removed: 0 }; + }); + + const results = await Promise.all(promises); + for (const { bookmark, added, removed } of results) { + if (added > 0 || removed > 0) { + result.set(bookmark, { added, removed }); + } + } + return result; +} + +/** + * Parse a CHANGE line data string into structured LogChange. + * Format: changeId|prefix|commitId|prefix|bookmarks|description|empty|immutable|conflict|timestamp|unsyncedBookmarks|behindTrunk|added|removed|fileCount + */ +export function parseChangeLine(data: string): LogChange { + const parts = data.split("|"); + const [ + changeId, + changeIdPrefix, + commitId, + commitIdPrefix, + bookmarksStr, + description, + emptyFlag, + immutableFlag, + conflictFlag, + timestampStr, + unsyncedBookmarksStr, + behindTrunkStr, + addedStr, + removedStr, + fileCountStr, + ] = parts; + + return { + changeId, + changeIdPrefix, + commitId, + commitIdPrefix, + bookmarks: parseBookmarks(bookmarksStr), + description: description || "", + isEmpty: emptyFlag === "1", + isImmutable: immutableFlag === "1", + hasConflict: conflictFlag === "1", + timestamp: new Date(timestampStr), + unsyncedBookmarks: + unsyncedBookmarksStr?.trim().split(",").filter(Boolean) || [], + isBehindTrunk: behindTrunkStr?.trim() === "1", + isWorkingCopy: false, // Set by caller from graphPrefix + linesAdded: parseInt(addedStr || "0", 10), + linesRemoved: parseInt(removedStr || "0", 10), + fileCount: parseInt(fileCountStr || "0", 10), + }; +} + +/** + * Format relative time for display. + */ +export function formatRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + const diffMonths = Math.floor(diffDays / 30); + + if (diffSecs < 60) return "just now"; + if (diffMins < 60) + return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`; + if (diffHours < 24) + return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; + if (diffWeeks < 4) + return `${diffWeeks} week${diffWeeks === 1 ? "" : "s"} ago`; + return `${diffMonths} month${diffMonths === 1 ? "" : "s"} ago`; +} diff --git a/packages/core/src/commands/merge.ts b/packages/core/src/commands/merge.ts new file mode 100644 index 00000000..991152c8 --- /dev/null +++ b/packages/core/src/commands/merge.ts @@ -0,0 +1,49 @@ +import type { Engine } from "../engine"; +import type { Result } from "../result"; +import { getMergeStack, mergeStack } from "../stacks"; +import type { MergeResult, PRToMerge } from "../types"; +import type { Command } from "./types"; + +interface MergeOptions { + method?: "merge" | "squash" | "rebase"; + engine: Engine; + onMerging?: (pr: PRToMerge, nextPr?: PRToMerge) => void; + onWaiting?: () => void; + onMerged?: (pr: PRToMerge) => void; +} + +/** + * Get the stack of PRs that can be merged. + */ +export async function getMergeablePrs(): Promise> { + return getMergeStack(); +} + +/** + * Merge the stack of PRs. + * Untracks merged bookmarks from the engine. + */ +export async function merge( + prs: PRToMerge[], + options: MergeOptions, +): Promise> { + return mergeStack( + prs, + { method: options.method ?? "squash", engine: options.engine }, + { + onMerging: options.onMerging, + onWaiting: options.onWaiting, + onMerged: options.onMerged, + }, + ); +} + +export const mergeCommand: Command = { + meta: { + name: "merge", + description: "Merge PRs from trunk to the current change via GitHub", + category: "management", + core: true, + }, + run: merge, +}; diff --git a/packages/core/src/commands/modify.ts b/packages/core/src/commands/modify.ts new file mode 100644 index 00000000..80646a27 --- /dev/null +++ b/packages/core/src/commands/modify.ts @@ -0,0 +1,54 @@ +import { runJJWithMutableConfigVoid, status } from "../jj"; +import { createError, err, ok, type Result } from "../result"; +import type { NavigationResult } from "../types"; +import type { Command } from "./types"; + +/** + * Modify the parent change by squashing the current working copy into it. + * This is useful when you want to add changes to an existing branch + * instead of creating a new one. + */ +export async function modify(): Promise> { + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const parents = statusResult.value.parents; + const hasChanges = statusResult.value.modifiedFiles.length > 0; + + if (!hasChanges) { + return err( + createError( + "INVALID_STATE", + "No changes to modify. Edit some files first.", + ), + ); + } + + if (parents.length === 0) { + return err(createError("INVALID_STATE", "No parent to modify.")); + } + + const parent = parents[0]; + + // Squash WC into parent + const squashResult = await runJJWithMutableConfigVoid(["squash"]); + if (!squashResult.ok) return squashResult; + + return ok({ + changeId: parent.changeId, + changeIdPrefix: parent.changeIdPrefix, + description: parent.description, + bookmark: parent.bookmarks[0], + position: "on-top", + }); +} + +export const modifyCommand: Command = { + meta: { + name: "modify", + description: "Add current changes to the parent (squash into parent)", + aliases: ["m"], + category: "management", + }, + run: modify, +}; diff --git a/packages/core/src/commands/navigation.ts b/packages/core/src/commands/navigation.ts new file mode 100644 index 00000000..79ab5e95 --- /dev/null +++ b/packages/core/src/commands/navigation.ts @@ -0,0 +1,94 @@ +import { getTrunk, jjNew, runJJ, status } from "../jj"; +import type { Changeset } from "../parser"; +import { ok, type Result } from "../result"; +import type { NavigationResult } from "../types"; + +/** + * Navigate to a change by creating a new WC on top of it. + * WC is always an empty commit on top of the target change. + * Returns info about the change we're now "on" (the parent). + * + * Note: jj automatically abandons empty undescribed changes when we navigate away. + */ +export async function navigateTo( + change: Changeset, +): Promise> { + const bookmark = change.bookmarks[0]; + + // Check if we're already on top of this change + const statusResult = await status(); + if (statusResult.ok) { + const parents = statusResult.value.parents; + const isParent = parents.some((p) => p.changeId === change.changeId); + + if (isParent) { + return ok({ + changeId: change.changeId, + changeIdPrefix: change.changeIdPrefix, + description: change.description, + bookmark, + position: "on-top", + }); + } + } + + // Create new working copy on top (jj auto-abandons empty undescribed WC) + const newResult = await jjNew({ parents: [change.changeId] }); + if (!newResult.ok) return newResult; + + return ok({ + changeId: change.changeId, + changeIdPrefix: change.changeIdPrefix, + description: change.description, + bookmark, + position: "on-top", + }); +} + +/** + * Get navigation result for "on-top" position (ready for new work). + * Returns info about the parent (the branch we're on top of). + */ +export async function getOnTopNavigationResult(): Promise< + Result +> { + const result = await runJJ([ + "log", + "-r", + "@-", + "--no-graph", + "-T", + 'change_id.short() ++ "\\t" ++ change_id.shortest().prefix() ++ "\\t" ++ description.first_line() ++ "\\t" ++ bookmarks.join(",")', + ]); + if (!result.ok) return result; + const [changeId, changeIdPrefix, description, bookmarksStr] = + result.value.stdout.trim().split("\t"); + const bookmarks = bookmarksStr ? bookmarksStr.split(",") : []; + return ok({ + changeId, + changeIdPrefix, + description: description || "", + bookmark: bookmarks[0], + position: "on-top", + }); +} + +/** + * Create a new change on trunk and return navigation result. + * Note: jj automatically abandons empty undescribed changes when we navigate away. + */ +export async function newOnTrunk( + trunkName?: string, +): Promise> { + const trunk = trunkName ?? (await getTrunk()); + const newResult = await jjNew({ parents: [trunk] }); + if (!newResult.ok) return newResult; + + return ok({ + changeId: "", + changeIdPrefix: "", + description: "", + bookmark: trunk, + position: "on-trunk", + }); +} diff --git a/packages/core/src/commands/resolve.ts b/packages/core/src/commands/resolve.ts new file mode 100644 index 00000000..fe3473a6 --- /dev/null +++ b/packages/core/src/commands/resolve.ts @@ -0,0 +1,231 @@ +import { runJJ, runJJWithMutableConfigVoid, status } from "../jj"; +import { clearResolveState, loadResolveState } from "../resolve-state"; +import { createError, err, ok, type Result } from "../result"; +import type { Command } from "./types"; + +/** Info about a conflicted commit */ +export interface ConflictInfo { + changeId: string; + changeIdPrefix: string; + description: string; + conflictedFiles: string[]; +} + +export interface ResolveResult { + /** The commit that was resolved */ + resolved: { + changeId: string; + changeIdPrefix: string; + description: string; + }; + /** If more conflicts remain, info about the next one */ + nextConflict?: ConflictInfo; + /** If all conflicts resolved, the bookmark we returned to */ + returnedTo?: string; +} + +/** + * Check if the parent commit has a conflict that has been resolved in the working copy. + * Returns true if jj status contains the hint about resolved conflicts. + */ +export async function hasResolvedConflict( + cwd = process.cwd(), +): Promise> { + const result = await runJJ(["status"], cwd); + if (!result.ok) return result; + + const hasHint = result.value.stdout.includes( + "Conflict in parent commit has been resolved in working copy", + ); + return ok(hasHint); +} + +/** + * Find all conflicted commits in ancestry (from trunk to @). + * Returns them in order from @ toward trunk (most recent first). + */ +async function findConflictedCommits( + cwd: string, +): Promise> { + const result = await runJJ( + [ + "log", + "-r", + "trunk()::@ & conflicts()", + "--no-graph", + "-T", + 'change_id.short() ++ "|" ++ change_id.shortest().prefix() ++ "|" ++ description.first_line() ++ "\\n"', + ], + cwd, + ); + + if (!result.ok) return result; + + const lines = result.value.stdout.trim().split("\n").filter(Boolean); + const conflicts: ConflictInfo[] = []; + + for (const line of lines) { + const [changeId, changeIdPrefix, description] = line.split("|"); + conflicts.push({ + changeId, + changeIdPrefix, + description: description || "(no description)", + conflictedFiles: [], // We'll populate for the next conflict only + }); + } + + return ok(conflicts); +} + +/** + * Parse conflicted files from jj status output. + * Must be called after navigating to the conflict commit. + */ +async function getConflictedFilesFromStatus(cwd: string): Promise { + const result = await runJJ(["status"], cwd); + if (!result.ok) return []; + + const files: string[] = []; + const lines = result.value.stdout.split("\n"); + let inConflictSection = false; + + for (const line of lines) { + if (line.includes("unresolved conflicts at these paths:")) { + inConflictSection = true; + continue; + } + if (inConflictSection) { + const match = line.match(/^(\S+)\s+\d+-sided conflict/); + if (match) { + files.push(match[1]); + } else if (line.trim() === "" || !line.startsWith(" ")) { + break; + } + } + } + return files; +} + +/** + * Resolve conflicts iteratively. + * + * Flow: + * 1. Check if parent has resolved conflict + * 2. Squash resolution into parent + * 3. Rebase descendants onto resolved parent + * 4. Check if more conflicts exist + * - If yes: navigate to next conflict, return info + * - If no: return to original bookmark, clear state + */ +export async function resolve( + cwd = process.cwd(), +): Promise> { + // Check if there's a resolved conflict + const resolvedResult = await hasResolvedConflict(cwd); + if (!resolvedResult.ok) return resolvedResult; + + if (!resolvedResult.value) { + // Check if parent has conflicts that aren't resolved yet + const statusResult = await status(cwd); + if (!statusResult.ok) return statusResult; + + const parent = statusResult.value.parents[0]; + if (parent?.hasConflicts) { + return err( + createError( + "INVALID_STATE", + "Parent has conflicts that need to be resolved. Edit the conflicted files to remove conflict markers first.", + ), + ); + } + + return err( + createError( + "INVALID_STATE", + "No conflicts to resolve. Use this command after resolving conflict markers in files.", + ), + ); + } + + const statusResult = await status(cwd); + if (!statusResult.ok) return statusResult; + + const parent = statusResult.value.parents[0]; + if (!parent) { + return err(createError("INVALID_STATE", "No parent commit found.")); + } + + const resolvedCommit = { + changeId: parent.changeId, + changeIdPrefix: parent.changeIdPrefix, + description: parent.description, + }; + + // Squash the resolution into the parent, keeping the parent's description + const squashResult = await runJJWithMutableConfigVoid( + ["squash", "--use-destination-message"], + cwd, + ); + if (!squashResult.ok) return squashResult; + + // After squash, we're now on the resolved commit + // Check if there are more conflicts up the stack + const conflictsResult = await findConflictedCommits(cwd); + if (!conflictsResult.ok) return conflictsResult; + + const remainingConflicts = conflictsResult.value; + + if (remainingConflicts.length > 0) { + // More conflicts - navigate to the next one (root/deepest) + const nextConflict = remainingConflicts[remainingConflicts.length - 1]; + + // Navigate to the next conflict + const newResult = await runJJWithMutableConfigVoid( + ["new", nextConflict.changeId], + cwd, + ); + if (!newResult.ok) return newResult; + + // Get conflicted files (after navigating) + nextConflict.conflictedFiles = await getConflictedFilesFromStatus(cwd); + + return ok({ + resolved: resolvedCommit, + nextConflict, + }); + } + + // No more conflicts - return to original bookmark if we have state + const state = loadResolveState(cwd); + if (state?.originalBookmark) { + // Use jj new to create fresh working copy on top of bookmark + const newResult = await runJJWithMutableConfigVoid( + ["new", state.originalBookmark], + cwd, + ); + if (!newResult.ok) return newResult; + + clearResolveState(cwd); + + return ok({ + resolved: resolvedCommit, + returnedTo: state.originalBookmark, + }); + } + + // No state - just report success + clearResolveState(cwd); + return ok({ + resolved: resolvedCommit, + }); +} + +export const resolveCommand: Command = { + meta: { + name: "resolve", + description: "Apply conflict resolution and continue to next conflict", + aliases: ["r"], + category: "management", + }, + run: resolve, +}; diff --git a/packages/core/src/commands/restack.ts b/packages/core/src/commands/restack.ts new file mode 100644 index 00000000..74ae1681 --- /dev/null +++ b/packages/core/src/commands/restack.ts @@ -0,0 +1,309 @@ +import { + getBookmarkTracking, + push, + runJJ, + runJJWithMutableConfigVoid, +} from "../jj"; +import { saveResolveState } from "../resolve-state"; +import { ok, type Result } from "../result"; +import type { Command } from "./types"; + +/** Info about a conflicted commit */ +export interface ConflictInfo { + changeId: string; + changeIdPrefix: string; + description: string; + conflictedFiles: string[]; +} + +interface RestackResult { + restacked: number; + pushed: string[]; + /** If conflicts were detected, info about root conflict */ + conflict?: ConflictInfo; + /** Original bookmark we navigated away from (if conflict navigation happened) */ + originalBookmark?: string; +} + +interface RestackOptions { + /** Tracked bookmarks to consider for restacking. If not provided, uses engine.getTrackedBookmarks() */ + trackedBookmarks?: string[]; + /** Engine instance - used to get tracked bookmarks if not provided */ + engine?: import("../engine").Engine; +} + +/** + * Find root bookmarks that are behind trunk. + * Roots are tracked bookmarks whose parent is NOT another tracked bookmark. + * We only rebase roots - descendants will follow automatically. + */ +async function getRootBookmarksBehindTrunk( + trackedBookmarks: string[], + cwd: string, +): Promise> { + if (trackedBookmarks.length === 0) { + return ok([]); + } + + const bookmarkRevsets = trackedBookmarks + .map((b) => `bookmarks(exact:"${b}")`) + .join(" | "); + + // Find roots of tracked bookmarks that are behind trunk + // roots(X) gives commits in X with no ancestors also in X + // ~ trunk():: filters to only those not already on trunk + const rootsRevset = `roots((${bookmarkRevsets}) & mutable()) ~ trunk()::`; + + const result = await runJJ( + [ + "log", + "-r", + rootsRevset, + "--no-graph", + "-T", + 'local_bookmarks.map(|b| b.name()).join(",") ++ "\\n"', + ], + cwd, + ); + + if (!result.ok) return result; + + const rootBookmarks = result.value.stdout + .trim() + .split("\n") + .filter((line) => line.trim()) + .flatMap((line) => line.split(",").filter((b) => b.trim())) + .filter((b) => trackedBookmarks.includes(b)); + + return ok(rootBookmarks); +} + +/** + * Rebase root tracked bookmarks that are behind trunk. + * Only rebases roots - descendants follow automatically. + */ +async function restackTracked( + trackedBookmarks: string[], + cwd: string, +): Promise> { + const rootsResult = await getRootBookmarksBehindTrunk(trackedBookmarks, cwd); + if (!rootsResult.ok) return rootsResult; + + const roots = rootsResult.value; + if (roots.length === 0) { + return ok({ restacked: 0 }); + } + + // Rebase each root bookmark onto trunk - descendants will follow + for (const bookmark of roots) { + const result = await runJJWithMutableConfigVoid( + ["rebase", "-b", bookmark, "-d", "trunk()"], + cwd, + ); + if (!result.ok) return result; + } + + return ok({ restacked: roots.length }); +} + +/** + * Push all bookmarks that have unpushed changes. + */ +async function pushAllUnpushed( + cwd: string, +): Promise> { + const trackingResult = await getBookmarkTracking(cwd); + if (!trackingResult.ok) return trackingResult; + + const unpushed = trackingResult.value.filter((t) => t.aheadCount > 0); + const pushed: string[] = []; + + for (const bookmark of unpushed) { + const result = await push({ bookmark: bookmark.name }, cwd); + if (result.ok) { + pushed.push(bookmark.name); + } + } + + return ok({ pushed }); +} + +/** + * Parse conflicted files from jj status output. + * Must be called after navigating to the conflict commit. + */ +async function getConflictedFilesFromStatus(cwd: string): Promise { + const result = await runJJ(["status"], cwd); + if (!result.ok) return []; + + const files: string[] = []; + const lines = result.value.stdout.split("\n"); + let inConflictSection = false; + + for (const line of lines) { + if (line.includes("unresolved conflicts at these paths:")) { + inConflictSection = true; + continue; + } + if (inConflictSection) { + // Lines in conflict section start with the file path, then conflict description + // e.g.: ".github/workflows/release.yml 2-sided conflict" + const match = line.match(/^(\S+)\s+\d+-sided conflict/); + if (match) { + files.push(match[1]); + } else if (line.trim() === "" || !line.startsWith(" ")) { + // End of conflict section + break; + } + } + } + return files; +} + +/** + * Find the root (deepest) conflicted commit in ancestry. + * Returns null if no conflicts. + */ +async function findRootConflict( + cwd: string, +): Promise> { + // Get all conflicted commits from trunk to current + const result = await runJJ( + [ + "log", + "-r", + "trunk()::@ & conflicts()", + "--no-graph", + "-T", + 'change_id.short() ++ "|" ++ change_id.shortest().prefix() ++ "|" ++ description.first_line() ++ "\\n"', + ], + cwd, + ); + + if (!result.ok) return result; + + const lines = result.value.stdout.trim().split("\n").filter(Boolean); + if (lines.length === 0) { + return ok(null); + } + + // Last line is the root conflict (closest to trunk) + const rootLine = lines[lines.length - 1]; + const [changeId, changeIdPrefix, description] = rootLine.split("|"); + + return ok({ + changeId, + changeIdPrefix, + description: description || "(no description)", + conflictedFiles: [], // Will be populated after navigating to conflict + }); +} + +/** + * Get current bookmark and change ID for state saving. + */ +async function getCurrentPosition( + cwd: string, +): Promise> { + const result = await runJJ( + [ + "log", + "-r", + "@", + "--no-graph", + "-T", + 'change_id.short() ++ "|" ++ local_bookmarks.map(|b| b.name()).join(",")', + ], + cwd, + ); + + if (!result.ok) return result; + + const [changeId, bookmarks] = result.value.stdout.trim().split("|"); + const bookmark = bookmarks?.split(",")[0] || null; + + return ok({ bookmark, changeId }); +} + +/** + * Fetch from remote, restack tracked bookmarks onto trunk, and push rebased bookmarks. + * If conflicts are detected, navigates to root conflict and saves state. + */ +export async function restack( + options: RestackOptions, + cwd = process.cwd(), +): Promise> { + // Get tracked bookmarks from engine if not provided + const trackedBookmarks = + options.trackedBookmarks ?? options.engine?.getTrackedBookmarks() ?? []; + + // Get current position before any changes + const positionResult = await getCurrentPosition(cwd); + if (!positionResult.ok) return positionResult; + const originalBookmark = positionResult.value.bookmark; + const originalChangeId = positionResult.value.changeId; + + // Fetch latest first + const fetchResult = await runJJ(["git", "fetch"], cwd); + if (!fetchResult.ok) return fetchResult; + + // Restack only tracked bookmarks that are behind trunk + const restackResult = await restackTracked(trackedBookmarks, cwd); + if (!restackResult.ok) return restackResult; + + // Check for conflicts after restack + const conflictResult = await findRootConflict(cwd); + if (!conflictResult.ok) return conflictResult; + + if (conflictResult.value) { + const conflict = conflictResult.value; + + // Save state for resolve to pick up later + if (originalBookmark) { + saveResolveState( + { + originalBookmark, + originalChangeId, + startedAt: new Date().toISOString(), + }, + cwd, + ); + } + + // Navigate to root conflict - create WC on top of it + const newResult = await runJJWithMutableConfigVoid( + ["new", conflict.changeId], + cwd, + ); + if (!newResult.ok) return newResult; + + // Now get the conflicted files (after navigating to the conflict) + conflict.conflictedFiles = await getConflictedFilesFromStatus(cwd); + + // Don't push if there are conflicts + return ok({ + restacked: restackResult.value.restacked, + pushed: [], + conflict, + originalBookmark: originalBookmark ?? undefined, + }); + } + + // No conflicts - push all unpushed bookmarks + const pushResult = await pushAllUnpushed(cwd); + if (!pushResult.ok) return pushResult; + + return ok({ + restacked: restackResult.value.restacked, + pushed: pushResult.value.pushed, + }); +} + +export const restackCommand: Command = { + meta: { + name: "restack", + description: "Rebase tracked stacks onto trunk and push updated bookmarks", + category: "workflow", + }, + run: restack, +}; diff --git a/packages/core/src/commands/split.ts b/packages/core/src/commands/split.ts new file mode 100644 index 00000000..42daf4a4 --- /dev/null +++ b/packages/core/src/commands/split.ts @@ -0,0 +1,263 @@ +import { resolveBookmarkConflict } from "../bookmark-utils"; +import type { Engine } from "../engine"; +import { ensureBookmark, list, runJJ, status } from "../jj"; +import { createError, err, ok, type Result } from "../result"; +import { datePrefixedLabel } from "../slugify"; +import type { Command } from "./types"; + +interface SplitResult { + /** Number of files that were split out */ + fileCount: number; + /** The paths that were split out */ + paths: string[]; + /** Description of the new commit (the split-out changes) */ + description: string; + /** Bookmark name for the split-out commit */ + bookmarkName: string; + /** Change ID of the split-out commit */ + changeId: string; +} + +interface SplitOptions { + /** File paths to split out into a new commit */ + paths: string[]; + /** Description for the new commit containing the split-out changes */ + description: string; + /** Engine for tracking */ + engine: Engine; +} + +interface FileInfo { + path: string; + status: string; +} + +const STATUS_MAP: Record = { + M: "modified", + A: "added", + D: "deleted", + 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 @-). + */ +export async function getSplittableFiles(): Promise> { + const parentDiffResult = await runJJ(["diff", "-r", "@-", "--summary"]); + if (!parentDiffResult.ok) return parentDiffResult; + return ok(parseDiffSummary(parentDiffResult.value.stdout)); +} + +/** + * Split the parent change by moving specified files into a new grandparent. + * Like `arr modify`, this targets the parent (the change you're "on"). + * + * Before: trunk -> parent (with all changes) -> WC (empty) + * After: trunk -> new (selected files) -> parent (remaining) -> WC (empty) + * + * Uses `jj split -r @- -m "" ` under the hood. + */ +export async function split( + options: SplitOptions, +): Promise> { + const { paths, description, engine } = options; + + if (paths.length === 0) { + return err(createError("INVALID_STATE", "No paths provided to split")); + } + + if (!description.trim()) { + return err( + createError("INVALID_STATE", "Description is required for split"), + ); + } + + // Get current status + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const { parents, modifiedFiles } = statusResult.value; + + // If WC has changes, tell user to create first + if (modifiedFiles.length > 0) { + return err( + createError( + "INVALID_STATE", + 'You have uncommitted changes. Run `arr create "message"` first.', + ), + ); + } + + // Get the parent (the change we're splitting) + const parent = parents[0]; + if (!parent) { + return err(createError("INVALID_STATE", "No parent change to split")); + } + + if (parent.isEmpty) { + return err(createError("INVALID_STATE", "Cannot split an empty change")); + } + + // Get the parent's modified files + const filesResult = await getSplittableFiles(); + if (!filesResult.ok) return filesResult; + const parentFiles = filesResult.value; + + if (parentFiles.length === 0) { + return err( + createError("INVALID_STATE", "Parent change has no files to split"), + ); + } + + // Check if any of the specified paths match parent's files + const changedPaths = new Set(parentFiles.map((f) => f.path)); + const matchingPaths: string[] = []; + + for (const path of paths) { + // Check for exact match or prefix match (for directories) + const matches = parentFiles.filter( + (f) => f.path === path || f.path.startsWith(`${path}/`), + ); + if (matches.length > 0) { + matchingPaths.push(...matches.map((m) => m.path)); + } else if (!changedPaths.has(path)) { + return err( + createError( + "INVALID_STATE", + `Path "${path}" is not in the parent change's files`, + ), + ); + } else { + matchingPaths.push(path); + } + } + + const uniquePaths = [...new Set(matchingPaths)]; + + // Generate bookmark name for the split-out commit + const timestamp = new Date(); + const initialBookmarkName = datePrefixedLabel(description, timestamp); + + // Check GitHub for name conflicts + const conflictResult = await resolveBookmarkConflict(initialBookmarkName); + if (!conflictResult.ok) return conflictResult; + + const bookmarkName = conflictResult.value.resolvedName; + + // Run jj split on the parent (-r @-) with the description and paths + const splitResult = await runJJ([ + "split", + "-r", + "@-", + "-m", + description.trim(), + ...uniquePaths, + ]); + + if (!splitResult.ok) return splitResult; + + // After split on @-, the new structure is: + // grandparent (split-out) -> parent (remaining, keeps bookmark) -> WC + // So the split-out commit is the grandparent (parent of @-) + const grandparentResult = await list({ revset: "@--" }); + if (!grandparentResult.ok) return grandparentResult; + + const splitChangeId = grandparentResult.value[0]?.changeId; + if (!splitChangeId) { + return err(createError("INVALID_STATE", "Could not find split change")); + } + + // Create bookmark on the split-out commit + const bookmarkResult = await ensureBookmark(bookmarkName, splitChangeId); + if (!bookmarkResult.ok) return bookmarkResult; + + // Export to git + const exportResult = await runJJ(["git", "export"]); + if (!exportResult.ok) return exportResult; + + // Track the new bookmark in the engine + const refreshResult = await engine.refreshFromJJ(bookmarkName); + if (!refreshResult.ok) return refreshResult; + + return ok({ + fileCount: uniquePaths.length, + paths: uniquePaths, + description: description.trim(), + bookmarkName, + changeId: splitChangeId, + }); +} + +interface SplitPreview { + /** Files available for splitting */ + availableFiles: FileInfo[]; + /** Files that would be split based on requested paths */ + matchingFiles: FileInfo[]; +} + +/** + * Preview what will be split. + * Use this to show confirmation to user before calling split(). + */ +export async function previewSplit( + paths: string[], +): Promise> { + const filesResult = await getSplittableFiles(); + if (!filesResult.ok) return filesResult; + + const availableFiles = filesResult.value; + + if (availableFiles.length === 0) { + return err( + createError("INVALID_STATE", "No files in parent change to split"), + ); + } + + if (paths.length === 0) { + return err(createError("INVALID_STATE", "No paths provided to split")); + } + + // Find matching files + const matchingFiles = availableFiles.filter((f) => + paths.some((p) => f.path === p || f.path.startsWith(`${p}/`)), + ); + + if (matchingFiles.length === 0) { + return err( + createError( + "INVALID_STATE", + "None of the specified paths match files in parent change", + ), + ); + } + + return ok({ availableFiles, matchingFiles }); +} + +export const splitCommand: Command = { + meta: { + name: "split", + args: "", + description: + "Split files from the parent change into a new change below it", + aliases: ["sp"], + category: "management", + }, + run: split, +}; diff --git a/packages/core/src/commands/squash.ts b/packages/core/src/commands/squash.ts new file mode 100644 index 00000000..4f83393a --- /dev/null +++ b/packages/core/src/commands/squash.ts @@ -0,0 +1,166 @@ +import type { Engine } from "../engine"; +import { + getTrunk, + list, + runJJ, + runJJWithMutableConfigVoid, + status, +} from "../jj"; +import { ok, type Result } from "../result"; +import type { Command } from "./types"; + +interface SquashResult { + /** Number of commits squashed */ + squashedCount: number; + /** The bookmark that was squashed */ + bookmark: string; + /** The base bookmark/trunk that we squashed down to */ + base: string; +} + +interface SquashOptions { + engine: Engine; + /** Optional message for the squashed commit */ + message?: string; +} + +/** + * Squash all commits in the current branch into a single commit. + * Only squashes from the current bookmark down to the nearest parent bookmark (or trunk). + * This preserves stacked PR structure - each bookmark's commits stay separate. + */ +export async function squash( + options: SquashOptions, +): Promise> { + const { engine, message } = options; + const trunk = await getTrunk(); + + // Get current status to find what bookmark we're on + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const parent = statusResult.value.parents[0]; + if (!parent) { + return { + ok: false, + error: { code: "INVALID_STATE", message: "No parent commit found" }, + }; + } + + const currentBookmark = parent.bookmarks[0]; + if (!currentBookmark) { + return { + ok: false, + error: { + code: "INVALID_STATE", + message: "Not on a bookmarked change. Use arr squash on a branch.", + }, + }; + } + + // Find the nearest parent bookmark (or trunk) + // Query: ancestors of current bookmark that have bookmarks, excluding current + const ancestorBookmarksResult = await runJJ([ + "log", + "-r", + `ancestors(${currentBookmark}) & bookmarks() & ~${currentBookmark}`, + "--no-graph", + "-T", + 'bookmarks.join(",") ++ "\\n"', + "--limit", + "1", + ]); + + let baseBookmark = trunk; + if (ancestorBookmarksResult.ok) { + const firstLine = ancestorBookmarksResult.value.stdout + .trim() + .split("\n")[0]; + if (firstLine) { + // Get first bookmark from the comma-separated list + const bookmarks = firstLine.split(",").filter(Boolean); + if (bookmarks.length > 0) { + baseBookmark = bookmarks[0]; + } + } + } + + // Get commits to squash (between base and current bookmark, excluding base) + const commitsResult = await list({ + revset: `${baseBookmark}::${currentBookmark} ~ ${baseBookmark}`, + }); + if (!commitsResult.ok) return commitsResult; + + const commitCount = commitsResult.value.length; + if (commitCount <= 1) { + return ok({ + squashedCount: 0, + bookmark: currentBookmark, + base: baseBookmark, + }); + } + + // Save the change IDs of commits to abandon BEFORE moving the bookmark + const oldChangeIds = commitsResult.value.map((c) => c.changeId); + + // Create a new commit on base with all changes from current bookmark + const newResult = await runJJWithMutableConfigVoid([ + "new", + baseBookmark, + "-m", + message || parent.description || currentBookmark, + ]); + if (!newResult.ok) return newResult; + + // Restore all files from the current bookmark tip + const restoreResult = await runJJWithMutableConfigVoid([ + "restore", + "--from", + currentBookmark, + ]); + if (!restoreResult.ok) return restoreResult; + + // Move the bookmark to this new squashed commit + // Need --allow-backwards since we're moving to a different commit lineage + const bookmarkResult = await runJJWithMutableConfigVoid([ + "bookmark", + "set", + currentBookmark, + "--allow-backwards", + "-r", + "@", + ]); + if (!bookmarkResult.ok) return bookmarkResult; + + // Abandon the old commits using saved change IDs + for (const changeId of oldChangeIds) { + await runJJWithMutableConfigVoid(["abandon", changeId]); + } + + // Create fresh working copy on top + await runJJWithMutableConfigVoid(["new", currentBookmark]); + + return ok({ + squashedCount: commitCount, + bookmark: currentBookmark, + base: baseBookmark, + }); +} + +export const squashCommand: Command = { + meta: { + name: "squash", + description: + "Squash all commits in the current branch into a single commit", + aliases: ["sq"], + flags: [ + { + name: "message", + short: "m", + description: "Message for the squashed commit", + }, + ], + category: "management", + }, + run: squash, +}; diff --git a/packages/core/src/commands/status.ts b/packages/core/src/commands/status.ts new file mode 100644 index 00000000..b38e887a --- /dev/null +++ b/packages/core/src/commands/status.ts @@ -0,0 +1,151 @@ +import { getDiffStats, getTrunk, status as jjStatus, list } from "../jj"; +import { ok, type Result } from "../result"; +import type { DiffStats, NextAction, StatusInfo } from "../types"; +import type { Command } from "./types"; + +interface StatusResult { + info: StatusInfo; + stats: DiffStats | null; + hasResolvedConflict: boolean; +} + +/** + * Get current status information including working copy state, + * stack path, conflicts, modified files, and diff stats. + */ +export async function status( + options: { debug?: boolean } = {}, +): Promise> { + const debug = options.debug ?? false; + const t0 = Date.now(); + + // Run jjStatus (single template call) and getDiffStats in parallel + const [statusResult, statsResult, trunkBranch] = await Promise.all([ + (async () => { + const t = Date.now(); + const r = await jjStatus(); + if (debug) console.log(` jjStatus: ${Date.now() - t}ms`); + return r; + })(), + (async () => { + const t = Date.now(); + const r = await getDiffStats("@"); + if (debug) console.log(` getDiffStats: ${Date.now() - t}ms`); + return r; + })(), + (async () => { + const t = Date.now(); + const r = await getTrunk(); + if (debug) console.log(` getTrunk: ${Date.now() - t}ms`); + return r; + })(), + ]); + + if (debug) { + console.log(` parallel calls: ${Date.now() - t0}ms`); + } + + if (!statusResult.ok) return statusResult; + + const { + workingCopy, + parents, + modifiedFiles, + conflicts, + hasResolvedConflict, + } = statusResult.value; + + // Current change is the working copy (what you're editing) + const currentChange = workingCopy; + const parentChange = parents[0] ?? null; + const hasChanges = modifiedFiles.length > 0; + const hasConflicts = conflicts.length > 0; + const wcHasConflicts = currentChange?.hasConflicts ?? false; + const parentHasConflicts = parentChange?.hasConflicts ?? false; + const isUndescribed = currentChange?.description.trim() === ""; + const isOnTrunk = currentChange?.bookmarks.includes(trunkBranch) ?? false; + + // Build stack path from current position down to trunk + // Query: ancestors of current change that are descendants of trunk (the stack) + const stackPath: string[] = []; + let isBehindTrunk = false; + if (currentChange) { + const stackResult = await list({ + revset: `${trunkBranch}::${currentChange.changeId} ~ ${trunkBranch}`, + }); + if (stackResult.ok) { + // Result is in current->trunk order (jj returns descendants first) + // Only show bookmarked changes (tracked branches), not every commit + for (const change of stackResult.value) { + const bookmark = change.bookmarks[0]; + if (bookmark) stackPath.push(bookmark); + } + } + + // Check if behind trunk: trunk is not an ancestor of current change + // This means the stack was based on an older version of trunk + const behindResult = await list({ + revset: `${trunkBranch} & ~(::${currentChange.changeId})`, + limit: 1, + }); + isBehindTrunk = behindResult.ok && behindResult.value.length > 0; + } + stackPath.push(trunkBranch); + + // Determine next action + let nextAction: NextAction; + if (hasConflicts || wcHasConflicts || parentHasConflicts) { + nextAction = { action: "continue", reason: "conflicts" }; + } else if (isUndescribed && hasChanges) { + nextAction = { action: "create", reason: "unsaved" }; + } else if (isUndescribed && !hasChanges) { + nextAction = { action: "create", reason: "empty" }; + } else if (isOnTrunk) { + nextAction = { action: "create", reason: "on_trunk" }; + } else { + const hasBookmark = currentChange && currentChange.bookmarks.length > 0; + if (modifiedFiles.length > 0) { + nextAction = { + action: "submit", + reason: hasBookmark ? "update_pr" : "create_pr", + }; + } else { + nextAction = { action: "up", reason: "start_new" }; + } + } + + const currentBookmark = currentChange?.bookmarks[0]; + + const info: StatusInfo = { + changeId: currentChange?.changeId ?? "", + changeIdPrefix: currentChange?.changeIdPrefix ?? "", + name: currentBookmark || currentChange?.description || "", + isUndescribed: isUndescribed ?? true, + hasChanges, + hasConflicts: hasConflicts || wcHasConflicts || parentHasConflicts, + isBehindTrunk, + stackPath, + modifiedFiles, + conflicts, + nextAction, + }; + + const stats = statsResult.ok ? statsResult.value : null; + + if (debug) { + console.log(` TOTAL: ${Date.now() - t0}ms`); + } + + return ok({ info, stats, hasResolvedConflict }); +} + +export const statusCommand: Command = { + meta: { + name: "status", + description: "Show the current change and working copy modifications", + aliases: ["st"], + context: "array", + category: "info", + }, + run: status, +}; diff --git a/packages/core/src/commands/submit.ts b/packages/core/src/commands/submit.ts new file mode 100644 index 00000000..4461810f --- /dev/null +++ b/packages/core/src/commands/submit.ts @@ -0,0 +1,88 @@ +import type { Engine } from "../engine"; +import type { Result } from "../result"; +import { submitStack } from "../stacks"; +import { syncPRInfo } from "./sync-pr-info"; +import type { Command } from "./types"; + +interface SubmitResult { + prs: Array<{ + bookmarkName: string; + prNumber: number; + prUrl: string; + base: string; + status: "created" | "updated" | "synced" | "untracked"; + }>; + created: number; + updated: number; + synced: number; +} + +interface SubmitOptions { + draft?: boolean; + dryRun?: boolean; + engine: Engine; +} + +/** + * Submit the current stack as linked PRs. + * Tracks bookmarks and updates PR info in the engine. + */ +export async function submit( + options: SubmitOptions, +): Promise> { + const { engine, dryRun } = options; + + // Refresh PR info before submitting to detect merged/closed PRs + await syncPRInfo({ engine }); + + const trackedBookmarks = engine.getTrackedBookmarks(); + const result = await submitStack({ + draft: options.draft, + dryRun, + trackedBookmarks, + }); + if (!result.ok) return result; + + // Skip engine updates for dry run + if (dryRun) { + return result; + } + + // Update PR info for tracked branches and newly created PRs + // Don't re-track branches that were explicitly untracked (existing PRs that aren't tracked) + for (const pr of result.value.prs) { + const isNewPR = pr.status === "created"; + const isAlreadyTracked = engine.isTracked(pr.bookmarkName); + + if (isNewPR || isAlreadyTracked) { + // Refresh from jj to get latest changeId/commitId/parentBranchName + await engine.refreshFromJJ(pr.bookmarkName); + // Update PR info + engine.updatePRInfo(pr.bookmarkName, { + number: pr.prNumber, + state: "OPEN", + url: pr.prUrl, + base: pr.base, + title: pr.title, + }); + } + } + + return result; +} + +export const submitCommand: Command = { + meta: { + name: "submit", + description: "Create or update GitHub PRs for 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" }, + ], + category: "workflow", + core: true, + }, + run: submit, +}; diff --git a/packages/core/src/commands/sync-pr-info.ts b/packages/core/src/commands/sync-pr-info.ts new file mode 100644 index 00000000..1a21402c --- /dev/null +++ b/packages/core/src/commands/sync-pr-info.ts @@ -0,0 +1,45 @@ +import type { Engine } from "../engine"; +import { batchGetPRsForBranches } from "../github/pr-status"; +import { ok, type Result } from "../result"; + +interface SyncPRInfoResult { + updated: number; + bookmarks: string[]; +} + +interface SyncPRInfoOptions { + engine: Engine; + /** Specific bookmarks to sync. If not provided, syncs all tracked bookmarks. */ + bookmarks?: string[]; +} + +/** + * Sync PR info from GitHub for tracked bookmarks. + * Updates the engine with fresh PR state (number, state, url, base, etc). + */ +export async function syncPRInfo( + options: SyncPRInfoOptions, +): Promise> { + const { engine } = options; + + // Get bookmarks to sync + const bookmarks = options.bookmarks ?? engine.getTrackedBookmarks(); + if (bookmarks.length === 0) { + return ok({ updated: 0, bookmarks: [] }); + } + + // Fetch PR info from GitHub + const prsResult = await batchGetPRsForBranches(bookmarks); + if (!prsResult.ok) { + return prsResult; + } + + // Update engine with fresh PR info + const updated: string[] = []; + for (const [bookmark, prInfo] of prsResult.value) { + engine.updatePRInfo(bookmark, prInfo); + updated.push(bookmark); + } + + return ok({ updated: updated.length, bookmarks: updated }); +} diff --git a/packages/core/src/commands/sync.ts b/packages/core/src/commands/sync.ts new file mode 100644 index 00000000..d53c199b --- /dev/null +++ b/packages/core/src/commands/sync.ts @@ -0,0 +1,266 @@ +import type { Engine } from "../engine"; +import { getTrunk, runJJ, runJJWithMutableConfigVoid, status } from "../jj"; +import { ok, type Result } from "../result"; +import { + findMergedChanges, + type MergedChange, + updateStackComments, +} from "../stacks"; +import { syncPRInfo } from "./sync-pr-info"; +import type { Command } from "./types"; + +interface SyncResult { + fetched: boolean; + rebased: boolean; + hasConflicts: boolean; + /** Changes with merged/closed PRs pending cleanup - caller should prompt before cleanup */ + pendingCleanup: MergedChange[]; + updatedComments: number; + stacksBehind: number; +} + +interface SyncOptions { + engine: Engine; +} + +/** + * Clean up orphaned bookmarks: + * 1. Local bookmarks marked as deleted (no target) + * 2. Local bookmarks without origin pointing to empty changes + */ +async function cleanupOrphanedBookmarks(): Promise> { + const template = + 'name ++ "\\t" ++ if(remote, remote, "local") ++ "\\t" ++ if(normal_target, "target", "no_target") ++ "\\t" ++ if(normal_target, normal_target.empty(), "") ++ "\\n"'; + const result = await runJJ(["bookmark", "list", "--all", "-T", template]); + if (!result.ok) return result; + + const bookmarksByName = new Map< + string, + { hasOrigin: boolean; hasLocalTarget: boolean; isEmpty: boolean } + >(); + + for (const line of result.value.stdout.trim().split("\n")) { + if (!line) continue; + const [name, remote, hasTarget, isEmpty] = line.split("\t"); + if (!name) continue; + + const existing = bookmarksByName.get(name); + if (remote === "origin") { + if (existing) { + existing.hasOrigin = true; + } else { + bookmarksByName.set(name, { + hasOrigin: true, + hasLocalTarget: false, + isEmpty: false, + }); + } + } else if (remote === "local") { + const localHasTarget = hasTarget === "target"; + const localIsEmpty = isEmpty === "true"; + if (existing) { + existing.hasLocalTarget = localHasTarget; + existing.isEmpty = localIsEmpty; + } else { + bookmarksByName.set(name, { + hasOrigin: false, + hasLocalTarget: localHasTarget, + isEmpty: localIsEmpty, + }); + } + } + } + + const forgotten: string[] = []; + for (const [name, info] of bookmarksByName) { + // Only forget bookmarks that have been deleted (no local target) + // Don't forget empty changes - the user may want to add content to them + const isDeleted = !info.hasLocalTarget; + + if (isDeleted) { + const forgetResult = await runJJ(["bookmark", "forget", name]); + if (forgetResult.ok) { + forgotten.push(name); + } + } + } + + return ok(forgotten); +} + +/** + * Check if there are mutable changes not based on current trunk. + */ +async function getStacksBehindTrunk(): Promise> { + const result = await runJJ([ + "log", + "-r", + "roots(mutable() ~ trunk()..)", + "--no-graph", + "-T", + `change_id ++ "\\n"`, + ]); + if (!result.ok) return result; + const roots = result.value.stdout + .split("\n") + .filter((line) => line.trim() !== ""); + return ok(roots.length); +} + +/** + * Sync with remote: fetch, rebase, cleanup merged PRs, update stack comments. + * Returns info about what was synced. Does NOT automatically restack - caller + * should prompt user and call restack() if desired. + * Untracks bookmarks for merged PRs. + */ +export async function sync(options: SyncOptions): Promise> { + const { engine } = options; + + // Capture which tracked bookmark we're on BEFORE sync + // (so we can return to it after the bookmark potentially moves) + const trackedBookmarks = engine.getTrackedBookmarks(); + let originalTrackedParent: string | null = null; + const preStatus = await status(); + if (preStatus.ok) { + const parentBookmarks = preStatus.value.parents[0]?.bookmarks ?? []; + originalTrackedParent = + parentBookmarks.find((b) => trackedBookmarks.includes(b)) ?? null; + } + + // Refresh PR info from GitHub for all tracked bookmarks + await syncPRInfo({ engine }); + + // Fetch from remote + const fetchResult = await runJJ(["git", "fetch"]); + if (!fetchResult.ok) return fetchResult; + + // Update local trunk bookmark to match remote + const trunk = await getTrunk(); + await runJJ(["bookmark", "set", trunk, "-r", `${trunk}@origin`]); + + // Rebase only tracked bookmarks onto trunk (not all mutable commits) + // This prevents rebasing unrelated orphaned commits from the repo history + let rebaseOk = true; + let rebaseError: string | undefined; + + // Build revset for all tracked bookmarks + if (trackedBookmarks.length > 0) { + const bookmarkRevsets = trackedBookmarks + .map((b) => `bookmarks(exact:"${b}")`) + .join(" | "); + + // Find roots of tracked bookmarks - those whose parent is NOT another tracked bookmark + // roots(X) gives us commits in X that have no ancestors also in X + const rootsRevset = `roots((${bookmarkRevsets}) & mutable())`; + + const rootsResult = await runJJ([ + "log", + "-r", + rootsRevset, + "--no-graph", + "-T", + 'local_bookmarks.map(|b| b.name()).join(",") ++ "\\n"', + ]); + + if (rootsResult.ok) { + const rootBookmarks = rootsResult.value.stdout + .trim() + .split("\n") + .filter((line) => line.trim()) + .flatMap((line) => line.split(",").filter((b) => b.trim())); + + // Only rebase root bookmarks - descendants will follow + for (const bookmark of rootBookmarks) { + if (!trackedBookmarks.includes(bookmark)) continue; + + const result = await runJJWithMutableConfigVoid([ + "rebase", + "-b", + bookmark, + "-d", + "trunk()", + ]); + if (!result.ok) { + rebaseOk = false; + rebaseError = result.error.message; + break; + } + } + } + } + + // Handle WC positioning after sync: + // - If we were on a tracked bookmark before sync, move WC to its NEW tip + // (the bookmark may have moved due to new commits from remote) + // - Otherwise, rebase WC onto trunk + if (originalTrackedParent) { + // Move WC to the current tip of the tracked bookmark we were on + await runJJWithMutableConfigVoid(["new", originalTrackedParent]); + } else { + // Not on a tracked bookmark - rebase onto trunk + await runJJWithMutableConfigVoid(["rebase", "-r", "@", "-d", "trunk()"]); + } + + // Check for conflicts on current change (the parent) + let hasConflicts = false; + if (rebaseOk) { + const statusResult = await status(); + if (statusResult.ok) { + hasConflicts = statusResult.value.parents[0]?.hasConflicts ?? false; + } + } else { + hasConflicts = rebaseError?.includes("conflict") ?? false; + } + + // Clean up orphaned bookmarks (bookmarks with no target) + await cleanupOrphanedBookmarks(); + + // Find changes with merged PRs - don't auto-cleanup, let caller prompt + const mergedResult = await findMergedChanges(); + const pendingCleanup = mergedResult.ok ? mergedResult.value : []; + + // Update stack comments + const updateResult = await updateStackComments(); + const updatedComments = updateResult.ok ? updateResult.value.updated : 0; + + // Check if there are other stacks behind trunk + const behindResult = await getStacksBehindTrunk(); + const stacksBehind = behindResult.ok ? behindResult.value : 0; + + return ok({ + fetched: true, + rebased: rebaseOk, + hasConflicts, + pendingCleanup, + updatedComments, + stacksBehind, + }); +} + +/** + * Cleanup a single merged/closed change. + * Reparents children, abandons the change, deletes bookmark, untracks. + */ +export async function cleanupMergedChange( + change: MergedChange, + engine: Engine, +): Promise> { + const { reparentAndCleanup } = await import("../stacks"); + const result = await reparentAndCleanup(change, engine); + if (!result.ok) return result; + return ok({ + reparentedCount: result.value.reparentedChildren.length, + prBasesUpdated: result.value.prBasesUpdated, + }); +} + +export const syncCommand: Command = { + meta: { + name: "sync", + description: + "Sync from remote, rebase stack onto trunk, and cleanup merged changes", + category: "workflow", + core: true, + }, + run: sync, +}; diff --git a/packages/core/src/commands/top.ts b/packages/core/src/commands/top.ts new file mode 100644 index 00000000..a4bd7d92 --- /dev/null +++ b/packages/core/src/commands/top.ts @@ -0,0 +1,68 @@ +import { list, status } from "../jj"; +import { createError, err, type Result } from "../result"; +import type { NavigationResult } from "../types"; +import { getOnTopNavigationResult, navigateTo } from "./navigation"; +import type { Command } from "./types"; + +/** + * Navigate to the top of the current stack. + */ +export async function top(): Promise> { + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const wc = statusResult.value.workingCopy; + const parents = statusResult.value.parents; + const hasChanges = statusResult.value.modifiedFiles.length > 0; + + if (hasChanges) { + return err( + createError( + "NAVIGATION_FAILED", + 'You have unsaved changes. Run `arr create "message"` to save them.', + ), + ); + } + + if (parents.length === 0) { + return getOnTopNavigationResult(); + } + + const current = parents[0]; + + // Find heads of stack from current position + const headsResult = await list({ + revset: `heads(descendants(${current.changeId}))`, + }); + if (!headsResult.ok) return headsResult; + + const heads = headsResult.value.filter( + (c) => !c.changeId.startsWith("zzzzzzzz") && c.changeId !== wc.changeId, + ); + + if (heads.length === 0) { + // Already at top + return getOnTopNavigationResult(); + } + + if (heads.length > 1) { + return err( + createError( + "NAVIGATION_FAILED", + "Stack has multiple heads - cannot determine top", + ), + ); + } + + return navigateTo(heads[0]); +} + +export const topCommand: Command = { + meta: { + name: "top", + description: "Switch to the tip of the current stack", + aliases: ["t"], + category: "navigation", + }, + run: top, +}; diff --git a/packages/core/src/commands/track.ts b/packages/core/src/commands/track.ts new file mode 100644 index 00000000..8ff448c6 --- /dev/null +++ b/packages/core/src/commands/track.ts @@ -0,0 +1,141 @@ +import type { Engine } from "../engine"; +import { ensureBookmark, getTrunk, list, resolveChange } from "../jj"; +import { createError, err, ok, type Result } from "../result"; +import { datePrefixedLabel } from "../slugify"; +import type { Command } from "./types"; + +interface TrackResult { + bookmark: string; + parent: string; +} + +interface TrackOptions { + engine: Engine; + /** + * Target to track. Can be: + * - A bookmark name (existing) + * - A change ID (will create bookmark from description) + * - A description search (will create bookmark from description) + * - Undefined (uses current change @-) + */ + target?: string; + /** Parent branch name. If not provided, auto-detects */ + parent?: string; +} + +/** + * Track a change with arr. + * Creates a bookmark if needed, then adds to the engine's tracking system. + */ +export async function track( + options: TrackOptions, +): Promise> { + const { engine, parent, target } = options; + + const trunk = await getTrunk(); + + // Resolve the target to a change (use @- for current if no target) + const targetRevset = target || "@-"; + const changeResult = await resolveChange(targetRevset, { + includeBookmarks: true, + }); + if (!changeResult.ok) { + // Provide a better error message for the default case + if (!target && changeResult.error.code === "INVALID_REVISION") { + return err(createError("INVALID_STATE", "No current change")); + } + return changeResult; + } + + const change = changeResult.value; + const changeId = change.changeId; + const description = change.description; + const existingBookmark = change.bookmarks[0]; + const timestamp = change.timestamp; + + // Check if change has no description + if (!description.trim()) { + return err( + createError( + "INVALID_STATE", + "Change has no description. Add a description before tracking.", + ), + ); + } + + // Use existing bookmark or create one from description + let bookmark: string; + if (existingBookmark) { + bookmark = existingBookmark; + } else { + bookmark = datePrefixedLabel(description, timestamp); + await ensureBookmark(bookmark, changeId); + } + + // Check if already tracked + if (engine.isTracked(bookmark)) { + return err( + createError("INVALID_STATE", `Branch "${bookmark}" is already tracked`), + ); + } + + // Determine parent branch + let parentBranch = parent; + if (!parentBranch) { + const changeResult = await list({ revset: changeId, limit: 1 }); + if (!changeResult.ok) return changeResult; + + const change = changeResult.value[0]; + const parentChangeId = change?.parents[0]; + + if (parentChangeId) { + // Check if parent is trunk + const trunkResult = await list({ + revset: `bookmarks(exact:"${trunk}")`, + limit: 1, + }); + const isTrunkParent = + trunkResult.ok && + trunkResult.value.length > 0 && + trunkResult.value[0].changeId === parentChangeId; + + if (isTrunkParent) { + parentBranch = trunk; + } else { + // Find parent's bookmark + const parentResult = await list({ revset: parentChangeId, limit: 1 }); + if (parentResult.ok && parentResult.value.length > 0) { + const parentBookmark = parentResult.value[0].bookmarks[0]; + if (parentBookmark && engine.isTracked(parentBookmark)) { + parentBranch = parentBookmark; + } else { + parentBranch = trunk; + } + } else { + parentBranch = trunk; + } + } + } else { + parentBranch = trunk; + } + } + + // Track the bookmark by refreshing from jj + const refreshResult = await engine.refreshFromJJ(bookmark); + if (!refreshResult.ok) { + return refreshResult; + } + + return ok({ bookmark, parent: parentBranch }); +} + +export const trackCommand: Command = { + meta: { + name: "track", + args: "[branch]", + description: "Start tracking a branch with arr", + category: "workflow", + core: true, + }, + run: track, +}; diff --git a/packages/core/src/commands/trunk.ts b/packages/core/src/commands/trunk.ts new file mode 100644 index 00000000..ac55c4e9 --- /dev/null +++ b/packages/core/src/commands/trunk.ts @@ -0,0 +1,20 @@ +import type { Result } from "../result"; +import type { NavigationResult } from "../types"; +import { newOnTrunk } from "./navigation"; +import type { Command } from "./types"; + +/** + * Navigate to trunk and create a fresh change for new work. + */ +export async function trunk(): Promise> { + return newOnTrunk(); +} + +export const trunkCommand: Command = { + meta: { + name: "trunk", + description: "Go directly to trunk, starting a fresh change", + category: "navigation", + }, + run: trunk, +}; diff --git a/packages/core/src/commands/types.ts b/packages/core/src/commands/types.ts new file mode 100644 index 00000000..dd5c9ade --- /dev/null +++ b/packages/core/src/commands/types.ts @@ -0,0 +1,33 @@ +import type { ContextLevel } from "../context"; +import type { Result } from "../result"; + +export type CommandCategory = + | "setup" + | "workflow" + | "navigation" + | "info" + | "management"; + +export interface CommandFlag { + name: string; + description: string; + short?: string; +} + +export interface CommandMeta { + name: string; + args?: string; + description: string; + aliases?: string[]; + flags?: CommandFlag[]; + /** Defaults to "jj" - only specify for CLI-only commands that need "none" */ + context?: ContextLevel; + category: CommandCategory; + core?: boolean; + disabled?: boolean; +} + +export interface Command { + meta: CommandMeta; + run: (...args: Args) => Promise>; +} diff --git a/packages/core/src/commands/undo.ts b/packages/core/src/commands/undo.ts new file mode 100644 index 00000000..545d70f5 --- /dev/null +++ b/packages/core/src/commands/undo.ts @@ -0,0 +1,22 @@ +import { runJJ } from "../jj"; +import { ok, type Result } from "../result"; +import type { Command } from "./types"; + +/** + * Undo the last jj operation. + */ +export async function undo(): Promise> { + const result = await runJJ(["undo"]); + if (!result.ok) return result; + return ok(undefined); +} + +export const undoCommand: Command = { + meta: { + name: "undo", + description: "Undo the last jj operation", + category: "management", + core: true, + }, + run: undo, +}; diff --git a/packages/core/src/commands/untrack.ts b/packages/core/src/commands/untrack.ts new file mode 100644 index 00000000..5293a93f --- /dev/null +++ b/packages/core/src/commands/untrack.ts @@ -0,0 +1,175 @@ +import type { Engine } from "../engine"; +import { findChange, status } from "../jj"; +import { createError, err, ok, type Result } from "../result"; +import type { Command } from "./types"; + +interface UntrackResult { + /** Branches that were untracked */ + untracked: string[]; +} + +interface UntrackPreview { + /** The resolved bookmark to untrack */ + bookmark: string; + /** All branches that will be untracked (including upstack children) */ + toUntrack: string[]; + /** Whether there are upstack children */ + hasChildren: boolean; +} + +interface UntrackOptions { + engine: Engine; + /** Branch to untrack. If not provided, uses current branch. */ + target?: string; +} + +/** + * Resolve a target to a tracked bookmark. + * Handles: no target (uses @-), bookmark name, or change ID. + */ +async function resolveTrackedBookmark( + engine: Engine, + target?: string, +): Promise> { + if (!target) { + // No target - use current branch (@-) + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const currentBookmark = statusResult.value.parents[0]?.bookmarks[0]; + if (!currentBookmark) { + return err( + createError( + "INVALID_STATE", + "No branch at current position. Specify a branch to untrack.", + ), + ); + } + return ok(currentBookmark); + } + + // Try to resolve target as a bookmark first + if (engine.isTracked(target)) { + return ok(target); + } + + // Try to find it as a change + const findResult = await findChange(target, { includeBookmarks: true }); + if (!findResult.ok) return findResult; + + if (findResult.value.status === "none") { + return err( + createError("NOT_FOUND", `Branch or change not found: ${target}`), + ); + } + if (findResult.value.status === "multiple") { + return err( + createError( + "AMBIGUOUS_REVISION", + `Multiple changes match "${target}". Use a more specific identifier.`, + ), + ); + } + + const change = findResult.value.change; + const changeBookmark = change.bookmarks[0]; + if (!changeBookmark || !engine.isTracked(changeBookmark)) { + return err( + createError( + "INVALID_STATE", + `"${target}" is not tracked by arr. Nothing to untrack.`, + ), + ); + } + return ok(changeBookmark); +} + +/** + * Collect a branch and all its upstack children (recursively). + */ +function collectUpstack(engine: Engine, bookmark: string): string[] { + const result: string[] = [bookmark]; + const children = engine.getChildren(bookmark); + for (const child of children) { + result.push(...collectUpstack(engine, child)); + } + return result; +} + +/** + * Untrack a branch and all its upstack children. + * Does not delete the branches from jj - just stops tracking them with arr. + */ +export async function untrack( + options: UntrackOptions, +): Promise> { + const { engine, target } = options; + + const resolveResult = await resolveTrackedBookmark(engine, target); + if (!resolveResult.ok) return resolveResult; + const bookmark = resolveResult.value; + + // Double-check it's tracked (resolveTrackedBookmark should guarantee this) + if (!engine.isTracked(bookmark)) { + return err( + createError( + "INVALID_STATE", + `"${bookmark}" is not tracked by arr. Nothing to untrack.`, + ), + ); + } + + // Collect this branch and all upstack children + const toUntrack = collectUpstack(engine, bookmark); + + // Untrack all + for (const b of toUntrack) { + engine.untrack(b); + } + + return ok({ untracked: toUntrack }); +} + +/** + * Preview what will be untracked without actually untracking. + * Use this to show confirmation to user before calling untrack(). + */ +export async function previewUntrack( + options: Omit, +): Promise> { + const { engine, target } = options; + + const resolveResult = await resolveTrackedBookmark(engine, target); + if (!resolveResult.ok) return resolveResult; + const bookmark = resolveResult.value; + + // Double-check it's tracked + if (!engine.isTracked(bookmark)) { + return err( + createError( + "INVALID_STATE", + `"${bookmark}" is not tracked by arr. Nothing to untrack.`, + ), + ); + } + + // Collect this branch and all upstack children + const toUntrack = collectUpstack(engine, bookmark); + const children = engine.getChildren(bookmark); + + return ok({ + bookmark, + toUntrack, + hasChildren: children.length > 0, + }); +} + +export const untrackCommand: Command = { + meta: { + name: "untrack", + args: "[branch]", + description: "Stop tracking a branch (and its upstack) with arr", + category: "workflow", + }, + run: untrack, +}; diff --git a/packages/core/src/commands/up.ts b/packages/core/src/commands/up.ts new file mode 100644 index 00000000..83aff1b8 --- /dev/null +++ b/packages/core/src/commands/up.ts @@ -0,0 +1,65 @@ +import { list, status } from "../jj"; +import { createError, err, type Result } from "../result"; +import type { NavigationResult } from "../types"; +import { getOnTopNavigationResult, navigateTo } from "./navigation"; +import type { Command } from "./types"; + +export async function up(): Promise> { + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const wc = statusResult.value.workingCopy; + const parents = statusResult.value.parents; + const hasChanges = statusResult.value.modifiedFiles.length > 0; + + if (parents.length === 0) { + return err(createError("NAVIGATION_FAILED", "No parent to navigate from")); + } + + const current = parents[0]; + + // Check for unsaved changes + if (hasChanges) { + return err( + createError( + "NAVIGATION_FAILED", + 'You have unsaved changes. Run `arr create "message"` to save them.', + ), + ); + } + + // Find children of current change (excluding the WC itself) + const childrenResult = await list({ revset: `${current.changeId}+` }); + if (!childrenResult.ok) return childrenResult; + + const children = childrenResult.value.filter( + (c) => !c.changeId.startsWith("zzzzzzzz") && c.changeId !== wc.changeId, + ); + + if (children.length === 0) { + // Already at top of stack + return getOnTopNavigationResult(); + } + + if (children.length > 1) { + return err( + createError( + "NAVIGATION_FAILED", + "Multiple children - navigation is ambiguous", + ), + ); + } + + return navigateTo(children[0]); +} + +export const upCommand: Command = { + meta: { + name: "up", + description: "Switch to the child of the current change", + aliases: ["u"], + category: "navigation", + core: true, + }, + run: up, +}; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 00000000..e2e55d4d --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,106 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; + +const UserConfigSchema = z.object({ + version: z.literal(1), + tipsEnabled: z.boolean().default(true), + tipsSeen: z.array(z.string()).default([]), +}); + +type UserConfig = z.infer; + +const USER_CONFIG_DIR = ".config/array"; +const USER_CONFIG_FILE = "config.json"; + +function getUserConfigDir(): string { + return join(homedir(), USER_CONFIG_DIR); +} + +function getUserConfigPath(): string { + return join(getUserConfigDir(), USER_CONFIG_FILE); +} + +export async function loadUserConfig(): Promise { + const configPath = getUserConfigPath(); + + try { + const file = Bun.file(configPath); + if (!(await file.exists())) { + return createDefaultUserConfig(); + } + + const content = await file.text(); + const parsed = JSON.parse(content); + return UserConfigSchema.parse(parsed); + } catch { + return createDefaultUserConfig(); + } +} + +export async function saveUserConfig(config: UserConfig): Promise { + const configDir = getUserConfigDir(); + const configPath = getUserConfigPath(); + + await ensureDir(configDir); + await Bun.write(configPath, JSON.stringify(config, null, 2)); +} + +export function createDefaultUserConfig(): UserConfig { + return { + version: 1, + tipsEnabled: true, + tipsSeen: [], + }; +} + +export async function isRepoInitialized(cwd: string): Promise { + try { + const { stat } = await import("node:fs/promises"); + const [gitExists, jjExists] = await Promise.all([ + stat(join(cwd, ".git")) + .then(() => true) + .catch(() => false), + stat(join(cwd, ".jj")) + .then(() => true) + .catch(() => false), + ]); + return gitExists && jjExists; + } catch { + return false; + } +} + +export async function markTipSeen(tipId: string): Promise { + const config = await loadUserConfig(); + if (!config.tipsSeen.includes(tipId)) { + config.tipsSeen.push(tipId); + await saveUserConfig(config); + } +} + +export async function shouldShowTip(tipId: string): Promise { + const config = await loadUserConfig(); + return config.tipsEnabled && !config.tipsSeen.includes(tipId); +} + +const TIPS: Record = { + create: "Run `arr log` to see your stack.", + submit: "Run `arr sync` to pull latest changes.", + enable: "Run `arr status` to see the combined preview.", + log: "Use `arr up` and `arr down` to navigate.", + sync: "Run `arr submit --stack` to create linked PRs.", +}; + +export function getTip(command: string): string | null { + return TIPS[command] ?? null; +} + +async function ensureDir(dirPath: string): Promise { + try { + const { mkdir } = await import("node:fs/promises"); + await mkdir(dirPath, { recursive: true }); + } catch { + // Directory might already exist + } +} diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts new file mode 100644 index 00000000..5e0b1e59 --- /dev/null +++ b/packages/core/src/context.ts @@ -0,0 +1,36 @@ +import { isRepoInitialized } from "./config"; +import { isInGitRepo } from "./git/repo"; +import { checkPrerequisites, isJjInitialized } from "./init"; + +export type ContextLevel = "none" | "jj" | "array"; + +export interface Context { + inGitRepo: boolean; + jjInstalled: boolean; + jjInitialized: boolean; + arrayInitialized: boolean; +} + +export async function checkContext(cwd: string): Promise { + const prereqs = await checkPrerequisites(); + const inGitRepo = await isInGitRepo(cwd); + const jjInitialized = inGitRepo ? await isJjInitialized(cwd) : false; + const arrayInitialized = inGitRepo ? await isRepoInitialized(cwd) : false; + + return { + inGitRepo, + jjInstalled: prereqs.jj.found, + jjInitialized, + arrayInitialized, + }; +} + +export function isContextValid(context: Context, level: ContextLevel): boolean { + if (level === "none") return true; + + const jjReady = + context.jjInstalled && context.inGitRepo && context.jjInitialized; + if (level === "jj") return jjReady; + + return jjReady && context.arrayInitialized; +} diff --git a/packages/core/src/engine/context.ts b/packages/core/src/engine/context.ts new file mode 100644 index 00000000..b0aa9c17 --- /dev/null +++ b/packages/core/src/engine/context.ts @@ -0,0 +1,31 @@ +import { getTrunk } from "../jj"; +import { createEngine, type Engine } from "./engine"; + +/** + * Context passed to command handlers. + * Contains the engine and other shared state. + */ +export interface ArrContext { + engine: Engine; + trunk: string; + cwd: string; +} + +/** + * Initialize context for a command. + * Engine is loaded and ready to use. + */ +export async function initContext( + cwd: string = process.cwd(), +): Promise { + const engine = createEngine(cwd); + engine.load(); + + const trunk = await getTrunk(cwd); + + return { + engine, + trunk, + cwd, + }; +} diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts new file mode 100644 index 00000000..837f5420 --- /dev/null +++ b/packages/core/src/engine/engine.ts @@ -0,0 +1,364 @@ +import { + type BranchMeta, + deleteMetadata, + listTrackedBranches, + type PRInfo, + readMetadataBatch, + writeMetadata, +} from "../git/metadata"; +import { getTrunk, list } from "../jj"; +import { createError, ok, type Result } from "../result"; +import type { TreeNode } from "./types"; + +export type { PRInfo }; + +/** + * Engine manages tracked branches and cached data. + * + * - Load once at command start + * - All mutations go through update methods + * - Persist once at command end + */ +export interface Engine { + // Lifecycle + load(): void; + persist(): void; + + // Tracking + isTracked(bookmark: string): boolean; + getTrackedBookmarks(): string[]; + + // Metadata access + getMeta(bookmark: string): BranchMeta | null; + getParent(bookmark: string): string | null; + getChildren(bookmark: string): string[]; + + // Mutations + /** + * Set metadata directly for a bookmark. + * Use this when you already have the full metadata (e.g., from remote refs). + */ + setMeta(bookmark: string, meta: BranchMeta): void; + + /** + * Refresh a bookmark's changeId/commitId/parentBranchName from jj. + * Preserves existing prInfo if present. + * Returns error if bookmark not found in jj. + */ + refreshFromJJ(bookmark: string): Promise>; + + /** + * Track a new bookmark by looking up its info from jj. + * @deprecated Use setMeta() for explicit metadata or refreshFromJJ() for jj lookup + */ + track(bookmark: string, prInfo?: PRInfo): Promise; + + /** + * Untrack a bookmark (delete metadata). + */ + untrack(bookmark: string): void; + + /** + * Update PR info for a tracked bookmark. + */ + updatePRInfo(bookmark: string, prInfo: PRInfo): void; + + // Tree building + buildTree(trunk: string): TreeNode[]; +} + +/** + * Create a new Engine instance. + */ +export function createEngine(cwd: string = process.cwd()): Engine { + // In-memory state + const branches: Map = new Map(); + const dirty: Set = new Set(); + const deleted: Set = new Set(); + let loaded = false; + + return { + /** + * Load all tracked branches from disk. + */ + load(): void { + if (loaded) return; + + // Load metadata from git refs - single git call for all branches + const tracked = listTrackedBranches(cwd); + const metadataMap = readMetadataBatch(tracked, cwd); + for (const [bookmarkName, meta] of metadataMap) { + branches.set(bookmarkName, meta); + } + + loaded = true; + }, + + /** + * Persist all changes to disk. + */ + persist(): void { + // Write dirty branches to git refs + for (const bookmarkName of dirty) { + const meta = branches.get(bookmarkName); + if (meta) { + writeMetadata(bookmarkName, meta, cwd); + } + } + + // Delete removed branches from git refs + for (const bookmarkName of deleted) { + deleteMetadata(bookmarkName, cwd); + } + + // Clear dirty state + dirty.clear(); + deleted.clear(); + }, + + /** + * Check if a bookmark is tracked. + */ + isTracked(bookmark: string): boolean { + return branches.has(bookmark); + }, + + /** + * Get all tracked bookmark names. + */ + getTrackedBookmarks(): string[] { + return Array.from(branches.keys()); + }, + + /** + * Get metadata for a bookmark. + */ + getMeta(bookmark: string): BranchMeta | null { + return branches.get(bookmark) ?? null; + }, + + /** + * Get the parent branch name for a bookmark. + */ + getParent(bookmark: string): string | null { + return branches.get(bookmark)?.parentBranchName ?? null; + }, + + /** + * Get all children of a bookmark (derived from parent scan). + */ + getChildren(bookmark: string): string[] { + const children: string[] = []; + for (const [name, meta] of branches) { + if (meta.parentBranchName === bookmark) { + children.push(name); + } + } + return children; + }, + + /** + * Set metadata directly for a bookmark. + * Use this when you already have the full metadata (e.g., from remote refs). + */ + setMeta(bookmark: string, meta: BranchMeta): void { + branches.set(bookmark, meta); + dirty.add(bookmark); + deleted.delete(bookmark); + }, + + /** + * Refresh a bookmark's changeId/commitId/parentBranchName from jj. + * Preserves existing prInfo if present. + * Returns error if bookmark not found in jj. + */ + async refreshFromJJ(bookmark: string): Promise> { + const trunk = await getTrunk(cwd); + const existing = branches.get(bookmark); + + // Get the change for this bookmark + const changeResult = await list( + { revset: `bookmarks(exact:"${bookmark}")`, limit: 1 }, + cwd, + ); + if (!changeResult.ok) { + return { + ok: false, + error: createError("COMMAND_FAILED", changeResult.error.message), + }; + } + if (changeResult.value.length === 0) { + return { + ok: false, + error: createError( + "NOT_FOUND", + `Bookmark "${bookmark}" not found in jj`, + ), + }; + } + + const change = changeResult.value[0]; + const parentChangeId = change.parents[0]; + + // Determine parent branch name + let parentBranchName = trunk; + + if (parentChangeId) { + // Check if parent is trunk + const trunkResult = await list( + { revset: `bookmarks(exact:"${trunk}")`, limit: 1 }, + cwd, + ); + const isTrunkParent = + trunkResult.ok && + trunkResult.value.length > 0 && + trunkResult.value[0].changeId === parentChangeId; + + if (!isTrunkParent) { + // Find parent's bookmark + const parentResult = await list( + { revset: parentChangeId, limit: 1 }, + cwd, + ); + if (parentResult.ok && parentResult.value.length > 0) { + const parentBookmark = parentResult.value[0].bookmarks[0]; + if (parentBookmark) { + parentBranchName = parentBookmark; + } + } + } + } + + const meta: BranchMeta = { + changeId: change.changeId, + commitId: change.commitId, + parentBranchName, + prInfo: existing?.prInfo, // Preserve existing prInfo + }; + + branches.set(bookmark, meta); + dirty.add(bookmark); + deleted.delete(bookmark); + + return ok(undefined); + }, + + /** + * Track a bookmark. Derives changeId, commitId, parentBranchName from jj. + * If already tracked, updates the metadata (upsert behavior). + * @deprecated Use setMeta() for explicit metadata or refreshFromJJ() for jj lookup + */ + async track(bookmark: string, prInfo?: PRInfo): Promise { + const trunk = await getTrunk(cwd); + + // Get the change for this bookmark + const changeResult = await list( + { revset: `bookmarks(exact:"${bookmark}")`, limit: 1 }, + cwd, + ); + if (!changeResult.ok || changeResult.value.length === 0) { + return; // Bookmark not found, skip tracking + } + + const change = changeResult.value[0]; + const parentChangeId = change.parents[0]; + + // Determine parent branch name + let parentBranchName = trunk; + + if (parentChangeId) { + // Check if parent is trunk + const trunkResult = await list( + { revset: `bookmarks(exact:"${trunk}")`, limit: 1 }, + cwd, + ); + const isTrunkParent = + trunkResult.ok && + trunkResult.value.length > 0 && + trunkResult.value[0].changeId === parentChangeId; + + if (!isTrunkParent) { + // Find parent's bookmark + const parentResult = await list( + { revset: parentChangeId, limit: 1 }, + cwd, + ); + if (parentResult.ok && parentResult.value.length > 0) { + const parentBookmark = parentResult.value[0].bookmarks[0]; + if (parentBookmark) { + parentBranchName = parentBookmark; + } + } + } + } + + const meta: BranchMeta = { + changeId: change.changeId, + commitId: change.commitId, + parentBranchName, + prInfo, + }; + + branches.set(bookmark, meta); + dirty.add(bookmark); + deleted.delete(bookmark); + }, + + /** + * Untrack a bookmark (delete metadata). + */ + untrack(bookmark: string): void { + branches.delete(bookmark); + dirty.delete(bookmark); + deleted.add(bookmark); + }, + + /** + * Update PR info for a tracked bookmark. + */ + updatePRInfo(bookmark: string, prInfo: PRInfo): void { + const existing = branches.get(bookmark); + if (!existing) { + return; // Not tracked, skip + } + branches.set(bookmark, { ...existing, prInfo }); + dirty.add(bookmark); + }, + + /** + * Build a tree of tracked branches for rendering. + * Returns roots (branches whose parent is trunk). + */ + buildTree(trunk: string): TreeNode[] { + const nodeMap = new Map(); + + // Create nodes for all tracked branches + for (const [bookmarkName, meta] of branches) { + nodeMap.set(bookmarkName, { + bookmarkName, + meta, + children: [], + }); + } + + // Build parent-child relationships + const roots: TreeNode[] = []; + for (const [_bookmarkName, node] of nodeMap) { + const parentName = node.meta.parentBranchName; + if (parentName === trunk) { + roots.push(node); + } else { + const parentNode = nodeMap.get(parentName); + if (parentNode) { + parentNode.children.push(node); + } else { + // Parent not tracked - treat as root (orphaned) + roots.push(node); + } + } + } + + return roots; + }, + }; +} diff --git a/packages/core/src/engine/index.ts b/packages/core/src/engine/index.ts new file mode 100644 index 00000000..04231dc2 --- /dev/null +++ b/packages/core/src/engine/index.ts @@ -0,0 +1,3 @@ +export { type ArrContext, initContext } from "./context"; +export type { Engine } from "./engine"; +export type { BranchMeta, PRInfo, TreeNode } from "./types"; diff --git a/packages/core/src/engine/types.ts b/packages/core/src/engine/types.ts new file mode 100644 index 00000000..531c7fe0 --- /dev/null +++ b/packages/core/src/engine/types.ts @@ -0,0 +1,18 @@ +// Re-export types from the single source of truth +import type { BranchMeta } from "../git/metadata"; + +export type { + BranchMeta, + PRInfo, + PRState, + ReviewDecision, +} from "../git/metadata"; + +/** + * Tree node for rendering arr log. + */ +export interface TreeNode { + bookmarkName: string; + meta: BranchMeta; + children: TreeNode[]; +} diff --git a/packages/core/src/executor.ts b/packages/core/src/executor.ts new file mode 100644 index 00000000..b16be3e9 --- /dev/null +++ b/packages/core/src/executor.ts @@ -0,0 +1,136 @@ +export interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +interface ExecuteOptions { + cwd: string; + env?: Record; + timeout?: number; +} + +export interface CommandExecutor { + execute( + command: string, + args: string[], + options: ExecuteOptions, + ): Promise; +} + +function createShellExecutor(): CommandExecutor { + return { + async execute( + command: string, + args: string[], + options: ExecuteOptions, + ): Promise { + const proc = Bun.spawn([command, ...args], { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + stdout: "pipe", + stderr: "pipe", + }); + + const timeoutMs = options.timeout ?? 30000; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + proc.kill(); + reject(new Error(`Command timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + const resultPromise = (async () => { + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { stdout, stderr, exitCode }; + })(); + + return Promise.race([resultPromise, timeoutPromise]); + }, + }; +} + +export const shellExecutor = createShellExecutor(); + +interface SyncOptions { + cwd?: string; + input?: string; + onError?: "throw" | "ignore"; +} + +/** + * Run a command synchronously. + * Returns stdout on success, throws or returns empty string on failure. + */ +export function runSync( + command: string, + args: string[], + options?: SyncOptions, +): string { + const result = Bun.spawnSync([command, ...args], { + cwd: options?.cwd ?? process.cwd(), + stdin: options?.input ? Buffer.from(options.input) : undefined, + }); + + if (result.exitCode !== 0) { + if (options?.onError === "ignore") return ""; + const stderr = result.stderr.toString(); + throw new Error(`${command} ${args.join(" ")} failed: ${stderr}`); + } + + return result.stdout.toString().trim(); +} + +/** + * Run a command synchronously and split output into lines. + */ +export function runSyncLines( + command: string, + args: string[], + options?: SyncOptions, +): string[] { + return runSync(command, args, options) + .split("\n") + .filter((line) => line.length > 0); +} + +/** + * Run an async command and check if it succeeded. + */ +export async function cmdCheck( + command: string, + args: string[], + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + try { + const result = await executor.execute(command, args, { cwd }); + return result.exitCode === 0; + } catch { + return false; + } +} + +/** + * Run an async command and return stdout if successful, null otherwise. + */ +export async function cmdOutput( + command: string, + args: string[], + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + try { + const result = await executor.execute(command, args, { cwd }); + if (result.exitCode === 0) { + return result.stdout.trim(); + } + return null; + } catch { + return null; + } +} diff --git a/packages/core/src/git/branch.ts b/packages/core/src/git/branch.ts new file mode 100644 index 00000000..bd4ac59f --- /dev/null +++ b/packages/core/src/git/branch.ts @@ -0,0 +1,32 @@ +import { type CommandExecutor, shellExecutor } from "../executor"; +import { createError, err, ok, type Result } from "../result"; +import { gitCheck } from "./runner"; + +export async function hasBranch( + cwd: string, + branch: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return gitCheck( + ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + cwd, + executor, + ); +} + +/** + * Exit jj mode by checking out the trunk branch in git. + */ +export async function exitToGit( + cwd: string, + trunk: string, + executor: CommandExecutor = shellExecutor, +): Promise> { + const result = await executor.execute("git", ["checkout", trunk], { cwd }); + if (result.exitCode !== 0) { + return err( + createError("COMMAND_FAILED", result.stderr || "git checkout failed"), + ); + } + return ok({ trunk }); +} diff --git a/packages/core/src/git/metadata.ts b/packages/core/src/git/metadata.ts new file mode 100644 index 00000000..e87256b7 --- /dev/null +++ b/packages/core/src/git/metadata.ts @@ -0,0 +1,256 @@ +import { z } from "zod"; +import { REFS_PREFIX, runGitSync, runGitSyncLines } from "./runner"; + +/** + * PR state - matches GitHub GraphQL API (uppercase) + */ +export type PRState = "OPEN" | "CLOSED" | "MERGED"; + +/** + * Review decision - matches GitHub GraphQL API (uppercase) + */ +export type ReviewDecision = + | "APPROVED" + | "REVIEW_REQUIRED" + | "CHANGES_REQUESTED"; + +const prInfoSchema = z.object({ + // Required fields + number: z.number(), + url: z.string(), + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + base: z.string(), + title: z.string(), + + // Optional fields + head: z.string().optional(), + body: z.string().optional(), + reviewDecision: z + .enum(["APPROVED", "REVIEW_REQUIRED", "CHANGES_REQUESTED"]) + .nullable() + .optional(), + isDraft: z.boolean().optional(), + /** Number of times PR was submitted (1 = initial, 2+ = updated via force-push) */ + version: z.number().optional(), +}); + +const branchMetaSchema = z.object({ + // Identity + changeId: z.string(), + commitId: z.string(), + + // Stack relationship + parentBranchName: z.string(), + + // PR info (cached from GitHub) + prInfo: prInfoSchema.optional(), +}); + +export type PRInfo = z.infer; +export type BranchMeta = z.infer; + +/** + * Write metadata for a branch to refs/arr/ + */ +export function writeMetadata( + branchName: string, + meta: BranchMeta, + cwd?: string, +): void { + const json = JSON.stringify(meta); + const objectId = runGitSync(["hash-object", "-w", "--stdin"], { + input: json, + cwd, + }); + runGitSync(["update-ref", `${REFS_PREFIX}/${branchName}`, objectId], { cwd }); +} + +/** + * Read metadata for a branch from refs/arr/ + * Returns null if not tracked or metadata is invalid. + */ +export function readMetadata( + branchName: string, + cwd?: string, +): BranchMeta | null { + const json = runGitSync(["cat-file", "-p", `${REFS_PREFIX}/${branchName}`], { + cwd, + onError: "ignore", + }); + if (!json) return null; + + try { + const parsed = branchMetaSchema.safeParse(JSON.parse(json)); + if (!parsed.success) return null; + return parsed.data; + } catch { + return null; + } +} + +/** + * Batch read metadata for multiple branches in a single git call. + * Much faster than calling readMetadata() for each branch individually. + */ +export function readMetadataBatch( + branches: Map, + cwd?: string, +): Map { + const result = new Map(); + if (branches.size === 0) return result; + + // Build input: one object ID per line + const objectIds = Array.from(branches.values()); + const input = objectIds.join("\n"); + + // Run git cat-file --batch + const output = runGitSync(["cat-file", "--batch"], { + cwd, + input, + onError: "ignore", + }); + + if (!output) return result; + + // Parse batch output format: + // blob + // + // (blank line or next header) + const branchNames = Array.from(branches.keys()); + const lines = output.split("\n"); + let lineIdx = 0; + let branchIdx = 0; + + while (lineIdx < lines.length && branchIdx < branchNames.length) { + const headerLine = lines[lineIdx]; + if (!headerLine || headerLine.includes("missing")) { + // Object not found, skip this branch + lineIdx++; + branchIdx++; + continue; + } + + // Parse header: + const headerMatch = headerLine.match(/^([a-f0-9]+) (\w+) (\d+)$/); + if (!headerMatch) { + lineIdx++; + branchIdx++; + continue; + } + + const size = parseInt(headerMatch[3], 10); + lineIdx++; // Move past header + + // Read content (may span multiple lines) + let content = ""; + let remaining = size; + while (remaining > 0 && lineIdx < lines.length) { + const line = lines[lineIdx]; + content += line; + remaining -= line.length; + lineIdx++; + if (remaining > 0) { + content += "\n"; + remaining -= 1; // Account for newline + } + } + + // Parse JSON and validate + try { + const parsed = branchMetaSchema.safeParse(JSON.parse(content)); + if (parsed.success) { + result.set(branchNames[branchIdx], parsed.data); + } + } catch { + // Invalid JSON, skip + } + + branchIdx++; + } + + return result; +} + +/** + * Delete metadata for a branch. + */ +export function deleteMetadata(branchName: string, cwd?: string): void { + runGitSync(["update-ref", "-d", `${REFS_PREFIX}/${branchName}`], { + cwd, + onError: "ignore", + }); +} + +/** + * List all tracked branches with their metadata object IDs. + * Returns a map of branchName -> objectId + */ +export function listTrackedBranches(cwd?: string): Map { + const result = new Map(); + + const lines = runGitSyncLines( + [ + "for-each-ref", + "--format=%(refname:lstrip=2):%(objectname)", + `${REFS_PREFIX}/`, + ], + { cwd, onError: "ignore" }, + ); + + for (const line of lines) { + const [branchName, objectId] = line.split(":"); + if (branchName && objectId) { + result.set(branchName, objectId); + } + } + + return result; +} + +/** + * Get all tracked branch names. + */ +export function getTrackedBranchNames(cwd?: string): string[] { + return Array.from(listTrackedBranches(cwd).keys()); +} + +/** + * Get all tracked branches with their metadata. + * Used for debugging/inspection. + */ +export function getAllBranchMetadata( + cwd?: string, +): Map { + const branches = listTrackedBranches(cwd); + const result = new Map(); + + for (const [branchName] of branches) { + result.set(branchName, readMetadata(branchName, cwd)); + } + + return result; +} + +/** + * Check if a branch is tracked by arr. + */ +export function isTracked(branchName: string, cwd?: string): boolean { + const meta = readMetadata(branchName, cwd); + return meta !== null; +} + +/** + * Update PR info for a tracked branch. + * Preserves other metadata fields. + */ +export function updatePRInfo( + branchName: string, + prInfo: PRInfo, + cwd?: string, +): void { + const meta = readMetadata(branchName, cwd); + if (!meta) { + throw new Error(`Branch ${branchName} is not tracked by arr`); + } + writeMetadata(branchName, { ...meta, prInfo }, cwd); +} diff --git a/packages/core/src/git/remote.ts b/packages/core/src/git/remote.ts new file mode 100644 index 00000000..7ab2dea2 --- /dev/null +++ b/packages/core/src/git/remote.ts @@ -0,0 +1,65 @@ +import { type CommandExecutor, shellExecutor } from "../executor"; +import { createError, err, ok, type Result } from "../result"; +import { gitCheck, gitOutput } from "./runner"; + +export async function hasRemote( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + const output = await gitOutput(["remote"], cwd, executor); + return output !== null && output.length > 0; +} + +export async function isBranchPushed( + cwd: string, + branch: string, + remote = "origin", + executor: CommandExecutor = shellExecutor, +): Promise { + return gitCheck( + ["show-ref", "--verify", "--quiet", `refs/remotes/${remote}/${branch}`], + cwd, + executor, + ); +} + +export async function pushBranch( + cwd: string, + branch: string, + remote = "origin", + executor: CommandExecutor = shellExecutor, +): Promise> { + try { + const result = await executor.execute( + "git", + ["push", "-u", remote, branch], + { cwd }, + ); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || `Failed to push ${branch} to ${remote}`, + ), + ); + } + return ok(undefined); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to push branch: ${e}`)); + } +} + +/** + * Gets the default branch from the remote (origin). + * Returns null if unable to determine. + */ +export async function getRemoteDefaultBranch( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + const output = await gitOutput(["remote", "show", "origin"], cwd, executor); + if (!output) return null; + + const match = output.match(/HEAD branch:\s*(\S+)/); + return match?.[1] ?? null; +} diff --git a/packages/core/src/git/repo.ts b/packages/core/src/git/repo.ts new file mode 100644 index 00000000..c315a389 --- /dev/null +++ b/packages/core/src/git/repo.ts @@ -0,0 +1,37 @@ +import { type CommandExecutor, shellExecutor } from "../executor"; +import { createError, err, ok, type Result } from "../result"; +import { gitCheck } from "./runner"; + +export async function isInGitRepo( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return gitCheck(["rev-parse", "--git-dir"], cwd, executor); +} + +export async function hasGitCommits( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return gitCheck(["rev-parse", "HEAD"], cwd, executor); +} + +export async function initGit( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise> { + try { + const result = await executor.execute("git", ["init"], { cwd }); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || "Failed to initialize git", + ), + ); + } + return ok(undefined); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to initialize git: ${e}`)); + } +} diff --git a/packages/core/src/git/runner.ts b/packages/core/src/git/runner.ts new file mode 100644 index 00000000..74c39cc1 --- /dev/null +++ b/packages/core/src/git/runner.ts @@ -0,0 +1,45 @@ +import { + type CommandExecutor, + cmdCheck, + cmdOutput, + runSync, + runSyncLines, + shellExecutor, +} from "../executor"; + +/** Namespace for arr metadata refs */ +export const REFS_PREFIX = "refs/arr"; + +/** Run an async git command and check if it succeeded. */ +export function gitCheck( + args: string[], + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return cmdCheck("git", args, cwd, executor); +} + +/** Run an async git command and return stdout if successful, null otherwise. */ +export function gitOutput( + args: string[], + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return cmdOutput("git", args, cwd, executor); +} + +/** Run a git command synchronously. */ +export function runGitSync( + args: string[], + options?: { cwd?: string; input?: string; onError?: "throw" | "ignore" }, +): string { + return runSync("git", args, options); +} + +/** Run a git command synchronously and split output into lines. */ +export function runGitSyncLines( + args: string[], + options?: { cwd?: string; onError?: "throw" | "ignore" }, +): string[] { + return runSyncLines("git", args, options); +} diff --git a/packages/core/src/git/status.ts b/packages/core/src/git/status.ts new file mode 100644 index 00000000..cfc8a6a6 --- /dev/null +++ b/packages/core/src/git/status.ts @@ -0,0 +1,13 @@ +import { type CommandExecutor, shellExecutor } from "../executor"; +import { gitOutput } from "./runner"; + +/** + * Get the current git branch name. + * Returns null if in detached HEAD state or not in a git repo. + */ +export async function getCurrentGitBranch( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + return gitOutput(["symbolic-ref", "--short", "HEAD"], cwd, executor); +} diff --git a/packages/core/src/git/trunk.ts b/packages/core/src/git/trunk.ts new file mode 100644 index 00000000..b7a7f7b8 --- /dev/null +++ b/packages/core/src/git/trunk.ts @@ -0,0 +1,57 @@ +import { type CommandExecutor, shellExecutor } from "../executor"; +import { getRemoteDefaultBranch } from "./remote"; +import { gitCheck, gitOutput } from "./runner"; + +/** + * Detects the trunk branch for a repository. + * + * Strategy: + * 1. Query the remote's HEAD branch (most authoritative) + * 2. If that fails or branch doesn't exist locally, fall back to checking + * common branch names and return all matches for user selection + */ +export async function detectTrunkBranches( + cwd: string, + executor: CommandExecutor = shellExecutor, +): Promise { + // First, try to get the remote's default branch + const remoteTrunk = await getRemoteDefaultBranch(cwd, executor); + if (remoteTrunk) { + // Verify the branch exists locally + const localExists = await gitCheck( + ["show-ref", "--verify", "--quiet", `refs/heads/${remoteTrunk}`], + cwd, + executor, + ); + if (localExists) { + return [remoteTrunk]; + } + // Branch exists on remote but not locally - still prefer it + const remoteExists = await gitCheck( + ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${remoteTrunk}`], + cwd, + executor, + ); + if (remoteExists) { + return [remoteTrunk]; + } + } + + // Fall back to checking common branch names + const candidates = ["main", "master", "develop", "trunk"]; + const found: string[] = []; + + const branchOutput = await gitOutput(["branch", "-a"], cwd, executor); + if (!branchOutput) { + return ["main", "master"]; + } + + const branches = branchOutput.toLowerCase(); + for (const candidate of candidates) { + if (branches.includes(candidate)) { + found.push(candidate); + } + } + + return found.length > 0 ? found : ["main"]; +} diff --git a/packages/core/src/github/branch.ts b/packages/core/src/github/branch.ts new file mode 100644 index 00000000..9f98c060 --- /dev/null +++ b/packages/core/src/github/branch.ts @@ -0,0 +1,40 @@ +import { createError, err, type Result } from "../result"; +import { withGitHub } from "./client"; + +export function isProtectedBranch(branchName: string): boolean { + const protectedBranches = ["main", "master", "trunk", "develop"]; + const lower = branchName.toLowerCase(); + return ( + protectedBranches.includes(branchName) || protectedBranches.includes(lower) + ); +} + +export async function deleteBranch( + branchName: string, + cwd = process.cwd(), +): Promise> { + if (isProtectedBranch(branchName)) { + return err( + createError( + "INVALID_STATE", + `Cannot delete protected branch: ${branchName}`, + ), + ); + } + + return withGitHub(cwd, "delete branch", async ({ octokit, owner, repo }) => { + try { + await octokit.git.deleteRef({ + owner, + repo, + ref: `heads/${branchName}`, + }); + } catch (e) { + const error = e as Error & { status?: number }; + // 422 means branch doesn't exist, which is fine + if (error.status !== 422) { + throw e; + } + } + }); +} diff --git a/packages/core/src/github/client.ts b/packages/core/src/github/client.ts new file mode 100644 index 00000000..611cb322 --- /dev/null +++ b/packages/core/src/github/client.ts @@ -0,0 +1,99 @@ +import { Octokit } from "@octokit/rest"; +import { shellExecutor } from "../executor"; +import { createError, err, ok, type Result } from "../result"; + +export interface RepoInfo { + owner: string; + repo: string; +} + +// Module-level caches (keyed by cwd) +const tokenCache = new Map(); +const repoCache = new Map(); +const octokitCache = new Map(); + +export async function getToken(cwd: string): Promise { + const cached = tokenCache.get(cwd); + if (cached) return cached; + + const result = await shellExecutor.execute("gh", ["auth", "token"], { cwd }); + if (result.exitCode !== 0) { + throw new Error(`Failed to get GitHub token: ${result.stderr}`); + } + const token = result.stdout.trim(); + tokenCache.set(cwd, token); + return token; +} + +export async function getRepoInfo(cwd: string): Promise> { + const cached = repoCache.get(cwd); + if (cached) return ok(cached); + + try { + const result = await shellExecutor.execute( + "git", + ["config", "--get", "remote.origin.url"], + { cwd }, + ); + + if (result.exitCode !== 0) { + return err(createError("COMMAND_FAILED", "No git remote found")); + } + + const url = result.stdout.trim(); + const match = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/); + if (!match) { + return err( + createError( + "COMMAND_FAILED", + "Could not parse GitHub repo from remote URL", + ), + ); + } + + const info = { owner: match[1], repo: match[2] }; + repoCache.set(cwd, info); + return ok(info); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to get repo info: ${e}`)); + } +} + +export async function getOctokit(cwd: string): Promise { + const cached = octokitCache.get(cwd); + if (cached) return cached; + + const token = await getToken(cwd); + const octokit = new Octokit({ auth: token }); + octokitCache.set(cwd, octokit); + return octokit; +} + +export interface GitHubContext { + octokit: Octokit; + owner: string; + repo: string; +} + +/** + * Helper to reduce boilerplate for GitHub API calls. + * Handles repo info lookup, octokit creation, and error wrapping. + */ +export async function withGitHub( + cwd: string, + operation: string, + fn: (ctx: GitHubContext) => Promise, +): Promise> { + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + + try { + const octokit = await getOctokit(cwd); + const result = await fn({ octokit, owner, repo }); + return ok(result); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to ${operation}: ${e}`)); + } +} diff --git a/packages/core/src/github/comments.ts b/packages/core/src/github/comments.ts new file mode 100644 index 00000000..27c44f50 --- /dev/null +++ b/packages/core/src/github/comments.ts @@ -0,0 +1,104 @@ +import { ok, type Result } from "../result"; +import { withGitHub } from "./client"; + +const STACK_COMMENT_MARKER = ""; + +export interface GitHubComment { + id: number; + body: string; + createdAt: string; + updatedAt: string; +} + +function listComments( + prNumber: number, + cwd = process.cwd(), +): Promise> { + return withGitHub(cwd, "list comments", async ({ octokit, owner, repo }) => { + const { data } = await octokit.issues.listComments({ + owner, + repo, + issue_number: prNumber, + }); + + return data.map((c) => ({ + id: c.id, + body: c.body ?? "", + createdAt: c.created_at, + updatedAt: c.updated_at, + })); + }); +} + +function createComment( + prNumber: number, + body: string, + cwd = process.cwd(), +): Promise> { + return withGitHub(cwd, "create comment", async ({ octokit, owner, repo }) => { + const { data } = await octokit.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + + return { + id: data.id, + body: data.body ?? "", + createdAt: data.created_at, + updatedAt: data.updated_at, + }; + }); +} + +function updateComment( + commentId: number, + body: string, + cwd = process.cwd(), +): Promise> { + return withGitHub(cwd, "update comment", async ({ octokit, owner, repo }) => { + await octokit.issues.updateComment({ + owner, + repo, + comment_id: commentId, + body, + }); + }); +} + +async function findStackComment( + prNumber: number, + cwd = process.cwd(), +): Promise> { + const commentsResult = await listComments(prNumber, cwd); + if (!commentsResult.ok) return commentsResult; + + const stackComment = commentsResult.value.find((c) => + c.body.includes(STACK_COMMENT_MARKER), + ); + return ok(stackComment ?? null); +} + +export async function upsertStackComment( + prNumber: number, + body: string, + cwd = process.cwd(), +): Promise> { + const markedBody = `${STACK_COMMENT_MARKER}\n${body}`; + + const existingResult = await findStackComment(prNumber, cwd); + if (!existingResult.ok) return existingResult; + + if (existingResult.value) { + const updateResult = await updateComment( + existingResult.value.id, + markedBody, + cwd, + ); + if (!updateResult.ok) return updateResult; + return ok({ ...existingResult.value, body: markedBody }); + } + + return createComment(prNumber, markedBody, cwd); +} diff --git a/packages/core/src/github/pr-actions.ts b/packages/core/src/github/pr-actions.ts new file mode 100644 index 00000000..11d250a9 --- /dev/null +++ b/packages/core/src/github/pr-actions.ts @@ -0,0 +1,238 @@ +import { shellExecutor } from "../executor"; +import { createError, err, ok, type Result } from "../result"; +import { isProtectedBranch } from "./branch"; +import { getOctokit, getRepoInfo, withGitHub } from "./client"; + +export async function createPR( + options: { + head: string; + title?: string; + body?: string; + base?: string; + draft?: boolean; + }, + cwd = process.cwd(), +): Promise> { + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + + try { + const octokit = await getOctokit(cwd); + const { data: pr } = await octokit.pulls.create({ + owner, + repo, + head: options.head, + title: options.title ?? options.head, + body: options.body, + base: options.base ?? "main", + draft: options.draft, + }); + + return ok({ url: pr.html_url, number: pr.number }); + } catch (e) { + // Special error handling for PR creation - extract GitHub's error details + const error = e as Error & { + status?: number; + response?: { + data?: { message?: string; errors?: Array<{ message?: string }> }; + }; + }; + const ghMessage = error.response?.data?.message || error.message; + const ghErrors = error.response?.data?.errors + ?.map((err) => err.message) + .join(", "); + const details = ghErrors ? `${ghMessage} (${ghErrors})` : ghMessage; + return err( + createError("COMMAND_FAILED", `Failed to create PR: ${details}`), + ); + } +} + +export async function mergePR( + prNumber: number, + options?: { + method?: "merge" | "squash" | "rebase"; + deleteHead?: boolean; + headRef?: string; + }, + cwd = process.cwd(), +): Promise> { + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + const method = options?.method ?? "squash"; + + try { + const octokit = await getOctokit(cwd); + await octokit.pulls.merge({ + owner, + repo, + pull_number: prNumber, + merge_method: method, + }); + + if (options?.deleteHead && options?.headRef) { + if (isProtectedBranch(options.headRef)) { + console.error( + `SAFETY: Refusing to delete protected branch: ${options.headRef}`, + ); + return ok(undefined); + } + + try { + await octokit.git.deleteRef({ + owner, + repo, + ref: `heads/${options.headRef}`, + }); + } catch { + // Branch deletion is best-effort + } + } + + return ok(undefined); + } catch (e) { + // Special error handling for merge - detect specific failure modes + const error = e as Error & { status?: number; message?: string }; + if (error.status === 405) { + return err( + createError( + "MERGE_BLOCKED", + "PR is not mergeable. Check for conflicts or required status checks.", + ), + ); + } + if (error.message?.includes("already been merged")) { + return err(createError("ALREADY_MERGED", "PR has already been merged")); + } + return err(createError("COMMAND_FAILED", `Failed to merge PR: ${e}`)); + } +} + +export function closePR( + prNumber: number, + cwd = process.cwd(), +): Promise> { + return withGitHub(cwd, "close PR", async ({ octokit, owner, repo }) => { + await octokit.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: "closed", + }); + }); +} + +export function updatePR( + prNumber: number, + options: { title?: string; body?: string; base?: string }, + cwd = process.cwd(), +): Promise> { + return withGitHub(cwd, "update PR", async ({ octokit, owner, repo }) => { + await octokit.pulls.update({ + owner, + repo, + pull_number: prNumber, + title: options.title, + body: options.body, + base: options.base, + }); + }); +} + +export async function updatePRBranch( + prNumber: number, + options?: { rebase?: boolean }, + cwd = process.cwd(), +): Promise> { + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + + try { + if (options?.rebase) { + // gh CLI is needed for rebase - octokit doesn't support it + const result = await shellExecutor.execute( + "gh", + [ + "pr", + "update-branch", + String(prNumber), + "--rebase", + "-R", + `${owner}/${repo}`, + ], + { cwd }, + ); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + `Failed to update PR branch: ${result.stderr}`, + ), + ); + } + return ok(undefined); + } + + const octokit = await getOctokit(cwd); + await octokit.pulls.updateBranch({ + owner, + repo, + pull_number: prNumber, + }); + + return ok(undefined); + } catch (e) { + return err( + createError("COMMAND_FAILED", `Failed to update PR branch: ${e}`), + ); + } +} + +export function waitForMergeable( + prNumber: number, + options?: { timeoutMs?: number; pollIntervalMs?: number }, + cwd = process.cwd(), +): Promise> { + const timeoutMs = options?.timeoutMs ?? 30000; + const pollIntervalMs = options?.pollIntervalMs ?? 2000; + + return withGitHub( + cwd, + "check mergeable status", + async ({ octokit, owner, repo }) => { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const { data: pr } = await octokit.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + + if (pr.mergeable === true) { + return { mergeable: true }; + } + + if (pr.mergeable === false) { + return { + mergeable: false, + reason: pr.mergeable_state || "Has conflicts or other issues", + }; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return { + mergeable: false, + reason: "Timeout waiting for merge status", + }; + }, + ); +} diff --git a/packages/core/src/github/pr-status.ts b/packages/core/src/github/pr-status.ts new file mode 100644 index 00000000..58b035b8 --- /dev/null +++ b/packages/core/src/github/pr-status.ts @@ -0,0 +1,203 @@ +import { graphql } from "@octokit/graphql"; +import type { PullRequestReviewState } from "@octokit/graphql-schema"; +import type { PRInfo, ReviewDecision } from "../git/metadata"; +import { createError, err, ok, type Result } from "../result"; +import { getRepoInfo, getToken } from "./client"; + +// Re-export PRInfo as the unified type for PR data +export type { PRInfo }; + +/** GraphQL fields for fetching PR status - shared between queries */ +const PR_STATUS_FIELDS = ` + number + title + state + merged + baseRefName + headRefName + url + reviews(last: 50) { + nodes { + state + author { login } + } + } + timelineItems(itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT], first: 100) { + totalCount + } +`; + +/** GraphQL response shape for a single PR */ +interface GraphQLPRNode { + number: number; + title: string; + state: "OPEN" | "CLOSED" | "MERGED"; + merged: boolean; + baseRefName: string; + headRefName: string; + url: string; + reviews: { + nodes: Array<{ + state: PullRequestReviewState; + author: { login: string } | null; + }>; + }; + timelineItems: { + totalCount: number; + }; +} + +function computeReviewDecision( + reviews: GraphQLPRNode["reviews"]["nodes"], +): ReviewDecision | null { + const latestByUser = new Map(); + for (const review of reviews) { + if (review.state !== "PENDING" && review.state !== "COMMENTED") { + latestByUser.set(review.author?.login ?? "", review.state); + } + } + + const states = [...latestByUser.values()]; + if (states.includes("CHANGES_REQUESTED")) return "CHANGES_REQUESTED"; + if (states.includes("APPROVED")) return "APPROVED"; + return null; +} + +/** Map a GraphQL PR node to our PRInfo type */ +function mapPRNodeToInfo(pr: GraphQLPRNode): PRInfo { + const forcePushCount = pr.timelineItems?.totalCount ?? 0; + return { + number: pr.number, + title: pr.title, + state: pr.merged ? "MERGED" : pr.state, + reviewDecision: computeReviewDecision(pr.reviews.nodes), + base: pr.baseRefName, + head: pr.headRefName, + url: pr.url, + version: 1 + forcePushCount, + }; +} + +export async function getMultiplePRInfos( + prNumbers: number[], + cwd = process.cwd(), +): Promise>> { + if (prNumbers.length === 0) { + return ok(new Map()); + } + + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + + try { + const prQueries = prNumbers + .map( + (num, i) => + `pr${i}: pullRequest(number: ${num}) { ${PR_STATUS_FIELDS} }`, + ) + .join("\n"); + + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + ${prQueries} + } + } + `; + + const token = await getToken(cwd); + + type Response = { + repository: { [key: `pr${number}`]: GraphQLPRNode | null }; + }; + + const response = await graphql(query, { + owner, + repo, + headers: { authorization: `token ${token}` }, + }); + + const infos = new Map(); + for (let i = 0; i < prNumbers.length; i++) { + const pr = response.repository[`pr${i}`]; + if (pr) { + infos.set(pr.number, mapPRNodeToInfo(pr)); + } + } + + return ok(infos); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to get PR info: ${e}`)); + } +} + +export async function getPRForBranch( + branchName: string, + cwd = process.cwd(), +): Promise> { + const result = await batchGetPRsForBranches([branchName], cwd); + if (!result.ok) return result; + return ok(result.value.get(branchName) ?? null); +} + +export async function batchGetPRsForBranches( + branchNames: string[], + cwd = process.cwd(), +): Promise>> { + if (branchNames.length === 0) { + return ok(new Map()); + } + + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + + try { + const branchQueries = branchNames + .map( + (branch, i) => + `branch${i}: pullRequests(first: 5, headRefName: "${branch}", states: [OPEN, CLOSED, MERGED]) { + nodes { ${PR_STATUS_FIELDS} } + }`, + ) + .join("\n"); + + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + ${branchQueries} + } + } + `; + + const token = await getToken(cwd); + + type Response = { + repository: { [key: `branch${number}`]: { nodes: GraphQLPRNode[] } }; + }; + + const response = await graphql(query, { + owner, + repo, + headers: { authorization: `token ${token}` }, + }); + + const prMap = new Map(); + for (let i = 0; i < branchNames.length; i++) { + const branchData = response.repository[`branch${i}`]; + const prs = branchData?.nodes ?? []; + // Prefer open PR, otherwise take first (most recent) + const pr = prs.find((p) => p.state === "OPEN") ?? prs[0]; + if (pr) { + prMap.set(branchNames[i], mapPRNodeToInfo(pr)); + } + } + + return ok(prMap); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to list PRs: ${e}`)); + } +} diff --git a/packages/core/src/init.ts b/packages/core/src/init.ts new file mode 100644 index 00000000..a24b3b3d --- /dev/null +++ b/packages/core/src/init.ts @@ -0,0 +1,122 @@ +import { join } from "node:path"; +import { shellExecutor } from "./executor"; +import { createError, err, ok, type Result } from "./result"; + +export interface Prerequisites { + git: { found: boolean; version?: string; path?: string }; + jj: { found: boolean; version?: string; path?: string }; +} + +export async function checkPrerequisites(): Promise { + const [git, jj] = await Promise.all([checkBinary("git"), checkBinary("jj")]); + + return { git, jj }; +} + +async function checkBinary( + name: string, +): Promise<{ found: boolean; version?: string; path?: string }> { + try { + const whichResult = await shellExecutor.execute("which", [name], { + cwd: process.cwd(), + }); + if (whichResult.exitCode !== 0) { + return { found: false }; + } + + const path = whichResult.stdout.trim(); + const versionResult = await shellExecutor.execute(name, ["--version"], { + cwd: process.cwd(), + }); + const version = versionResult.stdout.trim().split("\n")[0]; + + return { found: true, version, path }; + } catch { + return { found: false }; + } +} + +export async function isJjInitialized(cwd: string): Promise { + const jjDir = join(cwd, ".jj"); + try { + const { stat } = await import("node:fs/promises"); + const stats = await stat(jjDir); + return stats.isDirectory(); + } catch { + return false; + } +} + +export async function initJj(cwd: string): Promise> { + try { + const result = await shellExecutor.execute( + "jj", + ["git", "init", "--colocate"], + { cwd }, + ); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || "Failed to initialize jj", + ), + ); + } + return ok(undefined); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to initialize jj: ${e}`)); + } +} + +export async function configureTrunk( + cwd: string, + trunk: string, +): Promise> { + try { + const result = await shellExecutor.execute( + "jj", + ["config", "set", "--repo", 'revset-aliases."trunk()"', trunk], + { cwd }, + ); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || "Failed to configure trunk", + ), + ); + } + return ok(undefined); + } catch (e) { + return err( + createError("COMMAND_FAILED", `Failed to configure trunk: ${e}`), + ); + } +} + +export async function installJj( + method: "brew" | "cargo", +): Promise> { + try { + const cmd = + method === "brew" + ? ["brew", ["install", "jj"]] + : ["cargo", ["install", "jj-cli"]]; + const result = await shellExecutor.execute( + cmd[0] as string, + cmd[1] as string[], + { cwd: process.cwd() }, + ); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + result.stderr || `Failed to install jj via ${method}`, + ), + ); + } + return ok(undefined); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to install jj: ${e}`)); + } +} diff --git a/packages/core/src/jj/abandon.ts b/packages/core/src/jj/abandon.ts new file mode 100644 index 00000000..d822afd7 --- /dev/null +++ b/packages/core/src/jj/abandon.ts @@ -0,0 +1,9 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +export async function abandon( + changeId: string, + cwd = process.cwd(), +): Promise> { + return runJJVoid(["abandon", changeId], cwd); +} diff --git a/packages/core/src/jj/bookmark-create.ts b/packages/core/src/jj/bookmark-create.ts new file mode 100644 index 00000000..bafdfd6f --- /dev/null +++ b/packages/core/src/jj/bookmark-create.ts @@ -0,0 +1,24 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +async function createBookmark( + name: string, + revision?: string, + cwd = process.cwd(), +): Promise> { + const args = ["bookmark", "create", name]; + if (revision) { + args.push("-r", revision); + } + return runJJVoid(args, cwd); +} + +export async function ensureBookmark( + name: string, + changeId: string, + cwd = process.cwd(), +): Promise> { + const create = await createBookmark(name, changeId, cwd); + if (create.ok) return create; + return runJJVoid(["bookmark", "move", name, "-r", changeId], cwd); +} diff --git a/packages/core/src/jj/bookmark-delete.ts b/packages/core/src/jj/bookmark-delete.ts new file mode 100644 index 00000000..de8953b6 --- /dev/null +++ b/packages/core/src/jj/bookmark-delete.ts @@ -0,0 +1,9 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +export async function deleteBookmark( + name: string, + cwd = process.cwd(), +): Promise> { + return runJJVoid(["bookmark", "delete", name], cwd); +} diff --git a/packages/core/src/jj/bookmark-tracking.ts b/packages/core/src/jj/bookmark-tracking.ts new file mode 100644 index 00000000..2572a0d7 --- /dev/null +++ b/packages/core/src/jj/bookmark-tracking.ts @@ -0,0 +1,102 @@ +import { ok, type Result } from "../result"; +import type { BookmarkTrackingStatus } from "../types"; +import { runJJ } from "./runner"; + +export async function getBookmarkTracking( + cwd = process.cwd(), +): Promise> { + // Template to get bookmark name + tracking status from origin + const template = `if(remote == "origin", name ++ "\\t" ++ tracking_ahead_count.exact() ++ "/" ++ tracking_behind_count.exact() ++ "\\n")`; + const result = await runJJ(["bookmark", "list", "-T", template], cwd); + if (!result.ok) return result; + + const statuses: BookmarkTrackingStatus[] = []; + const lines = result.value.stdout.trim().split("\n").filter(Boolean); + + for (const line of lines) { + const parts = line.split("\t"); + if (parts.length !== 2) continue; + const [name, counts] = parts; + const [ahead, behind] = counts.split("/").map(Number); + if (!Number.isNaN(ahead) && !Number.isNaN(behind)) { + statuses.push({ name, aheadCount: ahead, behindCount: behind }); + } + } + + return ok(statuses); +} + +/** + * Clean up orphaned bookmarks: + * 1. Local bookmarks marked as deleted (no target) + * 2. Local bookmarks without origin pointing to empty changes + */ +export async function cleanupOrphanedBookmarks( + cwd = process.cwd(), +): Promise> { + // Get all bookmarks with their remote status and target info + // Format: name\tremote_or_local\thas_target\tis_empty + const template = + 'name ++ "\\t" ++ if(remote, remote, "local") ++ "\\t" ++ if(normal_target, "target", "no_target") ++ "\\t" ++ if(normal_target, normal_target.empty(), "") ++ "\\n"'; + const result = await runJJ( + ["bookmark", "list", "--all", "-T", template], + cwd, + ); + if (!result.ok) return result; + + // Parse bookmarks and group by name + const bookmarksByName = new Map< + string, + { hasOrigin: boolean; hasLocalTarget: boolean; isEmpty: boolean } + >(); + + for (const line of result.value.stdout.trim().split("\n")) { + if (!line) continue; + const [name, remote, hasTarget, isEmpty] = line.split("\t"); + if (!name) continue; + + const existing = bookmarksByName.get(name); + if (remote === "origin") { + if (existing) { + existing.hasOrigin = true; + } else { + bookmarksByName.set(name, { + hasOrigin: true, + hasLocalTarget: false, + isEmpty: false, + }); + } + } else if (remote === "local") { + const localHasTarget = hasTarget === "target"; + const localIsEmpty = isEmpty === "true"; + if (existing) { + existing.hasLocalTarget = localHasTarget; + existing.isEmpty = localIsEmpty; + } else { + bookmarksByName.set(name, { + hasOrigin: false, + hasLocalTarget: localHasTarget, + isEmpty: localIsEmpty, + }); + } + } + } + + // Find bookmarks to forget: + // 1. Deleted bookmarks (local has no target) - these show as "(deleted)" + // 2. Orphaned bookmarks (no origin AND empty change) + const forgotten: string[] = []; + for (const [name, info] of bookmarksByName) { + const isDeleted = !info.hasLocalTarget; + const isOrphaned = !info.hasOrigin && info.isEmpty; + + if (isDeleted || isOrphaned) { + const forgetResult = await runJJ(["bookmark", "forget", name], cwd); + if (forgetResult.ok) { + forgotten.push(name); + } + } + } + + return ok(forgotten); +} diff --git a/packages/core/src/jj/describe.ts b/packages/core/src/jj/describe.ts new file mode 100644 index 00000000..a364f3b6 --- /dev/null +++ b/packages/core/src/jj/describe.ts @@ -0,0 +1,15 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +/** + * Set the description of a change. + * @param description The new description + * @param revision The revision to describe (default: @) + */ +export async function describe( + description: string, + revision = "@", + cwd = process.cwd(), +): Promise> { + return runJJVoid(["describe", "-m", description, revision], cwd); +} diff --git a/packages/core/src/jj/diff.ts b/packages/core/src/jj/diff.ts new file mode 100644 index 00000000..04c97336 --- /dev/null +++ b/packages/core/src/jj/diff.ts @@ -0,0 +1,54 @@ +import { ok, type Result } from "../result"; +import type { DiffStats } from "../types"; +import { runJJ } from "./runner"; + +function parseDiffStats(stdout: string): DiffStats { + // Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)" + // or just "X file changed, ..." for single file + const summaryMatch = stdout.match( + /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/, + ); + + if (summaryMatch) { + return { + filesChanged: parseInt(summaryMatch[1], 10), + insertions: summaryMatch[2] ? parseInt(summaryMatch[2], 10) : 0, + deletions: summaryMatch[3] ? parseInt(summaryMatch[3], 10) : 0, + }; + } + + // No changes + return { filesChanged: 0, insertions: 0, deletions: 0 }; +} + +/** + * Get diff stats for a revision. + * If fromBookmark is provided, compares against the remote version of that bookmark. + */ +export async function getDiffStats( + revision: string, + options?: { fromBookmark?: string }, + cwd = process.cwd(), +): Promise> { + if (options?.fromBookmark) { + const result = await runJJ( + [ + "diff", + "--from", + `${options.fromBookmark}@origin`, + "--to", + revision, + "--stat", + ], + cwd, + ); + if (!result.ok) { + // If remote doesn't exist, fall back to total diff + return getDiffStats(revision, undefined, cwd); + } + return ok(parseDiffStats(result.value.stdout)); + } + const result = await runJJ(["diff", "-r", revision, "--stat"], cwd); + if (!result.ok) return result; + return ok(parseDiffStats(result.value.stdout)); +} diff --git a/packages/core/src/jj/edit.ts b/packages/core/src/jj/edit.ts new file mode 100644 index 00000000..570bdff7 --- /dev/null +++ b/packages/core/src/jj/edit.ts @@ -0,0 +1,9 @@ +import type { Result } from "../result"; +import { runJJWithMutableConfigVoid } from "./runner"; + +export async function edit( + revision: string, + cwd = process.cwd(), +): Promise> { + return runJJWithMutableConfigVoid(["edit", revision], cwd); +} diff --git a/packages/core/src/jj/find.ts b/packages/core/src/jj/find.ts new file mode 100644 index 00000000..62a77de5 --- /dev/null +++ b/packages/core/src/jj/find.ts @@ -0,0 +1,86 @@ +import type { Changeset } from "../parser"; +import { createError, err, ok, type Result } from "../result"; +import type { FindResult } from "../types"; +import { list } from "./list"; + +export async function findChange( + query: string, + options: { includeBookmarks?: boolean } = {}, + cwd = process.cwd(), +): Promise> { + // First, try direct revset lookup (handles change IDs, commit IDs, shortest prefixes, etc.) + // Change IDs: lowercase letters + digits (e.g., xnkxvwyk) + // Commit IDs: hex digits (e.g., 1af471ab) + const isChangeId = /^[a-z][a-z0-9]*$/.test(query); + const isCommitId = /^[0-9a-f]+$/.test(query); + + if (isChangeId || isCommitId) { + const idResult = await list({ revset: query, limit: 1 }, cwd); + if (idResult.ok && idResult.value.length === 1) { + return ok({ status: "found", change: idResult.value[0] }); + } + } + + // Search by description and bookmarks + // Escape backslashes first, then quotes + const escaped = query.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const revset = options.includeBookmarks + ? `description(substring-i:"${escaped}") | bookmarks(substring-i:"${escaped}")` + : `description(substring-i:"${escaped}")`; + + const listResult = await list({ revset }, cwd); + if (!listResult.ok) { + return ok({ status: "none" }); + } + + const matches = listResult.value.filter( + (cs) => !cs.changeId.startsWith("zzzzzzzz"), + ); + + if (matches.length === 0) { + return ok({ status: "none" }); + } + + // Check for exact bookmark match first + if (options.includeBookmarks) { + const exactBookmark = matches.find((cs) => + cs.bookmarks.some((b) => b.toLowerCase() === query.toLowerCase()), + ); + if (exactBookmark) { + return ok({ status: "found", change: exactBookmark }); + } + } + + if (matches.length === 1) { + return ok({ status: "found", change: matches[0] }); + } + + return ok({ status: "multiple", matches }); +} + +/** + * Resolve a target to a single Changeset, returning an error for not-found or ambiguous. + * This is a convenience wrapper around findChange that handles the common error patterns. + */ +export async function resolveChange( + target: string, + options: { includeBookmarks?: boolean } = {}, + cwd = process.cwd(), +): Promise> { + const findResult = await findChange(target, options, cwd); + if (!findResult.ok) return findResult; + + if (findResult.value.status === "none") { + return err(createError("INVALID_REVISION", `Change not found: ${target}`)); + } + if (findResult.value.status === "multiple") { + return err( + createError( + "AMBIGUOUS_REVISION", + `Multiple changes match "${target}". Use a more specific identifier.`, + ), + ); + } + + return ok(findResult.value.change); +} diff --git a/packages/core/src/jj/index.ts b/packages/core/src/jj/index.ts new file mode 100644 index 00000000..9ac5b5f2 --- /dev/null +++ b/packages/core/src/jj/index.ts @@ -0,0 +1,22 @@ +export { abandon } from "./abandon"; +export { ensureBookmark } from "./bookmark-create"; +export { deleteBookmark } from "./bookmark-delete"; +export { getBookmarkTracking } from "./bookmark-tracking"; +export { describe } from "./describe"; +export { getDiffStats } from "./diff"; +export { edit } from "./edit"; +export { findChange, resolveChange } from "./find"; +export { list } from "./list"; +export { getLog } from "./log"; +export { jjNew } from "./new"; +export { push } from "./push"; +export { rebase } from "./rebase"; +export { + getTrunk, + runJJ, + runJJWithMutableConfig, + runJJWithMutableConfigVoid, +} from "./runner"; +export { getStack } from "./stack"; +export { status } from "./status"; +export { sync } from "./sync"; diff --git a/packages/core/src/jj/list.ts b/packages/core/src/jj/list.ts new file mode 100644 index 00000000..9ebdb238 --- /dev/null +++ b/packages/core/src/jj/list.ts @@ -0,0 +1,24 @@ +import { type Changeset, parseChangesets } from "../parser"; +import type { Result } from "../result"; +import { CHANGESET_JSON_TEMPLATE } from "../templates"; +import type { ListOptions } from "../types"; +import { runJJ } from "./runner"; + +export async function list( + options?: ListOptions, + cwd = process.cwd(), +): Promise> { + const args = ["log", "--no-graph", "-T", CHANGESET_JSON_TEMPLATE]; + + if (options?.revset) { + args.push("-r", options.revset); + } + if (options?.limit) { + args.push("-n", String(options.limit)); + } + + const result = await runJJ(args, cwd); + if (!result.ok) return result; + + return parseChangesets(result.value.stdout); +} diff --git a/packages/core/src/jj/log.ts b/packages/core/src/jj/log.ts new file mode 100644 index 00000000..f6703001 --- /dev/null +++ b/packages/core/src/jj/log.ts @@ -0,0 +1,91 @@ +import { buildTree, flattenTree, type LogResult } from "../log"; +import { ok, type Result } from "../result"; +import { getBookmarkTracking } from "./bookmark-tracking"; +import { getDiffStats } from "./diff"; +import { list } from "./list"; +import { getTrunk } from "./runner"; +import { status } from "./status"; + +export async function getLog(cwd = process.cwd()): Promise> { + // Fetch all mutable changes (all stacks) plus trunk + const result = await list({ revset: "mutable() | trunk()" }, cwd); + if (!result.ok) return result; + + // Get status for modified files info + const statusResult = await status(cwd); + const modifiedFiles = statusResult.ok ? statusResult.value.modifiedFiles : []; + const hasUncommittedWork = modifiedFiles.length > 0; + + const trunkBranch = await getTrunk(cwd); + const trunk = + result.value.find( + (c) => c.bookmarks.includes(trunkBranch) && c.isImmutable, + ) ?? null; + const workingCopy = result.value.find((c) => c.isWorkingCopy) ?? null; + const allChanges = result.value.filter((c) => !c.isImmutable); + const trunkId = trunk?.changeId ?? ""; + const wcChangeId = workingCopy?.changeId ?? null; + + // Current change is the parent of WC + const currentChangeId = workingCopy?.parents[0] ?? null; + const isOnTrunk = currentChangeId === trunkId; + + // Filter changes to display in the log - exclude the WC itself + const changes = allChanges.filter((c) => { + if (c.description.trim() !== "" || c.hasConflicts) { + return true; + } + if (c.changeId === wcChangeId) { + return false; + } + return !c.isEmpty; + }); + + // Get bookmark tracking to find modified (unpushed) bookmarks + const trackingResult = await getBookmarkTracking(cwd); + const modifiedBookmarks = new Set(); + if (trackingResult.ok) { + for (const statusItem of trackingResult.value) { + if (statusItem.aheadCount > 0) { + modifiedBookmarks.add(statusItem.name); + } + } + } + + const roots = buildTree(changes, trunkId); + const entries = flattenTree(roots, currentChangeId, modifiedBookmarks); + + // Fetch diff stats for uncommitted work if present + let uncommittedWork: LogResult["uncommittedWork"] = null; + if (hasUncommittedWork && workingCopy) { + const statsResult = await getDiffStats( + workingCopy.changeId, + undefined, + cwd, + ); + uncommittedWork = { + changeId: workingCopy.changeId, + changeIdPrefix: workingCopy.changeIdPrefix, + isOnTrunk, + diffStats: statsResult.ok ? statsResult.value : null, + }; + } + + return ok({ + entries, + trunk: { + name: trunkBranch, + commitId: trunk?.commitId ?? "", + commitIdPrefix: trunk?.commitIdPrefix ?? "", + description: trunk?.description ?? "", + timestamp: trunk?.timestamp ?? new Date(), + }, + currentChangeId, + currentChangeIdPrefix: + changes.find((c) => c.changeId === currentChangeId)?.changeIdPrefix ?? + null, + isOnTrunk, + hasEmptyWorkingCopy: false, // Always false now - WC is always empty on top + uncommittedWork, + }); +} diff --git a/packages/core/src/jj/new.ts b/packages/core/src/jj/new.ts new file mode 100644 index 00000000..2eb18dd5 --- /dev/null +++ b/packages/core/src/jj/new.ts @@ -0,0 +1,29 @@ +import { ok, type Result } from "../result"; +import type { NewOptions } from "../types"; +import { runJJ } from "./runner"; +import { status } from "./status"; + +export async function jjNew( + options?: NewOptions, + cwd = process.cwd(), +): Promise> { + const args = ["new"]; + + if (options?.parents && options.parents.length > 0) { + args.push(...options.parents); + } + if (options?.message) { + args.push("-m", options.message); + } + if (options?.noEdit) { + args.push("--no-edit"); + } + + const result = await runJJ(args, cwd); + if (!result.ok) return result; + + const statusResult = await status(cwd); + if (!statusResult.ok) return statusResult; + + return ok(statusResult.value.workingCopy.changeId); +} diff --git a/packages/core/src/jj/push.ts b/packages/core/src/jj/push.ts new file mode 100644 index 00000000..19afd0f5 --- /dev/null +++ b/packages/core/src/jj/push.ts @@ -0,0 +1,26 @@ +import type { Result } from "../result"; +import type { PushOptions } from "../types"; +import { runJJ, runJJVoid } from "./runner"; + +export async function push( + options?: PushOptions, + cwd = process.cwd(), +): Promise> { + const remote = options?.remote ?? "origin"; + + // Track the bookmark on the remote if specified (required for new bookmarks) + if (options?.bookmark) { + // Track ignores already-tracked bookmarks, so safe to call always + await runJJ(["bookmark", "track", `${options.bookmark}@${remote}`], cwd); + } + + const args = ["git", "push"]; + if (options?.remote) { + args.push("--remote", options.remote); + } + if (options?.bookmark) { + args.push("--bookmark", options.bookmark); + } + + return runJJVoid(args, cwd); +} diff --git a/packages/core/src/jj/rebase.ts b/packages/core/src/jj/rebase.ts new file mode 100644 index 00000000..d7040536 --- /dev/null +++ b/packages/core/src/jj/rebase.ts @@ -0,0 +1,29 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +interface RebaseOptions { + /** The bookmark or revision to rebase */ + source: string; + /** The destination to rebase onto */ + destination: string; + /** + * Rebase mode: + * - "branch" (-b): Rebase source and all ancestors not in destination (default) + * - "revision" (-r): Rebase only the source commit, not its ancestors + */ + mode?: "branch" | "revision"; +} + +/** + * Rebase a bookmark/revision onto a new destination. + */ +export async function rebase( + options: RebaseOptions, + cwd = process.cwd(), +): Promise> { + const flag = options.mode === "revision" ? "-r" : "-b"; + return runJJVoid( + ["rebase", flag, options.source, "-d", options.destination], + cwd, + ); +} diff --git a/packages/core/src/jj/runner.ts b/packages/core/src/jj/runner.ts new file mode 100644 index 00000000..c5503b11 --- /dev/null +++ b/packages/core/src/jj/runner.ts @@ -0,0 +1,101 @@ +import { type CommandResult, shellExecutor } from "../executor"; +import { detectError } from "../parser"; +import { createError, err, type JJErrorCode, ok, type Result } from "../result"; + +// Module-level trunk cache (per cwd) +const trunkCache = new Map(); + +export async function getTrunk(cwd = process.cwd()): Promise { + const cached = trunkCache.get(cwd); + if (cached) return cached; + + const result = await shellExecutor.execute( + "jj", + ["config", "get", 'revset-aliases."trunk()"'], + { cwd }, + ); + if (result.exitCode === 0 && result.stdout.trim()) { + const trunk = result.stdout.trim(); + trunkCache.set(cwd, trunk); + return trunk; + } + throw new Error("Trunk branch not configured. Run `arr init` first."); +} + +export async function runJJ( + args: string[], + cwd = process.cwd(), +): Promise> { + try { + const result = await shellExecutor.execute("jj", args, { cwd }); + + if (result.exitCode !== 0) { + const detected = detectError(result.stderr); + if (detected) { + return err( + createError(detected.code as JJErrorCode, detected.message, { + command: `jj ${args.join(" ")}`, + stderr: result.stderr, + }), + ); + } + return err( + createError("COMMAND_FAILED", `jj command failed: ${result.stderr}`, { + command: `jj ${args.join(" ")}`, + stderr: result.stderr, + }), + ); + } + + return ok(result); + } catch (e) { + return err( + createError("COMMAND_FAILED", `Failed to execute jj: ${e}`, { + command: `jj ${args.join(" ")}`, + }), + ); + } +} + +/** + * Run a jj command that returns no meaningful output. + */ +export async function runJJVoid( + args: string[], + cwd = process.cwd(), +): Promise> { + const result = await runJJ(args, cwd); + if (!result.ok) return result; + return ok(undefined); +} + +/** + * Config override to make remote bookmarks mutable. + * Only trunk and tags remain immutable. + */ +const MUTABLE_CONFIG = + 'revset-aliases."immutable_heads()"="present(trunk()) | tags()"'; + +/** + * Run a JJ command with immutability override via --config. + * Use when operating on commits that may have been pushed to remote. + * This is a fallback for repos that weren't initialized with arr init. + */ +export async function runJJWithMutableConfig( + args: string[], + cwd = process.cwd(), +): Promise> { + return runJJ(["--config", MUTABLE_CONFIG, ...args], cwd); +} + +/** + * Run a JJ command with immutability override, returning void. + */ +export async function runJJWithMutableConfigVoid( + args: string[], + cwd = process.cwd(), +): Promise> { + const result = await runJJWithMutableConfig(args, cwd); + if (!result.ok) return result; + return ok(undefined); +} diff --git a/packages/core/src/jj/stack.ts b/packages/core/src/jj/stack.ts new file mode 100644 index 00000000..848ff64a --- /dev/null +++ b/packages/core/src/jj/stack.ts @@ -0,0 +1,19 @@ +import type { Changeset } from "../parser"; +import { ok, type Result } from "../result"; +import { list } from "./list"; + +export async function getStack( + cwd = process.cwd(), +): Promise> { + // Get the current stack from trunk to the current head(s) + // This shows the linear path from trunk through current position to its descendants + const result = await list({ revset: "trunk()..heads(descendants(@))" }, cwd); + if (!result.ok) return result; + + // Filter out empty changes without descriptions, but always keep the working copy + const filtered = result.value.filter( + (cs) => cs.isWorkingCopy || cs.description.trim() !== "" || !cs.isEmpty, + ); + + return ok(filtered); +} diff --git a/packages/core/src/jj/status.ts b/packages/core/src/jj/status.ts new file mode 100644 index 00000000..9da54140 --- /dev/null +++ b/packages/core/src/jj/status.ts @@ -0,0 +1,176 @@ +import { parseConflicts } from "../parser"; +import { ok, type Result } from "../result"; +import type { ChangesetStatus, FileChange } from "../types"; +import { runJJ } from "./runner"; + +// Single template that gets all status info in one jj call +// Diff summary is multi-line, so we put markers around it: DIFF_START and END_CHANGE +const STATUS_TEMPLATE = [ + '"CHANGE:"', + "change_id.short()", + '"|"', + "change_id.shortest().prefix()", + '"|"', + 'if(current_working_copy, "wc", "")', + '"|"', + 'bookmarks.join(",")', + '"|"', + "description.first_line()", + '"|"', + 'if(conflict, "1", "0")', + '"|"', + 'if(empty, "1", "0")', + '"\\nDIFF_START\\n"', + "self.diff().summary()", + '"END_CHANGE\\n"', +].join(" ++ "); + +interface ParsedChange { + changeId: string; + changeIdPrefix: string; + isWorkingCopy: boolean; + bookmarks: string[]; + description: string; + hasConflicts: boolean; + isEmpty: boolean; + diffSummary: string; +} + +function parseModifiedFiles(diffSummary: string): FileChange[] { + if (!diffSummary.trim()) return []; + + return diffSummary + .split("\n") + .filter(Boolean) + .map((line) => { + const status = line[0]; + const path = line.slice(2).trim(); + const statusMap: Record = { + M: "modified", + A: "added", + D: "deleted", + R: "renamed", + C: "copied", + }; + return { path, status: statusMap[status] || "modified" }; + }); +} + +/** + * Get working copy status in a single jj call. + */ +export async function status( + cwd = process.cwd(), +): Promise> { + // Single jj call with template - gets WC, parent, and grandparent for stack path + const result = await runJJ( + ["log", "-r", "@ | @- | @--", "--no-graph", "-T", STATUS_TEMPLATE], + cwd, + ); + + if (!result.ok) return result; + + // Split by END_CHANGE marker to handle multi-line diff summaries + const blocks = result.value.stdout.split("END_CHANGE").filter(Boolean); + const changes = blocks + .map((block) => { + // Split block into metadata and diff parts using DIFF_START marker + const [metaPart, diffPart] = block.split("DIFF_START"); + if (!metaPart) return null; + + const changeLine = metaPart.trim(); + if (!changeLine.startsWith("CHANGE:")) return null; + + const data = changeLine.slice(7); + const parts = data.split("|"); + + return { + changeId: parts[0] || "", + changeIdPrefix: parts[1] || "", + isWorkingCopy: parts[2] === "wc", + bookmarks: (parts[3] || "").split(",").filter(Boolean), + description: parts[4] || "", + hasConflicts: parts[5] === "1", + isEmpty: parts[6] === "1", + diffSummary: diffPart?.trim() || "", + }; + }) + .filter(Boolean) as ParsedChange[]; + + const workingCopy = changes.find((c) => c.isWorkingCopy); + const parent = changes.find((c) => !c.isWorkingCopy); + + // For hasResolvedConflict, we still need jj status output + // But only if parent has conflicts - otherwise skip it + let hasResolvedConflict = false; + if (parent?.hasConflicts) { + const statusResult = await runJJ(["status"], cwd); + if (statusResult.ok) { + hasResolvedConflict = statusResult.value.stdout.includes( + "Conflict in parent commit has been resolved in working copy", + ); + } + } + + // Parse conflicts from jj status if there are any + let conflicts: { path: string; type: "content" | "delete" | "rename" }[] = []; + if (workingCopy?.hasConflicts || parent?.hasConflicts) { + const statusResult = await runJJ(["status"], cwd); + if (statusResult.ok) { + const parsed = parseConflicts(statusResult.value.stdout); + if (parsed.ok) conflicts = parsed.value; + } + } + + return ok({ + workingCopy: workingCopy + ? { + changeId: workingCopy.changeId, + changeIdPrefix: workingCopy.changeIdPrefix, + commitId: "", + commitIdPrefix: "", + description: workingCopy.description, + bookmarks: workingCopy.bookmarks, + parents: parent ? [parent.changeId] : [], + isWorkingCopy: true, + isImmutable: false, + isEmpty: workingCopy.isEmpty, + hasConflicts: workingCopy.hasConflicts, + } + : { + changeId: "", + changeIdPrefix: "", + commitId: "", + commitIdPrefix: "", + description: "", + bookmarks: [], + parents: [], + isWorkingCopy: true, + isImmutable: false, + isEmpty: true, + hasConflicts: false, + }, + parents: parent + ? [ + { + changeId: parent.changeId, + changeIdPrefix: parent.changeIdPrefix, + commitId: "", + commitIdPrefix: "", + description: parent.description, + bookmarks: parent.bookmarks, + parents: [], + isWorkingCopy: false, + isImmutable: false, + isEmpty: parent.isEmpty, + hasConflicts: parent.hasConflicts, + }, + ] + : [], + modifiedFiles: workingCopy + ? parseModifiedFiles(workingCopy.diffSummary) + : [], + conflicts, + hasResolvedConflict, + }); +} diff --git a/packages/core/src/jj/sync.ts b/packages/core/src/jj/sync.ts new file mode 100644 index 00000000..b21618b5 --- /dev/null +++ b/packages/core/src/jj/sync.ts @@ -0,0 +1,66 @@ +import { ok, type Result } from "../result"; +import type { SyncResult } from "../types"; +import { abandon } from "./abandon"; +import { cleanupOrphanedBookmarks } from "./bookmark-tracking"; +import { list } from "./list"; +import { getTrunk, runJJ, runJJVoid } from "./runner"; +import { status } from "./status"; + +async function rebaseOntoTrunk(cwd = process.cwd()): Promise> { + return runJJVoid(["rebase", "-s", "roots(trunk()..@)", "-d", "trunk()"], cwd); +} + +export async function sync(cwd = process.cwd()): Promise> { + const fetchResult = await runJJ(["git", "fetch"], cwd); + if (!fetchResult.ok) return fetchResult; + + // Update local trunk bookmark to match remote (so trunk() points to latest) + // Intentionally ignore errors - remote may not exist for new repos + const trunk = await getTrunk(cwd); + await runJJ(["bookmark", "set", trunk, "-r", `${trunk}@origin`], cwd); + + const rebaseResult = await rebaseOntoTrunk(cwd); + + // Check for conflicts - jj rebase succeeds even with conflicts, so check status + let hasConflicts = false; + if (rebaseResult.ok) { + const statusResult = await status(cwd); + if (statusResult.ok) { + hasConflicts = statusResult.value.workingCopy.hasConflicts; + } + } else { + hasConflicts = rebaseResult.error.message.includes("conflict"); + } + + // Find empty changes, but exclude the current working copy if it's empty + // (jj would just recreate it, and it's not really "cleaned up") + const emptyResult = await list( + { revset: "(trunk()..@) & empty() & ~@" }, + cwd, + ); + const abandoned: Array<{ changeId: string; reason: "empty" | "merged" }> = []; + + if (emptyResult.ok) { + for (const change of emptyResult.value) { + const abandonResult = await abandon(change.changeId, cwd); + if (abandonResult.ok) { + // Empty changes with descriptions are likely merged (content now in trunk) + // Empty changes without descriptions are just staging area WCs + const reason = change.description.trim() !== "" ? "merged" : "empty"; + abandoned.push({ changeId: change.changeId, reason }); + } + } + } + + // Clean up local bookmarks whose remote was deleted and change is empty + const cleanupResult = await cleanupOrphanedBookmarks(cwd); + const forgottenBookmarks = cleanupResult.ok ? cleanupResult.value : []; + + return ok({ + fetched: true, + rebased: rebaseResult.ok, + abandoned, + forgottenBookmarks, + hasConflicts, + }); +} diff --git a/packages/core/src/log.ts b/packages/core/src/log.ts new file mode 100644 index 00000000..739a6fda --- /dev/null +++ b/packages/core/src/log.ts @@ -0,0 +1,147 @@ +import type { Changeset } from "./parser"; + +interface LogNode { + change: Changeset; + children: LogNode[]; +} + +interface LogEntry { + change: Changeset; + prefix: string; + isCurrent: boolean; + isLastInStack: boolean; + stackIndex: number; + /** True when the change's bookmark has local commits not yet pushed */ + isModified: boolean; +} + +/** Minimal PR info for log display */ +export interface LogPRInfo { + number: number; + state: "OPEN" | "MERGED" | "CLOSED"; + url: string; +} + +export interface EnrichedLogEntry extends LogEntry { + prInfo: LogPRInfo | null; + diffStats: { + filesChanged: number; + insertions: number; + deletions: number; + } | null; +} + +export interface EnrichedLogResult extends Omit { + entries: EnrichedLogEntry[]; + modifiedCount: number; +} + +export interface UncommittedWork { + changeId: string; + changeIdPrefix: string; + /** True when uncommitted work is directly on trunk, not in a stack */ + isOnTrunk: boolean; + diffStats: { + filesChanged: number; + insertions: number; + deletions: number; + } | null; +} + +export interface TrunkInfo { + name: string; + commitId: string; + commitIdPrefix: string; + description: string; + timestamp: Date; +} + +export interface LogResult { + entries: LogEntry[]; + trunk: TrunkInfo; + currentChangeId: string | null; + currentChangeIdPrefix: string | null; + isOnTrunk: boolean; + /** True when @ is an empty, undescribed change above the stack */ + hasEmptyWorkingCopy: boolean; + /** Present when @ has file changes but no description (uncommitted work) */ + uncommittedWork: UncommittedWork | null; +} + +export function buildTree(changes: Changeset[], trunkId: string): LogNode[] { + const nodeMap = new Map(); + const hasChild = new Set(); + + for (const change of changes) { + nodeMap.set(change.changeId, { change, children: [] }); + } + + // Build reverse tree: each node points to its parent as "child" + // This lets us traverse from heads down to roots + for (const change of changes) { + const parentId = change.parents[0]; + if (parentId && parentId !== trunkId && nodeMap.has(parentId)) { + // This node has a parent in our set, so parent is not a head + hasChild.add(parentId); + // Link: node -> parent (reversed direction for display) + const node = nodeMap.get(change.changeId)!; + const parent = nodeMap.get(parentId)!; + node.children.push(parent); + } + } + + // Heads are nodes that have no children pointing to them + const heads: LogNode[] = []; + for (const change of changes) { + if (!hasChild.has(change.changeId)) { + heads.push(nodeMap.get(change.changeId)!); + } + } + + return heads; +} + +export function flattenTree( + heads: LogNode[], + currentChangeId: string | null, + modifiedBookmarks: Set = new Set(), +): LogEntry[] { + const result: LogEntry[] = []; + + function visit(node: LogNode, prefix: string, stackIndex: number): void { + // isLastInStack = this node has no more ancestors (closest to trunk) + const isLastInStack = node.children.length === 0; + + // Check if any bookmark on this change is modified (has unpushed commits) + const isModified = node.change.bookmarks.some((b) => + modifiedBookmarks.has(b), + ); + + result.push({ + change: node.change, + prefix, + isCurrent: node.change.changeId === currentChangeId, + isLastInStack, + stackIndex, + isModified, + }); + + // children here are actually ancestors (going toward trunk) + for (const child of node.children) { + visit(child, prefix, stackIndex); + } + } + + // Sort heads by timestamp (newest first) + const sortedHeads = [...heads].sort( + (a, b) => b.change.timestamp.getTime() - a.change.timestamp.getTime(), + ); + + for (let i = 0; i < sortedHeads.length; i++) { + // First stack has no prefix, remaining stacks get │ prefix + const prefix = i === 0 ? "" : "│ "; + visit(sortedHeads[i], prefix, i); + } + + return result; +} diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts new file mode 100644 index 00000000..6fb3c8c4 --- /dev/null +++ b/packages/core/src/parser.ts @@ -0,0 +1,153 @@ +import { z } from "zod"; +import { createError, err, ok, type Result } from "./result"; +import type { ConflictInfo, FileChange } from "./types"; + +const BookmarkSchema = z.object({ + name: z.string(), + target: z.array(z.string().nullable()).optional(), +}); + +const DiffStatsSchema = z.object({ + filesChanged: z.number(), + insertions: z.number(), + deletions: z.number(), +}); + +const ChangesetSchema = z + .object({ + base: z.object({ + commit_id: z.string(), + change_id: z.string(), + description: z.string(), + author: z.object({ + name: z.string(), + email: z.string(), + timestamp: z.string(), + }), + }), + parentChangeIds: z.array(z.string()), + empty: z.boolean(), + conflict: z.boolean(), + immutable: z.boolean(), + workingCopy: z.boolean(), + bookmarks: z.array(BookmarkSchema), + changeIdPrefix: z.string(), + commitIdPrefix: z.string(), + diffStats: DiffStatsSchema.optional(), + }) + .transform((raw) => ({ + changeId: raw.base.change_id.slice(0, 12), + commitId: raw.base.commit_id.slice(0, 12), + changeIdPrefix: raw.changeIdPrefix, + commitIdPrefix: raw.commitIdPrefix, + description: raw.base.description.split("\n")[0], + author: { name: raw.base.author.name, email: raw.base.author.email }, + timestamp: new Date(raw.base.author.timestamp), + parents: raw.parentChangeIds.map((p) => p.slice(0, 12)), + isEmpty: raw.empty, + hasConflicts: raw.conflict, + isImmutable: raw.immutable, + isWorkingCopy: raw.workingCopy, + bookmarks: raw.bookmarks.map((b) => b.name.replace(/\*$/, "")), + diffStats: raw.diffStats ?? null, + })); + +export type Changeset = z.output; + +export function parseChangesets(stdout: string): Result { + try { + const lines = stdout.trim().split("\n").filter(Boolean); + return ok(lines.map((line) => ChangesetSchema.parse(JSON.parse(line)))); + } catch (e) { + return err(createError("PARSE_ERROR", `Failed to parse jj output: ${e}`)); + } +} + +export function parseFileChanges(stdout: string): Result { + try { + const lines = stdout.trim().split("\n").filter(Boolean); + const changes: FileChange[] = []; + + for (const line of lines) { + // Match status char followed by space(s) and path + // Use non-backtracking approach: check first char, then split + const firstChar = line[0]; + if (!"MADR".includes(firstChar)) continue; + if (line[1] !== " " && line[1] !== "\t") continue; + const path = line.slice(2).trim(); + if (!path) continue; + + const statusChar = firstChar; + const statusMap: Record = { + M: "modified", + A: "added", + D: "deleted", + R: "renamed", + }; + + changes.push({ + path, + status: statusMap[statusChar] ?? "modified", + }); + } + + return ok(changes); + } catch (e) { + return err( + createError("PARSE_ERROR", `Failed to parse file changes: ${e}`), + ); + } +} + +export function parseConflicts(stdout: string): Result { + try { + const lines = stdout.trim().split("\n").filter(Boolean); + const conflicts: ConflictInfo[] = []; + + for (const line of lines) { + // Old format: "C path/to/file" + if (line.startsWith("C ")) { + conflicts.push({ + path: line.slice(2).trim(), + type: "content", + }); + } + // New format: "path/to/file 2-sided conflict" or similar + // Must start with a path (non-space) and contain "-sided conflict" + // This excludes lines like "Working copy (@) : xyz (conflict) ..." + else if (line.includes("-sided conflict")) { + // Extract path: everything before first whitespace + const spaceIdx = line.search(/\s/); + if (spaceIdx > 0) { + const path = line.slice(0, spaceIdx); + // Verify the rest contains the conflict marker + if (line.slice(spaceIdx).includes("-sided conflict")) { + conflicts.push({ + path, + type: "content", + }); + } + } + } + } + + return ok(conflicts); + } catch (e) { + return err(createError("PARSE_ERROR", `Failed to parse conflicts: ${e}`)); + } +} + +export function detectError( + stderr: string, +): { code: string; message: string } | null { + if (stderr.includes("There is no jj repo in")) { + return { code: "NOT_IN_REPO", message: "Not in a jj repository" }; + } + if (stderr.includes("Revision") && stderr.includes("doesn't exist")) { + return { code: "INVALID_REVISION", message: "Invalid revision" }; + } + if (stderr.includes("Workspace") && stderr.includes("doesn't exist")) { + return { code: "WORKSPACE_NOT_FOUND", message: "Workspace not found" }; + } + return null; +} diff --git a/packages/core/src/resolve-state.ts b/packages/core/src/resolve-state.ts new file mode 100644 index 00000000..008659d2 --- /dev/null +++ b/packages/core/src/resolve-state.ts @@ -0,0 +1,63 @@ +import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runSync } from "./executor"; + +/** + * State persisted during conflict resolution. + * Stored in .jj/arr-resolve-state.json + */ +export interface ResolveState { + /** Original bookmark to return to when done */ + originalBookmark: string; + /** Original change ID for safety checks */ + originalChangeId: string; + /** Timestamp when resolution started */ + startedAt: string; +} + +function getStatePath(cwd: string): string { + // Find .jj directory + const jjRoot = runSync("jj", ["root"], { cwd, onError: "ignore" }); + if (!jjRoot) return join(cwd, ".jj", "arr-resolve-state.json"); + return join(jjRoot, ".jj", "arr-resolve-state.json"); +} + +/** + * Save resolve state to disk. + */ +export function saveResolveState(state: ResolveState, cwd: string): void { + const path = getStatePath(cwd); + writeFileSync(path, JSON.stringify(state, null, 2)); +} + +/** + * Load resolve state from disk, or null if not in resolution. + */ +export function loadResolveState(cwd: string): ResolveState | null { + const path = getStatePath(cwd); + if (!existsSync(path)) return null; + + try { + const content = readFileSync(path, "utf-8"); + return JSON.parse(content) as ResolveState; + } catch { + return null; + } +} + +/** + * Clear resolve state (resolution complete or aborted). + */ +export function clearResolveState(cwd: string): void { + const path = getStatePath(cwd); + if (existsSync(path)) { + unlinkSync(path); + } +} + +/** + * Check if we're currently in conflict resolution mode. + */ +export function isInResolveMode(cwd: string): boolean { + return loadResolveState(cwd) !== null; +} diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts new file mode 100644 index 00000000..66ce7c65 --- /dev/null +++ b/packages/core/src/result.ts @@ -0,0 +1,55 @@ +export type Result = + | { ok: true; value: T } + | { ok: false; error: E }; + +export function ok(value: T): Result { + return { ok: true, value }; +} + +export function err(error: E): Result { + return { ok: false, error }; +} + +interface JJError { + code: JJErrorCode; + message: string; + command?: string; + stderr?: string; +} + +export type JJErrorCode = + | "NOT_IN_REPO" + | "NOT_INITIALIZED" + | "COMMAND_FAILED" + | "CONFLICT" + | "INVALID_REVISION" + | "AMBIGUOUS_REVISION" + | "INVALID_STATE" + | "WORKSPACE_NOT_FOUND" + | "PARSE_ERROR" + | "DEPENDENCY_MISSING" + | "NAVIGATION_FAILED" + | "MERGE_BLOCKED" + | "ALREADY_MERGED" + | "NOT_FOUND" + | "EMPTY_CHANGE" + | "UNKNOWN"; + +export function createError( + code: JJErrorCode, + message: string, + details?: { command?: string; stderr?: string }, +): JJError { + return { + code, + message, + ...details, + }; +} + +export function unwrap(result: Result): T { + if (!result.ok) { + throw new Error(`unwrap called on error result: ${result.error.message}`); + } + return result.value; +} diff --git a/packages/core/src/slugify.ts b/packages/core/src/slugify.ts new file mode 100644 index 00000000..da78a220 --- /dev/null +++ b/packages/core/src/slugify.ts @@ -0,0 +1,52 @@ +/** + * Converts text to a branch-safe slug using hyphens. + * Used for git branch names where hyphens are conventional. + * + * - Lowercase all characters + * - Replace non-alphanumeric with hyphens + * - Collapse multiple hyphens + * - Trim leading/trailing hyphens + * - Limit to 50 characters + */ +function slugifyForBranch(text: string): string { + if (!text || !text.trim()) { + return "untitled"; + } + + // Replace non-alphanumeric with hyphens, then collapse via split/join + let result = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .split("-") + .filter(Boolean) + .join("-"); + + // Trim leading/trailing hyphens + while (result.startsWith("-")) result = result.slice(1); + while (result.endsWith("-")) result = result.slice(0, -1); + + return result.slice(0, 50) || "untitled"; +} + +/** + * Generate a display label for a change from its description and ID. + * Format: {slug}-{shortChangeId} e.g. "my-first-change-abc123" + * Used for CLI output and simple identification, not for actual git branch names. + */ +export function changeLabel(description: string, changeId: string): string { + const slug = slugifyForBranch(description); + const shortId = changeId.slice(0, 6); + return `${slug}-${shortId}`; +} + +/** + * Generate a date-prefixed display label for a change. + * Format: MM-DD-slug e.g. "01-15-my-feature" + * Used for log/timeline displays. + */ +export function datePrefixedLabel(description: string, date: Date): string { + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const slug = slugifyForBranch(description); + return `${month}-${day}-${slug}`; +} diff --git a/packages/core/src/stack-comment.ts b/packages/core/src/stack-comment.ts new file mode 100644 index 00000000..96ccacb5 --- /dev/null +++ b/packages/core/src/stack-comment.ts @@ -0,0 +1,49 @@ +export type StackEntryStatus = + | "this" + | "waiting" + | "approved" + | "merged" + | "closed"; + +export interface StackEntry { + prNumber: number; + title: string; + status: StackEntryStatus; +} + +export interface StackCommentOptions { + stack: StackEntry[]; +} + +export function generateStackComment(options: StackCommentOptions): string { + const { stack } = options; + + const lines: string[] = []; + + // Stack should be displayed top-to-bottom (newest first, closest to main last) + // Reverse the array since it comes in bottom-to-top order from submit + const topToBottom = [...stack].reverse(); + + for (const entry of topToBottom) { + const pointer = entry.status === "this" ? " 👈" : ""; + lines.push(`* **#${entry.prNumber}** ${entry.title}${pointer}`); + } + + lines.push("* `main`"); + lines.push(""); + lines.push("---"); + lines.push(""); + lines.push("Merge from bottom to top, or use `arr merge`"); + + return lines.join("\n"); +} + +export function mapReviewDecisionToStatus( + reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null, + state: "OPEN" | "CLOSED" | "MERGED", +): StackEntryStatus { + if (state === "MERGED") return "merged"; + if (state === "CLOSED") return "closed"; + if (reviewDecision === "APPROVED") return "approved"; + return "waiting"; +} diff --git a/packages/core/src/stacks/comments.ts b/packages/core/src/stacks/comments.ts new file mode 100644 index 00000000..ab977324 --- /dev/null +++ b/packages/core/src/stacks/comments.ts @@ -0,0 +1,121 @@ +import { upsertStackComment } from "../github/comments"; +import { updatePR } from "../github/pr-actions"; +import { batchGetPRsForBranches } from "../github/pr-status"; +import { getStack, getTrunk } from "../jj"; +import { ok, type Result } from "../result"; +import { + generateStackComment, + mapReviewDecisionToStatus, + type StackEntry, +} from "../stack-comment"; + +export async function updateStackComments(): Promise< + Result<{ updated: number }> +> { + const trunk = await getTrunk(); + const stackResult = await getStack(); + if (!stackResult.ok) return stackResult; + if (stackResult.value.length === 0) { + return ok({ updated: 0 }); + } + + const stack = [...stackResult.value].reverse(); + + const bookmarkMap = new Map< + string, + { change: (typeof stack)[0]; bookmark: string } + >(); + const allBookmarks: string[] = []; + for (const change of stack) { + const bookmark = change.bookmarks[0]; + if (!bookmark) continue; + bookmarkMap.set(change.changeId, { change, bookmark }); + allBookmarks.push(bookmark); + } + + const prsResult = await batchGetPRsForBranches(allBookmarks); + const prCache = prsResult.ok ? prsResult.value : new Map(); + + const prInfos: Array<{ + changeId: string; + prNumber: number; + change: (typeof stack)[0]; + bookmark: string; + currentBase: string; + }> = []; + + for (const change of stack) { + const entry = bookmarkMap.get(change.changeId); + if (!entry) continue; + const { bookmark } = entry; + const prItem = prCache.get(bookmark); + if (prItem) { + prInfos.push({ + changeId: change.changeId, + prNumber: prItem.number, + change, + bookmark, + currentBase: prItem.baseRefName, + }); + } + } + + if (prInfos.length === 0) { + return ok({ updated: 0 }); + } + + const statuses = new Map< + number, + { + reviewDecision: + | "APPROVED" + | "CHANGES_REQUESTED" + | "REVIEW_REQUIRED" + | null; + state: "OPEN" | "CLOSED" | "MERGED"; + } + >(); + for (const [, prItem] of prCache) { + statuses.set(prItem.number, { + reviewDecision: prItem.reviewDecision ?? null, + state: prItem.state, + }); + } + + for (let i = 0; i < prInfos.length; i++) { + const prInfo = prInfos[i]; + const expectedBase = i === 0 ? trunk : prInfos[i - 1].bookmark; + if (prInfo.currentBase !== expectedBase) { + await updatePR(prInfo.prNumber, { base: expectedBase }); + } + } + + const commentUpserts = prInfos.map((prInfo, i) => { + const stackEntries: StackEntry[] = prInfos.map((p, idx) => { + const prStatus = statuses.get(p.prNumber); + let entryStatus: StackEntry["status"] = "waiting"; + + if (idx === i) { + entryStatus = "this"; + } else if (prStatus) { + entryStatus = mapReviewDecisionToStatus( + prStatus.reviewDecision, + prStatus.state, + ); + } + + return { + prNumber: p.prNumber, + title: p.change.description || `Change ${p.changeId.slice(0, 8)}`, + status: entryStatus, + }; + }); + + const comment = generateStackComment({ stack: stackEntries }); + return upsertStackComment(prInfo.prNumber, comment); + }); + + await Promise.all(commentUpserts); + + return ok({ updated: prInfos.length }); +} diff --git a/packages/core/src/stacks/enriched-log.ts b/packages/core/src/stacks/enriched-log.ts new file mode 100644 index 00000000..79bcec0d --- /dev/null +++ b/packages/core/src/stacks/enriched-log.ts @@ -0,0 +1,81 @@ +import { batchGetPRsForBranches } from "../github/pr-status"; +import { getDiffStats, getLog } from "../jj"; +import type { EnrichedLogEntry, EnrichedLogResult, LogPRInfo } from "../log"; +import { ok, type Result } from "../result"; + +export async function getEnrichedLog(): Promise> { + const logResult = await getLog(); + if (!logResult.ok) return logResult; + + const { + entries, + trunk, + currentChangeId, + currentChangeIdPrefix, + isOnTrunk, + hasEmptyWorkingCopy, + uncommittedWork, + } = logResult.value; + + const bookmarkToChangeId = new Map(); + for (const entry of entries) { + const bookmark = entry.change.bookmarks[0]; + if (bookmark) { + bookmarkToChangeId.set(bookmark, entry.change.changeId); + } + } + const bookmarksList = Array.from(bookmarkToChangeId.keys()); + + const prInfoMap = new Map(); + if (bookmarksList.length > 0) { + const prsResult = await batchGetPRsForBranches(bookmarksList); + if (prsResult.ok) { + for (const [bookmark, prItem] of prsResult.value) { + const changeId = bookmarkToChangeId.get(bookmark); + if (changeId) { + prInfoMap.set(changeId, { + number: prItem.number, + state: prItem.state, + url: prItem.url, + }); + } + } + } + } + + const MAX_DIFF_STATS_ENTRIES = 20; + const diffStatsMap = new Map< + string, + { filesChanged: number; insertions: number; deletions: number } + >(); + if (entries.length <= MAX_DIFF_STATS_ENTRIES) { + const diffStatsPromises = entries.map(async (entry) => { + const result = await getDiffStats(entry.change.changeId); + if (result.ok) { + diffStatsMap.set(entry.change.changeId, result.value); + } + }); + await Promise.all(diffStatsPromises); + } + + let modifiedCount = 0; + const enrichedEntries: EnrichedLogEntry[] = entries.map((entry) => { + if (entry.isModified) modifiedCount++; + return { + ...entry, + prInfo: prInfoMap.get(entry.change.changeId) ?? null, + diffStats: diffStatsMap.get(entry.change.changeId) ?? null, + }; + }); + + return ok({ + entries: enrichedEntries, + trunk, + currentChangeId, + currentChangeIdPrefix, + isOnTrunk, + hasEmptyWorkingCopy, + uncommittedWork, + modifiedCount, + }); +} diff --git a/packages/core/src/stacks/index.ts b/packages/core/src/stacks/index.ts new file mode 100644 index 00000000..a3ae6119 --- /dev/null +++ b/packages/core/src/stacks/index.ts @@ -0,0 +1,11 @@ +export { updateStackComments } from "./comments"; +export { getEnrichedLog } from "./enriched-log"; +export { getMergeStack, mergeStack } from "./merge"; +export { + cleanupMergedChange, + findMergedChanges, + type MergedChange, + type ReparentResult, + reparentAndCleanup, +} from "./merged"; +export { submitStack } from "./submit"; diff --git a/packages/core/src/stacks/merge.ts b/packages/core/src/stacks/merge.ts new file mode 100644 index 00000000..12aac90d --- /dev/null +++ b/packages/core/src/stacks/merge.ts @@ -0,0 +1,225 @@ +import type { Engine } from "../engine"; +import { mergePR, updatePR, waitForMergeable } from "../github/pr-actions"; +import { getPRForBranch } from "../github/pr-status"; +import { + deleteBookmark, + getTrunk, + list, + push, + rebase, + runJJ, + status, +} from "../jj"; +import { createError, err, ok, type Result } from "../result"; +import type { MergeOptions, MergeResult, PRToMerge } from "../types"; + +export async function getMergeStack(): Promise> { + const trunk = await getTrunk(); + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const { parents } = statusResult.value; + + // Current change is the parent of WC + const current = parents[0]; + if (!current) { + return err(createError("INVALID_STATE", "No current change")); + } + + const bookmarkName = current.bookmarks[0]; + const changeId = current.changeId; + + if (!bookmarkName) { + return err(createError("INVALID_STATE", "No bookmark on current change")); + } + + const prsToMerge: PRToMerge[] = []; + let currentBookmark: string | null = bookmarkName; + let currentChangeId: string | null = changeId; + const visitedBranches = new Set(); + + while (currentBookmark) { + if (visitedBranches.has(currentBookmark)) { + return err( + createError( + "INVALID_STATE", + `Cycle detected in PR base chain at branch "${currentBookmark}". Fix PR bases manually on GitHub.`, + ), + ); + } + visitedBranches.add(currentBookmark); + + const prResult = await getPRForBranch(currentBookmark); + if (!prResult.ok) return prResult; + + const prItem = prResult.value; + if (!prItem) { + return err( + createError( + "INVALID_STATE", + `No PR found for branch ${currentBookmark}`, + ), + ); + } + + // Both merged and closed PRs signal the end of the chain. + // Closed PRs are treated like merged - the sync command will handle cleanup. + if (prItem.state === "MERGED" || prItem.state === "CLOSED") { + break; + } + + prsToMerge.unshift({ + prNumber: prItem.number, + prTitle: prItem.title, + prUrl: prItem.url, + bookmarkName: currentBookmark, + changeId: currentChangeId, + baseRefName: prItem.base, + }); + + if (prItem.base === trunk) { + break; + } + + currentBookmark = prItem.base; + // Look up the changeId for this bookmark + const listResult = await list({ + revset: `bookmarks(exact:"${currentBookmark}")`, + limit: 1, + }); + currentChangeId = + listResult.ok && listResult.value.length > 0 + ? listResult.value[0].changeId + : null; + } + + return ok(prsToMerge); +} + +interface MergeStackOptions extends MergeOptions { + engine: Engine; +} + +export async function mergeStack( + prs: PRToMerge[], + options: MergeStackOptions, + callbacks?: { + onMerging?: (pr: PRToMerge, nextPr?: PRToMerge) => void; + onWaiting?: (pr: PRToMerge) => void; + onMerged?: (pr: PRToMerge) => void; + }, +): Promise> { + const { engine } = options; + await runJJ(["git", "fetch"]); + + const trunk = await getTrunk(); + const method = options.method ?? "squash"; + const merged: PRToMerge[] = []; + + const protectedBranches = [trunk, "main", "master", "develop"]; + for (const prItem of prs) { + if (protectedBranches.includes(prItem.bookmarkName)) { + return err( + createError( + "INVALID_STATE", + `Cannot merge with protected branch as head: ${prItem.bookmarkName}`, + ), + ); + } + } + + for (let i = 0; i < prs.length; i++) { + const prItem = prs[i]; + const nextPR = prs[i + 1]; + + callbacks?.onMerging?.(prItem, nextPR); + + // Update this PR's base to trunk right before merging + // (Don't do this upfront for all PRs - that can cause GitHub to auto-close them) + if (prItem.baseRefName !== trunk) { + const baseUpdateResult = await updatePR(prItem.prNumber, { base: trunk }); + if (!baseUpdateResult.ok) return baseUpdateResult; + } + + // Update next PR's base to trunk BEFORE merging (and deleting the branch) + // Otherwise GitHub auto-closes the next PR when its base branch disappears + if (nextPR) { + const nextBaseUpdateResult = await updatePR(nextPR.prNumber, { + base: trunk, + }); + if (!nextBaseUpdateResult.ok) return nextBaseUpdateResult; + } + + callbacks?.onWaiting?.(prItem); + + const mergeableResult = await waitForMergeable(prItem.prNumber, { + timeoutMs: 60000, + pollIntervalMs: 2000, + }); + + if (!mergeableResult.ok) return mergeableResult; + + if (!mergeableResult.value.mergeable) { + return err( + createError( + "MERGE_BLOCKED", + `PR #${prItem.prNumber} is not mergeable: ${mergeableResult.value.reason}`, + ), + ); + } + + const mergeResult = await mergePR(prItem.prNumber, { + method, + deleteHead: true, + headRef: prItem.bookmarkName, + }); + + if (!mergeResult.ok) return mergeResult; + + callbacks?.onMerged?.(prItem); + + merged.push(prItem); + + // Clean up the merged commit (same as arr sync does): + // 1. Abandon the commit (must do before rebase because rebase -r changes changeIds) + // 2. Delete the local bookmark + // 3. Untrack from engine + if (prItem.changeId) { + await runJJ(["abandon", prItem.changeId]); + } + await deleteBookmark(prItem.bookmarkName); + if (engine.isTracked(prItem.bookmarkName)) { + engine.untrack(prItem.bookmarkName); + } + + if (nextPR) { + // Fetch to get the merged commit into local main + await runJJ(["git", "fetch"]); + // Update local trunk bookmark to match remote + await runJJ(["bookmark", "set", trunk, "-r", `${trunk}@origin`]); + + // Rebase just the next PR's commit onto trunk (not its ancestors) + // Using "revision" mode (-r) ensures we skip the just-merged commit + const rebaseResult = await rebase({ + source: nextPR.bookmarkName, + destination: trunk, + mode: "revision", + }); + if (!rebaseResult.ok) return rebaseResult; + + // Push the rebased branch to GitHub + const pushResult = await push({ bookmark: nextPR.bookmarkName }); + if (!pushResult.ok) return pushResult; + } + } + + // Final sync: fetch, update main, move WC to trunk + await runJJ(["git", "fetch"]); + await runJJ(["bookmark", "set", trunk, "-r", `${trunk}@origin`]); + await runJJ(["new", trunk]); + + return ok({ + merged, + synced: true, + }); +} diff --git a/packages/core/src/stacks/merged.ts b/packages/core/src/stacks/merged.ts new file mode 100644 index 00000000..8dd35ab6 --- /dev/null +++ b/packages/core/src/stacks/merged.ts @@ -0,0 +1,202 @@ +import { isTrackingBookmark } from "../bookmark-utils"; +import type { Engine } from "../engine"; +import { updatePR } from "../github/pr-actions"; +import { batchGetPRsForBranches, getPRForBranch } from "../github/pr-status"; +import { + deleteBookmark, + getTrunk, + list, + runJJWithMutableConfigVoid, +} from "../jj"; +import { ok, type Result } from "../result"; + +export interface MergedChange { + changeId: string; + bookmark: string; + prNumber: number; + description: string; + reason: "merged" | "closed"; +} + +/** + * Find changes with merged or closed PRs that can be cleaned up. + * Does NOT abandon them - caller should prompt user first. + */ +export async function findMergedChanges(): Promise> { + const changesResult = await list({ + revset: 'mutable() & description(regex:".")', + }); + if (!changesResult.ok) return changesResult; + + const bookmarkToChange = new Map< + string, + { changeId: string; description: string } + >(); + const allBookmarks: string[] = []; + + for (const change of changesResult.value) { + for (const bookmark of change.bookmarks) { + if (!isTrackingBookmark(bookmark)) { + bookmarkToChange.set(bookmark, { + changeId: change.changeId, + description: change.description, + }); + allBookmarks.push(bookmark); + } + } + } + + if (allBookmarks.length === 0) { + return ok([]); + } + + const prsResult = await batchGetPRsForBranches(allBookmarks); + if (!prsResult.ok) return prsResult; + + const prCache = prsResult.value; + const merged: MergedChange[] = []; + + for (const [bookmark, change] of bookmarkToChange) { + const prItem = prCache.get(bookmark); + if (prItem && (prItem.state === "MERGED" || prItem.state === "CLOSED")) { + merged.push({ + changeId: change.changeId, + bookmark, + prNumber: prItem.number, + description: change.description, + reason: prItem.state === "MERGED" ? "merged" : "closed", + }); + } + } + + return ok(merged); +} + +export interface ReparentResult { + reparentedChildren: Array<{ + changeId: string; + bookmarks: string[]; + }>; + prBasesUpdated: number; +} + +/** + * Reparent children of a merged/closed change to its parent, then clean up. + * This handles the case where A > B > C and B is merged/closed: C becomes child of A. + * + * Steps: + * 1. Find children of the change being removed + * 2. Get the parent of the change (the grandparent of children) + * 3. Rebase children onto grandparent + * 4. Update PR bases on GitHub for affected children + * 5. Abandon the merged/closed change + * 6. Delete the bookmark locally + * 7. Untrack from engine + */ +export async function reparentAndCleanup( + change: MergedChange, + engine: Engine, + cwd = process.cwd(), +): Promise> { + const trunk = await getTrunk(cwd); + + // 1. Find children of this change + const childrenResult = await list( + { revset: `children(${change.changeId})` }, + cwd, + ); + if (!childrenResult.ok) return childrenResult; + + // 2. Get the parent of the change being removed (grandparent of children) + const changeResult = await list({ revset: change.changeId }, cwd); + if (!changeResult.ok) return changeResult; + + const parentId = changeResult.value[0]?.parents[0] ?? `${trunk}@origin`; + + // Find the bookmark for the parent (for PR base updates) + const parentResult = await list({ revset: parentId }, cwd); + const parentBookmark = parentResult.ok + ? parentResult.value[0]?.bookmarks[0] + : null; + + const reparentedChildren: ReparentResult["reparentedChildren"] = []; + let prBasesUpdated = 0; + + // 3. Rebase children onto grandparent (with mutable config) + if (childrenResult.value.length > 0) { + const rebaseResult = await runJJWithMutableConfigVoid( + ["rebase", "-s", `children(${change.changeId})`, "-d", parentId], + cwd, + ); + if (!rebaseResult.ok) return rebaseResult; + + // Track which children were reparented + for (const child of childrenResult.value) { + reparentedChildren.push({ + changeId: child.changeId, + bookmarks: child.bookmarks, + }); + } + + // 4. Update PR bases on GitHub for affected children + for (const child of childrenResult.value) { + for (const bookmark of child.bookmarks) { + if (isTrackingBookmark(bookmark)) continue; + + const prResult = await getPRForBranch(bookmark, cwd); + if (prResult.ok && prResult.value && prResult.value.state === "OPEN") { + // New base is the grandparent's bookmark, or trunk if none + const newBase = parentBookmark ?? trunk; + const updateResult = await updatePR( + prResult.value.number, + { base: newBase }, + cwd, + ); + if (updateResult.ok) { + prBasesUpdated++; + } + } + } + } + } + + // 5. Abandon the merged/closed change (with mutable config) + const abandonResult = await runJJWithMutableConfigVoid( + ["abandon", change.changeId], + cwd, + ); + if (!abandonResult.ok) return abandonResult; + + // 6. Delete the bookmark locally + await deleteBookmark(change.bookmark, cwd); + + // 7. Untrack from engine + if (engine.isTracked(change.bookmark)) { + engine.untrack(change.bookmark); + } + + return ok({ reparentedChildren, prBasesUpdated }); +} + +/** + * Clean up a single merged change by abandoning it and untracking from engine. + * @deprecated Use reparentAndCleanup instead for proper child reparenting. + */ +export async function cleanupMergedChange( + change: MergedChange, + engine: Engine, + cwd = process.cwd(), +): Promise> { + const abandonResult = await runJJWithMutableConfigVoid( + ["abandon", change.changeId], + cwd, + ); + if (!abandonResult.ok) return abandonResult; + + // Untrack the bookmark from the engine + if (engine.isTracked(change.bookmark)) { + engine.untrack(change.bookmark); + } + + return ok(undefined); +} diff --git a/packages/core/src/stacks/submit.ts b/packages/core/src/stacks/submit.ts new file mode 100644 index 00000000..dffee9e7 --- /dev/null +++ b/packages/core/src/stacks/submit.ts @@ -0,0 +1,470 @@ +import { resolveBookmarkConflict } from "../bookmark-utils"; +import { upsertStackComment } from "../github/comments"; +import { closePR, createPR, updatePR } from "../github/pr-actions"; +import { + batchGetPRsForBranches, + getMultiplePRInfos, +} from "../github/pr-status"; +import { + deleteBookmark, + ensureBookmark, + getBookmarkTracking, + getStack, + getTrunk, + push, + runJJ, +} from "../jj"; +import type { Changeset } from "../parser"; +import { createError, err, ok, type Result } from "../result"; +import { datePrefixedLabel } from "../slugify"; +import { + generateStackComment, + mapReviewDecisionToStatus, + type StackEntry, +} from "../stack-comment"; +import type { + PRSubmitStatus, + RollbackResult, + StackPR, + SubmitOptions, + SubmitResult, + SubmitTransaction, +} from "../types"; + +function generateBranchName(description: string, timestamp?: Date): string { + return datePrefixedLabel(description, timestamp ?? new Date()); +} + +export async function submitStack( + options?: SubmitOptions, +): Promise> { + const trunk = await getTrunk(); + const stackResult = await getStack(); + if (!stackResult.ok) return stackResult; + + const allChanges = stackResult.value; + if (allChanges.length === 0) { + return err(createError("COMMAND_FAILED", "No changes in stack to submit")); + } + + // Include all changes with descriptions (including working copy if it has one) + const stack = allChanges.filter((c) => c.description.trim() !== ""); + + if (stack.length === 0) { + return err( + createError( + "COMMAND_FAILED", + "No described changes in stack to submit. Use 'arr describe' to add descriptions.", + ), + ); + } + + const undescribed = allChanges.filter( + (c) => c.description.trim() === "" && !c.isWorkingCopy && !c.isEmpty, + ); + if (undescribed.length > 0) { + return err( + createError( + "COMMAND_FAILED", + `Stack contains ${undescribed.length} undescribed change(s). Use 'arr describe' to add descriptions before submitting.`, + ), + ); + } + + const conflicted = allChanges.filter( + (c) => !c.isWorkingCopy && c.hasConflicts, + ); + if (conflicted.length > 0) { + return err( + createError( + "CONFLICT", + `Stack contains ${conflicted.length} conflicted change(s). Resolve conflicts before submitting.`, + ), + ); + } + + const prs: StackPR[] = []; + const prNumbers = new Map(); + const bookmarks = new Map(); + const hadCodeToPush = new Map(); + let previousBookmark = trunk; + let created = 0; + let updated = 0; + let synced = 0; + + const tx: SubmitTransaction = { + createdPRs: [], + createdBookmarks: [], + pushedBookmarks: [], + }; + + const trackedSet = options?.trackedBookmarks + ? new Set(options.trackedBookmarks) + : new Set(); + + // Filter stack: skip unbookmarked commits that are ancestors of a tracked bookmark + // (they're part of an imported branch's history, not new work) + const fullStack = [...stack].reverse(); // trunk → head order + const orderedStack: typeof stack = []; + + // Find first tracked bookmark position (from trunk toward head) + let firstTrackedIdx = -1; + for (let i = 0; i < fullStack.length; i++) { + const change = fullStack[i]; + if (change.bookmarks.length > 0 && trackedSet.has(change.bookmarks[0])) { + firstTrackedIdx = i; + break; + } + } + + for (let i = 0; i < fullStack.length; i++) { + const change = fullStack[i]; + const hasBookmark = change.bookmarks.length > 0; + const hasTrackedBookmark = + hasBookmark && trackedSet.has(change.bookmarks[0]); + + if (hasTrackedBookmark) { + // Tracked bookmark - always submit + orderedStack.push(change); + } else if ( + !hasBookmark && + (firstTrackedIdx === -1 || i > firstTrackedIdx) + ) { + // No bookmark AND either: + // - No tracked bookmarks in stack (all new work) + // - This is AFTER the first tracked bookmark (new work on top) + orderedStack.push(change); + } + // Skip: unbookmarked commits before the first tracked bookmark (imported history) + // Skip: untracked bookmarks (belongs to another stack/workflow) + } + + if (orderedStack.length === 0) { + return err( + createError( + "COMMAND_FAILED", + "No tracked changes to submit. Use 'arr create' to create a new change.", + ), + ); + } + + const trackingResult = await getBookmarkTracking(); + const trackingMap = new Map(); + if (trackingResult.ok) { + for (const t of trackingResult.value) { + trackingMap.set(t.name, { aheadCount: t.aheadCount }); + } + } + + const initialBookmarks: string[] = []; + for (const change of orderedStack) { + const bookmark = + change.bookmarks[0] ?? + generateBranchName(change.description, change.timestamp); + initialBookmarks.push(bookmark); + } + + const existingPRs = await batchGetPRsForBranches(initialBookmarks); + const prCache = existingPRs.ok ? existingPRs.value : new Map(); + + // Build the plan: determine bookmarks and what actions would be taken + const assignedNames = new Set(); + for (let i = 0; i < orderedStack.length; i++) { + const change = orderedStack[i]; + const initialBookmark = initialBookmarks[i]; + + const conflictResult = await resolveBookmarkConflict( + initialBookmark, + prCache, + assignedNames, + ); + if (!conflictResult.ok) return conflictResult; + + const bookmark = conflictResult.value.resolvedName; + bookmarks.set(change.changeId, bookmark); + assignedNames.add(bookmark); + + const existingBookmark = change.bookmarks[0]; + const isNewBookmark = !existingBookmark; + + let needsPush: boolean; + if (isNewBookmark || conflictResult.value.hadConflict) { + needsPush = true; + } else { + const tracking = trackingMap.get(existingBookmark); + needsPush = !tracking || tracking.aheadCount > 0; + } + hadCodeToPush.set(change.changeId, needsPush); + } + + // Build planned PRs list for dry run + for (let i = 0; i < orderedStack.length; i++) { + const change = orderedStack[i]; + const bookmark = bookmarks.get(change.changeId)!; + const existingPR = prCache.get(bookmark); + const codePushed = hadCodeToPush.get(change.changeId) ?? false; + + if ( + existingPR && + (existingPR.state === "MERGED" || existingPR.state === "CLOSED") + ) { + previousBookmark = bookmark; + continue; + } + + let prStatus: PRSubmitStatus; + if (existingPR && existingPR.state === "OPEN") { + prStatus = codePushed ? "updated" : "synced"; + if (codePushed) updated++; + else synced++; + prs.push({ + changeId: change.changeId, + bookmarkName: bookmark, + prNumber: existingPR.number, + prUrl: existingPR.url, + base: previousBookmark, + title: existingPR.title, + position: i, + status: prStatus, + }); + } else { + const title = change.description || "Untitled"; + prStatus = "created"; + created++; + prs.push({ + changeId: change.changeId, + bookmarkName: bookmark, + prNumber: 0, // Unknown until created + prUrl: "", + base: previousBookmark, + title, + position: i, + status: prStatus, + }); + } + + previousBookmark = bookmark; + } + + // Dry run: return plan without making changes + if (options?.dryRun) { + return ok({ prs, created, updated, synced, dryRun: true }); + } + + // Execute the plan + for (let i = 0; i < orderedStack.length; i++) { + const change = orderedStack[i]; + const bookmark = bookmarks.get(change.changeId)!; + const existingBookmark = change.bookmarks[0]; + const isNewBookmark = !existingBookmark; + const needsPush = hadCodeToPush.get(change.changeId) ?? false; + + await ensureBookmark(bookmark, change.changeId); + + if (isNewBookmark) { + tx.createdBookmarks.push(bookmark); + } + + if (needsPush) { + const pushResult = await push({ bookmark }); + if (!pushResult.ok) { + await rollbackSubmit(tx); + return err( + createError( + "COMMAND_FAILED", + `Failed to push bookmark "${bookmark}": ${pushResult.error.message}. Changes have been rolled back.`, + ), + ); + } + tx.pushedBookmarks.push(bookmark); + } + } + + // Update PR numbers from cache + for (const change of orderedStack) { + const bookmark = bookmarks.get(change.changeId)!; + const existingPR = prCache.get(bookmark); + if (existingPR) { + prNumbers.set(change.changeId, existingPR.number); + } + } + + // Create/update PRs + previousBookmark = trunk; + const finalPrs: StackPR[] = []; + for (let i = 0; i < orderedStack.length; i++) { + const change = orderedStack[i]; + const bookmark = bookmarks.get(change.changeId)!; + const existingPR = prCache.get(bookmark); + const codePushed = hadCodeToPush.get(change.changeId) ?? false; + + if ( + existingPR && + (existingPR.state === "MERGED" || existingPR.state === "CLOSED") + ) { + previousBookmark = bookmark; + continue; + } else if (existingPR && existingPR.state === "OPEN") { + const updateResult = await updatePR(existingPR.number, { + base: previousBookmark, + }); + if (!updateResult.ok) { + await rollbackSubmit(tx); + return err( + createError( + "COMMAND_FAILED", + `Failed to update PR #${existingPR.number}: ${updateResult.error.message}. Changes have been rolled back.`, + ), + ); + } + finalPrs.push({ + changeId: change.changeId, + bookmarkName: bookmark, + prNumber: existingPR.number, + prUrl: existingPR.url, + base: previousBookmark, + title: existingPR.title, + position: i, + status: codePushed ? "updated" : "synced", + }); + } else { + const title = change.description || "Untitled"; + const prResult = await createPR({ + head: bookmark, + title, + base: previousBookmark, + draft: options?.draft, + }); + + if (!prResult.ok) { + await rollbackSubmit(tx); + return err( + createError( + "COMMAND_FAILED", + `Failed to create PR for "${bookmark}": ${prResult.error.message}. Changes have been rolled back.`, + ), + ); + } + + tx.createdPRs.push({ number: prResult.value.number, bookmark }); + prNumbers.set(change.changeId, prResult.value.number); + finalPrs.push({ + changeId: change.changeId, + bookmarkName: bookmark, + prNumber: prResult.value.number, + prUrl: prResult.value.url, + base: previousBookmark, + title, + position: i, + status: "created", + }); + } + + previousBookmark = bookmark; + } + + // Use finalPrs for the actual result + prs.length = 0; + prs.push(...finalPrs); + + await addStackComments(prs, orderedStack, options?.trackedBookmarks); + + await runJJ(["git", "fetch"]); + + return ok({ prs, created, updated, synced }); +} + +async function addStackComments( + prs: StackPR[], + stack: Changeset[], + trackedBookmarks?: string[], +): Promise<{ succeeded: number; failed: number }> { + if (prs.length === 0) return { succeeded: 0, failed: 0 }; + + // Filter to only tracked bookmarks if provided + const trackedSet = trackedBookmarks ? new Set(trackedBookmarks) : null; + const filteredPrs = trackedSet + ? prs.filter((p) => trackedSet.has(p.bookmarkName)) + : prs; + const filteredStack = trackedSet + ? stack.filter((c) => c.bookmarks.some((b) => trackedSet.has(b))) + : stack; + + if (filteredPrs.length === 0) return { succeeded: 0, failed: 0 }; + + const prNumbersList = filteredPrs.map((p) => p.prNumber); + const infosResult = await getMultiplePRInfos(prNumbersList); + const infos = infosResult.ok ? infosResult.value : new Map(); + + const commentUpserts = filteredPrs.map((prItem, i) => { + const stackEntries: StackEntry[] = filteredPrs.map((p, idx) => { + const prInfo = infos.get(p.prNumber); + let entryStatus: StackEntry["status"] = "waiting"; + + if (idx === i) { + entryStatus = "this"; + } else if (prInfo) { + entryStatus = mapReviewDecisionToStatus( + prInfo.reviewDecision ?? null, + prInfo.state, + ); + } + + return { + prNumber: p.prNumber, + title: + filteredStack[idx]?.description || `Change ${p.changeId.slice(0, 8)}`, + status: entryStatus, + }; + }); + + const comment = generateStackComment({ stack: stackEntries }); + return upsertStackComment(prItem.prNumber, comment); + }); + + const results = await Promise.allSettled(commentUpserts); + + let succeeded = 0; + let failed = 0; + for (const result of results) { + if (result.status === "fulfilled" && result.value.ok) { + succeeded++; + } else { + failed++; + } + } + + return { succeeded, failed }; +} + +async function rollbackSubmit(tx: SubmitTransaction): Promise { + const result: RollbackResult = { + closedPRs: [], + deletedBookmarks: [], + failures: [], + }; + + for (const prItem of [...tx.createdPRs].reverse()) { + const closeResult = await closePR(prItem.number); + if (closeResult.ok) { + result.closedPRs.push(prItem.number); + } else { + result.failures.push( + `Failed to close PR #${prItem.number}: ${closeResult.error.message}`, + ); + } + } + + for (const bookmark of tx.createdBookmarks) { + const deleteResult = await deleteBookmark(bookmark); + if (deleteResult.ok) { + result.deletedBookmarks.push(bookmark); + } else { + result.failures.push( + `Failed to delete bookmark ${bookmark}: ${deleteResult.error.message}`, + ); + } + } + + return result; +} diff --git a/packages/core/src/templates.ts b/packages/core/src/templates.ts new file mode 100644 index 00000000..b798ec96 --- /dev/null +++ b/packages/core/src/templates.ts @@ -0,0 +1,37 @@ +export const CHANGESET_JSON_TEMPLATE = + '"{" ++' + + '"\\"base\\":" ++ json(self) ++ "," ++' + + '"\\"parentChangeIds\\":[" ++ parents.map(|p| "\\"" ++ p.change_id() ++ "\\"").join(",") ++ "]," ++' + + '"\\"empty\\":" ++ json(empty) ++ "," ++' + + '"\\"conflict\\":" ++ json(conflict) ++ "," ++' + + '"\\"immutable\\":" ++ json(immutable) ++ "," ++' + + '"\\"workingCopy\\":" ++ json(current_working_copy) ++ "," ++' + + '"\\"bookmarks\\":" ++ json(local_bookmarks) ++ "," ++' + + '"\\"changeIdPrefix\\":\\"" ++ change_id.shortest().prefix() ++ "\\"," ++' + + '"\\"commitIdPrefix\\":\\"" ++ commit_id.shortest().prefix() ++ "\\"" ++' + + '"}\\n"'; + +/** + * Template for log graph output with placeholders. + * jj handles graph rendering (markers like @, ○, ◆ and │ prefixes). + * We replace jj's markers with styled versions in post-processing. + * + * Output format per change: + * {{LABEL:changeId|prefix|timestamp|description|conflict|wc|empty|immutable|localBookmarks|remoteBookmarks}} + * + * The CLI handles: + * - Hiding empty undescribed WC (abstracted away) + * - Showing green marker on "current" branch (parent of empty WC) + * - Showing "(uncommitted)" badge when WC has changes + */ +export const LOG_GRAPH_TEMPLATE = ` +"{{LABEL:" ++ change_id.short(8) ++ "|" ++ change_id.shortest().prefix() ++ "|" ++ committer.timestamp().format("%s") ++ "|" ++ if(description, description.first_line(), "") ++ "|" ++ if(conflict, "1", "0") ++ "|" ++ if(current_working_copy, "1", "0") ++ "|" ++ if(empty, "1", "0") ++ "|" ++ if(immutable, "1", "0") ++ "|" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "|" ++ remote_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n" ++ +"{{TIME:" ++ committer.timestamp().format("%s") ++ "}}\\n" ++ +if(current_working_copy && empty && !description, "{{HINT_EMPTY}}\\n", "") ++ +if(current_working_copy && !empty && !description, "{{HINT_UNCOMMITTED}}\\n", "") ++ +if(current_working_copy && description && local_bookmarks, "{{HINT_SUBMIT}}\\n", "") ++ +"\\n" ++ +if(local_bookmarks, "{{PR:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "|" ++ if(description, description.first_line(), "") ++ "}}\\n{{PRURL:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n", "") ++ +"{{COMMIT:" ++ commit_id.short(8) ++ "|" ++ commit_id.shortest().prefix() ++ "|" ++ if(description, description.first_line(), "") ++ "}}\\n" ++ +"\\n" +`.trim(); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 00000000..80c971f9 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,233 @@ +import type { Changeset } from "./parser"; + +export interface Author { + name: string; + email: string; +} + +export interface ConflictInfo { + path: string; + type: "content" | "delete" | "rename"; +} + +/** Lightweight changeset info for status display */ +export interface StatusChangeset { + changeId: string; + changeIdPrefix: string; + commitId: string; + commitIdPrefix: string; + description: string; + bookmarks: string[]; + parents: string[]; + isWorkingCopy: boolean; + isImmutable: boolean; + isEmpty: boolean; + hasConflicts: boolean; +} + +export interface ChangesetStatus { + workingCopy: StatusChangeset; + parents: StatusChangeset[]; + modifiedFiles: FileChange[]; + conflicts: ConflictInfo[]; + hasResolvedConflict: boolean; +} + +export interface FileChange { + path: string; + status: "modified" | "added" | "deleted" | "renamed" | "copied"; + originalPath?: string; +} + +export interface DiffStats { + filesChanged: number; + insertions: number; + deletions: number; +} + +export interface Bookmark { + name: string; + changeId: string; + isTracking: boolean; + remote?: string; +} + +export interface BookmarkTrackingStatus { + name: string; + aheadCount: number; + behindCount: number; +} + +export interface WorkspaceInfo { + name: string; + path: string; + isCurrent: boolean; +} + +export interface ListOptions { + revset?: string; + limit?: number; +} + +export interface NewOptions { + parents?: string[]; + message?: string; + noEdit?: boolean; +} + +export interface BookmarkOptions { + revision?: string; + create?: boolean; + move?: boolean; +} + +export interface PushOptions { + remote?: string; + bookmark?: string; +} + +export interface PROptions { + title?: string; + body?: string; + base?: string; + draft?: boolean; +} + +export interface PRResult { + url: string; + number: number; +} + +export interface CreateOptions { + message: string; + all?: boolean; + /** Optional bookmark name. If not provided, one will be generated from the message. */ + bookmarkName?: string; +} + +export type PRSubmitStatus = "created" | "updated" | "synced" | "untracked"; + +export interface StackPR { + changeId: string; + bookmarkName: string; + prNumber: number; + prUrl: string; + base: string; + title: string; + position: number; + status: PRSubmitStatus; +} + +export interface SubmitOptions { + stack?: boolean; + draft?: boolean; + /** Tracked bookmarks - only these will be shown in stack comments */ + trackedBookmarks?: string[]; + /** Dry run - show what would be done without making changes */ + dryRun?: boolean; +} + +export interface SubmitResult { + prs: StackPR[]; + created: number; + updated: number; + synced: number; + /** True if this was a dry run (no changes made) */ + dryRun?: boolean; +} + +export interface AbandonedChange { + changeId: string; + reason: "empty" | "merged"; +} + +export interface SyncResult { + fetched: boolean; + rebased: boolean; + abandoned: AbandonedChange[]; + forgottenBookmarks: string[]; + hasConflicts: boolean; +} + +export type NavigationPosition = + | "editing" // On a branch, editing it directly + | "on-top" // At top of stack, ready for new work + | "on-trunk"; // On trunk, starting fresh + +export interface NavigationResult { + changeId: string; + changeIdPrefix: string; + description: string; + /** The bookmark/branch name if on a tracked branch */ + bookmark?: string; + /** Where we ended up */ + position: NavigationPosition; +} + +export interface FindOptions { + query: string; + includeBookmarks?: boolean; +} + +export type FindResult = + | { status: "found"; change: Changeset } + | { status: "multiple"; matches: Changeset[] } + | { status: "none" }; + +export type ModifyResult = + | { status: "squashed" } + | { status: "already_editing"; description: string } + | { status: "no_parent" }; + +export type NextAction = + | { action: "create"; reason: "unsaved" | "empty" | "on_trunk" } + | { action: "submit"; reason: "create_pr" | "update_pr" } + | { action: "continue"; reason: "conflicts" } + | { action: "up"; reason: "start_new" }; + +export interface StatusInfo { + changeId: string; + changeIdPrefix: string; + name: string; + isUndescribed: boolean; + hasChanges: boolean; + hasConflicts: boolean; + /** True if the current stack is behind trunk and needs restacking */ + isBehindTrunk: boolean; + stackPath: string[]; + modifiedFiles: FileChange[]; + conflicts: ConflictInfo[]; + nextAction: NextAction; +} + +export interface PRToMerge { + prNumber: number; + prTitle: string; + prUrl: string; + bookmarkName: string; + changeId: string | null; + baseRefName: string; +} + +export interface MergeOptions { + method?: "merge" | "squash" | "rebase"; +} + +export interface MergeResult { + merged: PRToMerge[]; + synced: boolean; +} + +/** Transaction state for tracking resources created during submitStack */ +export interface SubmitTransaction { + createdPRs: Array<{ number: number; bookmark: string }>; + createdBookmarks: string[]; + pushedBookmarks: string[]; +} + +/** Result of rolling back a failed submission */ +export interface RollbackResult { + closedPRs: number[]; + deletedBookmarks: string[]; + failures: string[]; +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 00000000..b442a6da --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..9e7aa6df --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ES2022", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "declaration": true, + "declarationMap": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b9ee8e1..772c4d2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 9.1.7 knip: specifier: ^5.66.3 - version: 5.70.1(@types/node@22.19.1)(typescript@5.9.3) + version: 5.70.1(@types/node@25.0.3)(typescript@5.9.3) lint-staged: specifier: ^15.5.2 version: 15.5.2 @@ -374,11 +374,30 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1)) vitest: specifier: ^4.0.10 - version: 4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + version: 4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) yaml: specifier: ^2.8.1 version: 2.8.1 + apps/cli: + dependencies: + '@array/core': + specifier: workspace:* + version: link:../../packages/core + devDependencies: + '@types/bun': + specifier: latest + version: 1.3.5 + '@types/node': + specifier: ^25.0.3 + version: 25.0.3 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vitest: + specifier: ^4.0.16 + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + packages/agent: dependencies: '@agentclientprotocol/sdk': @@ -414,7 +433,7 @@ importers: devDependencies: '@changesets/cli': specifier: ^2.27.8 - version: 2.29.7(@types/node@22.19.1) + version: 2.29.7(@types/node@25.0.3) '@types/bun': specifier: latest version: 1.3.5 @@ -431,6 +450,28 @@ importers: specifier: ^5.5.0 version: 5.9.3 + packages/core: + dependencies: + '@octokit/graphql': + specifier: ^9.0.3 + version: 9.0.3 + '@octokit/graphql-schema': + specifier: ^15.26.1 + version: 15.26.1 + '@octokit/rest': + specifier: ^22.0.1 + version: 22.0.1 + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + '@types/bun': + specifier: latest + version: 1.3.5 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + packages/electron-trpc: devDependencies: '@trpc/client': @@ -444,7 +485,7 @@ importers: version: 20.19.25 '@vitest/coverage-v8': specifier: ^0.34.0 - version: 0.34.6(vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(terser@5.44.1)) + version: 0.34.6(vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1)) builtin-modules: specifier: ^3.3.0 version: 3.3.0 @@ -454,9 +495,6 @@ importers: electron: specifier: ^35.2.1 version: 35.7.5 - superjson: - specifier: ^2.2.2 - version: 2.2.6 typescript: specifier: ^5.8.3 version: 5.9.3 @@ -468,10 +506,7 @@ importers: version: 0.1.4(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(terser@5.44.1) - zod: - specifier: ^3.24.1 - version: 3.25.76 + version: 2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1) packages: @@ -1565,6 +1600,10 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + '@inquirer/checkbox@3.0.1': resolution: {integrity: sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ==} engines: {node: '>=18'} @@ -1573,6 +1612,24 @@ packages: resolution: {integrity: sha512-46yL28o2NJ9doViqOy0VDcoTzng7rAb6yPQKU7VDLqkmbCaH4JqK4yk4XqlzNWy9PVC5pG1ZUXPBQv+VqnYs2w==} engines: {node: '>=18'} + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/core@9.2.1': resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==} engines: {node: '>=18'} @@ -1634,6 +1691,15 @@ packages: resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} engines: {node: '>=18'} + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inversifyjs/common@1.5.2': resolution: {integrity: sha512-WlzR9xGadABS9gtgZQ+luoZ8V6qm4Ii6RQfcfC9Ho2SOlE6ZuemFo7PKJvKI0ikm8cmKbU8hw5UK6E4qovH21w==} @@ -1778,6 +1844,10 @@ packages: '@cfworker/json-schema': optional: true + '@mswjs/interceptors@0.40.0': + resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} + engines: {node: '>=18'} + '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} @@ -1814,42 +1884,82 @@ packages: resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} engines: {node: '>= 18'} + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + '@octokit/core@5.2.2': resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} engines: {node: '>= 18'} + '@octokit/core@7.0.6': + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.2': + resolution: {integrity: sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==} + engines: {node: '>= 20'} + '@octokit/endpoint@9.0.6': resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} engines: {node: '>= 18'} + '@octokit/graphql-schema@15.26.1': + resolution: {integrity: sha512-RFDC2MpRBd4AxSRvUeBIVeBU7ojN/SxDfALUd7iVYOSeEK3gZaqR2MGOysj4Zh2xj2RY5fQAUT+Oqq7hWTraMA==} + '@octokit/graphql@7.1.1': resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} engines: {node: '>= 18'} + '@octokit/graphql@9.0.3': + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} + '@octokit/openapi-types@12.11.0': resolution: {integrity: sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==} '@octokit/openapi-types@24.2.0': resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + '@octokit/plugin-paginate-rest@11.4.4-cjs.2': resolution: {integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==} engines: {node: '>= 18'} peerDependencies: '@octokit/core': '5' + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + '@octokit/plugin-request-log@4.0.1': resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==} engines: {node: '>= 18'} peerDependencies: '@octokit/core': '5' + '@octokit/plugin-request-log@6.0.0': + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1': resolution: {integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==} engines: {node: '>= 18'} peerDependencies: '@octokit/core': ^5 + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + '@octokit/plugin-retry@6.1.0': resolution: {integrity: sha512-WrO3bvq4E1Xh1r2mT9w6SDFg01gFmP81nIG77+p/MqW1JeXXgL++6umim3t6x0Zj5pZm3rXAN+0HEjmmdhIRig==} engines: {node: '>= 18'} @@ -1860,6 +1970,14 @@ packages: resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} engines: {node: '>= 18'} + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.7': + resolution: {integrity: sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==} + engines: {node: '>= 20'} + '@octokit/request@8.4.1': resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} engines: {node: '>= 18'} @@ -1868,9 +1986,16 @@ packages: resolution: {integrity: sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==} engines: {node: '>= 18'} + '@octokit/rest@22.0.1': + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} + engines: {node: '>= 20'} + '@octokit/types@13.10.0': resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@octokit/types@6.41.0': resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==} @@ -1878,6 +2003,15 @@ packages: resolution: {integrity: sha512-w7FhUXfqpzw9igTZFfKS7cUNW1FK+tT426ZkClG2X8vufW0jyGqfgPd6Uq8+gJgSTLxayF9I802FDW2KjYcfYQ==} engines: {node: '>=18'} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -3304,6 +3438,9 @@ packages: '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/node@25.0.3': + resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -3318,6 +3455,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3360,6 +3500,9 @@ packages: '@vitest/expect@4.0.12': resolution: {integrity: sha512-is+g0w8V3/ZhRNrRizrJNr8PFQKwYmctWlU4qg8zy5r9aIV5w8IxXLlfbbxJCwSpsVl2PXPTm2/zruqTqz3QSg==} + '@vitest/expect@4.0.16': + resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + '@vitest/mocker@2.1.9': resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} peerDependencies: @@ -3382,30 +3525,53 @@ packages: vite: optional: true + '@vitest/mocker@4.0.16': + resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} '@vitest/pretty-format@4.0.12': resolution: {integrity: sha512-R7nMAcnienG17MvRN8TPMJiCG8rrZJblV9mhT7oMFdBXvS0x+QD6S1G4DxFusR2E0QIS73f7DqSR1n87rrmE+g==} + '@vitest/pretty-format@4.0.16': + resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} '@vitest/runner@4.0.12': resolution: {integrity: sha512-hDlCIJWuwlcLumfukPsNfPDOJokTv79hnOlf11V+n7E14rHNPz0Sp/BO6h8sh9qw4/UjZiKyYpVxK2ZNi+3ceQ==} + '@vitest/runner@4.0.16': + resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + '@vitest/snapshot@2.1.9': resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} '@vitest/snapshot@4.0.12': resolution: {integrity: sha512-2jz9zAuBDUSbnfyixnyOd1S2YDBrZO23rt1bicAb6MA/ya5rHdKFRikPIDpBj/Dwvh6cbImDmudegnDAkHvmRQ==} + '@vitest/snapshot@4.0.16': + resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + '@vitest/spy@2.1.9': resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} '@vitest/spy@4.0.12': resolution: {integrity: sha512-GZjI9PPhiOYNX8Nsyqdw7JQB+u0BptL5fSnXiottAUBHlcMzgADV58A7SLTXXQwcN1yZ6gfd1DH+2bqjuUlCzw==} + '@vitest/spy@4.0.16': + resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + '@vitest/ui@4.0.12': resolution: {integrity: sha512-RCqeApCnbwd5IFvxk6OeKMXTvzHU/cVqY8HAW0gWk0yAO6wXwQJMKhDfDtk2ss7JCy9u7RNC3kyazwiaDhBA/g==} peerDependencies: @@ -3417,6 +3583,9 @@ packages: '@vitest/utils@4.0.12': resolution: {integrity: sha512-DVS/TLkLdvGvj1avRy0LSmKfrcI9MNFvNGN6ECjTUHWJdlcgPDOXhjMis5Dh7rBH62nAmSXnkPbE+DZ5YD75Rw==} + '@vitest/utils@4.0.16': + resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + '@vscode/sudo-prompt@9.3.1': resolution: {integrity: sha512-9ORTwwS74VaTn38tNbQhsA5U44zkJfcb0BdTSyyG6frP4e8KMtHuTXYmwefe5dpL8XB1aGSIVTaLjD3BbWb5iA==} @@ -3700,6 +3869,9 @@ packages: before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -4008,8 +4180,8 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - copy-anything@4.0.5: - resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} core-js@3.47.0: @@ -4419,6 +4591,9 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4693,6 +4868,16 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql-tag@2.12.6: + resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} + engines: {node: '>=10'} + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + graphql@16.12.0: + resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4718,6 +4903,9 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -4904,6 +5092,9 @@ packages: is-my-json-valid@2.20.6: resolution: {integrity: sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw==} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -4937,10 +5128,6 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-what@5.5.0: - resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} - engines: {node: '>=18'} - is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -5494,6 +5681,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.12.4: + resolution: {integrity: sha512-rHNiVfTyKhzc0EjoXUBVGteNKBevdjOlVC6GlIRXpy+/3LHEIGRovnB5WPjcvmNODVQ1TNFnoa7wsGbd0V3epg==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + murmur-32@0.2.0: resolution: {integrity: sha512-ZkcWZudylwF+ir3Ld1n7gL6bI2mQAzXvSobPwVtu8aYi2sbXeipeSkdcanRLzIofLcM5F53lGaKm2dk7orBi7Q==} @@ -5501,6 +5698,10 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -5619,6 +5820,9 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -5658,6 +5862,9 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + oxc-resolver@11.13.2: resolution: {integrity: sha512-1SXVyYQ9bqMX3uZo8Px81EG7jhZkO9PvvR5X9roY5TLYVm4ZA7pbPDNlYaDBBeF9U+YO3OeMNoHde52hrcCu8w==} @@ -5795,6 +6002,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -6268,6 +6478,9 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + rettime@0.7.0: + resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -6498,6 +6711,9 @@ packages: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -6576,10 +6792,6 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} - superjson@2.2.6: - resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} - engines: {node: '>=16'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -6658,6 +6870,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -6684,10 +6900,17 @@ packages: tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -6715,6 +6938,10 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -6870,6 +7097,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -6907,6 +7137,9 @@ packages: universal-user-agent@6.0.1: resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -6923,6 +7156,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + update-browserslist-db@1.1.4: resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} hasBin: true @@ -7177,6 +7413,40 @@ packages: jsdom: optional: true + vitest@4.0.16: + resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.16 + '@vitest/browser-preview': 4.0.16 + '@vitest/browser-webdriverio': 4.0.16 + '@vitest/ui': 4.0.16 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-icons-js@11.6.1: resolution: {integrity: sha512-rht18IFYv117UlqBn6o9j258SOtwhDBmtVrGwdoLPpSj6Z5LKQIzarQDd/tCRWneU68KEX25+nsh48tAoknKNw==} @@ -7225,6 +7495,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -7642,7 +7913,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.7(@types/node@22.19.1)': + '@changesets/cli@2.29.7(@types/node@25.0.3)': dependencies: '@changesets/apply-release-plan': 7.0.13 '@changesets/assemble-release-plan': 6.0.9 @@ -7658,7 +7929,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@22.19.1) + '@inquirer/external-editor': 1.0.3(@types/node@25.0.3) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -8725,6 +8996,9 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@inquirer/ansi@1.0.2': + optional: true + '@inquirer/checkbox@3.0.1': dependencies: '@inquirer/core': 9.2.1 @@ -8738,6 +9012,50 @@ snapshots: '@inquirer/core': 9.2.1 '@inquirer/type': 2.0.0 + '@inquirer/confirm@5.1.21(@types/node@20.19.25)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.25) + '@inquirer/type': 3.0.10(@types/node@20.19.25) + optionalDependencies: + '@types/node': 20.19.25 + optional: true + + '@inquirer/confirm@5.1.21(@types/node@25.0.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@25.0.3) + '@inquirer/type': 3.0.10(@types/node@25.0.3) + optionalDependencies: + '@types/node': 25.0.3 + optional: true + + '@inquirer/core@10.3.2(@types/node@20.19.25)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.25) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.25 + optional: true + + '@inquirer/core@10.3.2(@types/node@25.0.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@25.0.3) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 25.0.3 + optional: true + '@inquirer/core@9.2.1': dependencies: '@inquirer/figures': 1.0.15 @@ -8765,12 +9083,12 @@ snapshots: '@inquirer/type': 2.0.0 yoctocolors-cjs: 2.1.3 - '@inquirer/external-editor@1.0.3(@types/node@22.19.1)': + '@inquirer/external-editor@1.0.3(@types/node@25.0.3)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.0 optionalDependencies: - '@types/node': 22.19.1 + '@types/node': 25.0.3 '@inquirer/figures@1.0.15': {} @@ -8832,6 +9150,16 @@ snapshots: dependencies: mute-stream: 1.0.0 + '@inquirer/type@3.0.10(@types/node@20.19.25)': + optionalDependencies: + '@types/node': 20.19.25 + optional: true + + '@inquirer/type@3.0.10(@types/node@25.0.3)': + optionalDependencies: + '@types/node': 25.0.3 + optional: true + '@inversifyjs/common@1.5.2': {} '@inversifyjs/container@1.14.3(reflect-metadata@0.2.2)': @@ -9045,6 +9373,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@mswjs/interceptors@0.40.0': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + optional: true + '@napi-rs/wasm-runtime@1.0.7': dependencies: '@emnapi/core': 1.7.1 @@ -9090,6 +9428,8 @@ snapshots: '@octokit/auth-token@4.0.0': {} + '@octokit/auth-token@6.0.0': {} + '@octokit/core@5.2.2': dependencies: '@octokit/auth-token': 4.0.0 @@ -9100,35 +9440,77 @@ snapshots: before-after-hook: 2.2.3 universal-user-agent: 6.0.1 + '@octokit/core@7.0.6': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.7 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.2': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + '@octokit/endpoint@9.0.6': dependencies: '@octokit/types': 13.10.0 universal-user-agent: 6.0.1 + '@octokit/graphql-schema@15.26.1': + dependencies: + graphql: 16.12.0 + graphql-tag: 2.12.6(graphql@16.12.0) + '@octokit/graphql@7.1.1': dependencies: '@octokit/request': 8.4.1 '@octokit/types': 13.10.0 universal-user-agent: 6.0.1 + '@octokit/graphql@9.0.3': + dependencies: + '@octokit/request': 10.0.7 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + '@octokit/openapi-types@12.11.0': {} '@octokit/openapi-types@24.2.0': {} + '@octokit/openapi-types@27.0.0': {} + '@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2)': dependencies: '@octokit/core': 5.2.2 '@octokit/types': 13.10.0 + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + '@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.2)': dependencies: '@octokit/core': 5.2.2 + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2)': dependencies: '@octokit/core': 5.2.2 '@octokit/types': 13.10.0 + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + '@octokit/plugin-retry@6.1.0(@octokit/core@5.2.2)': dependencies: '@octokit/core': 5.2.2 @@ -9142,6 +9524,18 @@ snapshots: deprecation: 2.3.1 once: 1.4.0 + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.7': + dependencies: + '@octokit/endpoint': 11.0.2 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + fast-content-type-parse: 3.0.0 + universal-user-agent: 7.0.3 + '@octokit/request@8.4.1': dependencies: '@octokit/endpoint': 9.0.6 @@ -9156,16 +9550,39 @@ snapshots: '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.2) '@octokit/plugin-rest-endpoint-methods': 13.3.2-cjs.1(@octokit/core@5.2.2) + '@octokit/rest@22.0.1': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + '@octokit/types@13.10.0': dependencies: '@octokit/openapi-types': 24.2.0 + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + '@octokit/types@6.41.0': dependencies: '@octokit/openapi-types': 12.11.0 '@openai/codex-sdk@0.60.1': {} + '@open-draft/deferred-promise@2.2.0': + optional: true + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + optional: true + + '@open-draft/until@2.1.0': + optional: true + '@opentelemetry/api@1.9.0': {} '@oxc-resolver/binding-android-arm-eabi@11.13.2': @@ -10549,6 +10966,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@25.0.3': + dependencies: + undici-types: 7.16.0 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.27)': @@ -10564,6 +10985,9 @@ snapshots: dependencies: '@types/node': 20.19.25 + '@types/statuses@2.0.6': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -10595,7 +11019,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@0.34.6(vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(terser@5.44.1))': + '@vitest/coverage-v8@0.34.6(vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -10608,7 +11032,7 @@ snapshots: std-env: 3.10.0 test-exclude: 6.0.0 v8-to-istanbul: 9.3.0 - vitest: 2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(terser@5.44.1) + vitest: 2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1) transitivePeerDependencies: - supports-color @@ -10628,22 +11052,42 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1))': + '@vitest/expect@4.0.16': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + chai: 6.2.1 + tinyrainbow: 3.0.3 + + '@vitest/mocker@2.1.9(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: + msw: 2.12.4(@types/node@20.19.25)(typescript@5.9.3) vite: 5.4.21(@types/node@20.19.25)(terser@5.44.1) - '@vitest/mocker@4.0.12(vite@7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + '@vitest/mocker@4.0.12(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(vite@7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.12 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: + msw: 2.12.4(@types/node@20.19.25)(typescript@5.9.3) vite: 7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/mocker@4.0.16(msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3))(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.16 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.4(@types/node@25.0.3)(typescript@5.9.3) + vite: 6.4.1(@types/node@25.0.3)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -10652,6 +11096,10 @@ snapshots: dependencies: tinyrainbow: 3.0.3 + '@vitest/pretty-format@4.0.16': + dependencies: + tinyrainbow: 3.0.3 + '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 @@ -10662,6 +11110,11 @@ snapshots: '@vitest/utils': 4.0.12 pathe: 2.0.3 + '@vitest/runner@4.0.16': + dependencies: + '@vitest/utils': 4.0.16 + pathe: 2.0.3 + '@vitest/snapshot@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 @@ -10674,12 +11127,20 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@2.1.9': dependencies: tinyspy: 3.0.2 '@vitest/spy@4.0.12': {} + '@vitest/spy@4.0.16': {} + '@vitest/ui@4.0.12(vitest@4.0.12)': dependencies: '@vitest/utils': 4.0.12 @@ -10689,7 +11150,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vitest: 4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) '@vitest/utils@2.1.9': dependencies: @@ -10702,6 +11163,11 @@ snapshots: '@vitest/pretty-format': 4.0.12 tinyrainbow: 3.0.3 + '@vitest/utils@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + tinyrainbow: 3.0.3 + '@vscode/sudo-prompt@9.3.1': {} '@webassemblyjs/ast@1.14.1': @@ -10990,6 +11456,8 @@ snapshots: before-after-hook@2.2.3: {} + before-after-hook@4.0.0: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -11313,9 +11781,8 @@ snapshots: cookie@0.7.2: {} - copy-anything@4.0.5: - dependencies: - is-what: 5.5.0 + cookie@1.1.1: + optional: true core-js@3.47.0: {} @@ -11787,6 +12254,8 @@ snapshots: transitivePeerDependencies: - supports-color + fast-content-type-parse@3.0.0: {} + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -12115,6 +12584,13 @@ snapshots: graceful-fs@4.2.11: {} + graphql-tag@2.12.6(graphql@16.12.0): + dependencies: + graphql: 16.12.0 + tslib: 2.8.1 + + graphql@16.12.0: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -12156,6 +12632,9 @@ snapshots: dependencies: '@types/hast': 3.0.4 + headers-polyfill@4.0.3: + optional: true + hosted-git-info@2.8.9: {} html-encoding-sniffer@4.0.0: @@ -12328,6 +12807,9 @@ snapshots: xtend: 4.0.2 optional: true + is-node-process@1.2.0: + optional: true + is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -12349,8 +12831,6 @@ snapshots: is-unicode-supported@0.1.0: {} - is-what@5.5.0: {} - is-windows@1.0.2: {} isbinaryfile@4.0.10: {} @@ -12477,10 +12957,10 @@ snapshots: dependencies: json-buffer: 3.0.1 - knip@5.70.1(@types/node@22.19.1)(typescript@5.9.3): + knip@5.70.1(@types/node@25.0.3)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 - '@types/node': 22.19.1 + '@types/node': 25.0.3 fast-glob: 3.3.3 formatly: 0.3.0 jiti: 2.6.1 @@ -13160,6 +13640,58 @@ snapshots: ms@2.1.3: {} + msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@20.19.25) + '@mswjs/interceptors': 0.40.0 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.7.0 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.2.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + + msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@25.0.3) + '@mswjs/interceptors': 0.40.0 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.7.0 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.2.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + murmur-32@0.2.0: dependencies: encode-utf8: 1.0.3 @@ -13169,6 +13701,9 @@ snapshots: mute-stream@1.0.0: {} + mute-stream@2.0.0: + optional: true + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -13273,6 +13808,8 @@ snapshots: object-keys@1.1.1: optional: true + obug@2.1.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -13317,6 +13854,9 @@ snapshots: outdent@0.5.0: {} + outvariant@1.4.3: + optional: true + oxc-resolver@11.13.2: optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.13.2 @@ -13451,6 +13991,9 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@6.3.0: + optional: true + path-to-regexp@8.3.0: {} path-type@2.0.0: @@ -14009,6 +14552,9 @@ snapshots: retry@0.12.0: {} + rettime@0.7.0: + optional: true + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -14285,6 +14831,9 @@ snapshots: stream-buffers@2.2.0: optional: true + strict-event-emitter@0.5.1: + optional: true + string-argv@0.3.2: {} string-width@4.2.3: @@ -14370,10 +14919,6 @@ snapshots: transitivePeerDependencies: - supports-color - superjson@2.2.6: - dependencies: - copy-anything: 4.0.5 - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -14471,6 +15016,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -14490,10 +15037,18 @@ snapshots: tldts-core@6.1.86: {} + tldts-core@7.0.19: + optional: true + tldts@6.1.86: dependencies: tldts-core: 6.1.86 + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + optional: true + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -14518,6 +15073,11 @@ snapshots: dependencies: tldts: 6.1.86 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + optional: true + tr46@0.0.3: {} tr46@5.1.1: @@ -14658,6 +15218,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.16.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -14709,6 +15271,8 @@ snapshots: universal-user-agent@6.0.1: {} + universal-user-agent@7.0.3: {} + universalify@0.1.2: {} universalify@2.0.1: {} @@ -14718,6 +15282,9 @@ snapshots: unpipe@1.0.0: {} + until-async@3.0.2: + optional: true + update-browserslist-db@1.1.4(browserslist@4.28.0): dependencies: browserslist: 4.28.0 @@ -14839,6 +15406,22 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 + vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.3 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.44.1 + tsx: 4.20.6 + yaml: 2.8.1 + vite@7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.12 @@ -14855,10 +15438,10 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(terser@5.44.1): + vitest@2.1.9(@types/node@20.19.25)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1)) + '@vitest/mocker': 2.1.9(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(vite@5.4.21(@types/node@20.19.25)(terser@5.44.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -14891,10 +15474,10 @@ snapshots: - supports-color - terser - vitest@4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + vitest@4.0.12(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@4.0.12)(jiti@1.21.7)(jsdom@26.1.0)(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.12 - '@vitest/mocker': 4.0.12(vite@7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.12(msw@2.12.4(@types/node@20.19.25)(typescript@5.9.3))(vite@7.2.4(@types/node@20.19.25)(jiti@1.21.7)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.12 '@vitest/runner': 4.0.12 '@vitest/snapshot': 4.0.12 @@ -14933,6 +15516,45 @@ snapshots: - tsx - yaml + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + '@vitest/expect': 4.0.16 + '@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3))(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/pretty-format': 4.0.16 + '@vitest/runner': 4.0.16 + '@vitest/snapshot': 4.0.16 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.4.1(@types/node@25.0.3)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.0.3 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vscode-icons-js@11.6.1: dependencies: '@types/jasmine': 3.10.18