diff --git a/.github/workflows/agent-release.yml b/.github/workflows/agent-release.yml index 8540ab99..333ceedb 100644 --- a/.github/workflows/agent-release.yml +++ b/.github/workflows/agent-release.yml @@ -56,6 +56,12 @@ jobs: cache: "pnpm" registry-url: https://registry.npmjs.org + - name: Install pnpm + run: npm install -g pnpm + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/array-stack-check.yml b/.github/workflows/array-stack-check.yml new file mode 100644 index 00000000..a658e7b0 --- /dev/null +++ b/.github/workflows/array-stack-check.yml @@ -0,0 +1,134 @@ +# 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(`Re-checking PR #${dependentPR.number}`); + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: dependentPR.number, + base: 'main' + }); + } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 34fce9f6..f65cd3b9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,10 +2,9 @@ name: Build on: pull_request: - -concurrency: - group: build-${{ github.head_ref || github.ref }} - cancel-in-progress: true + push: + branches: + - main jobs: build: @@ -17,24 +16,20 @@ jobs: uses: actions/checkout@v6 with: persist-credentials: false - - name: Setup pnpm uses: pnpm/action-setup@v4 - - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 - cache: "pnpm" - + cache: 'pnpm' + - name: Setup Bun + uses: oven-sh/setup-bun@v2 - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build electron-trpc run: pnpm --filter @posthog/electron-trpc build - - name: Build agent run: pnpm --filter agent build - - name: Build array run: pnpm --filter array build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..52c88050 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,150 @@ +name: Publish Release + +on: + push: + branches: + - main + paths: + - "apps/array/**" + - "packages/agent/**" + - "packages/electron-trpc/**" + - "pnpm-lock.yaml" + - "package.json" + - "turbo.json" + - ".github/workflows/release.yml" + +concurrency: + group: release + cancel-in-progress: true + +jobs: + publish: + runs-on: macos-latest + env: + NODE_ENV: production + APPLE_CODESIGN_IDENTITY: ${{ secrets.APPLE_CODESIGN_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_CODESIGN_CERT_BASE64: ${{ secrets.APPLE_CODESIGN_CERT_BASE64 }} + APPLE_CODESIGN_CERT_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} + APPLE_CODESIGN_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_CODESIGN_KEYCHAIN_PASSWORD }} + steps: + - name: Get app token + id: app-token + uses: getsentry/action-github-app-token@d4b5da6c5e37703f8c3b3e43abb5705b46e159cc # v3 + with: + app_id: ${{ secrets.GH_APP_ARRAY_RELEASER_APP_ID }} + private_key: ${{ secrets.GH_APP_ARRAY_RELEASER_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Compute version from git tags + id: version + run: | + # Find the latest minor version tag (vX.Y format - exactly 2 parts) + # These are manually created to mark new minor releases + # Release tags (vX.Y.Z) are ignored for base version calculation + LATEST_TAG=$(git tag --list 'v[0-9]*.[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+$' | head -1) + + # Fall back to vX.Y.0 format if no vX.Y tags exist (backward compat) + if [ -z "$LATEST_TAG" ]; then + LATEST_TAG=$(git tag --list 'v[0-9]*.[0-9]*.0' --sort=-v:refname | head -1) + fi + + if [ -z "$LATEST_TAG" ]; then + echo "No version tag found. Create one with: git tag v0.15 && git push origin v0.15" + exit 1 + fi + + # Extract major.minor from tag + VERSION_PART=${LATEST_TAG#v} + MAJOR=$(echo "$VERSION_PART" | cut -d. -f1) + MINOR=$(echo "$VERSION_PART" | cut -d. -f2) + + # Count commits since the base tag + PATCH=$(git rev-list "$LATEST_TAG"..HEAD --count) + + if [ "$PATCH" -eq 0 ]; then + echo "No commits since $LATEST_TAG. Nothing to release." + exit 1 + fi + + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + echo "Version: $NEW_VERSION (from base tag $LATEST_TAG + $PATCH commits)" + + echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "base_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" + + - name: Set version in package.json + env: + APP_VERSION: ${{ steps.version.outputs.version }} + run: | + # Update package.json for the build (not committed) + jq --arg v "$APP_VERSION" '.version = $v' apps/array/package.json > tmp.json && mv tmp.json apps/array/package.json + echo "Set apps/array/package.json version to $APP_VERSION" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build electron-trpc package + run: pnpm --filter @posthog/electron-trpc run build + + - name: Build agent package + run: pnpm --filter @posthog/agent run build + + - name: Import code signing certificate + if: env.APPLE_CODESIGN_IDENTITY != '' + env: + CERT_BASE64: ${{ env.APPLE_CODESIGN_CERT_BASE64 }} + CERT_PASSWORD: ${{ env.APPLE_CODESIGN_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ env.APPLE_CODESIGN_KEYCHAIN_PASSWORD }} + run: | + if [ -z "$CERT_BASE64" ] || [ -z "$CERT_PASSWORD" ] || [ -z "$KEYCHAIN_PASSWORD" ]; then + echo "Missing code signing certificate secrets" + exit 1 + fi + KEYCHAIN="$RUNNER_TEMP/codesign.keychain-db" + echo "$CERT_BASE64" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$RUNNER_TEMP/certificate.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + rm "$RUNNER_TEMP/certificate.p12" + + - name: Create tag + env: + APP_VERSION: ${{ steps.version.outputs.version }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPOSITORY: ${{ github.repository }} + run: | + TAG="v$APP_VERSION" + git tag -a "$TAG" -m "Release $TAG" + git push "https://x-access-token:${GH_TOKEN}@github.com/$REPOSITORY" "$TAG" + + - name: Build native modules + run: pnpm --filter array run build-native + + - name: Publish with Electron Forge + env: + APP_VERSION: ${{ steps.version.outputs.version }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: pnpm --filter array run publish diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 8c7426a5..73fb6069 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -3,10 +3,6 @@ name: Typecheck on: pull_request: -concurrency: - group: typecheck-${{ github.head_ref || github.ref }} - cancel-in-progress: true - jobs: typecheck: runs-on: ubuntu-latest @@ -17,18 +13,16 @@ jobs: uses: actions/checkout@v6 with: persist-credentials: false - - name: Setup pnpm uses: pnpm/action-setup@v4 - - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: "pnpm" - + - name: Setup Bun + uses: oven-sh/setup-bun@v2 - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run type check run: pnpm run typecheck 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..d308e28e --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,117 @@ +> [!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. + +## Features + +- Stacked PRs synced to GitHub +- Simpler interface compared to `jj` for managing your work. +- Visual stack log with PR status. +- Comes with a GitHub Action to enforce merge order +- Unknown commands pass through to `jj` + +## Why + +Stacked PRs keep reviews small and manageable. Managing them with `git` is painful, this involves rebasing, force-pushing, updating PR bases and describing the PR stack via comments. + +`arr` makes it easy to create, manage and submit (stacked) PRs by using `jj` under the hood. + +## 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 PR via GitHub +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. PRs are stacked so reviewers see the dependency. + +## CI + +``` +arr ci +``` + +Adds a GitHub Action that blocks merging a PR if its parent PR hasn't merged yet, which helps keep your stack in order. + +## 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. + +**How is `arr` related to Array?** + +`arr` is the CLI component of Array, an agentic development environment. + +## 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` 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..315bb285 --- /dev/null +++ b/apps/cli/src/cli.ts @@ -0,0 +1,212 @@ +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): string { + const full = c.args ? `${c.name} ${c.args}` : c.name; + const aliasStr = + showAliases && c.aliases?.length + ? ` ${dim(`[aliases: ${c.aliases.join(", ")}]`)}` + : ""; + return ` ${cyan(full.padEnd(CMD_WIDTH))}${c.description}.${aliasStr}`; +} + +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)).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 prereqs = await checkContext(); + if (!isContextValid(prereqs, requiredLevel)) { + printContextError(prereqs, requiredLevel); + process.exit(1); + } + + // Initialize context with engine + let context: ArrContext | null = null; + try { + context = await initContext(); + + // Trigger background PR refresh (rate-limited) + triggerBackgroundRefresh(context.cwd); + + await handler(parsed, context); + } 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..bf794233 --- /dev/null +++ b/apps/cli/src/commands/bottom.ts @@ -0,0 +1,7 @@ +import { bottom as coreBottom } from "@array/core/commands/bottom"; +import { printNav } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function bottom(): Promise { + printNav("down", 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..95ccdf7f --- /dev/null +++ b/apps/cli/src/commands/checkout.ts @@ -0,0 +1,23 @@ +import { checkout as checkoutCmd } from "@array/core/commands/checkout"; +import { changeLabel } from "@array/core/slugify"; +import { cyan, dim, formatSuccess, message } from "../utils/output"; +import { requireArg, unwrap } from "../utils/run"; + +export async function checkout(id: string): Promise { + requireArg(id, "Usage: arr checkout "); + + const result = unwrap(await checkoutCmd(id)); + + // Handle trunk checkout - creates new empty change on main + if (id === "main" || id === "master" || id === "trunk") { + message(formatSuccess(`Switched to ${cyan(id)}`)); + return; + } + + const label = changeLabel(result.change.description, result.change.changeId); + message( + formatSuccess( + `Switched to ${cyan(label)}: ${result.change.description || dim("(no description)")}`, + ), + ); +} diff --git a/apps/cli/src/commands/ci.ts b/apps/cli/src/commands/ci.ts new file mode 100644 index 00000000..773eabee --- /dev/null +++ b/apps/cli/src/commands/ci.ts @@ -0,0 +1,133 @@ +import { + checkRulesetExists, + enableStackCheckProtection, + getBranchProtectionUrl, + getRepoInfoFromRemote, + 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); + 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 succeeded = await tryEnableProtection(cwd, repoInfo, rulesetExists); + if (succeeded) return; + } + + // 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"); +} + +async function getRepoInfo( + cwd: string, +): Promise<{ owner: string; repo: string } | null> { + const remoteResult = await shellExecutor.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; +} + +async function tryEnableProtection( + cwd: string, + repoInfo: { owner: string; repo: string }, + isUpdate: boolean, +): Promise { + const trunk = await getTrunk(cwd); + + status(isUpdate ? "Updating ruleset..." : `Creating ruleset for ${trunk}...`); + + const result = await enableStackCheckProtection( + { owner: repoInfo.owner, repo: repoInfo.repo, trunk }, + shellExecutor, + cwd, + ); + + const rulesetsUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/rules`; + + if (result.success) { + if (result.updated) { + message(formatSuccess("Updated ruleset 'Array Stack Check'")); + } else if (result.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 true; + } + + message(formatError(result.error ?? "Failed to create ruleset")); + return false; +} 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..1daa4ade --- /dev/null +++ b/apps/cli/src/commands/create.ts @@ -0,0 +1,34 @@ +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)}`)); + indent( + `${dim("Run")} ${arr(COMMANDS.submit)} ${dim("to create a PR, or keep editing")}`, + ); + + 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..3f9f9dc2 --- /dev/null +++ b/apps/cli/src/commands/down.ts @@ -0,0 +1,28 @@ +import { down as coreDown } from "@array/core/commands/down"; +import { COMMANDS } from "../registry"; +import { + arr, + cyan, + dim, + formatChangeId, + green, + hint, + message, +} from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function down(): Promise { + const result = unwrap(await coreDown()); + + if (result.createdOnTrunk) { + message(`${green("◉")} Started fresh on ${cyan("main")}`); + hint(`Run ${arr(COMMANDS.top)} to go back to your stack`); + } else { + const shortId = formatChangeId( + result.changeId.slice(0, 8), + result.changeIdPrefix, + ); + const desc = result.description || dim("(empty)"); + message(`↓ ${green(desc)} ${shortId}`); + } +} 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/hidden/dump-refs.ts b/apps/cli/src/commands/hidden/dump-refs.ts new file mode 100644 index 00000000..0cabde5d --- /dev/null +++ b/apps/cli/src/commands/hidden/dump-refs.ts @@ -0,0 +1,31 @@ +import { $ } from "bun"; + +/** + * 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 result = + await $`git for-each-ref refs/arr --format='%(refname:short)'`.quiet(); + const refs = result.stdout.toString().trim().split("\n").filter(Boolean); + + if (refs.length === 0) { + console.log("No arr refs found."); + return; + } + + for (const ref of refs) { + console.log(`=== ${ref} ===`); + const blob = await $`git cat-file blob refs/${ref}`.quiet(); + const content = blob.stdout.toString().trim(); + try { + console.log(JSON.stringify(JSON.parse(content), null, 2)); + } catch { + console.log(content); + } + 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..2de3c24e --- /dev/null +++ b/apps/cli/src/commands/log.ts @@ -0,0 +1,260 @@ +import { log as logCmd } from "@array/core/commands/log"; +import type { ArrContext } from "@array/core/engine"; +import type { LogGraphData, PRInfo } from "@array/core/log-graph"; +import { COMMANDS } from "../registry"; +import { + arr, + blank, + cyan, + dim, + formatChangeId, + formatCommitId, + green, + hint, + magenta, + message, + red, + yellow, +} from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function log(ctx: ArrContext): Promise { + const result = unwrap( + await logCmd({ + engine: ctx.engine, + trunk: ctx.trunk, + cwd: ctx.cwd, + }), + ); + + const { data, unmanagedBranch } = result; + + // isEmpty means: on trunk with empty working copy and no tracked branches + // In this case, show a simplified view + if (data.isEmpty) { + message(`${green("◉")} ${ctx.trunk} ${dim("(current)")}`); + hint(`Run ${cyan("arr create")} to start a new stack`); + return; + } + + const output = renderLogGraph(data, ctx.trunk, unmanagedBranch === null); + message(output); + + if (data.modifiedCount > 0) { + const changeWord = data.modifiedCount === 1 ? "change has" : "changes have"; + message( + `${data.modifiedCount} ${changeWord} unpushed commits. Run ${arr(COMMANDS.submit)} to update PRs.`, + ); + } + + if (unmanagedBranch !== null) { + blank(); + message(yellow(`⚠ You're on git branch '${unmanagedBranch}'.`)); + blank(); + hint( + `To use arr, run ${arr(COMMANDS.checkout, ctx.trunk)} or ${arr(COMMANDS.checkout, "")}.`, + ); + hint("To continue with git, use git commands."); + } +} + +function renderLogGraph( + data: LogGraphData, + trunk: string, + inSync: boolean, +): string { + const output = data.rawOutput; + + // Process each line to handle placeholders + const lines = output.split("\n"); + const processedLines: string[] = []; + + for (const line of lines) { + let processed = line; + + // {{LABEL:changeId|prefix|timestamp|description|conflict|wc|empty|immutable|localBookmarks|remoteBookmarks}} + // Note: jj outputs the graph marker (@, ○, ◆), we just output the label content + processed = processed.replace(/\{\{LABEL:([^}]+)\}\}/g, (_, content) => { + const parts = content.split("|"); + const [ + changeId, + prefix, + timestamp, + description, + conflict, + _wc, + empty, + _immutable, + localBookmarks, + _remoteBookmarks, + ] = parts; + + const hasConflicts = conflict === "1"; + const isEmpty = empty === "1"; + const bookmarks = localBookmarks + ? localBookmarks.split(",").filter(Boolean) + : []; + + // Check if modified + const isModified = bookmarks.some((b: string) => + data.modifiedBookmarks.has(b), + ); + + // Format change ID with colors + const shortId = formatChangeId(changeId, prefix); + + // Build label + let label = + description || (isEmpty ? dim("(empty)") : dim("(no description)")); + + // Add date prefix for older changes + const date = new Date(Number(timestamp) * 1000); + const now = new Date(); + const diffDays = Math.floor( + (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24), + ); + if (diffDays >= 1 && description) { + const month = date.toLocaleString("en-US", { month: "short" }); + const day = date.getDate(); + label = `[${month} ${day}] ${description}`; + } + + // Build badges + const badges: string[] = []; + if (isModified) badges.push(yellow("unpushed")); + if (hasConflicts) badges.push(yellow("conflicts")); + const badgeStr = + badges.length > 0 ? ` ${dim("(")}${badges.join(", ")}${dim(")")}` : ""; + + return `${label} ${shortId}${badgeStr}`; + }); + + // {{TIME:timestamp}} + processed = processed.replace(/\{\{TIME:([^}]+)\}\}/g, (_, timestamp) => { + const date = new Date(Number(timestamp) * 1000); + return dim(formatRelativeTime(date)); + }); + + // {{HINT_EMPTY}} - only show if in sync + processed = processed.replace(/\{\{HINT_EMPTY\}\}/g, () => { + if (!inSync) return ""; + return `${dim("Run")} ${arr(COMMANDS.create)} ${dim('"message"')} ${dim("to save as a change")}`; + }); + + // {{HINT_UNCOMMITTED}} - only show if in sync + processed = processed.replace(/\{\{HINT_UNCOMMITTED\}\}/g, () => { + if (!inSync) return ""; + return `${dim("Run")} ${arr(COMMANDS.create)} ${dim('"message"')} ${dim("to save as a change")}`; + }); + + // {{HINT_SUBMIT}} - only show if in sync + processed = processed.replace(/\{\{HINT_SUBMIT\}\}/g, () => { + if (!inSync) return ""; + return `${dim("Run")} ${arr(COMMANDS.submit)} ${dim("to create a PR")}`; + }); + + // {{PR:bookmarks|description}} + processed = processed.replace( + /\{\{PR:([^|]+)\|([^}]*)\}\}/g, + (_, bookmarksStr, description) => { + const bookmarks = bookmarksStr.split(",").filter(Boolean); + const bookmark = bookmarks[0]; + if (!bookmark) return ""; + + const prInfo = data.prInfoMap.get(bookmark); + if (!prInfo) { + return dim("Not submitted"); + } + + return formatPRLine(prInfo, description); + }, + ); + + // {{PRURL:bookmarks}} + processed = processed.replace( + /\{\{PRURL:([^}]+)\}\}/g, + (_, bookmarksStr) => { + const bookmarks = bookmarksStr.split(",").filter(Boolean); + const bookmark = bookmarks[0]; + if (!bookmark) return ""; + + const prInfo = data.prInfoMap.get(bookmark); + if (!prInfo) return ""; + + return cyan(prInfo.url); + }, + ); + + // {{COMMIT:commitId|prefix|description}} + processed = processed.replace( + /\{\{COMMIT:([^|]+)\|([^|]+)\|([^}]*)\}\}/g, + (_, commitId, prefix, description) => { + const shortCommitId = formatCommitId(commitId, prefix); + return `${shortCommitId} ${dim(`- ${description || "(no description)"}`)}`; + }, + ); + + // {{TRUNK:bookmark}} - trunk label (prefer actual trunk name if present) + processed = processed.replace( + /\{\{TRUNK:([^}]*)\}\}/g, + (_, bookmarksStr) => { + const bookmarks = bookmarksStr.split(",").filter(Boolean); + // Prefer the actual trunk name if this commit has multiple bookmarks + if (bookmarks.includes(trunk)) { + return trunk; + } + return bookmarks[0] || "trunk"; + }, + ); + + processedLines.push(processed); + } + + let result = processedLines.join("\n"); + + // Replace jj's graph markers with styled versions + // @ = working copy (green ◉, or ◯ if out of sync) + // ○ = mutable commit (◯) + // ◆ = immutable commit (◯ - same as mutable, we don't distinguish) + // × = conflict (red ×) + // ~ = elided (│) + result = result.replace(/^(@)(\s+)/gm, inSync ? `${green("◉")}$2` : "◯$2"); + result = result.replace(/^(○)(\s+)/gm, "◯$2"); + result = result.replace(/^(◆)(\s+)/gm, "◯$2"); + result = result.replace(/^(×)(\s+)/gm, `${red("×")}$2`); + result = result.replace(/^(~)(\s+)/gm, "│$2"); + + // Remove trailing newlines + result = result.trimEnd(); + + return result; +} + +function formatPRLine(prInfo: PRInfo, description: string): string { + const stateColor = + prInfo.state === "merged" ? magenta : prInfo.state === "open" ? green : red; + const stateLabel = + prInfo.state.charAt(0).toUpperCase() + prInfo.state.slice(1); + return `${stateColor(`PR #${prInfo.number}`)} ${dim(`(${stateLabel})`)} ${description}`; +} + +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/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/restack.ts b/apps/cli/src/commands/restack.ts new file mode 100644 index 00000000..36e54227 --- /dev/null +++ b/apps/cli/src/commands/restack.ts @@ -0,0 +1,28 @@ +import { restack as coreRestack } from "@array/core/commands/restack"; +import { formatSuccess, message, status } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function restack(): Promise { + status("Restacking all changes onto trunk..."); + + const result = unwrap(await coreRestack()); + + if (result.restacked === 0) { + message("All stacks already up to date with trunk"); + return; + } + + message( + formatSuccess( + `Restacked ${result.restacked} stack${result.restacked === 1 ? "" : "s"} onto trunk`, + ), + ); + + if (result.pushed.length > 0) { + message( + formatSuccess( + `Pushed ${result.pushed.length} bookmark${result.pushed.length === 1 ? "" : "s"}`, + ), + ); + } +} diff --git a/apps/cli/src/commands/squash.ts b/apps/cli/src/commands/squash.ts new file mode 100644 index 00000000..685d6474 --- /dev/null +++ b/apps/cli/src/commands/squash.ts @@ -0,0 +1,19 @@ +import { squash as squashCmd } from "@array/core/commands/squash"; +import type { ArrContext } from "@array/core/engine"; +import { changeLabel } from "@array/core/slugify"; +import { cyan, formatSuccess, hint, message, yellow } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function squash( + id: string | undefined, + ctx: ArrContext, +): Promise { + const result = unwrap(await squashCmd({ id, engine: ctx.engine })); + + const label = changeLabel(result.change.description, result.change.changeId); + message(formatSuccess(`Squashed ${cyan(label)} into parent`)); + + if (result.movedTo) { + hint(`Now on: ${yellow(result.movedTo)}`); + } +} diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts new file mode 100644 index 00000000..039989b6 --- /dev/null +++ b/apps/cli/src/commands/status.ts @@ -0,0 +1,104 @@ +import { status as statusCmd } from "@array/core/commands/status"; +import { COMMANDS } from "../registry"; +import { + arr, + blank, + cmd, + cyan, + dim, + formatChangeId, + formatDiffStats, + green, + hint, + indent, + message, + red, + warning, + yellow, +} from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function status(): Promise { + const { info, stats } = unwrap(await statusCmd()); + 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}`); + message(dim(` ↳ ${info.stackPath.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}`); + } + } + + // Guidance + blank(); + const { action, reason } = info.nextAction; + + if (isOnMainFresh && !info.hasChanges) { + message( + `Edit files, then run ${arr(COMMANDS.create)} to start a new stack`, + ); + hint(`Or run ${arr(COMMANDS.top)} to return to your previous stack`); + } else { + switch (action) { + case "continue": + message(`Fix conflicts, then run ${cmd("jj squash")}`); + break; + case "create": + if (reason === "unsaved") { + message(`Run ${arr(COMMANDS.create)} to save as a new change`); + } else { + message(`Edit files, then run ${arr(COMMANDS.create)}`); + } + break; + case "submit": + message( + `Run ${arr(COMMANDS.submit)} to ${reason === "update_pr" ? "update PR" : "create PR"}`, + ); + break; + case "up": + message(`Run ${arr(COMMANDS.up)} to start a new change`); + break; + } + } +} diff --git a/apps/cli/src/commands/submit.ts b/apps/cli/src/commands/submit.ts new file mode 100644 index 00000000..38f3ae34 --- /dev/null +++ b/apps/cli/src/commands/submit.ts @@ -0,0 +1,65 @@ +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 { 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); + } + + status("Submitting stack as linked PRs..."); + blank(); + + const result = unwrap( + await submitCmd({ + draft: Boolean(flags.draft), + engine: ctx.engine, + }), + ); + + // Only show PRs that were created or pushed (not synced) + const changedPrs = result.prs.filter((pr) => pr.status !== "synced"); + + for (const pr of changedPrs) { + const label = pr.status === "created" ? green("Created") : yellow("Pushed"); + message(`${label} PR #${pr.prNumber}: ${cyan(pr.bookmarkName)}`); + indent(cyan(pr.prUrl)); + } + + // Summary + const parts: string[] = []; + if (result.created > 0) parts.push(`${green("Created:")} ${result.created}`); + if (result.pushed > 0) parts.push(`${yellow("Pushed:")} ${result.pushed}`); + if (result.synced > 0) parts.push(`${dim(`(${result.synced} unchanged)`)}`); + + if (parts.length > 0) { + blank(); + message(parts.join(" ")); + } +} diff --git a/apps/cli/src/commands/sync.ts b/apps/cli/src/commands/sync.ts new file mode 100644 index 00000000..a10c26d7 --- /dev/null +++ b/apps/cli/src/commands/sync.ts @@ -0,0 +1,129 @@ +import { restack as coreRestack } from "@array/core/commands/restack"; +import { sync as coreSync } from "@array/core/commands/sync"; +import type { ArrContext, Engine } from "@array/core/engine"; +import { cleanupMergedChange, type MergedChange } from "@array/core/stacks"; +import { 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 for each merged PR and cleanup if confirmed. + * Returns number of PRs cleaned up. + */ +async function promptAndCleanupMerged( + pending: MergedChange[], + engine: Engine, +): Promise { + if (pending.length === 0) return 0; + + let cleanedUp = 0; + + for (const change of pending) { + const prLabel = magenta(`PR #${change.prNumber}`); + const branchLabel = dim(`(${change.bookmark})`); + const desc = change.description || "(no description)"; + + const confirmed = await confirm( + `Delete merged branch ${prLabel} ${branchLabel}: ${desc}?`, + { default: true }, + ); + + if (confirmed) { + const result = await cleanupMergedChange(change, 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.merged.length > 0 || + result.empty.length > 0 || + result.hasConflicts; + + if (!hadChanges && result.pendingCleanup.length === 0) { + message(formatSuccess("Already up to date")); + } else { + if (result.fetched && !result.hasConflicts && result.merged.length === 0) { + message(formatSuccess("Synced with remote")); + } + if (result.hasConflicts) { + warning("Rebase resulted in conflicts"); + hint(`Resolve conflicts and run ${arr(COMMANDS.sync)} again`); + } + + if (result.merged.length > 0) { + message( + formatSuccess(`Cleaned up ${result.merged.length} merged change(s)`), + ); + } + + if (result.empty.length > 0) { + hint(`Removed ${result.empty.length} empty change(s)`); + } + } + + // Prompt for each merged PR cleanup + const cleanedUp = await promptAndCleanupMerged( + result.pendingCleanup, + ctx.engine, + ); + + if (cleanedUp > 0) { + message(formatSuccess(`Cleaned up ${cleanedUp} merged PR(s)`)); + } + + 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 restackResult = unwrap(await coreRestack()); + + 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..c72ffaab --- /dev/null +++ b/apps/cli/src/commands/top.ts @@ -0,0 +1,7 @@ +import { top as coreTop } from "@array/core/commands/top"; +import { printNav } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function top(): Promise { + printNav("up", 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..f787b8e1 --- /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( + bookmark: string | undefined, + ctx: ArrContext, +): Promise { + const result = unwrap( + await trackCmd({ + engine: ctx.engine, + bookmark, + }), + ); + + 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..9f5eea97 --- /dev/null +++ b/apps/cli/src/commands/trunk.ts @@ -0,0 +1,10 @@ +import { trunk as coreTrunk } from "@array/core/commands/trunk"; +import { COMMANDS } from "../registry"; +import { arr, cyan, green, hint, message } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function trunk(): Promise { + unwrap(await coreTrunk()); + message(`${green("◉")} Started fresh on ${cyan("main")}`); + hint(`Run ${arr(COMMANDS.top)} to go back to your stack`); +} 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/up.ts b/apps/cli/src/commands/up.ts new file mode 100644 index 00000000..7ff1c4a6 --- /dev/null +++ b/apps/cli/src/commands/up.ts @@ -0,0 +1,7 @@ +import { up as coreUp } from "@array/core/commands/up"; +import { printNav } from "../utils/output"; +import { unwrap } from "../utils/run"; + +export async function up(): Promise { + printNav("up", unwrap(await coreUp())); +} diff --git a/apps/cli/src/registry.ts b/apps/cli/src/registry.ts new file mode 100644 index 00000000..c5a1f477 --- /dev/null +++ b/apps/cli/src/registry.ts @@ -0,0 +1,168 @@ +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 { logCommand } from "@array/core/commands/log"; +import { mergeCommand } from "@array/core/commands/merge"; +import { restackCommand } from "@array/core/commands/restack"; +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 { 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 { init, meta as initMeta } from "./commands/init"; +import { log } from "./commands/log"; +import { merge } from "./commands/merge"; +import { restack } from "./commands/restack"; +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 { 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", +}; + +export const COMMANDS = { + auth: authMeta, + init: initMeta, + create: createCommand.meta, + submit: submitCommand.meta, + sync: syncCommand.meta, + restack: restackCommand.meta, + track: trackCommand.meta, + bottom: bottomCommand.meta, + checkout: checkoutCommand.meta, + down: downCommand.meta, + top: topCommand.meta, + trunk: trunkCommand.meta, + up: upCommand.meta, + log: logCommand.meta, + status: statusCommand.meta, + delete: deleteCommand.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: () => status(), + create: (p, ctx) => create(p.args.join(" "), ctx!), + submit: (p, ctx) => submit(p.flags, ctx!), + track: (p, ctx) => track(p.args[0], ctx!), + up: () => up(), + down: () => down(), + top: () => top(), + trunk: () => trunk(), + bottom: () => bottom(), + log: (_p, ctx) => log(ctx!), + sync: (_p, ctx) => sync(ctx!), + restack: () => restack(), + checkout: (p) => checkout(p.args[0]), + delete: (p, ctx) => + deleteChange(p.args[0], ctx!, { yes: !!p.flags.yes || !!p.flags.y }), + squash: (p, ctx) => squash(p.args[0], 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..5026c4a1 --- /dev/null +++ b/apps/cli/src/utils/args.ts @@ -0,0 +1,35 @@ +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) { + flags[arg.slice(1)] = 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..50ef63e3 --- /dev/null +++ b/apps/cli/src/utils/output.ts @@ -0,0 +1,205 @@ +import type { CommandMeta } from "@array/core/commands/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", +}; + +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 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 `${pink(prefix)}${dim(rest)}`; +} + +export function formatCommitId(fullId: string, prefix: string): string { + const rest = fullId.slice(prefix.length); + return `${colors.brightBlue}${prefix}${colors.reset}${dim(rest)}`; +} + +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}`; +} + +interface NavInfo { + changeId: string; + changeIdPrefix?: string; + description: string; +} + +export function printNav(direction: "up" | "down", nav: NavInfo): void { + const arrow = direction === "up" ? "↑" : "↓"; + const shortId = nav.changeIdPrefix + ? formatChangeId(nav.changeId.slice(0, 8), nav.changeIdPrefix) + : dim(nav.changeId.slice(0, 8)); + const desc = nav.description || dim("(empty)"); + console.log(`${arrow} ${green(desc)} ${shortId}`); +} + +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..1817cc93 --- /dev/null +++ b/apps/cli/src/utils/prompt.ts @@ -0,0 +1,126 @@ +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 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 index ced718f7..5a0fb744 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,6 +5,12 @@ "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": { 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 index df9ad525..d737d6e7 100644 --- a/packages/core/src/bookmark-utils.ts +++ b/packages/core/src/bookmark-utils.ts @@ -1,4 +1,4 @@ -import { getPRForBranch, type PRStatus } from "./github"; +import { getPRForBranch, type PRStatus } from "./github/pr-status"; import { createError, err, ok, type Result } from "./result"; /** Maximum number of suffix attempts before giving up on conflict resolution */ diff --git a/packages/core/src/commands/bottom.ts b/packages/core/src/commands/bottom.ts index 6760d6a6..de26ba3d 100644 --- a/packages/core/src/commands/bottom.ts +++ b/packages/core/src/commands/bottom.ts @@ -1,6 +1,7 @@ -import { runJJ, status } from "../jj"; -import { createError, err, ok, type Result } from "../result"; +import { runJJ } from "../jj"; +import { createError, err, type Result } from "../result"; import type { NavigationResult } from "../types"; +import { getNavigationResult } from "./navigation"; import type { Command } from "./types"; /** @@ -20,16 +21,6 @@ export async function bottom(): Promise> { return getNavigationResult(); } -async function getNavigationResult(): Promise> { - const statusResult = await status(); - if (!statusResult.ok) return statusResult; - return ok({ - changeId: statusResult.value.workingCopy.changeId, - changeIdPrefix: statusResult.value.workingCopy.changeIdPrefix, - description: statusResult.value.workingCopy.description, - }); -} - export const bottomCommand: Command = { meta: { name: "bottom", diff --git a/packages/core/src/commands/checkout.ts b/packages/core/src/commands/checkout.ts index 2576b2a9..821ba100 100644 --- a/packages/core/src/commands/checkout.ts +++ b/packages/core/src/commands/checkout.ts @@ -1,15 +1,18 @@ -import { edit, jjNew, status } from "../jj"; -import { ok, type Result } from "../result"; +import { edit, findChange, jjNew, status } from "../jj"; +import type { Changeset } from "../parser"; +import { createError, err, ok, type Result } from "../result"; import type { Command } from "./types"; interface CheckoutResult { changeId: string; description: string; createdNew: boolean; + /** The change that was checked out (for CLI display) */ + change: Changeset; } /** - * Checkout a change by its ID or bookmark. + * 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( @@ -28,11 +31,28 @@ export async function checkout( changeId: statusResult.value.workingCopy.changeId, description: "", createdNew: true, + change: statusResult.value.workingCopy, }); } + // Resolve the change + const findResult = await findChange(target, { includeBookmarks: true }); + 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.`, + ), + ); + } + const change = findResult.value.change; + // Regular checkout - edit the change - const editResult = await edit(target); + const editResult = await edit(change.changeId); if (!editResult.ok) return editResult; const statusResult = await status(); @@ -42,6 +62,7 @@ export async function checkout( changeId: statusResult.value.workingCopy.changeId, description: statusResult.value.workingCopy.description, createdNew: false, + change: statusResult.value.workingCopy, }); } diff --git a/packages/core/src/commands/create.ts b/packages/core/src/commands/create.ts index 117b4302..5e42bf74 100644 --- a/packages/core/src/commands/create.ts +++ b/packages/core/src/commands/create.ts @@ -1,5 +1,8 @@ -import type { Result } from "../result"; -import { create as stacksCreate } from "../stacks"; +import { resolveBookmarkConflict } from "../bookmark-utils"; +import type { Engine } from "../engine"; +import { ensureBookmark, runJJ, status } from "../jj"; +import { ok, type Result } from "../result"; +import { datePrefixedLabel } from "../slugify"; import type { Command } from "./types"; interface CreateResult { @@ -7,15 +10,76 @@ interface CreateResult { 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(message: string): Promise> { - return stacksCreate(message); +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; + let createdChangeId: string; + + if (wc.description.trim() !== "") { + // Working copy already has a description - create new change with message + const newResult = await runJJ(["new", "-m", message]); + if (!newResult.ok) return newResult; + + const newStatus = await status(); + if (!newStatus.ok) return newStatus; + createdChangeId = newStatus.value.parents[0]?.changeId || wc.changeId; + + // Create empty working copy on top + const emptyResult = await runJJ(["new"]); + if (!emptyResult.ok) return emptyResult; + } else { + // Working copy is empty - describe it with message + const describeResult = await runJJ(["describe", "-m", message]); + if (!describeResult.ok) return describeResult; + + createdChangeId = wc.changeId; + + // Create empty working copy 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 + await engine.track(bookmarkName); + + return ok({ changeId: createdChangeId, bookmarkName }); } -export const createCommand: Command = { +export const createCommand: Command = { meta: { name: "create", args: "[message]", diff --git a/packages/core/src/commands/delete.ts b/packages/core/src/commands/delete.ts index 1325b9d0..599efbba 100644 --- a/packages/core/src/commands/delete.ts +++ b/packages/core/src/commands/delete.ts @@ -1,45 +1,66 @@ -import { abandon, edit, list, runJJ, status } from "../jj"; +import type { Engine } from "../engine"; +import { abandon, edit, findChange, list, runJJ, status } from "../jj"; +import type { Changeset } from "../parser"; import { createError, err, 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( - changeId: string, + options: DeleteOptions, ): Promise> { + const { id, engine } = options; + const statusBefore = await status(); if (!statusBefore.ok) return statusBefore; - const wasOnChange = - statusBefore.value.workingCopy.changeId === changeId || - statusBefore.value.workingCopy.changeId.startsWith(changeId); - - const changeResult = await list({ revset: changeId, limit: 1 }); - if (!changeResult.ok) return changeResult; - if (changeResult.value.length === 0) { + // Resolve the change + const findResult = await findChange(id, { includeBookmarks: true }); + if (!findResult.ok) return findResult; + if (findResult.value.status === "none") { + return err(createError("INVALID_REVISION", `Change not found: ${id}`)); + } + if (findResult.value.status === "multiple") { return err( - createError("INVALID_REVISION", `Change not found: ${changeId}`), + createError( + "AMBIGUOUS_REVISION", + `Multiple changes match "${id}". Use a more specific identifier.`, + ), ); } + const change = findResult.value.change; - const change = changeResult.value[0]; + const wasOnChange = + statusBefore.value.workingCopy.changeId === change.changeId; const parentId = change.parents[0]; - const childrenResult = await list({ revset: `children(${changeId})` }); + const childrenResult = await list({ + revset: `children(${change.changeId})`, + }); const hasChildren = childrenResult.ok && childrenResult.value.length > 0; if (hasChildren) { const rebaseResult = await runJJ([ "rebase", "-s", - `children(${changeId})`, + `children(${change.changeId})`, "-d", parentId || "trunk()", ]); @@ -57,6 +78,15 @@ export async function deleteChange( const abandonResult = await 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); @@ -65,10 +95,10 @@ export async function deleteChange( } } - return ok({ movedTo }); + return ok({ movedTo, untrackedBookmarks, change }); } -export const deleteCommand: Command = { +export const deleteCommand: Command = { meta: { name: "delete", args: "", diff --git a/packages/core/src/commands/down.ts b/packages/core/src/commands/down.ts index 515c032a..3bd30f32 100644 --- a/packages/core/src/commands/down.ts +++ b/packages/core/src/commands/down.ts @@ -1,6 +1,7 @@ -import { getTrunk, jjNew, runJJ, status } from "../jj"; -import { ok, type Result } from "../result"; +import { getTrunk, runJJ, status } from "../jj"; +import type { Result } from "../result"; import type { NavigationResult } from "../types"; +import { getNavigationResult, newOnTrunk } from "./navigation"; import type { Command } from "./types"; /** @@ -32,25 +33,6 @@ export async function down(): Promise> { return getNavigationResult(); } -async function newOnTrunk(): Promise> { - const trunk = await getTrunk(); - const newResult = await jjNew({ parents: [trunk] }); - if (!newResult.ok) return newResult; - const navResult = await getNavigationResult(); - if (!navResult.ok) return navResult; - return ok({ ...navResult.value, createdOnTrunk: true }); -} - -async function getNavigationResult(): Promise> { - const statusResult = await status(); - if (!statusResult.ok) return statusResult; - return ok({ - changeId: statusResult.value.workingCopy.changeId, - changeIdPrefix: statusResult.value.workingCopy.changeIdPrefix, - description: statusResult.value.workingCopy.description, - }); -} - export const downCommand: Command = { meta: { name: "down", diff --git a/packages/core/src/commands/log.ts b/packages/core/src/commands/log.ts index c234efaa..165703eb 100644 --- a/packages/core/src/commands/log.ts +++ b/packages/core/src/commands/log.ts @@ -1,16 +1,72 @@ -import { getLogGraphData, type LogGraphData } from "../log-graph"; -import type { Result } from "../result"; +import type { Engine } from "../engine"; +import { getCurrentGitBranch } from "../git/status"; +import { + type CachedPRInfo, + getLogGraphData, + type LogGraphData, +} from "../log-graph"; +import { ok, type Result } from "../result"; import type { Command } from "./types"; +export type { CachedPRInfo }; + +export interface LogOptions { + trunk?: string; + engine: Engine; + cwd?: string; +} + +export interface LogResult { + data: LogGraphData; + /** Git branch name if on an unmanaged branch, null otherwise */ + unmanagedBranch: string | null; +} + /** * Get log graph data for rendering the stack view. * Returns raw jj output with placeholders + PR info for the CLI to render. + * Only shows tracked bookmarks from the engine (plus working copy). */ -export async function log(): Promise> { - return getLogGraphData(); +export async function log(options: LogOptions): Promise> { + const { engine, trunk, cwd = process.cwd() } = options; + + const trackedBookmarks = engine.getTrackedBookmarks(); + + // Check if on an unmanaged git branch + const gitBranch = await getCurrentGitBranch(cwd); + const isOnUnmanagedBranch = + gitBranch !== null && gitBranch !== trunk && !engine.isTracked(gitBranch); + + // Build cache from engine + const cachedPRInfo = new Map(); + for (const bookmark of trackedBookmarks) { + const meta = engine.getMeta(bookmark); + if (meta?.prInfo) { + cachedPRInfo.set(bookmark, { + number: meta.prInfo.number, + state: meta.prInfo.state, + url: meta.prInfo.url, + }); + } + } + + const dataResult = await getLogGraphData({ + trunk, + trackedBookmarks, + cachedPRInfo: cachedPRInfo.size > 0 ? cachedPRInfo : undefined, + }); + + if (!dataResult.ok) { + return dataResult; + } + + return ok({ + data: dataResult.value, + unmanagedBranch: isOnUnmanagedBranch ? gitBranch : null, + }); } -export const logCommand: Command = { +export const logCommand: Command = { meta: { name: "log", description: "Show a visual overview of the current stack with PR status", diff --git a/packages/core/src/commands/merge.ts b/packages/core/src/commands/merge.ts index 42ba528a..991152c8 100644 --- a/packages/core/src/commands/merge.ts +++ b/packages/core/src/commands/merge.ts @@ -1,3 +1,4 @@ +import type { Engine } from "../engine"; import type { Result } from "../result"; import { getMergeStack, mergeStack } from "../stacks"; import type { MergeResult, PRToMerge } from "../types"; @@ -5,6 +6,7 @@ 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; @@ -19,14 +21,15 @@ export async function getMergeablePrs(): Promise> { /** * Merge the stack of PRs. + * Untracks merged bookmarks from the engine. */ export async function merge( prs: PRToMerge[], - options: MergeOptions = {}, + options: MergeOptions, ): Promise> { return mergeStack( prs, - { method: options.method ?? "squash" }, + { method: options.method ?? "squash", engine: options.engine }, { onMerging: options.onMerging, onWaiting: options.onWaiting, @@ -35,13 +38,12 @@ export async function merge( ); } -export const mergeCommand: Command = - { - meta: { - name: "merge", - description: "Merge PRs from trunk to the current change via GitHub", - category: "management", - core: true, - }, - run: merge, - }; +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/navigation.ts b/packages/core/src/commands/navigation.ts new file mode 100644 index 00000000..a6a637df --- /dev/null +++ b/packages/core/src/commands/navigation.ts @@ -0,0 +1,50 @@ +import { getTrunk, jjNew, runJJ, status } from "../jj"; +import { ok, type Result } from "../result"; +import type { NavigationResult } from "../types"; + +/** + * Get navigation result from current working copy. + */ +export async function getNavigationResult(): Promise> { + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + return ok({ + changeId: statusResult.value.workingCopy.changeId, + changeIdPrefix: statusResult.value.workingCopy.changeIdPrefix, + description: statusResult.value.workingCopy.description, + }); +} + +/** + * Get navigation result from the parent of the working copy. + * Used when we've created a new empty WC and want to report the parent. + */ +export async function getParentNavigationResult(): Promise< + Result +> { + const result = await runJJ([ + "log", + "-r", + "@-", + "--no-graph", + "-T", + 'change_id.short() ++ "\\t" ++ change_id.shortest().prefix() ++ "\\t" ++ description.first_line()', + ]); + if (!result.ok) return result; + const [changeId, changeIdPrefix, description] = result.value.stdout + .trim() + .split("\t"); + return ok({ changeId, changeIdPrefix, description: description || "" }); +} + +/** + * Create a new change on trunk and return navigation result. + */ +export async function newOnTrunk(): Promise> { + const trunk = await getTrunk(); + const newResult = await jjNew({ parents: [trunk] }); + if (!newResult.ok) return newResult; + const navResult = await getNavigationResult(); + if (!navResult.ok) return navResult; + return ok({ ...navResult.value, createdOnTrunk: true }); +} diff --git a/packages/core/src/commands/restack.ts b/packages/core/src/commands/restack.ts index 9e2d2e9b..6f405bba 100644 --- a/packages/core/src/commands/restack.ts +++ b/packages/core/src/commands/restack.ts @@ -1,3 +1,4 @@ +import { fetchMetadataRefs } from "../git/refs"; import { getBookmarkTracking, push, runJJ } from "../jj"; import { ok, type Result } from "../result"; import type { Command } from "./types"; @@ -77,6 +78,9 @@ export async function restack(): Promise> { const fetchResult = await runJJ(["git", "fetch"]); if (!fetchResult.ok) return fetchResult; + // Fetch arr metadata refs from remote + fetchMetadataRefs(); + // Restack all changes onto trunk const restackResult = await restackAll(); if (!restackResult.ok) return restackResult; diff --git a/packages/core/src/commands/squash.ts b/packages/core/src/commands/squash.ts index f597c405..d80b8079 100644 --- a/packages/core/src/commands/squash.ts +++ b/packages/core/src/commands/squash.ts @@ -1,43 +1,72 @@ -import { abandon, edit, list, runJJ, status } from "../jj"; +import type { Engine } from "../engine"; +import { abandon, edit, findChange, list, runJJ, status } from "../jj"; +import type { Changeset } from "../parser"; import { createError, err, ok, type Result } from "../result"; import type { Command } from "./types"; interface SquashResult { movedTo: string | null; + untrackedBookmarks: string[]; + /** The change that was squashed (for CLI display) */ + change: Changeset; +} + +interface SquashOptions { + /** Change ID, bookmark name, or search query. If not provided, uses current working copy. */ + id?: string; + engine: Engine; } /** * Squash a change into its parent, preserving the work. * If the change has children, they are rebased onto the parent. * If squashing the current change, moves to parent. + * Untracks any bookmarks on the squashed change from the engine. */ -export async function squash(changeId: string): Promise> { +export async function squash( + options: SquashOptions, +): Promise> { + const { id, engine } = options; + const statusBefore = await status(); if (!statusBefore.ok) return statusBefore; - const wasOnChange = - statusBefore.value.workingCopy.changeId === changeId || - statusBefore.value.workingCopy.changeId.startsWith(changeId); - - const changeResult = await list({ revset: changeId, limit: 1 }); - if (!changeResult.ok) return changeResult; - if (changeResult.value.length === 0) { - return err( - createError("INVALID_REVISION", `Change not found: ${changeId}`), - ); + // Resolve the change + let change: Changeset; + if (id) { + const findResult = await findChange(id, { includeBookmarks: true }); + if (!findResult.ok) return findResult; + if (findResult.value.status === "none") { + return err(createError("INVALID_REVISION", `Change not found: ${id}`)); + } + if (findResult.value.status === "multiple") { + return err( + createError( + "AMBIGUOUS_REVISION", + `Multiple changes match "${id}". Use a more specific identifier.`, + ), + ); + } + change = findResult.value.change; + } else { + // Use current working copy + change = statusBefore.value.workingCopy; } - const change = changeResult.value[0]; + const wasOnChange = + statusBefore.value.workingCopy.changeId === change.changeId; const parentId = change.parents[0]; - const childrenResult = await list({ revset: `children(${changeId})` }); + const childrenResult = await list({ + revset: `children(${change.changeId})`, + }); const hasChildren = childrenResult.ok && childrenResult.value.length > 0; if (hasChildren) { const rebaseResult = await runJJ([ "rebase", "-s", - `children(${changeId})`, + `children(${change.changeId})`, "-d", parentId || "trunk()", ]); @@ -48,6 +77,15 @@ export async function squash(changeId: string): Promise> { const abandonResult = await abandon(change.changeId); if (!abandonResult.ok) return abandonResult; + // Untrack any bookmarks on the squashed 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); @@ -56,10 +94,10 @@ export async function squash(changeId: string): Promise> { } } - return ok({ movedTo }); + return ok({ movedTo, untrackedBookmarks, change }); } -export const squashCommand: Command = { +export const squashCommand: Command = { meta: { name: "squash", args: "[id]", diff --git a/packages/core/src/commands/submit.ts b/packages/core/src/commands/submit.ts index c486b8f8..5fdf5b1e 100644 --- a/packages/core/src/commands/submit.ts +++ b/packages/core/src/commands/submit.ts @@ -1,5 +1,7 @@ +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 { @@ -7,6 +9,7 @@ interface SubmitResult { bookmarkName: string; prNumber: number; prUrl: string; + base: string; status: "created" | "pushed" | "synced"; }>; created: number; @@ -16,18 +19,38 @@ interface SubmitResult { interface SubmitOptions { draft?: 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 = {}, + options: SubmitOptions, ): Promise> { - return submitStack({ draft: options.draft }); + const { engine } = options; + + // Refresh PR info before submitting to detect merged/closed PRs + await syncPRInfo({ engine }); + + const result = await submitStack({ draft: options.draft }); + if (!result.ok) return result; + + // Track all submitted PRs with their PR info + for (const pr of result.value.prs) { + await engine.track(pr.bookmarkName, { + number: pr.prNumber, + state: "OPEN", + url: pr.prUrl, + base: pr.base, + }); + } + + return result; } -export const submitCommand: Command = { +export const submitCommand: Command = { meta: { name: "submit", description: "Create or update GitHub PRs for the current stack", 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..25abe31c --- /dev/null +++ b/packages/core/src/commands/sync-pr-info.ts @@ -0,0 +1,65 @@ +import type { Engine, PRInfo } 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, prStatus] of prsResult.value) { + const prInfo: PRInfo = { + number: prStatus.number, + state: + prStatus.state === "open" + ? "OPEN" + : prStatus.state === "merged" + ? "MERGED" + : "CLOSED", + url: prStatus.url, + base: prStatus.baseRefName, + title: prStatus.title, + reviewDecision: + prStatus.reviewDecision === "approved" + ? "APPROVED" + : prStatus.reviewDecision === "changes_requested" + ? "CHANGES_REQUESTED" + : prStatus.reviewDecision === "review_required" + ? "REVIEW_REQUIRED" + : undefined, + }; + 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 index 997dca61..9ed7eee3 100644 --- a/packages/core/src/commands/sync.ts +++ b/packages/core/src/commands/sync.ts @@ -1,7 +1,14 @@ +import type { Engine } from "../engine"; +import { fetchMetadataRefs } from "../git/refs"; import { abandon, getTrunk, list, runJJ, status } from "../jj"; import { ok, type Result } from "../result"; -import { cleanupMergedChanges, updateStackComments } from "../stacks"; +import { + findMergedChanges, + type MergedChange, + updateStackComments, +} from "../stacks"; import type { AbandonedChange } from "../types"; +import { syncPRInfo } from "./sync-pr-info"; import type { Command } from "./types"; interface SyncResult { @@ -10,11 +17,16 @@ interface SyncResult { hasConflicts: boolean; merged: AbandonedChange[]; empty: AbandonedChange[]; - cleanedUpPrs: number[]; + /** Changes with merged 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) @@ -102,12 +114,21 @@ async function getStacksBehindTrunk(): Promise> { * 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(): Promise> { +export async function sync(options: SyncOptions): Promise> { + const { engine } = options; + + // 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; + // Fetch arr metadata refs from remote + fetchMetadataRefs(); + // Update local trunk bookmark to match remote const trunk = await getTrunk(); await runJJ(["bookmark", "set", trunk, "-r", `${trunk}@origin`]); @@ -152,11 +173,9 @@ export async function sync(): Promise> { const merged = abandoned.filter((a) => a.reason === "merged"); const empty = abandoned.filter((a) => a.reason === "empty"); - // Check for changes with merged PRs that weren't caught by rebase - const cleanupResult = await cleanupMergedChanges(); - const cleanedUpPrs = cleanupResult.ok - ? cleanupResult.value.abandoned.map((a) => a.prNumber) - : []; + // 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(); @@ -172,13 +191,13 @@ export async function sync(): Promise> { hasConflicts, merged, empty, - cleanedUpPrs, + pendingCleanup, updatedComments, stacksBehind, }); } -export const syncCommand: Command = { +export const syncCommand: Command = { meta: { name: "sync", description: diff --git a/packages/core/src/commands/top.ts b/packages/core/src/commands/top.ts index 5cbcaf1c..ecad559b 100644 --- a/packages/core/src/commands/top.ts +++ b/packages/core/src/commands/top.ts @@ -1,6 +1,7 @@ -import { runJJ, status } from "../jj"; -import { createError, err, ok, type Result } from "../result"; +import { runJJ } from "../jj"; +import { createError, err, type Result } from "../result"; import type { NavigationResult } from "../types"; +import { getParentNavigationResult } from "./navigation"; import type { Command } from "./types"; /** @@ -25,7 +26,7 @@ export async function top(): Promise> { const [empty, desc = ""] = wcResult.value.stdout.trim().split("\t"); const hasChildren = childrenResult.value.stdout.trim() !== ""; if (empty === "true" && desc === "" && !hasChildren) { - return getNavigationResult("parent"); + return getParentNavigationResult(); } } @@ -50,34 +51,7 @@ export async function top(): Promise> { const newResult = await runJJ(["new"]); if (!newResult.ok) return newResult; - return getNavigationResult("parent"); -} - -async function getNavigationResult( - target: "current" | "parent" = "current", -): Promise> { - if (target === "parent") { - const result = await runJJ([ - "log", - "-r", - "@-", - "--no-graph", - "-T", - 'change_id.short() ++ "\\t" ++ change_id.shortest().prefix() ++ "\\t" ++ description.first_line()', - ]); - if (!result.ok) return result; - const [changeId, changeIdPrefix, description] = result.value.stdout - .trim() - .split("\t"); - return ok({ changeId, changeIdPrefix, description: description || "" }); - } - const statusResult = await status(); - if (!statusResult.ok) return statusResult; - return ok({ - changeId: statusResult.value.workingCopy.changeId, - changeIdPrefix: statusResult.value.workingCopy.changeIdPrefix, - description: statusResult.value.workingCopy.description, - }); + return getParentNavigationResult(); } export const topCommand: Command = { diff --git a/packages/core/src/commands/track.ts b/packages/core/src/commands/track.ts new file mode 100644 index 00000000..899c2895 --- /dev/null +++ b/packages/core/src/commands/track.ts @@ -0,0 +1,131 @@ +import type { Engine } from "../engine"; +import { getTrunk, list, status } from "../jj"; +import { createError, err, ok, type Result } from "../result"; +import type { Command } from "./types"; + +interface TrackResult { + bookmark: string; + parent: string; +} + +interface TrackOptions { + engine: Engine; + /** Bookmark to track. If not provided, uses current working copy's bookmark */ + bookmark?: string; + /** Parent branch name. If not provided, auto-detects or prompts */ + parent?: string; +} + +/** + * Track a bookmark with arr. + * This adds the bookmark to the engine's tracking system. + */ +export async function track( + options: TrackOptions, +): Promise> { + const { engine, parent } = options; + let { bookmark } = options; + + const trunk = await getTrunk(); + + // If no bookmark provided, get from current working copy + if (!bookmark) { + const statusResult = await status(); + if (!statusResult.ok) return statusResult; + + const wc = statusResult.value.workingCopy; + if (wc.bookmarks.length === 0) { + // Check parent for bookmark + const parentBookmark = statusResult.value.parents[0]?.bookmarks[0]; + if (parentBookmark) { + bookmark = parentBookmark; + } else { + return err( + createError( + "INVALID_STATE", + "No bookmark on current change. Create a bookmark first with jj bookmark create.", + ), + ); + } + } else { + bookmark = wc.bookmarks[0]; + } + } + + // 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) { + // Auto-detect parent from the change's parent + const changeResult = await list({ + revset: `bookmarks(exact:"${bookmark}")`, + limit: 1, + }); + if (!changeResult.ok) return changeResult; + if (changeResult.value.length === 0) { + return err( + createError("INVALID_STATE", `Bookmark "${bookmark}" not found`), + ); + } + + 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 { + // Parent is not tracked - default to trunk + parentBranch = trunk; + } + } else { + parentBranch = trunk; + } + } + } else { + parentBranch = trunk; + } + } + + // Track the bookmark + await engine.track(bookmark); + + 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/up.ts b/packages/core/src/commands/up.ts index 11e056dc..0fa06661 100644 --- a/packages/core/src/commands/up.ts +++ b/packages/core/src/commands/up.ts @@ -1,6 +1,7 @@ import { jjNew, runJJ, status } from "../jj"; -import { createError, err, ok, type Result } from "../result"; +import { createError, err, type Result } from "../result"; import type { NavigationResult } from "../types"; +import { getNavigationResult } from "./navigation"; import type { Command } from "./types"; export async function up(): Promise> { @@ -51,33 +52,6 @@ export async function up(): Promise> { return getNavigationResult(); } -async function getNavigationResult( - target: "current" | "parent" = "current", -): Promise> { - if (target === "parent") { - const result = await runJJ([ - "log", - "-r", - "@-", - "--no-graph", - "-T", - 'change_id.short() ++ "\\t" ++ change_id.shortest().prefix() ++ "\\t" ++ description.first_line()', - ]); - if (!result.ok) return result; - const [changeId, changeIdPrefix, description] = result.value.stdout - .trim() - .split("\t"); - return ok({ changeId, changeIdPrefix, description: description || "" }); - } - const statusResult = await status(); - if (!statusResult.ok) return statusResult; - return ok({ - changeId: statusResult.value.workingCopy.changeId, - changeIdPrefix: statusResult.value.workingCopy.changeIdPrefix, - description: statusResult.value.workingCopy.description, - }); -} - export const upCommand: Command = { meta: { name: "up", diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index cecef40b..5e0b1e59 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -1,5 +1,5 @@ import { isRepoInitialized } from "./config"; -import { isInGitRepo } from "./git"; +import { isInGitRepo } from "./git/repo"; import { checkPrerequisites, isJjInitialized } from "./init"; export type ContextLevel = "none" | "jj" | "array"; 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..ef87caea --- /dev/null +++ b/packages/core/src/engine/engine.ts @@ -0,0 +1,253 @@ +import { + type BranchMeta, + deleteMetadata, + listTrackedBranches, + type PRInfo, + readMetadata, + writeMetadata, +} from "../git/metadata"; +import { getTrunk, list } from "../jj"; +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 - engine derives changeId/commitId/parentBranchName internally + track(bookmark: string, prInfo?: PRInfo): Promise; + untrack(bookmark: string): void; + 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 + const tracked = listTrackedBranches(cwd); + for (const [bookmarkName] of tracked) { + const meta = readMetadata(bookmarkName, cwd); + if (meta) { + 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; + }, + + /** + * Track a bookmark. Derives changeId, commitId, parentBranchName from jj. + * If already tracked, updates the metadata (upsert behavior). + */ + 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..044705b2 --- /dev/null +++ b/packages/core/src/engine/types.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +/** + * PR information cached from GitHub. + * Stored in git refs, refreshed on submit/sync. + */ +const prInfoSchema = z.object({ + number: z.number(), + url: z.string(), + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + base: z.string(), + title: z.string().optional(), + reviewDecision: z + .enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]) + .optional(), + isDraft: z.boolean().optional(), +}); + +export type PRInfo = z.infer; + +/** + * Branch metadata stored in git refs at refs/arr/. + * This is the authoritative tracking data that persists across machines. + */ +const branchMetaSchema = z.object({ + // Identity + changeId: z.string(), + commitId: z.string(), + + // Stack relationship - the key field for PR base chains + parentBranchName: z.string(), + + // PR info (cached from GitHub) + prInfo: prInfoSchema.optional(), +}); + +export type BranchMeta = z.infer; + +/** + * 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 index f288d466..b16be3e9 100644 --- a/packages/core/src/executor.ts +++ b/packages/core/src/executor.ts @@ -55,3 +55,82 @@ function createShellExecutor(): CommandExecutor { } 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..358d2008 --- /dev/null +++ b/packages/core/src/git/metadata.ts @@ -0,0 +1,136 @@ +import { z } from "zod"; +import { REFS_PREFIX, runGitSync, runGitSyncLines } from "./runner"; + +const prInfoSchema = z.object({ + number: z.number(), + url: z.string(), + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + base: z.string(), + title: z.string().optional(), + body: z.string().optional(), + reviewDecision: z + .enum(["APPROVED", "REVIEW_REQUIRED", "CHANGES_REQUESTED"]) + .optional(), + isDraft: z.boolean().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; + } +} + +/** + * 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()); +} + +/** + * 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/refs.ts b/packages/core/src/git/refs.ts new file mode 100644 index 00000000..065b509c --- /dev/null +++ b/packages/core/src/git/refs.ts @@ -0,0 +1,47 @@ +import { runGitSync } from "./runner"; + +const REFS_PREFIX = "refs/arr"; + +/** + * Push all arr metadata refs to remote. + */ +export function pushMetadataRefs(remote = "origin", cwd?: string): void { + runGitSync(["push", remote, `${REFS_PREFIX}/*:${REFS_PREFIX}/*`], { + cwd, + onError: "ignore", + }); +} + +/** + * Fetch all arr metadata refs from remote. + */ +export function fetchMetadataRefs(remote = "origin", cwd?: string): void { + runGitSync(["fetch", remote, `${REFS_PREFIX}/*:${REFS_PREFIX}/*`], { + cwd, + onError: "ignore", + }); +} + +/** + * Push metadata ref for a single branch. + */ +export function pushBranchMetadata( + branchName: string, + remote = "origin", + cwd?: string, +): void { + const ref = `${REFS_PREFIX}/${branchName}`; + runGitSync(["push", remote, `${ref}:${ref}`], { cwd, onError: "ignore" }); +} + +/** + * Fetch metadata ref for a single branch. + */ +export function fetchBranchMetadata( + branchName: string, + remote = "origin", + cwd?: string, +): void { + const ref = `${REFS_PREFIX}/${branchName}`; + runGitSync(["fetch", remote, `${ref}:${ref}`], { cwd, onError: "ignore" }); +} diff --git a/packages/core/src/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..0ca8458e --- /dev/null +++ b/packages/core/src/github/pr-actions.ts @@ -0,0 +1,246 @@ +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, + }); + }); +} + +function _updatePRBase( + prNumber: number, + newBase: string, + cwd = process.cwd(), +): Promise> { + return updatePR(prNumber, { base: newBase }, cwd); +} + +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..2d5c9ad4 --- /dev/null +++ b/packages/core/src/github/pr-status.ts @@ -0,0 +1,223 @@ +import { graphql } from "@octokit/graphql"; +import type { PullRequestReviewState } from "@octokit/graphql-schema"; +import { createError, err, ok, type Result } from "../result"; +import { getRepoInfo, getToken } from "./client"; + +export interface PRStatus { + number: number; + state: "open" | "closed" | "merged"; + reviewDecision: "approved" | "changes_requested" | "review_required" | null; + title: string; + baseRefName: string; + url: string; + /** Number of times PR was submitted (1 = initial, 2+ = updated via force-push) */ + version: number; +} + +/** GraphQL fields for fetching PR status - shared between queries */ +const PR_STATUS_FIELDS = ` + number + title + state + merged + baseRefName + 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; + url: string; + reviews: { + nodes: Array<{ + state: PullRequestReviewState; + author: { login: string } | null; + }>; + }; + timelineItems: { + totalCount: number; + }; +} + +function computeReviewDecision( + reviews: GraphQLPRNode["reviews"]["nodes"], +): PRStatus["reviewDecision"] { + 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 PRStatus type */ +function mapPRNodeToStatus(pr: GraphQLPRNode): PRStatus { + const forcePushCount = pr.timelineItems?.totalCount ?? 0; + return { + number: pr.number, + title: pr.title, + state: pr.merged ? "merged" : (pr.state.toLowerCase() as "open" | "closed"), + reviewDecision: computeReviewDecision(pr.reviews.nodes), + baseRefName: pr.baseRefName, + url: pr.url, + version: 1 + forcePushCount, + }; +} + +async function _getPRStatus( + prNumber: number, + cwd = process.cwd(), +): Promise> { + const result = await getMultiplePRStatuses([prNumber], cwd); + if (!result.ok) return result; + + const status = result.value.get(prNumber); + if (!status) { + return err(createError("COMMAND_FAILED", `PR #${prNumber} not found`)); + } + return ok(status); +} + +export async function getMultiplePRStatuses( + 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 statuses = new Map(); + for (let i = 0; i < prNumbers.length; i++) { + const pr = response.repository[`pr${i}`]; + if (pr) { + statuses.set(pr.number, mapPRNodeToStatus(pr)); + } + } + + return ok(statuses); + } catch (e) { + return err( + createError("COMMAND_FAILED", `Failed to get PR statuses: ${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], mapPRNodeToStatus(pr)); + } + } + + return ok(prMap); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to list PRs: ${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/diff.ts b/packages/core/src/jj/diff.ts new file mode 100644 index 00000000..e6ed1483 --- /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`, + "-r", + 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..53bfbe8b --- /dev/null +++ b/packages/core/src/jj/edit.ts @@ -0,0 +1,9 @@ +import type { Result } from "../result"; +import { runJJVoid } from "./runner"; + +export async function edit( + revision: string, + cwd = process.cwd(), +): Promise> { + return runJJVoid(["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..203be153 --- /dev/null +++ b/packages/core/src/jj/find.ts @@ -0,0 +1,55 @@ +import { 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, shortest prefixes, etc.) + // Only try if query looks like it could be a change ID (lowercase alphanumeric) + const isRevsetLike = /^[a-z][a-z0-9]*$/.test(query); + if (isRevsetLike) { + 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 }); +} diff --git a/packages/core/src/jj/index.ts b/packages/core/src/jj/index.ts new file mode 100644 index 00000000..3142f11b --- /dev/null +++ b/packages/core/src/jj/index.ts @@ -0,0 +1,15 @@ +export { abandon } from "./abandon"; +export { ensureBookmark } from "./bookmark-create"; +export { deleteBookmark } from "./bookmark-delete"; +export { getBookmarkTracking } from "./bookmark-tracking"; +export { getDiffStats } from "./diff"; +export { edit } from "./edit"; +export { findChange } from "./find"; +export { list } from "./list"; +export { getLog } from "./log"; +export { jjNew } from "./new"; +export { push } from "./push"; +export { getTrunk, runJJ } 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..753807e4 --- /dev/null +++ b/packages/core/src/jj/log.ts @@ -0,0 +1,111 @@ +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"; + +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; + + 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; + + const wcIsEmpty = + workingCopy?.isEmpty && + workingCopy.description.trim() === "" && + !workingCopy.hasConflicts; + + // Uncommitted work: has file changes but no description + const wcHasUncommittedWork = + workingCopy !== null && + !workingCopy.isEmpty && + workingCopy.description.trim() === "" && + !workingCopy.hasConflicts; + + const isOnTrunk = + wcIsEmpty && workingCopy !== null && workingCopy.parents[0] === trunkId; + + // Uncommitted work directly on trunk (not in a stack) + const uncommittedWorkOnTrunk = + wcHasUncommittedWork && + workingCopy !== null && + workingCopy.parents[0] === trunkId; + + // Filter changes to display in the log + const changes = allChanges.filter((c) => { + // Always show changes with description or conflicts + if (c.description.trim() !== "" || c.hasConflicts) { + return true; + } + // Exclude the current working copy (shown separately as uncommitted work) + if (c.changeId === wcChangeId) { + return false; + } + // Show undescribed changes only if they have file changes + return !c.isEmpty; + }); + + let displayCurrentId = wcChangeId; + if (wcIsEmpty || wcHasUncommittedWork) { + displayCurrentId = workingCopy?.parents[0] ?? null; + } + + // 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, displayCurrentId, modifiedBookmarks); + + // Empty working copy above the stack (not on trunk) + const hasEmptyWorkingCopy = wcIsEmpty === true && !isOnTrunk; + + // Fetch diff stats for uncommitted work if present + let uncommittedWork: LogResult["uncommittedWork"] = null; + if (wcHasUncommittedWork && workingCopy) { + const statsResult = await getDiffStats( + workingCopy.changeId, + undefined, + cwd, + ); + uncommittedWork = { + changeId: workingCopy.changeId, + changeIdPrefix: workingCopy.changeIdPrefix, + isOnTrunk: uncommittedWorkOnTrunk, + 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: wcChangeId, + currentChangeIdPrefix: workingCopy?.changeIdPrefix ?? null, + isOnTrunk: isOnTrunk === true, + hasEmptyWorkingCopy, + 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..804e2cc5 --- /dev/null +++ b/packages/core/src/jj/push.ts @@ -0,0 +1,34 @@ +import { pushBranchMetadata } from "../git/refs"; +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); + } + + const result = await runJJVoid(args, cwd); + + // Push metadata ref for the bookmark if push succeeded + if (result.ok && options?.bookmark) { + pushBranchMetadata(options.bookmark, remote, cwd); + } + + return result; +} diff --git a/packages/core/src/jj/runner.ts b/packages/core/src/jj/runner.ts new file mode 100644 index 00000000..4873ce91 --- /dev/null +++ b/packages/core/src/jj/runner.ts @@ -0,0 +1,70 @@ +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); +} 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..b98fd81d --- /dev/null +++ b/packages/core/src/jj/status.ts @@ -0,0 +1,39 @@ +import { parseConflicts, parseFileChanges } from "../parser"; +import { createError, err, ok, type Result } from "../result"; +import type { ChangesetStatus } from "../types"; +import { list } from "./list"; +import { runJJ } from "./runner"; + +export async function status( + cwd = process.cwd(), +): Promise> { + const changesResult = await list({ revset: "(@ | @-)" }, cwd); + if (!changesResult.ok) return changesResult; + + const workingCopy = changesResult.value.find((c) => c.isWorkingCopy); + if (!workingCopy) { + return err(createError("PARSE_ERROR", "Could not find working copy")); + } + + const parents = changesResult.value.filter((c) => !c.isWorkingCopy); + + const [diffResult, statusResult] = await Promise.all([ + runJJ(["diff", "--summary"], cwd), + runJJ(["status"], cwd), + ]); + + const modifiedFiles = diffResult.ok + ? parseFileChanges(diffResult.value.stdout) + : ok([]); + + const conflicts = statusResult.ok + ? parseConflicts(statusResult.value.stdout) + : ok([]); + + return ok({ + workingCopy, + parents, + modifiedFiles: modifiedFiles.ok ? modifiedFiles.value : [], + conflicts: conflicts.ok ? conflicts.value : [], + }); +} 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-graph.ts b/packages/core/src/log-graph.ts index dd728b22..de0148b5 100644 --- a/packages/core/src/log-graph.ts +++ b/packages/core/src/log-graph.ts @@ -1,8 +1,7 @@ -import { batchGetPRsForBranches } from "./github"; -import { getTrunk } from "./jj"; +import { batchGetPRsForBranches } from "./github/pr-status"; +import { getLog, getTrunk } from "./jj"; import type { Result } from "./result"; import { createError, ok } from "./result"; -import { getEnrichedLog } from "./stacks"; import { LOG_GRAPH_TEMPLATE } from "./templates"; export interface PRInfo { @@ -12,6 +11,12 @@ export interface PRInfo { version: number; } +export interface CachedPRInfo { + number: number; + state: "OPEN" | "CLOSED" | "MERGED"; + url: string; +} + export interface LogGraphData { /** Raw output from jj log with placeholders */ rawOutput: string; @@ -29,26 +34,45 @@ export interface LogGraphData { isEmpty: boolean; } +export interface LogGraphOptions { + trunk?: string; + /** Tracked bookmarks from engine - only these are shown */ + trackedBookmarks?: string[]; + /** Cached PR info from engine - if provided, skips GitHub API call */ + cachedPRInfo?: Map; +} + /** * Fetch all data needed to render the log graph. * Returns structured data that the CLI formats. + * + * If cachedPRInfo is provided, uses it instead of fetching from GitHub. + * This significantly speeds up the command (from ~2-4s to ~250ms). */ export async function getLogGraphData( - trunkName?: string, + options: LogGraphOptions = {}, ): Promise> { - const trunk = trunkName ?? (await getTrunk()); + const trunk = options.trunk ?? (await getTrunk()); + + // Build revset: show only tracked bookmarks + trunk + // This matches Graphite behavior - untracked branches are not shown + const trackedBookmarks = options.trackedBookmarks ?? []; + + let revset: string; + if (trackedBookmarks.length === 0) { + // No tracked branches - show trunk + working copy + revset = `${trunk} | @`; + } else { + // Show tracked bookmarks (only mutable ones) + trunk + working copy + // Immutable tracked bookmarks are stale (merged) and shouldn't be shown + const bookmarkRevsets = trackedBookmarks + .map((b) => `(bookmarks(exact:"${b}") & mutable())`) + .join(" | "); + revset = `(${bookmarkRevsets}) | ${trunk} | @`; + } - // Run jj log with our template const result = Bun.spawnSync( - [ - "jj", - "log", - "--color=never", - "-r", - `mutable() | ${trunk}`, - "-T", - LOG_GRAPH_TEMPLATE, - ], + ["jj", "log", "--color=never", "-r", revset, "-T", LOG_GRAPH_TEMPLATE], { cwd: process.cwd() }, ); @@ -70,32 +94,45 @@ export async function getLogGraphData( } } - // Fetch PR info and enriched log in parallel - const [prsResult, enrichedLogResult] = await Promise.all([ - bookmarks.size > 0 - ? batchGetPRsForBranches(Array.from(bookmarks)) - : Promise.resolve({ ok: true, value: new Map() } as const), - getEnrichedLog(), - ]); - - // Build PR info map + // Build PR info map - use cache if provided, otherwise fetch from GitHub const prInfoMap = new Map(); - if (prsResult.ok) { - for (const [bookmark, pr] of prsResult.value) { - prInfoMap.set(bookmark, { - number: pr.number, - state: pr.state, - url: pr.url, - version: pr.version, - }); + + if (options.cachedPRInfo && options.cachedPRInfo.size > 0) { + // Use cached PR info - much faster path + for (const bookmark of bookmarks) { + const cached = options.cachedPRInfo.get(bookmark); + if (cached) { + prInfoMap.set(bookmark, { + number: cached.number, + state: cached.state.toLowerCase(), + url: cached.url, + version: 0, // Cache doesn't track version + }); + } + } + } else if (bookmarks.size > 0) { + // Fetch from GitHub - slower path + const prsResult = await batchGetPRsForBranches(Array.from(bookmarks)); + if (prsResult.ok) { + for (const [bookmark, pr] of prsResult.value) { + prInfoMap.set(bookmark, { + number: pr.number, + state: pr.state, + url: pr.url, + version: pr.version, + }); + } } } + // Get log data for modified status (much faster than getEnrichedLog) + const logResult = await getLog(); + // Build modified changes and bookmarks sets const modifiedChangeIds = new Set(); const modifiedBookmarks = new Set(); - if (enrichedLogResult.ok) { - for (const entry of enrichedLogResult.value.entries) { + if (logResult.ok) { + for (const entry of logResult.value.entries) { if (entry.isModified) { modifiedChangeIds.add(entry.change.changeId); for (const bookmark of entry.change.bookmarks) { @@ -105,13 +142,13 @@ export async function getLogGraphData( } } - const enrichedData = enrichedLogResult.ok ? enrichedLogResult.value : null; - const isOnTrunk = enrichedData?.isOnTrunk ?? false; - const modifiedCount = enrichedData?.modifiedCount ?? 0; - const isEmpty = enrichedData - ? enrichedData.entries.length === 0 && - isOnTrunk && - !enrichedData.uncommittedWork + const logData = logResult.ok ? logResult.value : null; + const isOnTrunk = logData?.isOnTrunk ?? false; + const modifiedCount = logData + ? logData.entries.filter((e) => e.isModified).length + : 0; + const isEmpty = logData + ? logData.entries.length === 0 && isOnTrunk && !logData.uncommittedWork : false; return ok({ diff --git a/packages/core/src/log.ts b/packages/core/src/log.ts index 9bd371f9..10268a2f 100644 --- a/packages/core/src/log.ts +++ b/packages/core/src/log.ts @@ -67,7 +67,7 @@ export interface LogResult { uncommittedWork: UncommittedWork | null; } -export function formatDescriptionWithDate( +function _formatDescriptionWithDate( description: string, timestamp: Date, ): string { @@ -84,7 +84,7 @@ export function formatDescriptionWithDate( return description; } -export function formatRelativeTime(date: Date): string { +function _formatRelativeTime(date: Date): string { const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSecs = Math.floor(diffMs / 1000); diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts index aaae8d51..4842284f 100644 --- a/packages/core/src/result.ts +++ b/packages/core/src/result.ts @@ -23,6 +23,7 @@ export type JJErrorCode = | "COMMAND_FAILED" | "CONFLICT" | "INVALID_REVISION" + | "AMBIGUOUS_REVISION" | "INVALID_STATE" | "WORKSPACE_NOT_FOUND" | "PARSE_ERROR" diff --git a/packages/core/src/stack-comment.ts b/packages/core/src/stack-comment.ts index 4da88aec..1e1948f1 100644 --- a/packages/core/src/stack-comment.ts +++ b/packages/core/src/stack-comment.ts @@ -33,9 +33,7 @@ export function generateStackComment(options: StackCommentOptions): string { lines.push(""); lines.push("---"); lines.push(""); - lines.push("**Merge order:** bottom → top, or use `arr merge`"); - lines.push(""); - lines.push("*Managed by [Array](https://github.com/posthog/array)*"); + lines.push("Merge from bottom to top, or use `arr merge`"); return lines.join("\n"); } diff --git a/packages/core/src/stacks/comments.ts b/packages/core/src/stacks/comments.ts new file mode 100644 index 00000000..13e273a8 --- /dev/null +++ b/packages/core/src/stacks/comments.ts @@ -0,0 +1,120 @@ +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 }); + } + + type ReviewDecision = + | "approved" + | "changes_requested" + | "review_required" + | null; + type PRState = "open" | "closed" | "merged"; + const statuses = new Map< + number, + { reviewDecision: ReviewDecision; state: PRState } + >(); + for (const [, prItem] of prCache) { + statuses.set(prItem.number, { + reviewDecision: prItem.reviewDecision, + 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..f188b21f --- /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, PRInfo } 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..f70ddbac --- /dev/null +++ b/packages/core/src/stacks/index.ts @@ -0,0 +1,9 @@ +export { updateStackComments } from "./comments"; +export { getEnrichedLog } from "./enriched-log"; +export { getMergeStack, mergeStack } from "./merge"; +export { + cleanupMergedChange, + findMergedChanges, + type MergedChange, +} 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..d37e330d --- /dev/null +++ b/packages/core/src/stacks/merge.ts @@ -0,0 +1,211 @@ +import type { Engine } from "../engine"; +import { + mergePR, + updatePR, + updatePRBranch, + waitForMergeable, +} from "../github/pr-actions"; +import { getPRForBranch } from "../github/pr-status"; +import { + abandon, + deleteBookmark, + getTrunk, + sync as jjSync, + 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 { workingCopy, parents } = statusResult.value; + + let bookmarkName: string | null = null; + let changeId: string | null = null; + + if (workingCopy.bookmarks.length > 0) { + bookmarkName = workingCopy.bookmarks[0]; + changeId = workingCopy.changeId; + } else if (workingCopy.isEmpty && workingCopy.description.trim() === "") { + for (const parent of parents) { + if (parent.bookmarks.length > 0) { + bookmarkName = parent.bookmarks[0]; + changeId = parent.changeId; + break; + } + } + } + + 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}`, + ), + ); + } + + if (prItem.state === "merged") { + break; + } + + if (prItem.state === "closed") { + return err( + createError( + "INVALID_STATE", + `PR #${prItem.number} is closed (not merged)`, + ), + ); + } + + prsToMerge.unshift({ + prNumber: prItem.number, + prTitle: prItem.title, + prUrl: prItem.url, + bookmarkName: currentBookmark, + changeId: currentChangeId, + baseRefName: prItem.baseRefName, + }); + + if (prItem.baseRefName === trunk) { + break; + } + + currentBookmark = prItem.baseRefName; + currentChangeId = 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}`, + ), + ); + } + } + + const baseUpdates: Promise>[] = []; + for (const prItem of prs) { + if (prItem.baseRefName !== trunk) { + baseUpdates.push(updatePR(prItem.prNumber, { base: trunk })); + } + } + if (baseUpdates.length > 0) { + const updateResults = await Promise.all(baseUpdates); + for (const result of updateResults) { + if (!result.ok) return result; + } + } + + for (let i = 0; i < prs.length; i++) { + const prItem = prs[i]; + const nextPR = prs[i + 1]; + + callbacks?.onMerging?.(prItem, nextPR); + 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); + + await deleteBookmark(prItem.bookmarkName); + if (prItem.changeId) { + await abandon(prItem.changeId); + } + + // Untrack the merged bookmark from the engine + if (engine.isTracked(prItem.bookmarkName)) { + engine.untrack(prItem.bookmarkName); + } + + merged.push(prItem); + + if (nextPR) { + await updatePRBranch(nextPR.prNumber, { rebase: true }); + await runJJ(["git", "fetch"]); + } + } + + const syncResult = await jjSync(); + + return ok({ + merged, + synced: syncResult.ok, + }); +} diff --git a/packages/core/src/stacks/merged.ts b/packages/core/src/stacks/merged.ts new file mode 100644 index 00000000..12319865 --- /dev/null +++ b/packages/core/src/stacks/merged.ts @@ -0,0 +1,83 @@ +import { isTrackingBookmark } from "../bookmark-utils"; +import type { Engine } from "../engine"; +import { batchGetPRsForBranches } from "../github/pr-status"; +import { abandon, list } from "../jj"; +import { ok, type Result } from "../result"; + +export interface MergedChange { + changeId: string; + bookmark: string; + prNumber: number; + description: string; +} + +/** + * Find changes with merged 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") { + merged.push({ + changeId: change.changeId, + bookmark, + prNumber: prItem.number, + description: change.description, + }); + } + } + + return ok(merged); +} + +/** + * Clean up a single merged change by abandoning it and untracking from engine. + */ +export async function cleanupMergedChange( + change: MergedChange, + engine: Engine, +): Promise> { + const abandonResult = await abandon(change.changeId); + 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..4eb2e715 --- /dev/null +++ b/packages/core/src/stacks/submit.ts @@ -0,0 +1,339 @@ +import { resolveBookmarkConflict } from "../bookmark-utils"; +import { upsertStackComment } from "../github/comments"; +import { closePR, createPR, updatePR } from "../github/pr-actions"; +import { + batchGetPRsForBranches, + getMultiplePRStatuses, +} 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 pushed = 0; + let synced = 0; + + const tx: SubmitTransaction = { + createdPRs: [], + createdBookmarks: [], + pushedBookmarks: [], + }; + + const orderedStack = [...stack].reverse(); + + 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(); + + 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); + + 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); + } + } + + for (const change of orderedStack) { + const bookmark = bookmarks.get(change.changeId)!; + const existingPR = prCache.get(bookmark); + if (existingPR) { + prNumbers.set(change.changeId, existingPR.number); + } + } + + 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; + + let prStatus: PRSubmitStatus; + + 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.`, + ), + ); + } + prStatus = codePushed ? "pushed" : "synced"; + if (codePushed) { + pushed++; + } else { + synced++; + } + prs.push({ + changeId: change.changeId, + bookmarkName: bookmark, + prNumber: existingPR.number, + prUrl: existingPR.url, + base: previousBookmark, + position: i, + status: prStatus, + }); + } else { + const prResult = await createPR({ + head: bookmark, + title: change.description || "Untitled", + 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); + prStatus = "created"; + created++; + prs.push({ + changeId: change.changeId, + bookmarkName: bookmark, + prNumber: prResult.value.number, + prUrl: prResult.value.url, + base: previousBookmark, + position: i, + status: prStatus, + }); + } + + previousBookmark = bookmark; + } + + await addStackComments(prs, orderedStack); + + await runJJ(["git", "fetch"]); + + return ok({ prs, created, pushed, synced }); +} + +async function addStackComments( + prs: StackPR[], + stack: Changeset[], +): Promise<{ succeeded: number; failed: number }> { + if (prs.length === 0) return { succeeded: 0, failed: 0 }; + + const prNumbersList = prs.map((p) => p.prNumber); + const statusesResult = await getMultiplePRStatuses(prNumbersList); + const statuses = statusesResult.ok ? statusesResult.value : new Map(); + + const commentUpserts = prs.map((prItem, i) => { + const stackEntries: StackEntry[] = prs.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: stack[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 index f731a739..647660ba 100644 --- a/packages/core/src/templates.ts +++ b/packages/core/src/templates.ts @@ -1,4 +1,3 @@ -// Fast template without diff stats export const CHANGESET_JSON_TEMPLATE = '"{" ++' + '"\\"base\\":" ++ json(self) ++ "," ++' + @@ -12,43 +11,28 @@ export const CHANGESET_JSON_TEMPLATE = '"\\"commitIdPrefix\\":\\"" ++ commit_id.shortest().prefix() ++ "\\"" ++' + '"}\\n"'; -// Template with diff stats (slower, use only when needed) -export const CHANGESET_WITH_STATS_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() ++ "\\"," ++' + - '"\\"diffStats\\":{\\"filesChanged\\":" ++ self.diff().stat().files().len() ++ "," ++' + - '"\\"insertions\\":" ++ self.diff().stat().total_added() ++ "," ++' + - '"\\"deletions\\":" ++ self.diff().stat().total_removed() ++ "}" ++' + - '"}\\n"'; - /** * Template for log graph output with placeholders. - * jj handles graph rendering, we handle formatting. + * 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|added|removed|files}} - * {{TIME:timestamp}} - * {{HINT_EMPTY}} (if empty working copy) - * {{HINT_UNCOMMITTED}} (if uncommitted changes) - * {{PR:bookmarks|description}} (if has bookmarks) - * {{PRURL:bookmarks}} (if has bookmarks) - * {{COMMIT:commitId|prefix|description}} + * - Immutable (trunk): {{TRUNK:bookmark}} + time + commit (no trailing blank line) + * - Mutable: {{LABEL:...}} + time + hints + PR info + commit */ 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(",") ++ "|" ++ diff.stat().total_added() ++ "|" ++ diff.stat().total_removed() ++ "|" ++ diff.stat().files().len() ++ "}}\\n" ++ -"{{TIME:" ++ committer.timestamp().format("%s") ++ "}}\\n" ++ -if(current_working_copy && empty, "{{HINT_EMPTY}}\\n", "") ++ -if(current_working_copy && !empty && !description, "{{HINT_UNCOMMITTED}}\\n", "") ++ -"\\n" ++ -if(!immutable && 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\\n", "") ++ -"{{COMMIT:" ++ commit_id.short(8) ++ "|" ++ commit_id.shortest().prefix() ++ "|" ++ if(description, description.first_line(), "") ++ "}}\\n" ++ -"\\n" +if(immutable, + "{{TRUNK:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n" ++ + "{{TIME:" ++ committer.timestamp().format("%s") ++ "}}\\n" ++ + "{{COMMIT:" ++ commit_id.short(8) ++ "|" ++ commit_id.shortest().prefix() ++ "|" ++ if(description, description.first_line(), "") ++ "}}", + "{{LABEL:" ++ change_id.short(8) ++ "|" ++ change_id.shortest().prefix() ++ "|" ++ committer.timestamp().format("%s") ++ "|" ++ if(description, description.first_line(), "") ++ "|" ++ if(conflict, "1", "0") ++ "|" ++ if(current_working_copy, "1", "0") ++ "|" ++ if(empty, "1", "0") ++ "|" ++ if(immutable, "1", "0") ++ "|" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "|" ++ remote_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n" ++ + "{{TIME:" ++ committer.timestamp().format("%s") ++ "}}\\n" ++ + if(current_working_copy && empty && !description, "{{HINT_EMPTY}}\\n", "") ++ + if(current_working_copy && !empty && !description, "{{HINT_UNCOMMITTED}}\\n", "") ++ + if(current_working_copy && description && local_bookmarks, "{{HINT_SUBMIT}}\\n", "") ++ + "\\n" ++ + if(local_bookmarks, "{{PR:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "|" ++ if(description, description.first_line(), "") ++ "}}\\n{{PRURL:" ++ local_bookmarks.map(|b| b.name()).join(",") ++ "}}\\n", "") ++ + "{{COMMIT:" ++ commit_id.short(8) ++ "|" ++ commit_id.shortest().prefix() ++ "|" ++ if(description, description.first_line(), "") ++ "}}\\n" ++ + "\\n" +) `.trim(); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 28680414..0c55615d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -51,8 +51,6 @@ export interface WorkspaceInfo { export interface ListOptions { revset?: string; limit?: number; - /** Include diff stats in the result (slower) */ - includeStats?: boolean; } export interface NewOptions { diff --git a/packages/electron-trpc/package.json b/packages/electron-trpc/package.json index 2d12f8cf..4a4cf8d8 100644 --- a/packages/electron-trpc/package.json +++ b/packages/electron-trpc/package.json @@ -23,15 +23,11 @@ "build": "vite build -c src/main/vite.config.ts && vite build -c src/renderer/vite.config.ts && pnpm build:types", "build:types": "tsc -p tsconfig.build.json", "dev": "pnpm build", - "typecheck": "tsc --noEmit", - "test": "vitest run -c vitest.config.ts", - "test:ci": "vitest run -c vitest.config.ts --coverage" + "typecheck": "tsc --noEmit" }, "devDependencies": { "@trpc/client": "^11.8.0", "@trpc/server": "^11.8.0", - "superjson": "^2.2.2", - "zod": "^3.24.1", "@types/node": "^20.3.1", "@vitest/coverage-v8": "^0.34.0", "builtin-modules": "^3.3.0", 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